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,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>My Week</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;700;800&family=Nunito:wght@500;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/shared.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="crumbs"><a href="/dashboard.html">← Dashboard</a></nav>
|
||||
|
||||
<header>
|
||||
<div>
|
||||
<h1><span id="childName" contenteditable="true">…</span>'s <span id="calTitle" contenteditable="true">Week</span></h1>
|
||||
<div class="subtitle">Tap a box to check it off. Click any word to edit it.</div>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button class="tool" id="toggleWeekend">Show weekend</button>
|
||||
<button class="tool" id="resetChecks">Reset checkboxes</button>
|
||||
<button class="tool primary" id="printBtn">Print this week</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="period-toggles" id="periodToggles">
|
||||
<span class="label">Show / print:</span>
|
||||
</div>
|
||||
|
||||
<div class="board" id="weekdayBoard"></div>
|
||||
<div class="weekend-toggle-wrap hidden" id="weekendWrap"></div>
|
||||
|
||||
<div class="legend" id="legend"></div>
|
||||
<div class="hint">Tip: the colors stay the same every day on purpose — same color always means same kind of task, so she can recognize it at a glance.</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/calendarRender.js"></script>
|
||||
<script src="/js/calendar.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,451 @@
|
||||
:root{
|
||||
--bg: #F1F5FB;
|
||||
--card: #FFFFFF;
|
||||
--ink: #2E3446;
|
||||
--ink-soft: #6B7280;
|
||||
--line: #E3E8F0;
|
||||
|
||||
--morning: #FF8C64;
|
||||
--morning-bg: #FFF0E7;
|
||||
--school: #4A90D9;
|
||||
--school-bg: #E9F2FC;
|
||||
--after: #4FAE72;
|
||||
--after-bg: #E9F7EE;
|
||||
--evening: #8B7FD1;
|
||||
--evening-bg: #F0EDFB;
|
||||
|
||||
--today-ring: #FFC94A;
|
||||
--danger: #D14343;
|
||||
}
|
||||
|
||||
*{ box-sizing: border-box; }
|
||||
|
||||
body{
|
||||
margin:0;
|
||||
background: var(--bg);
|
||||
font-family: 'Nunito', sans-serif;
|
||||
color: var(--ink);
|
||||
padding: 24px 16px 60px;
|
||||
}
|
||||
|
||||
a{ color: var(--school); }
|
||||
|
||||
h1{
|
||||
font-family: 'Baloo 2', sans-serif;
|
||||
font-size: 2rem;
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
}
|
||||
h1 span{ color: var(--morning); }
|
||||
|
||||
header{
|
||||
max-width: 1400px;
|
||||
margin: 0 auto 20px;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.subtitle{
|
||||
font-size: 0.95rem;
|
||||
color: var(--ink-soft);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.toolbar{
|
||||
display:flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
button.tool, a.tool{
|
||||
font-family: 'Nunito', sans-serif;
|
||||
font-weight: 800;
|
||||
font-size: 0.85rem;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 10px 18px;
|
||||
cursor: pointer;
|
||||
background: var(--card);
|
||||
color: var(--ink);
|
||||
border: 2px solid var(--line);
|
||||
transition: transform .12s ease, background .12s ease;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
button.tool:hover, a.tool:hover{ transform: translateY(-1px); background: #F7F9FC; }
|
||||
button.tool.primary, a.tool.primary{ background: var(--school); color: white; border-color: var(--school); }
|
||||
button.tool.primary:hover, a.tool.primary:hover{ background: #3d7fc4; }
|
||||
button.tool.danger{ background: var(--card); color: var(--danger); border-color: #F3D0D0; }
|
||||
button.tool.danger:hover{ background: #FDF1F1; }
|
||||
button.tool:disabled{ opacity: .5; cursor: default; transform:none; }
|
||||
|
||||
.board{
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.weekend-toggle-wrap{
|
||||
max-width: 1400px;
|
||||
margin: 14px auto 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.day-card{
|
||||
background: var(--card);
|
||||
border-radius: 20px;
|
||||
padding: 14px;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap: 10px;
|
||||
border: 3px solid transparent;
|
||||
box-shadow: 0 2px 10px rgba(46,52,70,0.06);
|
||||
}
|
||||
|
||||
.day-card.today{
|
||||
border-color: var(--today-ring);
|
||||
box-shadow: 0 4px 18px rgba(255,201,74,0.35);
|
||||
}
|
||||
|
||||
.day-head{
|
||||
display:flex;
|
||||
align-items:baseline;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.day-name{
|
||||
font-family: 'Baloo 2', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.today-badge{
|
||||
font-size: 0.65rem;
|
||||
font-weight: 800;
|
||||
background: var(--today-ring);
|
||||
color: #6B4A00;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
letter-spacing: .03em;
|
||||
}
|
||||
|
||||
.block{
|
||||
border-radius: 14px;
|
||||
padding: 10px 10px 8px;
|
||||
}
|
||||
|
||||
.block.morning{ background: var(--morning-bg); }
|
||||
.block.school{ background: var(--school-bg); }
|
||||
.block.after{ background: var(--after-bg); }
|
||||
.block.evening{ background: var(--evening-bg); }
|
||||
|
||||
.block-title{
|
||||
font-family: 'Baloo 2', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.morning .block-title{ color: #C0501F; }
|
||||
.school .block-title{ color: #2C6BAF; }
|
||||
.after .block-title{ color: #2D7B4C; }
|
||||
.evening .block-title{ color: #5F51B0; }
|
||||
|
||||
.task{
|
||||
display:flex;
|
||||
align-items:flex-start;
|
||||
gap: 8px;
|
||||
padding: 5px 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.task:hover{ background: rgba(255,255,255,0.6); }
|
||||
.task:hover .del{ opacity: 1; }
|
||||
|
||||
.task input[type=checkbox]{
|
||||
margin-top: 3px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: none;
|
||||
accent-color: var(--ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
.task.kiosk input[type=checkbox]{
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.task-text{
|
||||
flex: 1;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
outline: none;
|
||||
line-height: 1.25;
|
||||
padding: 1px 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.task-text:focus{ background: rgba(255,255,255,0.85); }
|
||||
.task.done .task-text{
|
||||
text-decoration: line-through;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.del{
|
||||
opacity: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--ink-soft);
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
padding: 0 3px;
|
||||
transition: opacity .12s ease;
|
||||
}
|
||||
.del:hover{ color: var(--danger); }
|
||||
|
||||
.add-task{
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
padding: 4px 4px 2px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.add-task:hover{ opacity: 1; text-decoration: underline; }
|
||||
|
||||
.legend{
|
||||
max-width: 1400px;
|
||||
margin: 22px auto 0;
|
||||
display:flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.82rem;
|
||||
color: var(--ink-soft);
|
||||
align-items:center;
|
||||
}
|
||||
.legend .dot{
|
||||
display:inline-block;
|
||||
width: 10px; height:10px;
|
||||
border-radius: 50%;
|
||||
margin-right: 5px;
|
||||
}
|
||||
.legend .item{ display:flex; align-items:center; }
|
||||
|
||||
.hint{
|
||||
max-width: 1400px;
|
||||
margin: 6px auto 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px){
|
||||
.board{ grid-template-columns: repeat(2, 1fr); }
|
||||
.weekend-toggle-wrap{ grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 600px){
|
||||
.board{ grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media print{
|
||||
body{ background: white; padding: 0; }
|
||||
.toolbar, .add-task, .del, .hint, .period-toggles, nav.crumbs{ display:none !important; }
|
||||
.board{ grid-template-columns: repeat(5, 1fr); gap: 8px; }
|
||||
.day-card{ box-shadow:none; border: 2px solid var(--line) !important; break-inside: avoid; }
|
||||
.weekend-toggle-wrap{ margin-top: 8px; grid-template-columns: repeat(2,1fr); }
|
||||
}
|
||||
|
||||
#weekendWrap.hidden{ display:none; }
|
||||
|
||||
body.hide-morning .block.morning{ display:none; }
|
||||
body.hide-school .block.school{ display:none; }
|
||||
body.hide-after .block.after{ display:none; }
|
||||
body.hide-evening .block.evening{ display:none; }
|
||||
|
||||
[contenteditable="true"]{
|
||||
outline: none;
|
||||
border-radius: 4px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
[contenteditable="true"]:focus{ background: rgba(255,255,255,0.7); }
|
||||
|
||||
.period-toggles{
|
||||
max-width: 1400px;
|
||||
margin: 0 auto 18px;
|
||||
display:flex;
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
align-items:center;
|
||||
background: var(--card);
|
||||
border: 2px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
.period-toggles .label{
|
||||
font-size: 0.8rem;
|
||||
font-weight: 800;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.period-toggles label.opt{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.period-toggles input[type=checkbox]{
|
||||
width: 16px; height:16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.block-title .icon{ margin-right: 2px; }
|
||||
.block-title .label{
|
||||
outline: none;
|
||||
border-radius: 4px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.block-title .label:focus{ background: rgba(255,255,255,0.75); }
|
||||
|
||||
/* --- App chrome shared across auth/dashboard pages --- */
|
||||
|
||||
nav.crumbs{
|
||||
max-width: 1400px;
|
||||
margin: 0 auto 14px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
nav.crumbs a{ text-decoration: none; font-weight: 700; }
|
||||
|
||||
.auth-wrap{
|
||||
max-width: 420px;
|
||||
margin: 60px auto;
|
||||
background: var(--card);
|
||||
border-radius: 20px;
|
||||
padding: 32px;
|
||||
box-shadow: 0 2px 10px rgba(46,52,70,0.06);
|
||||
}
|
||||
.auth-wrap h1{ font-size: 1.5rem; margin-bottom: 4px; }
|
||||
.auth-wrap p.lead{ color: var(--ink-soft); margin-top: 0; margin-bottom: 20px; font-size: 0.9rem; }
|
||||
|
||||
.field{ margin-bottom: 14px; display:flex; flex-direction:column; gap: 6px; }
|
||||
.field label{ font-size: 0.82rem; font-weight: 800; color: var(--ink-soft); }
|
||||
.field input{
|
||||
font-family: 'Nunito', sans-serif;
|
||||
font-size: 0.95rem;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid var(--line);
|
||||
outline: none;
|
||||
}
|
||||
.field input:focus{ border-color: var(--school); }
|
||||
|
||||
.error-msg{
|
||||
background: #FDF1F1;
|
||||
border: 2px solid #F3D0D0;
|
||||
color: var(--danger);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.form-actions{ margin-top: 18px; display:flex; flex-direction:column; gap: 10px; }
|
||||
.form-actions button.tool{ width: 100%; text-align:center; }
|
||||
.switch-link{ font-size: 0.85rem; color: var(--ink-soft); text-align:center; }
|
||||
|
||||
.section{
|
||||
max-width: 1400px;
|
||||
margin: 0 auto 28px;
|
||||
}
|
||||
.section h2{
|
||||
font-family: 'Baloo 2', sans-serif;
|
||||
font-size: 1.3rem;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.card-grid{
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.entity-card{
|
||||
background: var(--card);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
border: 2px solid var(--line);
|
||||
display:flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.entity-card h3{ margin: 0; font-family: 'Baloo 2', sans-serif; font-size: 1.1rem; }
|
||||
.entity-card .meta{ font-size: 0.8rem; color: var(--ink-soft); }
|
||||
.entity-card .row{ display:flex; gap: 8px; flex-wrap: wrap; }
|
||||
.entity-card .row button.tool, .entity-card .row a.tool{ padding: 7px 12px; font-size: 0.78rem; }
|
||||
|
||||
.empty-state{
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.9rem;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
background: var(--card);
|
||||
border: 2px dashed var(--line);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.kiosk-lock-note{
|
||||
max-width: 1400px;
|
||||
margin: 0 auto 14px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--ink-soft);
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-backdrop{
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(46,52,70,0.35);
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
padding: 20px;
|
||||
z-index: 50;
|
||||
}
|
||||
.modal-backdrop.hidden{ display:none; }
|
||||
.modal{
|
||||
background: var(--card);
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
}
|
||||
.modal h2{ margin-top:0; font-family:'Baloo 2', sans-serif; }
|
||||
.modal .form-actions{ flex-direction: row; }
|
||||
.modal .form-actions button{ flex: 1; }
|
||||
|
||||
.toast{
|
||||
position: fixed;
|
||||
bottom: 20px; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--ink);
|
||||
color: white;
|
||||
padding: 10px 18px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity .2s ease;
|
||||
z-index: 100;
|
||||
}
|
||||
.toast.show{ opacity: 1; }
|
||||
@@ -0,0 +1,115 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Dashboard — Kids Calendar</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;700;800&family=Nunito:wght@500;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/shared.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div>
|
||||
<h1 id="householdName">Loading…</h1>
|
||||
<div class="subtitle" id="whoami"></div>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button class="tool" id="inviteBtn">Invite spouse</button>
|
||||
<button class="tool danger" id="logoutBtn">Log out</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="section">
|
||||
<h2>Household</h2>
|
||||
<div id="parentsList" class="card-grid"></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:12px;">
|
||||
<h2 style="margin:0;">Children</h2>
|
||||
<button class="tool primary" id="addChildBtn">+ Add child</button>
|
||||
</div>
|
||||
<div id="childrenList" class="card-grid"></div>
|
||||
</div>
|
||||
|
||||
<!-- Invite modal -->
|
||||
<div class="modal-backdrop hidden" id="inviteModal">
|
||||
<div class="modal">
|
||||
<h2>Invite your spouse</h2>
|
||||
<p id="inviteLeadText">Share this link — it lets them create a login joined to your household. Expires in 7 days.</p>
|
||||
<div class="field">
|
||||
<input type="text" id="inviteLinkField" readonly>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="tool primary" id="copyInviteBtn">Copy link</button>
|
||||
<button class="tool" id="closeInviteBtn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add child modal -->
|
||||
<div class="modal-backdrop hidden" id="addChildModal">
|
||||
<div class="modal">
|
||||
<h2>Add a child</h2>
|
||||
<form id="addChildForm">
|
||||
<div class="field">
|
||||
<label for="childNameInput">Name</label>
|
||||
<input type="text" id="childNameInput" required>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="tool primary">Add</button>
|
||||
<button type="button" class="tool" id="cancelAddChildBtn">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New calendar modal -->
|
||||
<div class="modal-backdrop hidden" id="newCalendarModal">
|
||||
<div class="modal">
|
||||
<h2>New calendar</h2>
|
||||
<form id="newCalendarForm">
|
||||
<input type="hidden" id="newCalChildId">
|
||||
<div class="field">
|
||||
<label for="newCalTitle">Title</label>
|
||||
<input type="text" id="newCalTitle" placeholder="e.g. Week of Aug 24" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="opt" style="display:flex; align-items:center; gap:8px; font-weight:700;">
|
||||
<input type="checkbox" id="newCalDuplicate">
|
||||
Duplicate tasks from most recent calendar (checkmarks reset)
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="tool primary">Create</button>
|
||||
<button type="button" class="tool" id="cancelNewCalBtn">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kiosk link modal -->
|
||||
<div class="modal-backdrop hidden" id="kioskModal">
|
||||
<div class="modal">
|
||||
<h2>Tablet link for <span id="kioskChildName"></span></h2>
|
||||
<p>Open this link on the tablet's browser and add it to the home screen. It only shows the active calendar and lets your child check items off — nothing else.</p>
|
||||
<div class="field">
|
||||
<input type="text" id="kioskLinkField" readonly>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="tool primary" id="copyKioskBtn">Copy link</button>
|
||||
<button class="tool danger" id="regenKioskBtn">Regenerate link</button>
|
||||
<button class="tool" id="closeKioskBtn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/dashboard.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,77 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Join family — Kids Calendar</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;700;800&family=Nunito:wght@500;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/shared.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="auth-wrap">
|
||||
<h1 id="heading">Join family</h1>
|
||||
<p class="lead" id="lead">Checking your invite link…</p>
|
||||
<div class="error-msg" id="errorMsg" style="display:none;"></div>
|
||||
<form id="joinForm" style="display:none;">
|
||||
<div class="field">
|
||||
<label for="name">Your name</label>
|
||||
<input type="text" id="name" autocomplete="name" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" autocomplete="email" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" autocomplete="new-password" minlength="8" required>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="tool primary">Join</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script>
|
||||
const token = new URLSearchParams(window.location.search).get('token');
|
||||
const errorMsg = document.getElementById('errorMsg');
|
||||
const lead = document.getElementById('lead');
|
||||
const joinForm = document.getElementById('joinForm');
|
||||
|
||||
function showError(msg) {
|
||||
errorMsg.textContent = msg;
|
||||
errorMsg.style.display = 'block';
|
||||
lead.style.display = 'none';
|
||||
}
|
||||
|
||||
(async () => {
|
||||
if (!token) return showError('This link is missing an invite token.');
|
||||
try {
|
||||
const data = await api.get(`/api/invites/${encodeURIComponent(token)}`);
|
||||
lead.textContent = `You've been invited to join "${data.householdName}". Create your login below.`;
|
||||
joinForm.style.display = 'block';
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
})();
|
||||
|
||||
joinForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
errorMsg.style.display = 'none';
|
||||
try {
|
||||
await api.post(`/api/invites/${encodeURIComponent(token)}/accept`, {
|
||||
name: document.getElementById('name').value,
|
||||
email: document.getElementById('email').value,
|
||||
password: document.getElementById('password').value,
|
||||
});
|
||||
window.location.href = '/dashboard.html';
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
const api = {
|
||||
async request(method, path, body) {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (res.status === 204) return null;
|
||||
|
||||
let data = null;
|
||||
const text = await res.text();
|
||||
if (text) {
|
||||
try { data = JSON.parse(text); } catch { data = null; }
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const err = new Error((data && data.error) || `Request failed (${res.status})`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
},
|
||||
get(path) { return this.request('GET', path); },
|
||||
post(path, body) { return this.request('POST', path, body === undefined ? {} : body); },
|
||||
patch(path, body) { return this.request('PATCH', path, body === undefined ? {} : body); },
|
||||
del(path) { return this.request('DELETE', path); },
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
const calendarId = new URLSearchParams(window.location.search).get('calendarId');
|
||||
let calendar = null;
|
||||
|
||||
const weekdayBoard = document.getElementById('weekdayBoard');
|
||||
const weekendWrap = document.getElementById('weekendWrap');
|
||||
const legend = document.getElementById('legend');
|
||||
const toast = document.getElementById('toast');
|
||||
|
||||
function showToast(msg) {
|
||||
toast.textContent = msg;
|
||||
toast.classList.add('show');
|
||||
setTimeout(() => toast.classList.remove('show'), 1800);
|
||||
}
|
||||
|
||||
function debounce(fn, ms) {
|
||||
let t;
|
||||
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
|
||||
}
|
||||
|
||||
const handlers = {
|
||||
onToggleDone: (taskId, done) => api.patch(`/api/calendars/${calendarId}/tasks/${taskId}`, { done }).catch(() => showToast('Could not save')),
|
||||
onTextEdit: (taskId, text) => api.patch(`/api/calendars/${calendarId}/tasks/${taskId}`, { text }).catch(() => showToast('Could not save')),
|
||||
onLabelEdit: (blockKey, label) => api.patch(`/api/calendars/${calendarId}/blocks/${blockKey}`, { label }).catch(() => showToast('Could not save')),
|
||||
onAddTask: async (dayOfWeek, blockKey, text) => {
|
||||
const { task } = await api.post(`/api/calendars/${calendarId}/tasks`, { dayOfWeek, blockKey, text });
|
||||
return task;
|
||||
},
|
||||
onDeleteTask: (taskId) => api.del(`/api/calendars/${calendarId}/tasks/${taskId}`).catch(() => showToast('Could not delete')),
|
||||
};
|
||||
|
||||
function todayName() {
|
||||
return new Date().toLocaleDateString('en-US', { weekday: 'long' });
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const childNameEl = document.getElementById('childName');
|
||||
if (document.activeElement !== childNameEl) childNameEl.textContent = calendar.childName || '';
|
||||
const titleEl = document.getElementById('calTitle');
|
||||
if (document.activeElement !== titleEl) titleEl.textContent = calendar.title;
|
||||
|
||||
renderCalendar({
|
||||
calendar,
|
||||
editable: true,
|
||||
weekdayBoard,
|
||||
weekendWrap,
|
||||
handlers,
|
||||
todayName: todayName(),
|
||||
});
|
||||
renderLegend(legend, calendar);
|
||||
|
||||
weekendWrap.classList.toggle('hidden', !calendar.showWeekend);
|
||||
document.getElementById('toggleWeekend').textContent = calendar.showWeekend ? 'Hide weekend' : 'Show weekend';
|
||||
|
||||
buildPeriodToggles();
|
||||
}
|
||||
|
||||
function buildPeriodToggles() {
|
||||
const el = document.getElementById('periodToggles');
|
||||
el.querySelectorAll('label.opt').forEach((n) => n.remove());
|
||||
calendar.blocks.forEach((block) => {
|
||||
const label = document.createElement('label');
|
||||
label.className = 'opt';
|
||||
const cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.checked = !document.body.classList.contains('hide-' + block.key);
|
||||
cb.onchange = () => document.body.classList.toggle('hide-' + block.key, !cb.checked);
|
||||
const span = document.createElement('span');
|
||||
span.textContent = block.label;
|
||||
label.appendChild(cb);
|
||||
label.appendChild(span);
|
||||
el.appendChild(label);
|
||||
});
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!calendarId) {
|
||||
document.body.innerHTML = '<p style="padding:40px;">No calendar selected. <a href="/dashboard.html">Back to dashboard</a></p>';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await api.get(`/api/calendars/${calendarId}`);
|
||||
calendar = data.calendar;
|
||||
} catch (err) {
|
||||
if (err.status === 401) { window.location.href = '/login.html'; return; }
|
||||
document.body.innerHTML = `<p style="padding:40px;">${err.message}. <a href="/dashboard.html">Back to dashboard</a></p>`;
|
||||
return;
|
||||
}
|
||||
draw();
|
||||
}
|
||||
|
||||
document.getElementById('toggleWeekend').addEventListener('click', async () => {
|
||||
calendar.showWeekend = !calendar.showWeekend;
|
||||
weekendWrap.classList.toggle('hidden', !calendar.showWeekend);
|
||||
document.getElementById('toggleWeekend').textContent = calendar.showWeekend ? 'Hide weekend' : 'Show weekend';
|
||||
await api.patch(`/api/calendars/${calendarId}`, { showWeekend: calendar.showWeekend });
|
||||
});
|
||||
|
||||
document.getElementById('resetChecks').addEventListener('click', async () => {
|
||||
const data = await api.post(`/api/calendars/${calendarId}/reset-checks`);
|
||||
calendar = data.calendar;
|
||||
draw();
|
||||
showToast('Checkboxes reset');
|
||||
});
|
||||
|
||||
document.getElementById('printBtn').addEventListener('click', () => window.print());
|
||||
|
||||
const titleEl = document.getElementById('calTitle');
|
||||
titleEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); titleEl.blur(); } });
|
||||
const debouncedTitleSave = debounce(() => {
|
||||
if (titleEl.textContent.trim()) api.patch(`/api/calendars/${calendarId}`, { title: titleEl.textContent.trim() });
|
||||
}, 500);
|
||||
titleEl.addEventListener('input', debouncedTitleSave);
|
||||
|
||||
const childNameEl = document.getElementById('childName');
|
||||
childNameEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); childNameEl.blur(); } });
|
||||
const debouncedChildNameSave = debounce(() => {
|
||||
if (childNameEl.textContent.trim()) api.patch(`/api/children/${calendar.childId}`, { name: childNameEl.textContent.trim() });
|
||||
}, 500);
|
||||
childNameEl.addEventListener('input', debouncedChildNameSave);
|
||||
|
||||
// Polling keeps this view in sync with kiosk checkbox taps (and the other
|
||||
// parent's edits) without a manual reload. Paused while the parent is mid-edit
|
||||
// in any contenteditable field, so an incoming poll can't yank their cursor.
|
||||
let isEditingFocused = false;
|
||||
document.addEventListener('focusin', (e) => {
|
||||
if (e.target.isContentEditable) isEditingFocused = true;
|
||||
});
|
||||
document.addEventListener('focusout', (e) => {
|
||||
if (e.target.isContentEditable) {
|
||||
setTimeout(() => {
|
||||
isEditingFocused = !!(document.activeElement && document.activeElement.isContentEditable);
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
|
||||
async function poll() {
|
||||
if (!calendar || isEditingFocused || document.hidden) return;
|
||||
try {
|
||||
const data = await api.get(`/api/calendars/${calendarId}/poll?since=${encodeURIComponent(calendar.updatedAt)}`);
|
||||
if (data.changed) {
|
||||
calendar = data.calendar;
|
||||
draw();
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.status === 401) window.location.href = '/login.html';
|
||||
}
|
||||
}
|
||||
setInterval(poll, 4000);
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) poll(); });
|
||||
|
||||
load();
|
||||
@@ -0,0 +1,165 @@
|
||||
const BLOCK_ICONS = { morning: '🌅', school: '🎒', after: '⚽', evening: '🌙' };
|
||||
const WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'];
|
||||
const WEEKEND = ['Saturday', 'Sunday'];
|
||||
|
||||
function debounce(fn, ms) {
|
||||
let t;
|
||||
return (...args) => {
|
||||
clearTimeout(t);
|
||||
t = setTimeout(() => fn(...args), ms);
|
||||
};
|
||||
}
|
||||
|
||||
// Renders the weekly board. `editable` gates every mutation affordance
|
||||
// (contenteditable, delete buttons, add-task buttons) — the kiosk view passes
|
||||
// editable:false and gets a board with nothing but working checkboxes.
|
||||
function renderCalendar({ calendar, editable, weekdayBoard, weekendWrap, handlers, todayName }) {
|
||||
weekdayBoard.innerHTML = '';
|
||||
weekendWrap.innerHTML = '';
|
||||
|
||||
const currentLabels = {};
|
||||
calendar.blocks.forEach((b) => { currentLabels[b.key] = b.label; });
|
||||
|
||||
function syncLabel(key, sourceEl) {
|
||||
document.querySelectorAll(`.label[data-block="${key}"]`).forEach((el) => {
|
||||
if (el !== sourceEl) el.textContent = sourceEl.textContent;
|
||||
});
|
||||
document.querySelectorAll(`.legend-label[data-block="${key}"]`).forEach((el) => {
|
||||
el.textContent = sourceEl.textContent;
|
||||
});
|
||||
}
|
||||
|
||||
function makeTask(dayName, blockKey, task) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'task' + (task.done ? ' done' : '') + (editable ? '' : ' kiosk');
|
||||
row.dataset.taskId = task.id;
|
||||
|
||||
const cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.checked = task.done;
|
||||
cb.onchange = () => {
|
||||
row.classList.toggle('done', cb.checked);
|
||||
handlers.onToggleDone(task.id, cb.checked);
|
||||
};
|
||||
|
||||
const span = document.createElement('div');
|
||||
span.className = 'task-text';
|
||||
span.textContent = task.text;
|
||||
|
||||
row.appendChild(cb);
|
||||
row.appendChild(span);
|
||||
|
||||
if (editable) {
|
||||
span.contentEditable = 'true';
|
||||
span.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); span.blur(); }
|
||||
});
|
||||
const debouncedEdit = debounce(() => {
|
||||
if (span.textContent.trim()) handlers.onTextEdit(task.id, span.textContent.trim());
|
||||
}, 500);
|
||||
span.addEventListener('input', debouncedEdit);
|
||||
span.addEventListener('blur', () => {
|
||||
if (!span.textContent.trim()) span.textContent = task.text;
|
||||
});
|
||||
|
||||
const del = document.createElement('button');
|
||||
del.className = 'del';
|
||||
del.textContent = '✕';
|
||||
del.onclick = () => {
|
||||
row.remove();
|
||||
handlers.onDeleteTask(task.id);
|
||||
};
|
||||
row.appendChild(del);
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function makeDayCard(dayName) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'day-card' + (dayName === todayName ? ' today' : '');
|
||||
|
||||
const head = document.createElement('div');
|
||||
head.className = 'day-head';
|
||||
head.innerHTML = `<div class="day-name">${dayName}</div>` +
|
||||
(dayName === todayName ? `<div class="today-badge">TODAY</div>` : '');
|
||||
card.appendChild(head);
|
||||
|
||||
calendar.blocks.forEach((block) => {
|
||||
const blockEl = document.createElement('div');
|
||||
blockEl.className = 'block ' + block.key;
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'block-title';
|
||||
|
||||
const iconSpan = document.createElement('span');
|
||||
iconSpan.className = 'icon';
|
||||
iconSpan.textContent = BLOCK_ICONS[block.key] || '';
|
||||
|
||||
const labelSpan = document.createElement('span');
|
||||
labelSpan.className = 'label';
|
||||
labelSpan.dataset.block = block.key;
|
||||
labelSpan.textContent = currentLabels[block.key];
|
||||
|
||||
if (editable) {
|
||||
labelSpan.contentEditable = 'true';
|
||||
labelSpan.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); labelSpan.blur(); }
|
||||
});
|
||||
const debouncedLabel = debounce(() => {
|
||||
if (labelSpan.textContent.trim()) {
|
||||
currentLabels[block.key] = labelSpan.textContent.trim();
|
||||
handlers.onLabelEdit(block.key, labelSpan.textContent.trim());
|
||||
}
|
||||
}, 500);
|
||||
labelSpan.addEventListener('input', () => { syncLabel(block.key, labelSpan); debouncedLabel(); });
|
||||
}
|
||||
|
||||
title.appendChild(iconSpan);
|
||||
title.appendChild(labelSpan);
|
||||
blockEl.appendChild(title);
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'task-list';
|
||||
(calendar.days[dayName][block.key] || []).forEach((task) => {
|
||||
list.appendChild(makeTask(dayName, block.key, task));
|
||||
});
|
||||
blockEl.appendChild(list);
|
||||
|
||||
if (editable) {
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.className = 'add-task';
|
||||
addBtn.textContent = '+ add task';
|
||||
addBtn.onclick = async () => {
|
||||
const newTask = await handlers.onAddTask(dayName, block.key, 'New task');
|
||||
const row = makeTask(dayName, block.key, newTask);
|
||||
list.appendChild(row);
|
||||
const span = row.querySelector('.task-text');
|
||||
span.focus();
|
||||
document.execCommand('selectAll', false, null);
|
||||
};
|
||||
blockEl.appendChild(addBtn);
|
||||
}
|
||||
|
||||
card.appendChild(blockEl);
|
||||
});
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
WEEKDAYS.forEach((d) => weekdayBoard.appendChild(makeDayCard(d)));
|
||||
WEEKEND.forEach((d) => weekendWrap.appendChild(makeDayCard(d)));
|
||||
|
||||
return { currentLabels };
|
||||
}
|
||||
|
||||
function renderLegend(legendEl, calendar) {
|
||||
legendEl.innerHTML = '';
|
||||
calendar.blocks.forEach((block) => {
|
||||
const item = document.createElement('span');
|
||||
item.className = 'item';
|
||||
item.innerHTML = `<span class="dot" style="background:var(--${block.key})"></span>` +
|
||||
`<span class="legend-label" data-block="${block.key}">${block.label}</span>`;
|
||||
legendEl.appendChild(item);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
@@ -0,0 +1,59 @@
|
||||
const token = window.location.pathname.split('/').filter(Boolean).pop();
|
||||
|
||||
const weekdayBoard = document.getElementById('weekdayBoard');
|
||||
const weekendWrap = document.getElementById('weekendWrap');
|
||||
|
||||
async function apiGet(path) {
|
||||
const res = await fetch(path, { credentials: 'omit' });
|
||||
if (!res.ok) throw new Error('request failed');
|
||||
return res.json();
|
||||
}
|
||||
async function apiPatch(path, body) {
|
||||
const res = await fetch(path, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error('request failed');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
const handlers = {
|
||||
onToggleDone: (taskId, done) => apiPatch(`/api/kiosk/${token}/tasks/${taskId}`, { done }).catch(() => {}),
|
||||
};
|
||||
|
||||
function todayName() {
|
||||
return new Date().toLocaleDateString('en-US', { weekday: 'long' });
|
||||
}
|
||||
|
||||
function draw(calendar) {
|
||||
document.getElementById('childName').textContent = calendar.childName || '';
|
||||
document.getElementById('calTitle').textContent = calendar.title || '';
|
||||
|
||||
renderCalendar({
|
||||
calendar,
|
||||
editable: false,
|
||||
weekdayBoard,
|
||||
weekendWrap,
|
||||
handlers,
|
||||
todayName: todayName(),
|
||||
});
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const data = await apiGet(`/api/kiosk/${token}/calendar`);
|
||||
if (!data.calendar) {
|
||||
document.getElementById('emptyState').style.display = 'block';
|
||||
document.getElementById('childName').textContent = data.child ? data.child.name : '';
|
||||
return;
|
||||
}
|
||||
document.getElementById('emptyState').style.display = 'none';
|
||||
draw(data.calendar);
|
||||
} catch {
|
||||
// transient network error — try again next tick
|
||||
}
|
||||
}
|
||||
|
||||
poll();
|
||||
setInterval(poll, 4000);
|
||||
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>My Week</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;700;800&family=Nunito:wght@500;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/shared.css">
|
||||
<style>
|
||||
/* Kiosk is touch-first and has nothing to edit — bigger targets, no toolbar chrome. */
|
||||
header{ justify-content: center; text-align: center; }
|
||||
.toolbar{ display:none; }
|
||||
.board{ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
|
||||
.task-text{ font-size: 1.02rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div>
|
||||
<h1 id="childName">…</h1>
|
||||
<div class="subtitle" id="calTitle"></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="board" id="weekdayBoard"></div>
|
||||
<div class="weekend-toggle-wrap" id="weekendWrap"></div>
|
||||
|
||||
<div id="emptyState" class="empty-state" style="display:none; max-width:600px; margin:60px auto;">
|
||||
No calendar has been set up yet. Ask a parent to create one.
|
||||
</div>
|
||||
|
||||
<script src="/js/calendarRender.js"></script>
|
||||
<script src="/js/kiosk.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,53 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Log in — Kids Calendar</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;700;800&family=Nunito:wght@500;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/shared.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="auth-wrap">
|
||||
<h1>Welcome back</h1>
|
||||
<p class="lead">Log in to see and edit your family's calendars.</p>
|
||||
<div class="error-msg" id="errorMsg" style="display:none;"></div>
|
||||
<form id="loginForm">
|
||||
<div class="field">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" autocomplete="email" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="tool primary">Log in</button>
|
||||
<div class="switch-link">No account yet? <a href="/signup.html">Sign up</a></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script>
|
||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const errorMsg = document.getElementById('errorMsg');
|
||||
errorMsg.style.display = 'none';
|
||||
try {
|
||||
await api.post('/api/auth/login', {
|
||||
email: document.getElementById('email').value,
|
||||
password: document.getElementById('password').value,
|
||||
});
|
||||
window.location.href = '/dashboard.html';
|
||||
} catch (err) {
|
||||
errorMsg.textContent = err.message;
|
||||
errorMsg.style.display = 'block';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,63 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Sign up — Kids Calendar</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;700;800&family=Nunito:wght@500;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/shared.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="auth-wrap">
|
||||
<h1>Create your family</h1>
|
||||
<p class="lead">This creates a new household. Your spouse can join it afterward with an invite link.</p>
|
||||
<div class="error-msg" id="errorMsg" style="display:none;"></div>
|
||||
<form id="signupForm">
|
||||
<div class="field">
|
||||
<label for="name">Your name</label>
|
||||
<input type="text" id="name" autocomplete="name" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="householdName">Family name (optional)</label>
|
||||
<input type="text" id="householdName" placeholder="e.g. The Smiths">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" autocomplete="email" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" autocomplete="new-password" minlength="8" required>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="tool primary">Sign up</button>
|
||||
<div class="switch-link">Already have an account? <a href="/login.html">Log in</a></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script>
|
||||
document.getElementById('signupForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const errorMsg = document.getElementById('errorMsg');
|
||||
errorMsg.style.display = 'none';
|
||||
try {
|
||||
await api.post('/api/auth/signup', {
|
||||
name: document.getElementById('name').value,
|
||||
householdName: document.getElementById('householdName').value,
|
||||
email: document.getElementById('email').value,
|
||||
password: document.getElementById('password').value,
|
||||
});
|
||||
window.location.href = '/dashboard.html';
|
||||
} catch (err) {
|
||||
errorMsg.textContent = err.message;
|
||||
errorMsg.style.display = 'block';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user