"use client"; import Link from "next/link"; import { useCallback, useEffect, useState } from "react"; type Tone = "cyan" | "amber" | "violet" | "emerald" | "rose"; const TONES: Tone[] = ["cyan", "amber", "violet", "emerald", "rose"]; const TONE_COLOR: Record = { cyan: "#67e8f9", amber: "#fbbf24", violet: "#a78bfa", emerald: "#34d399", rose: "#fb7185", }; type SavedCalendar = { id: string; name: string; tone: Tone; enabled: boolean; masked: string }; type TestResult = { ok: boolean; sourceName?: string; eventCount?: number; upcoming?: number; error?: string }; type DraftCalendar = { key: string; id?: string; name: string; tone: Tone; enabled: boolean; masked?: string; newUrl: string; replacing: boolean; test?: TestResult | "running"; }; type HistoryEntry = { id: string; savedAt: string; calendarCount: number; note: string }; type Tab = "calendars" | "photos" | "account"; async function api(path: string, init?: RequestInit & { body?: string }) { const response = await fetch(path, { ...init, credentials: "same-origin", cache: "no-store", headers: init?.body ? { "Content-Type": "application/json" } : undefined, }); const data = (await response.json().catch(() => ({}))) as T & { error?: string }; return { status: response.status, ok: response.ok, data }; } function toDraft(calendar: SavedCalendar): DraftCalendar { return { key: calendar.id, ...calendar, newUrl: "", replacing: false }; } function formatWhen(iso: string) { const date = new Date(iso); if (Number.isNaN(date.getTime()) || date.getTime() === 0) return "initial setup"; return date.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }); } const inputClass = "w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-500 focus:border-cyan-300/60 focus:outline-none"; const buttonClass = "rounded-lg px-3 py-2 text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"; const primaryButton = `${buttonClass} bg-cyan-300 text-slate-950 hover:bg-cyan-200`; const ghostButton = `${buttonClass} border border-white/10 bg-white/5 text-slate-200 hover:bg-white/10`; const dangerButton = `${buttonClass} border border-rose-400/30 bg-rose-400/10 text-rose-200 hover:bg-rose-400/20`; function PasswordForm({ mode, onDone }: { mode: "setup" | "login"; onDone: () => void }) { const [password, setPassword] = useState(""); const [confirm, setConfirm] = useState(""); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); const submit = async (event: React.FormEvent) => { event.preventDefault(); setError(""); if (mode === "setup" && password !== confirm) { setError("The two passwords don't match."); return; } setBusy(true); const result = await api<{ ok?: boolean }>(`/api/admin/${mode}`, { method: "POST", body: JSON.stringify({ password }) }); setBusy(false); if (result.ok) onDone(); else setError(result.data.error || "Something went wrong."); }; return (

{mode === "setup" ? "Set the admin password" : "Organizer admin"}

{mode === "setup" ? "No password exists yet. Choose one now. You'll need it to change calendars and photos." : "Sign in to manage calendars and photos."}

setPassword(event.target.value)} autoFocus /> {mode === "setup" && ( setConfirm(event.target.value)} /> )} {error &&

{error}

}
); } function TestBadge({ test }: { test?: TestResult | "running" }) { if (!test) return null; if (test === "running") return Testing…; if (!test.ok) return ✕ {test.error || "Failed"}; return ( ✓ {test.sourceName} · {test.upcoming} upcoming ); } function CalendarsTab() { const [drafts, setDrafts] = useState([]); const [saved, setSaved] = useState([]); const [history, setHistory] = useState([]); const [message, setMessage] = useState<{ tone: "ok" | "error"; text: string } | null>(null); const [busy, setBusy] = useState(false); const [loaded, setLoaded] = useState(false); const load = useCallback(async () => { const [calendars, versions] = await Promise.all([ api<{ calendars: SavedCalendar[] }>("/api/admin/calendars"), api<{ history: HistoryEntry[] }>("/api/admin/history"), ]); if (calendars.ok) { setSaved(calendars.data.calendars); setDrafts(calendars.data.calendars.map(toDraft)); } if (versions.ok) setHistory(versions.data.history); setLoaded(true); }, []); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- initial fetch on mount void load(); }, [load]); const update = (key: string, patch: Partial) => setDrafts((current) => current.map((draft) => (draft.key === key ? { ...draft, ...patch } : draft))); const dirty = drafts.length !== saved.length || drafts.some((draft, index) => { const original = saved[index]; return ( !original || draft.id !== original.id || draft.name !== original.name || draft.tone !== original.tone || draft.enabled !== original.enabled || draft.newUrl.trim() !== "" ); }); const addCalendar = () => setDrafts((current) => [ ...current, { key: `new-${Date.now()}`, name: "", tone: TONES[current.length % TONES.length], enabled: true, newUrl: "", replacing: true, }, ]); const move = (index: number, delta: number) => setDrafts((current) => { const next = [...current]; const target = index + delta; if (target < 0 || target >= next.length) return current; [next[index], next[target]] = [next[target], next[index]]; return next; }); const test = async (draft: DraftCalendar) => { update(draft.key, { test: "running" }); const body = draft.newUrl.trim() ? { url: draft.newUrl.trim() } : { id: draft.id }; const result = await api("/api/admin/calendars/test", { method: "POST", body: JSON.stringify(body) }); update(draft.key, { test: result.data.ok !== undefined ? result.data : { ok: false, error: result.data.error } }); }; const save = async () => { setBusy(true); setMessage(null); const result = await api<{ calendars: SavedCalendar[] }>("/api/admin/calendars", { method: "PUT", body: JSON.stringify({ calendars: drafts.map((draft) => ({ id: draft.id, name: draft.name, tone: draft.tone, enabled: draft.enabled, ...(draft.newUrl.trim() ? { url: draft.newUrl.trim() } : {}), })), }), }); setBusy(false); if (result.ok) { setMessage({ tone: "ok", text: "Saved. The TVs pick up calendar changes within about 5 minutes." }); await load(); } else { setMessage({ tone: "error", text: result.data.error || "Save failed." }); } }; const revert = async (entry: HistoryEntry) => { if (!window.confirm(`Restore the calendars as they were on ${formatWhen(entry.savedAt)}? The current list is kept in history.`)) return; const result = await api<{ ok: boolean }>("/api/admin/history", { method: "POST", body: JSON.stringify({ id: entry.id }) }); setMessage(result.ok ? { tone: "ok", text: "Restored." } : { tone: "error", text: result.data.error || "Restore failed." }); await load(); }; if (!loaded) return

Loading…

; return (
{drafts.length === 0 &&

No calendars yet. Add one below.

} {drafts.map((draft, index) => (
update(draft.key, { name: event.target.value })} />
Color {TONES.map((tone) => (
{draft.id && !draft.replacing ? (
{draft.masked}
) : ( update(draft.key, { newUrl: event.target.value, test: undefined })} /> )}
))}
{dirty && ( )}
{message &&

{message.text}

}

In Google Calendar: Settings → pick the calendar → Integrate calendar → copy the{" "} Secret address in iCal format. Saved addresses are never shown again. They work like passwords.

History

{history.length === 0 ? (

No earlier versions yet.

) : (
    {history.map((entry) => (
  • {formatWhen(entry.savedAt)} · {entry.calendarCount} calendar(s)
  • ))}
)}
); } function AccountTab({ onSignedOut }: { onSignedOut: () => void }) { const [current, setCurrent] = useState(""); const [next, setNext] = useState(""); const [message, setMessage] = useState<{ tone: "ok" | "error"; text: string } | null>(null); const change = async (event: React.FormEvent) => { event.preventDefault(); const result = await api("/api/admin/password", { method: "POST", body: JSON.stringify({ currentPassword: current, newPassword: next }), }); setMessage(result.ok ? { tone: "ok", text: "Password changed. Other devices were signed out." } : { tone: "error", text: result.data.error || "Failed." }); if (result.ok) { setCurrent(""); setNext(""); } }; const signOut = async () => { await api("/api/admin/logout", { method: "POST" }); onSignedOut(); }; return (

Change password

setCurrent(event.target.value)} /> setNext(event.target.value)} /> {message &&

{message.text}

}

Forgot the password? On the server: docker exec -u 10001 hobbs-family-organizer-organizer-1 node container/reset-admin.mjs

); } export default function AdminPage() { const [state, setState] = useState<"loading" | "setup" | "login" | "ready" | "blocked">("loading"); const [tab, setTab] = useState("calendars"); const check = useCallback(async () => { const result = await api<{ setupRequired: boolean; authenticated: boolean }>("/api/admin/status"); if (result.status === 403) setState("blocked"); else if (result.data.setupRequired) setState("setup"); else setState(result.data.authenticated ? "ready" : "login"); }, []); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- initial fetch on mount void check(); const fromHash = window.location.hash.replace("#", "") as Tab; if (["calendars", "photos", "account"].includes(fromHash)) setTab(fromHash); }, [check]); const selectTab = (next: Tab) => { setTab(next); window.history.replaceState(null, "", `#${next}`); }; return (
{state === "loading" &&

Loading…

} {state === "blocked" &&

Admin is only available on the home network.

} {(state === "setup" || state === "login") && setState("ready")} />} {state === "ready" && ( <>

Organizer admin

← Open the dashboard
{tab === "calendars" && } {tab === "photos" && (

Photo albums arrive in the next update.

)} {tab === "account" && setState("login")} />} )}
); }