Files
KCal/public/js/dashboard.js
T
ort 66b8fc5d4d Add self-hosted kids calendar app
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.
2026-08-15 13:48:01 -04:00

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();