Express + SQLite (node:sqlite, no native build step) family calendar: parent accounts with household invites, per-child calendars with save/duplicate/print, a token-gated read-only kiosk view for tablets, and polling to keep parent and kiosk views in sync. Defaults to port 3007.
214 lines
8.4 KiB
JavaScript
214 lines
8.4 KiB
JavaScript
let children = [];
|
|
|
|
function showToast(msg) {
|
|
const toast = document.getElementById('toast');
|
|
toast.textContent = msg;
|
|
toast.classList.add('show');
|
|
setTimeout(() => toast.classList.remove('show'), 2200);
|
|
}
|
|
|
|
function fmtDate(iso) {
|
|
if (!iso) return '';
|
|
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
}
|
|
|
|
async function boot() {
|
|
try {
|
|
const me = await api.get('/api/auth/me');
|
|
document.getElementById('householdName').textContent = me.household.name;
|
|
document.getElementById('whoami').textContent = `Logged in as ${me.parent.name} (${me.parent.email})`;
|
|
} catch {
|
|
window.location.href = '/login.html';
|
|
return;
|
|
}
|
|
|
|
await Promise.all([loadParents(), loadChildren()]);
|
|
}
|
|
|
|
async function loadParents() {
|
|
const { parents } = await api.get('/api/household');
|
|
const el = document.getElementById('parentsList');
|
|
el.innerHTML = '';
|
|
parents.forEach((p) => {
|
|
const card = document.createElement('div');
|
|
card.className = 'entity-card';
|
|
card.innerHTML = `<h3>${escapeHtml(p.name)}</h3><div class="meta">${escapeHtml(p.email)}</div>`;
|
|
el.appendChild(card);
|
|
});
|
|
}
|
|
|
|
async function loadChildren() {
|
|
const data = await api.get('/api/children');
|
|
children = data.children;
|
|
const el = document.getElementById('childrenList');
|
|
el.innerHTML = '';
|
|
|
|
if (children.length === 0) {
|
|
el.innerHTML = '<div class="empty-state">No children yet — add one to create their first calendar.</div>';
|
|
return;
|
|
}
|
|
|
|
for (const child of children) {
|
|
const card = await renderChildCard(child);
|
|
el.appendChild(card);
|
|
}
|
|
}
|
|
|
|
async function renderChildCard(child) {
|
|
const { calendars } = await api.get(`/api/children/${child.id}/calendars`);
|
|
|
|
const card = document.createElement('div');
|
|
card.className = 'entity-card';
|
|
|
|
const calendarsHtml = calendars.length
|
|
? calendars.map((c) => calendarRowHtml(child, c)).join('')
|
|
: '<div class="meta">No calendars yet.</div>';
|
|
|
|
card.innerHTML = `
|
|
<h3>${escapeHtml(child.name)}</h3>
|
|
<div class="meta">${calendars.length} calendar${calendars.length === 1 ? '' : 's'}</div>
|
|
<div>${calendarsHtml}</div>
|
|
<div class="row">
|
|
<button class="tool primary" data-action="new-cal" data-child="${child.id}">+ New calendar</button>
|
|
<button class="tool" data-action="kiosk-link" data-child="${child.id}">Tablet link</button>
|
|
<button class="tool danger" data-action="delete-child" data-child="${child.id}">Delete child</button>
|
|
</div>
|
|
`;
|
|
|
|
card.querySelectorAll('[data-action]').forEach((btn) => {
|
|
btn.addEventListener('click', () => handleCardAction(btn.dataset.action, btn.dataset));
|
|
});
|
|
|
|
return card;
|
|
}
|
|
|
|
function calendarRowHtml(child, cal) {
|
|
const isActive = child.activeCalendarId === cal.id;
|
|
return `
|
|
<div class="row" style="justify-content:space-between; align-items:center; padding:6px 0; border-top:1px solid var(--line);">
|
|
<div>
|
|
<strong>${escapeHtml(cal.title)}</strong>
|
|
<div class="meta">Updated ${fmtDate(cal.updated_at)}${isActive ? ' · on tablet' : ''}</div>
|
|
</div>
|
|
<div class="row">
|
|
<a class="tool" href="/calendar.html?calendarId=${cal.id}">Open</a>
|
|
<button class="tool" data-action="duplicate-cal" data-child="${child.id}" data-cal="${cal.id}" data-title="${escapeHtml(cal.title)}">Duplicate</button>
|
|
${isActive ? '' : `<button class="tool" data-action="set-active" data-child="${child.id}" data-cal="${cal.id}">Set on tablet</button>`}
|
|
<button class="tool danger" data-action="delete-cal" data-child="${child.id}" data-cal="${cal.id}">Delete</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
async function handleCardAction(action, ds) {
|
|
if (action === 'new-cal') {
|
|
document.getElementById('newCalChildId').value = ds.child;
|
|
document.getElementById('newCalTitle').value = '';
|
|
document.getElementById('newCalDuplicate').checked = false;
|
|
openModal('newCalendarModal');
|
|
} else if (action === 'duplicate-cal') {
|
|
const title = prompt('Title for the duplicated calendar:', `Copy of ${ds.title}`);
|
|
if (!title) return;
|
|
await api.post(`/api/children/${ds.child}/calendars`, { title, duplicateFromCalendarId: Number(ds.cal) });
|
|
showToast('Calendar duplicated');
|
|
await loadChildren();
|
|
} else if (action === 'set-active') {
|
|
await api.patch(`/api/children/${ds.child}`, { activeCalendarId: Number(ds.cal) });
|
|
showToast('Tablet will now show this calendar');
|
|
await loadChildren();
|
|
} else if (action === 'delete-cal') {
|
|
if (!confirm('Delete this calendar? This cannot be undone.')) return;
|
|
await api.del(`/api/calendars/${ds.cal}`);
|
|
showToast('Calendar deleted');
|
|
await loadChildren();
|
|
} else if (action === 'kiosk-link') {
|
|
const child = children.find((c) => c.id === Number(ds.child));
|
|
openKioskModal(child);
|
|
} else if (action === 'delete-child') {
|
|
if (!confirm('Delete this child and all their calendars? This cannot be undone.')) return;
|
|
await api.del(`/api/children/${ds.child}`);
|
|
showToast('Child deleted');
|
|
await loadChildren();
|
|
}
|
|
}
|
|
|
|
function openModal(id) { document.getElementById(id).classList.remove('hidden'); }
|
|
function closeModal(id) { document.getElementById(id).classList.add('hidden'); }
|
|
|
|
document.getElementById('addChildBtn').addEventListener('click', () => {
|
|
document.getElementById('childNameInput').value = '';
|
|
openModal('addChildModal');
|
|
});
|
|
document.getElementById('cancelAddChildBtn').addEventListener('click', () => closeModal('addChildModal'));
|
|
document.getElementById('addChildForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
await api.post('/api/children', { name: document.getElementById('childNameInput').value });
|
|
closeModal('addChildModal');
|
|
showToast('Child added');
|
|
await loadChildren();
|
|
});
|
|
|
|
document.getElementById('cancelNewCalBtn').addEventListener('click', () => closeModal('newCalendarModal'));
|
|
document.getElementById('newCalendarForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const childId = document.getElementById('newCalChildId').value;
|
|
const title = document.getElementById('newCalTitle').value;
|
|
const duplicate = document.getElementById('newCalDuplicate').checked;
|
|
|
|
let body = { title };
|
|
if (duplicate) {
|
|
const { calendars } = await api.get(`/api/children/${childId}/calendars`);
|
|
if (calendars.length) body.duplicateFromCalendarId = calendars[0].id;
|
|
}
|
|
const { calendar } = await api.post(`/api/children/${childId}/calendars`, body);
|
|
closeModal('newCalendarModal');
|
|
window.location.href = `/calendar.html?calendarId=${calendar.id}`;
|
|
});
|
|
|
|
document.getElementById('inviteBtn').addEventListener('click', async () => {
|
|
const { invite } = await api.post('/api/household/invites');
|
|
const link = `${window.location.origin}${invite.joinPath}`;
|
|
document.getElementById('inviteLinkField').value = link;
|
|
openModal('inviteModal');
|
|
});
|
|
document.getElementById('closeInviteBtn').addEventListener('click', () => closeModal('inviteModal'));
|
|
document.getElementById('copyInviteBtn').addEventListener('click', () => {
|
|
document.getElementById('inviteLinkField').select();
|
|
navigator.clipboard.writeText(document.getElementById('inviteLinkField').value);
|
|
showToast('Link copied');
|
|
});
|
|
|
|
let kioskModalChild = null;
|
|
function openKioskModal(child) {
|
|
kioskModalChild = child;
|
|
document.getElementById('kioskChildName').textContent = child.name;
|
|
document.getElementById('kioskLinkField').value = `${window.location.origin}${child.kioskPath}`;
|
|
openModal('kioskModal');
|
|
}
|
|
document.getElementById('closeKioskBtn').addEventListener('click', () => closeModal('kioskModal'));
|
|
document.getElementById('copyKioskBtn').addEventListener('click', () => {
|
|
document.getElementById('kioskLinkField').select();
|
|
navigator.clipboard.writeText(document.getElementById('kioskLinkField').value);
|
|
showToast('Link copied');
|
|
});
|
|
document.getElementById('regenKioskBtn').addEventListener('click', async () => {
|
|
if (!confirm('Regenerate this tablet link? The old link will stop working.')) return;
|
|
const data = await api.post(`/api/children/${kioskModalChild.id}/kiosk-token/regenerate`);
|
|
document.getElementById('kioskLinkField').value = `${window.location.origin}${data.kioskPath}`;
|
|
showToast('Link regenerated');
|
|
await loadChildren();
|
|
});
|
|
|
|
document.getElementById('logoutBtn').addEventListener('click', async () => {
|
|
await api.post('/api/auth/logout');
|
|
window.location.href = '/login.html';
|
|
});
|
|
|
|
boot();
|