diff --git a/public/js/sheets.js b/public/js/sheets.js new file mode 100644 index 0000000..6d5103f --- /dev/null +++ b/public/js/sheets.js @@ -0,0 +1,207 @@ +import { api } from './api.js'; +import { tr, setLocale, LOCALE, ldoc } from './i18n.js'; +import { state } from './state.js'; +import { tpl, showSheet, closeOv, showToast, updateHeader } from './ui.js'; +import { render } from './render.js'; +import { showChangePassword, showLogin } from './auth.js'; + +export function escHtml(s) { + return String(s).replace(/&/g, '&').replace(//g, '>'); +} + +export function openNew() { + const c = tpl('tpl-new-goal'); + showSheet(c, true); + const name = c.querySelector('.ng-name'), unit = c.querySelector('.ng-unit'); + const daily = c.querySelector('.ng-daily'), weekly = c.querySelector('.ng-weekly'); + const days = c.querySelector('.ng-days'), sub = c.querySelector('.ng-sub'); + daily.addEventListener('input', () => { + if (daily.value) weekly.value = Math.round(parseFloat(daily.value) * 7 * 100) / 100; + }); + weekly.addEventListener('input', () => { + if (weekly.value) daily.value = Math.round(parseFloat(weekly.value) / 7 * 100) / 100; + }); + setTimeout(() => name.focus(), 50); + c.querySelector('.ng-can').onclick = closeOv; + sub.onclick = () => { + const nv = (name.value || '').trim(), uv = (unit.value || '').trim() || tr('unitDefault'); + const dv = parseFloat(daily.value) || 1, dyv = parseInt(days.value, 10) || 30; + if (!nv) { name.focus(); return; } + sub.disabled = true; + api('POST', 'goals', { name: nv, unit: uv, daily: dv, days: dyv, start: state.TODAY.toISOString() }) + .then(r => { + state.goals.push({ id: r.id, name: r.name, unit: r.unit, daily: r.daily, days: r.days, start: r.start, sets: r.sets || {} }); + closeOv(); render(); + }) + .catch(e => { + sub.disabled = false; + if (e.status !== 401) showToast(tr('errCreate')); + }); + }; // closes sub.onclick +} // closes openNew + +export function openData() { + const c = tpl('tpl-data-menu'); + showSheet(c, true); + c.querySelector('.dm-cls').onclick = closeOv; + + c.querySelector('.dm-name').onclick = () => { + const nc = tpl('tpl-change-name'); + showSheet(nc, true); + const inp = nc.querySelector('.cn-name'), errEl = nc.querySelector('.login-err'), sub = nc.querySelector('.cn-sub'); + inp.value = state.userName; + setTimeout(() => { inp.focus(); inp.select(); }, 50); + nc.querySelector('.cn-can').onclick = closeOv; + sub.onclick = () => { + const nv = inp.value.trim(); + if (!nv) { errEl.textContent = tr('errNameEmpty'); errEl.style.display = ''; return; } + sub.disabled = true; sub.textContent = '…'; + api('PATCH', 'me', { name: nv }) + .then(r => { state.userName = r.name; closeOv(); render(); showToast(tr('nameSaved')); }) + .catch(() => { sub.disabled = false; sub.textContent = tr('save'); showToast(tr('errNameSave')); }); + }; + }; + + c.querySelector('.dm-cpw').onclick = () => { closeOv(); showChangePassword(); }; + + c.querySelector('.dm-lgout').onclick = () => { + api('POST', 'logout').then(() => { state.goals = []; closeOv(); render(); showLogin(); }); + }; + + const adminBtn = c.querySelector('.dm-admin'); + if (state.isAdmin) { + adminBtn.style.display = ''; + adminBtn.onclick = () => { closeOv(); openAdmin(); }; + } + + c.querySelector('.dm-inv').onclick = () => { + const ic = tpl('tpl-invite-form'); + showSheet(ic, true); + const invName = ic.querySelector('.inv-name'); + setTimeout(() => invName.focus(), 50); + ic.querySelector('.inv-cancel').onclick = closeOv; + ic.querySelector('.inv-gen').onclick = function() { + const note = invName.value.trim(), btn = this; + btn.disabled = true; btn.textContent = '…'; + api('POST', 'invite', { note }) + .then(res => { + const lc = tpl('tpl-invite-link'); + lc.querySelector('.stitle').textContent = tr('inviteLinkTitle') + (note ? ' — ' + note : ''); + const urlInp = lc.querySelector('.il-url'); + urlInp.value = res.url; + showSheet(lc, true); + lc.querySelector('.il-close').onclick = closeOv; + lc.querySelector('.il-copy').onclick = () => { + navigator.clipboard.writeText(res.url).then(() => { showToast(tr('linkCopied')); closeOv(); }); + }; + setTimeout(() => urlInp.select(), 50); + }) + .catch(err => { + btn.disabled = false; btn.textContent = tr('generateLink'); + showToast(err.message || tr('errGenerate')); + }); + }; + }; + + c.querySelector('.dm-invlist').onclick = () => { + api('GET', 'invites').then(list => { + const statusLabel = { pending: tr('statusPending'), used: tr('statusUsed'), expired: tr('statusExpired') }; + const statusColor = { pending: 'var(--amber)', used: 'var(--green)', expired: 'var(--red)' }; + const lc = tpl('tpl-invite-list'); + const body = lc.querySelector('.dpanel-body'); + if (!list.length) { + const empty = document.createElement('div'); + empty.className = 'nosets'; empty.style.padding = '16px'; + empty.textContent = tr('noInvites'); + body.appendChild(empty); + } else { + for (const inv of list) { + const label = inv.note || new Date(inv.created_at).toLocaleDateString(ldoc(), { day: 'numeric', month: 'short', year: 'numeric' }); + const detail = inv.used_by_email + ? (tr('acceptedBy') + ' ' + inv.used_by_email) + : (inv.status === 'pending' ? tr('expiresAt') + ' ' + new Date(inv.expires_at).toLocaleDateString(ldoc(), { day: 'numeric', month: 'short' }) : ''); + const row = tpl('tpl-invite-row'); + row.querySelector('.ir-label').textContent = label; + if (detail) row.querySelector('.ir-detail').textContent = ' ' + detail; + const st = row.querySelector('.ir-status'); + st.textContent = statusLabel[inv.status]; st.style.color = statusColor[inv.status]; + if (inv.url) { + const cp = row.querySelector('.ir-copy'); cp.style.display = ''; + cp.onclick = () => { navigator.clipboard.writeText(inv.url).then(() => { showToast(tr('linkCopied')); }); }; + } + body.appendChild(row); + } + } + showSheet(lc, true); + lc.querySelector('.il-close').onclick = closeOv; + }).catch(() => showToast(tr('errLoad'))); + }; + + c.querySelector('.dm-exp').onclick = () => { + const blob = new Blob( + [JSON.stringify({ goals: state.goals, at: new Date().toISOString() }, null, 2)], + { type: 'application/json' } + ); + const url = URL.createObjectURL(blob), a = document.createElement('a'); + a.href = url; a.download = 'dudi-backup.json'; a.click(); URL.revokeObjectURL(url); closeOv(); + }; + + c.querySelector('.dm-imp').onclick = () => { + const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.json'; + inp.onchange = e => { + const f = e.target.files[0]; if (!f) return; + const r = new FileReader(); + r.onload = ev => { + try { + const p = JSON.parse(ev.target.result); + if (!p.goals || !Array.isArray(p.goals)) throw new Error(tr('invalidFormat')); + if (!confirm(tr('confirmImport').replace('{n}', p.goals.length))) return; + const promises = p.goals.map(g => + api('POST', 'goals', { name: g.name, unit: g.unit, daily: g.daily, days: g.days, start: g.start, sets: g.sets || {} }) + .then(r => { state.goals.push({ id: r.id, name: r.name, unit: r.unit, daily: r.daily, days: r.days, start: r.start, sets: r.sets || {} }); }) + ); + Promise.all(promises).then(() => { closeOv(); render(); alert(tr('importDone').replace('{n}', p.goals.length)); }); + } catch (err) { alert(err.message); } + }; + r.readAsText(f); + }; + inp.click(); + }; + + c.querySelectorAll('.btn-lang').forEach(b => { + if (b.dataset.lang === LOCALE) b.classList.add('active'); + b.onclick = function() { + const lang = this.dataset.lang; + setLocale(lang, true); + api('PATCH', 'me', { locale: lang }).catch(() => {}); + render(); updateHeader(); closeOv(); + }; + }); + + c.querySelector('.dm-clr').onclick = () => { + if (!confirm(tr('confirmClear'))) return; + const ids = state.goals.map(g => g.id); + state.goals = []; render(); + Promise.all(ids.map(id => api('DELETE', 'goals/' + id))).catch(() => showToast(tr('errDelete'))); + closeOv(); + }; +} + +export function openAdmin() { + api('GET', 'admin/users').then(rows => { + const c = tpl('tpl-admin-users'); + const body = c.querySelector('.au-body'); + rows.forEach(u => { + const row = document.createElement('tr'); + row.style.borderBottom = '1px solid var(--border)'; + const name = u.username || '—'; + const date = new Date(u.registered * 1000).toLocaleDateString(ldoc(), { day: 'numeric', month: 'short', year: 'numeric' }); + row.innerHTML = '