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