Add sheets.js module
This commit is contained in:
parent
b7de2cb300
commit
c026f54163
1 changed files with 207 additions and 0 deletions
207
public/js/sheets.js
Normal file
207
public/js/sheets.js
Normal file
|
|
@ -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, '<').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 = '<td style="padding:7px 12px">' + escHtml(name) + '</td>'
|
||||
+ '<td style="padding:7px 12px;color:var(--text2)">' + escHtml(u.email) + '</td>'
|
||||
+ '<td style="padding:7px 12px;color:var(--text2);font-size:.85em">' + date + '</td>';
|
||||
body.appendChild(row);
|
||||
});
|
||||
showSheet(c, true);
|
||||
c.querySelector('.au-close').onclick = closeOv;
|
||||
}).catch(() => showToast(tr('errLoad')));
|
||||
}
|
||||
Loading…
Reference in a new issue