"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; albumCount: number; note: string }; type Tab = "calendars" | "photos" | "tvs" | "account"; const TAB_LABELS: Record = { calendars: "Calendars", photos: "Photos", tvs: "TVs", account: "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 (calendars and photo settings)

{history.length === 0 ? (

No earlier versions yet.

) : (
    {history.map((entry) => (
  • {formatWhen(entry.savedAt)} · {entry.calendarCount} calendar(s), {entry.albumCount ?? 0} album(s)
  • ))}
)}
); } type AlbumView = { id: string; name: string; enabled: boolean; masked: string; title: string; photoCount: number; lastSuccessAt: number | null; lastAttemptAt: number; lastError: string; truncated: boolean; stale: boolean; syncing: boolean; }; type PhotoDisplay = { intervalSeconds: number; shuffle: boolean; showDate: boolean; fit: "contain-blur" | "cover"; includeUploads: boolean }; type UploadView = { key: string; thumb: string; src: string }; type PhotosView = { display: PhotoDisplay; albums: AlbumView[]; uploads: UploadView[] }; type AlbumDraft = { key: string; id?: string; name: string; enabled: boolean; shareUrl: string; view?: AlbumView; check?: string }; function minutesSince(timestamp: number) { return (Date.now() - timestamp) / 60_000; } function ago(timestamp: number | null) { if (!timestamp) return "never"; const minutes = Math.round((Date.now() - timestamp) / 60_000); if (minutes < 1) return "just now"; if (minutes < 60) return `${minutes} min ago`; const hours = Math.round(minutes / 60); return hours < 48 ? `${hours} h ago` : `${Math.round(hours / 24)} days ago`; } /** Resize in the browser to a 1920px JPEG, a 400px gallery tile and a 64px thumbnail, so the server needs no image library. */ async function resizeForUpload(file: File) { const bitmap = await createImageBitmap(file); const encode = async (edge: number, quality: number) => { const scale = Math.min(1, edge / Math.max(bitmap.width, bitmap.height)); const canvas = document.createElement("canvas"); canvas.width = Math.max(1, Math.round(bitmap.width * scale)); canvas.height = Math.max(1, Math.round(bitmap.height * scale)); canvas.getContext("2d")!.drawImage(bitmap, 0, 0, canvas.width, canvas.height); const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", quality)); if (!blob) throw new Error("Could not encode the photo"); return { blob, width: canvas.width, height: canvas.height }; }; const photo = await encode(1920, 0.88); const tile = await encode(400, 0.82); const thumb = await encode(64, 0.7); bitmap.close(); return { photo, tile, thumb }; } function PhotosTab() { const [view, setView] = useState(null); const [display, setDisplay] = useState(null); const [albums, setAlbums] = useState([]); const [message, setMessage] = useState<{ tone: "ok" | "error"; text: string } | null>(null); const [busy, setBusy] = useState(false); const [uploading, setUploading] = useState(""); const apply = useCallback((next: PhotosView) => { setView(next); setDisplay(next.display); setAlbums(next.albums.map((album) => ({ key: album.id, id: album.id, name: album.name, enabled: album.enabled, shareUrl: "", view: album }))); }, []); const load = useCallback(async () => { const result = await api("/api/admin/photos"); if (result.ok) apply(result.data); }, [apply]); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- initial fetch on mount void load(); }, [load]); // While anything is syncing, refresh the status every few seconds. const anySyncing = view?.albums.some((album) => album.syncing) ?? false; useEffect(() => { if (!anySyncing) return; const timer = window.setInterval(async () => { const result = await api("/api/admin/photos"); if (result.ok) setView(result.data); }, 4000); return () => window.clearInterval(timer); }, [anySyncing]); const update = (key: string, patch: Partial) => setAlbums((current) => current.map((album) => (album.key === key ? { ...album, ...patch } : album))); const dirty = !!view && !!display && (JSON.stringify(display) !== JSON.stringify(view.display) || albums.length !== view.albums.length || albums.some((album, index) => { const original = view.albums[index]; return !original || album.id !== original.id || album.name !== original.name || album.enabled !== original.enabled || album.shareUrl.trim() !== ""; })); const check = async (draft: AlbumDraft) => { update(draft.key, { check: "Checking…" }); const result = await api<{ ok: boolean; title?: string; photos?: number; videos?: number; truncated?: boolean; error?: string }>( "/api/admin/photos/check", { method: "POST", body: JSON.stringify({ shareUrl: draft.shareUrl }) }, ); const data = result.data; update(draft.key, { check: data.ok ? `✓ "${data.title}": ${data.photos} photos${data.videos ? `, ${data.videos} videos (skipped)` : ""}${data.truncated ? " (only the first page will sync)" : ""}` : `✕ ${data.error || "Could not read the album"}`, name: draft.name || (data.ok && data.title ? data.title : draft.name), }); }; const save = async () => { if (!display) return; setBusy(true); setMessage(null); const result = await api("/api/admin/photos", { method: "PUT", body: JSON.stringify({ display, albums: albums.map((album) => ({ id: album.id, name: album.name, enabled: album.enabled, ...(album.shareUrl.trim() ? { shareUrl: album.shareUrl.trim() } : {}) })), }), }); setBusy(false); if (result.ok) { apply(result.data); setMessage({ tone: "ok", text: "Saved. New albums are downloading now. The TVs update within about 30 seconds." }); window.setTimeout(load, 3000); } else setMessage({ tone: "error", text: result.data.error || "Save failed." }); }; const syncNow = async (album: AlbumView) => { setView((current) => current && { ...current, albums: current.albums.map((entry) => (entry.id === album.id ? { ...entry, syncing: true } : entry)) }); const result = await api<{ ok: boolean; photoCount?: number; lastError?: string }>("/api/admin/photos/sync", { method: "POST", body: JSON.stringify({ id: album.id }) }); setMessage( result.data.ok ? { tone: "ok", text: `Synced: ${result.data.photoCount} photos${result.data.lastError ? ` (${result.data.lastError})` : ""}.` } : { tone: "error", text: result.data.lastError || result.data.error || "Sync failed." }, ); await load(); }; const upload = async (files: FileList | null) => { if (!files?.length) return; let done = 0; let failed = 0; for (const file of Array.from(files)) { setUploading(`Uploading ${done + failed + 1} of ${files.length}…`); try { const { photo, tile, thumb } = await resizeForUpload(file); const form = new FormData(); form.append("photo", photo.blob, "photo.jpg"); form.append("thumb", thumb.blob, "thumb.jpg"); form.append("tile", tile.blob, "tile.jpg"); form.append("width", String(photo.width)); form.append("height", String(photo.height)); form.append("takenAt", String(file.lastModified || "")); const response = await fetch("/api/admin/photos/upload", { method: "POST", body: form, credentials: "same-origin" }); if (response.ok) done += 1; else failed += 1; } catch { failed += 1; } } setUploading(""); setMessage(failed ? { tone: "error", text: `Uploaded ${done}; ${failed} failed (use JPEG, PNG or WebP photos).` } : { tone: "ok", text: `Uploaded ${done} photo(s).` }); await load(); }; const removeUpload = async (key: string) => { await api(`/api/admin/photos/upload?key=${key}`, { method: "DELETE" }); await load(); }; if (!view || !display) return

Loading…

; const statusFor = (draft: AlbumDraft) => view.albums.find((album) => album.id === draft.id); return (

Albums

Open the photo frame ↗
{albums.length === 0 &&

No albums yet. Add a Google Photos shared album below.

} {albums.map((draft) => { const status = statusFor(draft); const stale = status?.stale ?? false; return (
update(draft.key, { name: event.target.value })} />
{draft.id ? (

{status?.masked}{" "} {status?.title && · “{status.title}”}

{status?.syncing ? "Syncing…" : `${status?.photoCount ?? 0} photos · last synced ${ago(status?.lastSuccessAt ?? null)}${status?.truncated ? " · only the first page synced" : ""}`} {stale && " · ⚠ not synced for over 48 hours"}

{status?.lastError && !status.syncing &&

⚠ {status.lastError}

}
) : (
update(draft.key, { shareUrl: event.target.value, check: undefined })} /> {draft.check &&

{draft.check}

}
)}
{!draft.id && ( )} {status && ( )}
); })}

In Google Photos, open the album → Share → Create link, and paste it here. Albums re-sync every 6 hours. Adding photos to the album in Google Photos is all it takes. Google doesn't offer an official way to read albums, so this reads the share page. If Google changes that page, syncing stops (the photos you already have keep showing) and a warning appears here.

Slideshow

{( [ ["shuffle", "Shuffle"], ["showDate", "Show the date taken"], ["includeUploads", "Include uploaded photos"], ] as const ).map(([field, label]) => ( ))}
{dirty && ( )}
{message &&

{message.text}

}

Uploaded photos

For photos that aren't in a Google album. They're resized on this device before upload.

{view.uploads.length > 0 && (
{view.uploads.map((photo) => (
{/* eslint-disable-next-line @next/next/no-img-element -- same-origin cached JPEG */}
))}
)}
); } type TvStatusView = { deviceName: string; model: string; ip: string; appVersion: string; lastSeenAt: number; appliedVersion: string; lastEvent: { at: number; kind: string; ok: boolean; detail: string } | null; }; type TvView = { id: string; name: string; wake: { enabled: boolean; time: string; days: number[] }; music: { enabled: boolean; playlistId: string }; volume: { enabled: boolean; level: number }; playlistUrl: string; version: string; status: (TvStatusView & { id: string }) | null; }; type TvsView = { checkInMinutes: number; tvs: TvView[]; found: (TvStatusView & { id: string })[] }; type TvDraft = { id: string; name: string; wake: { enabled: boolean; time: string; days: number[] }; music: { enabled: boolean; playlist: string }; volume: { enabled: boolean; level: number }; }; const DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; function tvDraft(tv: TvView): TvDraft { return { id: tv.id, name: tv.name, wake: { ...tv.wake }, music: { enabled: tv.music.enabled, playlist: tv.playlistUrl }, volume: { ...tv.volume } }; } function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (value: boolean) => void; label: string }) { return ( ); } function TvStatusLine({ tv, checkInMinutes }: { tv: TvView; checkInMinutes: number }) { const status = tv.status; if (!status) return Never checked in; const applied = status.appliedVersion === tv.version; const seenMinutes = minutesSince(status.lastSeenAt); return ( {applied ? ( ✓ Settings are on the TV ) : ( Waiting for the TV to pick up changes (it checks every {checkInMinutes} min) )} checkInMinutes * 3 ? "text-rose-300" : "text-slate-500"}>· last check-in {ago(status.lastSeenAt)} ); } function TvsTab() { const [view, setView] = useState(null); const [drafts, setDrafts] = useState([]); const [message, setMessage] = useState<{ tone: "ok" | "error"; text: string } | null>(null); const [busy, setBusy] = useState(false); const apply = useCallback((next: TvsView) => { setView(next); setDrafts(next.tvs.map(tvDraft)); }, []); useEffect(() => { void (async () => { const result = await api("/api/admin/tvs"); if (result.ok) apply(result.data); })(); // Keep check-in status fresh without touching unsaved edits. const timer = window.setInterval(async () => { const result = await api("/api/admin/tvs"); if (result.ok) setView(result.data); }, 15000); return () => window.clearInterval(timer); }, [apply]); const saved = JSON.stringify(view?.tvs.map(tvDraft) ?? []); const dirty = view !== null && JSON.stringify(drafts) !== saved; const update = (id: string, change: (draft: TvDraft) => TvDraft) => setDrafts((current) => current.map((draft) => (draft.id === id ? change(draft) : draft))); const add = (found: TvsView["found"][number]) => setDrafts((current) => [ ...current, { id: found.id, name: found.deviceName || found.model || "TV", wake: { enabled: true, time: "06:30", days: [0, 1, 2, 3, 4, 5, 6] }, music: { enabled: false, playlist: "" }, volume: { enabled: false, level: 40 }, }, ]); const save = async () => { setBusy(true); setMessage(null); const result = await api("/api/admin/tvs", { method: "PUT", body: JSON.stringify({ tvs: drafts }) }); setBusy(false); if (result.ok) { apply(result.data); setMessage({ tone: "ok", text: `Saved. Each TV picks up its settings within ${result.data.checkInMinutes} minutes; the status below turns green when it has.` }); } else setMessage({ tone: "error", text: result.data.error || "Save failed." }); }; if (!view) return

Loading…

; const byId = new Map(view.tvs.map((tv) => [tv.id, tv])); const draftIds = new Set(drafts.map((draft) => draft.id)); const found = view.found.filter((entry) => !draftIds.has(entry.id)); return (

Morning routine for each TV, run by the Hobbs Family Photos app: wake the TV, set the volume, start the playlist, and bring up the calendar at the wake time (music starts 30 seconds earlier). The TV turns on through HDMI-CEC.

{drafts.map((draft) => { const tv = byId.get(draft.id); const status = tv?.status ?? view.found.find((entry) => entry.id === draft.id) ?? null; return (
update(draft.id, (d) => ({ ...d, name: event.target.value }))} />

{status ? `${status.deviceName || status.model} · ${status.ip} · photo app ${status.appVersion}` : draft.id}

{tv ? : New — save to send settings}

{status?.lastEvent && (

Last morning routine {ago(status.lastEvent.at)}: {status.lastEvent.ok ? "✓ " : "✗ "} {status.lastEvent.detail}

)}
update(draft.id, (d) => ({ ...d, wake: { ...d.wake, enabled: value } }))} />
update(draft.id, (d) => ({ ...d, wake: { ...d.wake, time: event.target.value } }))} /> {DAY_LABELS.map((label, day) => { const on = draft.wake.days.includes(day); return ( ); })}
update(draft.id, (d) => ({ ...d, music: { ...d.music, enabled: value } }))} />
update(draft.id, (d) => ({ ...d, music: { ...d.music, playlist: event.target.value } }))} />
update(draft.id, (d) => ({ ...d, volume: { ...d.volume, enabled: value } }))} />
update(draft.id, (d) => ({ ...d, volume: { ...d.volume, level: Number(event.target.value) } }))} /> {draft.volume.level}%

This is the Shield's own volume; the TV's volume still applies on top.

); })} {!drafts.length &&

No TVs set up yet.

} {found.length > 0 && (

TVs found on the network

{found.map((entry) => (
{entry.deviceName || entry.model} · {entry.ip} · checked in {ago(entry.lastSeenAt)}
))}
)} {!found.length && !drafts.length && (

TVs appear here once the Hobbs Family Photos app (version 1.2 or later) has checked in.

)}
{dirty && ( )}
{message &&

{message.text}

}
); } 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 (fromHash in TAB_LABELS) 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" && } {tab === "tvs" && } {tab === "account" && setState("login")} />} )}
); }