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.
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user