Files
hobbs-iac/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/admin/page.tsx
T
jeremy 6e94a0c998 feat(hobbs-organizer): v1.4.0 per-TV morning routine (admin TVs tab) + photo app 1.2.1
- Admin TVs tab: wake time/days, YouTube Music playlist, Shield volume per TV,
  pushed via 5-min TV check-ins (/api/tv/config, /api/tv/report, LAN-only)
- com.dayline.photos 1.2.1: MorningActivity (screen on, volume, playlist),
  calendar alarm at wake time, Doze-safe chained check-ins
- Both TVs configured for 06:30 daily with the family playlist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3
2026-11-04 22:48:50 -06:00

1055 lines
46 KiB
TypeScript

"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<Tone, string> = {
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<Tab, string> = { calendars: "Calendars", photos: "Photos", tvs: "TVs", account: "Account" };
async function api<T>(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 (
<form onSubmit={submit} className="mx-auto mt-16 max-w-sm space-y-4 rounded-2xl border border-white/10 bg-[#0c121d] p-6">
<div>
<h1 className="text-lg font-bold">{mode === "setup" ? "Set the admin password" : "Organizer admin"}</h1>
<p className="mt-1 text-sm text-slate-400">
{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."}
</p>
</div>
<input
className={inputClass}
type="password"
autoComplete={mode === "setup" ? "new-password" : "current-password"}
placeholder="Password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoFocus
/>
{mode === "setup" && (
<input
className={inputClass}
type="password"
autoComplete="new-password"
placeholder="Confirm password"
value={confirm}
onChange={(event) => setConfirm(event.target.value)}
/>
)}
{error && <p className="text-sm text-rose-300">{error}</p>}
<button className={`${primaryButton} w-full`} disabled={busy || password.length < 1}>
{busy ? "…" : mode === "setup" ? "Save password" : "Sign in"}
</button>
</form>
);
}
function TestBadge({ test }: { test?: TestResult | "running" }) {
if (!test) return null;
if (test === "running") return <span className="text-xs text-slate-400">Testing…</span>;
if (!test.ok) return <span className="text-xs text-rose-300">✕ {test.error || "Failed"}</span>;
return (
<span className="text-xs text-emerald-300">
✓ {test.sourceName} · {test.upcoming} upcoming
</span>
);
}
function CalendarsTab() {
const [drafts, setDrafts] = useState<DraftCalendar[]>([]);
const [saved, setSaved] = useState<SavedCalendar[]>([]);
const [history, setHistory] = useState<HistoryEntry[]>([]);
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<DraftCalendar>) =>
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<TestResult>("/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 <p className="text-sm text-slate-400">Loading…</p>;
return (
<div className="space-y-6">
<section className="space-y-3">
{drafts.length === 0 && <p className="text-sm text-slate-400">No calendars yet. Add one below.</p>}
{drafts.map((draft, index) => (
<article key={draft.key} className="space-y-3 rounded-xl border border-white/10 bg-[#0c121d] p-4">
<div className="flex items-center gap-3">
<span className="h-3 w-3 shrink-0 rounded-full" style={{ background: TONE_COLOR[draft.tone] }} />
<input
className={inputClass}
placeholder="Name (blank = use the calendar's own name)"
value={draft.name}
maxLength={50}
onChange={(event) => update(draft.key, { name: event.target.value })}
/>
<label className="flex shrink-0 items-center gap-2 text-xs text-slate-300">
<input
type="checkbox"
checked={draft.enabled}
onChange={(event) => update(draft.key, { enabled: event.target.checked })}
/>
Shown
</label>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-slate-400">Color</span>
{TONES.map((tone) => (
<button
key={tone}
type="button"
aria-label={tone}
onClick={() => update(draft.key, { tone })}
className={`h-6 w-6 rounded-full border-2 ${draft.tone === tone ? "border-white" : "border-transparent"}`}
style={{ background: TONE_COLOR[tone] }}
/>
))}
</div>
{draft.id && !draft.replacing ? (
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-400">
<code className="rounded bg-white/5 px-2 py-1 text-slate-300">{draft.masked}</code>
<button type="button" className="text-cyan-300 underline-offset-2 hover:underline" onClick={() => update(draft.key, { replacing: true })}>
Replace address
</button>
</div>
) : (
<input
className={inputClass}
placeholder="https://calendar.google.com/calendar/ical/…/basic.ics"
autoComplete="off"
spellCheck={false}
value={draft.newUrl}
onChange={(event) => update(draft.key, { newUrl: event.target.value, test: undefined })}
/>
)}
<div className="flex flex-wrap items-center gap-2">
<button type="button" className={ghostButton} onClick={() => test(draft)} disabled={!draft.id && !draft.newUrl.trim()}>
Test
</button>
<button type="button" className={ghostButton} onClick={() => move(index, -1)} disabled={index === 0} aria-label="Move up">
↑
</button>
<button type="button" className={ghostButton} onClick={() => move(index, 1)} disabled={index === drafts.length - 1} aria-label="Move down">
↓
</button>
<button
type="button"
className={dangerButton}
onClick={() => setDrafts((current) => current.filter((entry) => entry.key !== draft.key))}
>
Remove
</button>
<TestBadge test={draft.test} />
</div>
</article>
))}
<div className="flex flex-wrap items-center gap-3">
<button type="button" className={ghostButton} onClick={addCalendar} disabled={drafts.length >= 10}>
+ Add calendar
</button>
<button type="button" className={primaryButton} onClick={save} disabled={!dirty || busy}>
{busy ? "Saving…" : "Save changes"}
</button>
{dirty && (
<button type="button" className={ghostButton} onClick={() => setDrafts(saved.map(toDraft))}>
Discard
</button>
)}
</div>
{message && <p className={`text-sm ${message.tone === "ok" ? "text-emerald-300" : "text-rose-300"}`}>{message.text}</p>}
<p className="text-xs leading-relaxed text-slate-500">
In Google Calendar: Settings → pick the calendar → <em>Integrate calendar</em> → copy the{" "}
<em>Secret address in iCal format</em>. Saved addresses are never shown again. They work like passwords.
</p>
</section>
<section className="space-y-2">
<h2 className="text-sm font-bold uppercase tracking-wider text-slate-400">History (calendars and photo settings)</h2>
{history.length === 0 ? (
<p className="text-sm text-slate-500">No earlier versions yet.</p>
) : (
<ul className="divide-y divide-white/5 rounded-xl border border-white/10 bg-[#0c121d]">
{history.map((entry) => (
<li key={entry.id} className="flex items-center justify-between gap-3 px-4 py-2 text-sm">
<span className="text-slate-300">
{formatWhen(entry.savedAt)} <span className="text-slate-500">· {entry.calendarCount} calendar(s), {entry.albumCount ?? 0} album(s)</span>
</span>
<button type="button" className={ghostButton} onClick={() => revert(entry)}>
Restore
</button>
</li>
))}
</ul>
)}
</section>
</div>
);
}
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<Blob | null>((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<PhotosView | null>(null);
const [display, setDisplay] = useState<PhotoDisplay | null>(null);
const [albums, setAlbums] = useState<AlbumDraft[]>([]);
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<PhotosView>("/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<PhotosView>("/api/admin/photos");
if (result.ok) setView(result.data);
}, 4000);
return () => window.clearInterval(timer);
}, [anySyncing]);
const update = (key: string, patch: Partial<AlbumDraft>) =>
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<PhotosView>("/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 <p className="text-sm text-slate-400">Loading…</p>;
const statusFor = (draft: AlbumDraft) => view.albums.find((album) => album.id === draft.id);
return (
<div className="space-y-6">
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-bold uppercase tracking-wider text-slate-400">Albums</h2>
<Link href="/photo" className="text-xs text-cyan-300 hover:underline" target="_blank">
Open the photo frame ↗
</Link>
</div>
{albums.length === 0 && <p className="text-sm text-slate-400">No albums yet. Add a Google Photos shared album below.</p>}
{albums.map((draft) => {
const status = statusFor(draft);
const stale = status?.stale ?? false;
return (
<article key={draft.key} className="space-y-3 rounded-xl border border-white/10 bg-[#0c121d] p-4">
<div className="flex items-center gap-3">
<input
className={inputClass}
placeholder="Name (blank = the album's own title)"
value={draft.name}
maxLength={60}
onChange={(event) => update(draft.key, { name: event.target.value })}
/>
<label className="flex shrink-0 items-center gap-2 text-xs text-slate-300">
<input type="checkbox" checked={draft.enabled} onChange={(event) => update(draft.key, { enabled: event.target.checked })} />
Shown
</label>
</div>
{draft.id ? (
<div className="space-y-1 text-xs">
<p className="text-slate-400">
<code className="rounded bg-white/5 px-2 py-1 text-slate-300">{status?.masked}</code>{" "}
{status?.title && <span className="text-slate-500">· “{status.title}”</span>}
</p>
<p className={status?.lastError || stale ? "text-amber-300" : "text-slate-400"}>
{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"}
</p>
{status?.lastError && !status.syncing && <p className="text-amber-300">⚠ {status.lastError}</p>}
</div>
) : (
<div className="space-y-2">
<input
className={inputClass}
placeholder="https://photos.app.goo.gl/…"
autoComplete="off"
spellCheck={false}
value={draft.shareUrl}
onChange={(event) => update(draft.key, { shareUrl: event.target.value, check: undefined })}
/>
{draft.check && <p className={`text-xs ${draft.check.startsWith("✓") ? "text-emerald-300" : "text-slate-400"}`}>{draft.check}</p>}
</div>
)}
<div className="flex flex-wrap gap-2">
{!draft.id && (
<button type="button" className={ghostButton} onClick={() => check(draft)} disabled={!draft.shareUrl.trim()}>
Check link
</button>
)}
{status && (
<button type="button" className={ghostButton} onClick={() => syncNow(status)} disabled={status.syncing || !status.enabled}>
Sync now
</button>
)}
<button type="button" className={dangerButton} onClick={() => setAlbums((current) => current.filter((entry) => entry.key !== draft.key))}>
Remove
</button>
</div>
</article>
);
})}
<button
type="button"
className={ghostButton}
disabled={albums.length >= 10}
onClick={() => setAlbums((current) => [...current, { key: `new-${Date.now()}`, name: "", enabled: true, shareUrl: "" }])}
>
+ Add album
</button>
<p className="text-xs leading-relaxed text-slate-500">
In Google Photos, open the album → Share → <em>Create link</em>, and paste it here. Albums re-sync every 6 hours. Adding photos to the
album in Google Photos is all it takes. Google doesn&apos;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.
</p>
</section>
<section className="space-y-3 rounded-xl border border-white/10 bg-[#0c121d] p-4">
<h2 className="text-sm font-bold uppercase tracking-wider text-slate-400">Slideshow</h2>
<label className="flex items-center justify-between gap-3 text-sm">
<span>Seconds per photo</span>
<input
type="number"
min={5}
max={3600}
className={`${inputClass} w-24`}
value={display.intervalSeconds}
onChange={(event) => setDisplay({ ...display, intervalSeconds: Number(event.target.value) })}
/>
</label>
<label className="flex items-center justify-between gap-3 text-sm">
<span>Fit</span>
<select className={`${inputClass} w-56`} value={display.fit} onChange={(event) => setDisplay({ ...display, fit: event.target.value as PhotoDisplay["fit"] })}>
<option value="contain-blur">Whole photo, blurred background</option>
<option value="cover">Fill the screen (crops edges)</option>
</select>
</label>
{(
[
["shuffle", "Shuffle"],
["showDate", "Show the date taken"],
["includeUploads", "Include uploaded photos"],
] as const
).map(([field, label]) => (
<label key={field} className="flex items-center justify-between gap-3 text-sm">
<span>{label}</span>
<input type="checkbox" checked={display[field]} onChange={(event) => setDisplay({ ...display, [field]: event.target.checked })} />
</label>
))}
</section>
<div className="flex flex-wrap items-center gap-3">
<button type="button" className={primaryButton} onClick={save} disabled={!dirty || busy}>
{busy ? "Saving…" : "Save changes"}
</button>
{dirty && (
<button type="button" className={ghostButton} onClick={() => apply(view)}>
Discard
</button>
)}
</div>
{message && <p className={`text-sm ${message.tone === "ok" ? "text-emerald-300" : "text-rose-300"}`}>{message.text}</p>}
<section className="space-y-3">
<h2 className="text-sm font-bold uppercase tracking-wider text-slate-400">Uploaded photos</h2>
<p className="text-xs text-slate-500">For photos that aren&apos;t in a Google album. They&apos;re resized on this device before upload.</p>
<label className={`${ghostButton} inline-block cursor-pointer`}>
{uploading || "Upload photos…"}
<input type="file" accept="image/*" multiple className="hidden" disabled={!!uploading} onChange={(event) => void upload(event.target.files)} />
</label>
{view.uploads.length > 0 && (
<div className="grid grid-cols-4 gap-2 sm:grid-cols-6">
{view.uploads.map((photo) => (
<div key={photo.key} className="group relative aspect-square overflow-hidden rounded-lg bg-white/5">
{/* eslint-disable-next-line @next/next/no-img-element -- same-origin cached JPEG */}
<img src={photo.src} alt="" className="h-full w-full object-cover" loading="lazy" />
<button
type="button"
onClick={() => removeUpload(photo.key)}
className="absolute right-1 top-1 rounded bg-black/70 px-1.5 text-xs text-rose-200"
aria-label="Delete photo"
>
✕
</button>
</div>
))}
</div>
)}
</section>
</div>
);
}
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 (
<label className="flex cursor-pointer items-center gap-2 text-sm font-semibold">
<input type="checkbox" className="h-4 w-4 accent-cyan-300" checked={checked} onChange={(event) => onChange(event.target.checked)} />
{label}
</label>
);
}
function TvStatusLine({ tv, checkInMinutes }: { tv: TvView; checkInMinutes: number }) {
const status = tv.status;
if (!status) return <span className="text-amber-300">Never checked in</span>;
const applied = status.appliedVersion === tv.version;
const seenMinutes = minutesSince(status.lastSeenAt);
return (
<span className="space-x-2">
{applied ? (
<span className="text-emerald-300">✓ Settings are on the TV</span>
) : (
<span className="text-amber-300">Waiting for the TV to pick up changes (it checks every {checkInMinutes} min)</span>
)}
<span className={seenMinutes > checkInMinutes * 3 ? "text-rose-300" : "text-slate-500"}>· last check-in {ago(status.lastSeenAt)}</span>
</span>
);
}
function TvsTab() {
const [view, setView] = useState<TvsView | null>(null);
const [drafts, setDrafts] = useState<TvDraft[]>([]);
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<TvsView>("/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<TvsView>("/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<TvsView>("/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 <p className="text-sm text-slate-400">Loading…</p>;
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 (
<div className="space-y-5">
<p className="text-sm text-slate-400">
Morning routine for each TV, run by the <b>Hobbs Family Photos</b> 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.
</p>
{drafts.map((draft) => {
const tv = byId.get(draft.id);
const status = tv?.status ?? view.found.find((entry) => entry.id === draft.id) ?? null;
return (
<section key={draft.id} className="space-y-4 rounded-xl border border-white/10 bg-[#0c121d] p-4">
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1 space-y-1">
<input className={inputClass} value={draft.name} maxLength={40} placeholder="TV name" onChange={(event) => update(draft.id, (d) => ({ ...d, name: event.target.value }))} />
<p className="text-xs text-slate-500">
{status ? `${status.deviceName || status.model} · ${status.ip} · photo app ${status.appVersion}` : draft.id}
</p>
<p className="text-xs">{tv ? <TvStatusLine tv={tv} checkInMinutes={view.checkInMinutes} /> : <span className="text-amber-300">New — save to send settings</span>}</p>
{status?.lastEvent && (
<p className={`text-xs ${status.lastEvent.ok ? "text-slate-400" : "text-rose-300"}`}>
Last morning routine {ago(status.lastEvent.at)}: {status.lastEvent.ok ? "✓ " : "✗ "}
{status.lastEvent.detail}
</p>
)}
</div>
<button type="button" className={dangerButton} onClick={() => setDrafts((current) => current.filter((entry) => entry.id !== draft.id))}>
Remove
</button>
</div>
<div className="space-y-2 border-t border-white/5 pt-3">
<Toggle label="Wake up and show the calendar" checked={draft.wake.enabled} onChange={(value) => update(draft.id, (d) => ({ ...d, wake: { ...d.wake, enabled: value } }))} />
<div className={`flex flex-wrap items-center gap-2 pl-6 ${draft.wake.enabled ? "" : "opacity-40"}`}>
<input
type="time"
className={inputClass.replace("w-full", "w-36")}
value={draft.wake.time}
disabled={!draft.wake.enabled}
onChange={(event) => 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 (
<button
key={label}
type="button"
disabled={!draft.wake.enabled}
onClick={() =>
update(draft.id, (d) => ({
...d,
wake: { ...d.wake, days: on ? d.wake.days.filter((entry) => entry !== day) : [...d.wake.days, day].sort() },
}))
}
className={`rounded-md px-2 py-1 text-xs font-semibold ${on ? "bg-cyan-300 text-slate-950" : "bg-white/5 text-slate-400"}`}
>
{label}
</button>
);
})}
</div>
</div>
<div className={`space-y-2 border-t border-white/5 pt-3 ${draft.wake.enabled ? "" : "opacity-40"}`}>
<Toggle label="Play music" checked={draft.music.enabled} onChange={(value) => update(draft.id, (d) => ({ ...d, music: { ...d.music, enabled: value } }))} />
<div className="pl-6">
<input
className={inputClass}
placeholder="YouTube Music playlist link (Share → Copy link)"
value={draft.music.playlist}
onChange={(event) => update(draft.id, (d) => ({ ...d, music: { ...d.music, playlist: event.target.value } }))}
/>
</div>
</div>
<div className={`space-y-2 border-t border-white/5 pt-3 ${draft.wake.enabled ? "" : "opacity-40"}`}>
<Toggle label="Set the volume" checked={draft.volume.enabled} onChange={(value) => update(draft.id, (d) => ({ ...d, volume: { ...d.volume, enabled: value } }))} />
<div className="flex items-center gap-3 pl-6">
<input
type="range"
min={0}
max={100}
step={5}
className="flex-1 accent-cyan-300"
value={draft.volume.level}
disabled={!draft.volume.enabled}
onChange={(event) => update(draft.id, (d) => ({ ...d, volume: { ...d.volume, level: Number(event.target.value) } }))}
/>
<span className="w-12 text-right text-sm tabular-nums">{draft.volume.level}%</span>
</div>
<p className="pl-6 text-xs text-slate-500">This is the Shield&apos;s own volume; the TV&apos;s volume still applies on top.</p>
</div>
</section>
);
})}
{!drafts.length && <p className="text-sm text-slate-400">No TVs set up yet.</p>}
{found.length > 0 && (
<section className="space-y-2 rounded-xl border border-dashed border-white/15 p-4">
<h2 className="text-sm font-bold">TVs found on the network</h2>
{found.map((entry) => (
<div key={entry.id} className="flex items-center gap-3 text-sm">
<span className="flex-1">
{entry.deviceName || entry.model} <span className="text-slate-500">· {entry.ip} · checked in {ago(entry.lastSeenAt)}</span>
</span>
<button type="button" className={ghostButton} onClick={() => add(entry)}>
Add
</button>
</div>
))}
</section>
)}
{!found.length && !drafts.length && (
<p className="text-xs text-slate-500">TVs appear here once the Hobbs Family Photos app (version 1.2 or later) has checked in.</p>
)}
<div className="flex items-center gap-2">
<button type="button" className={primaryButton} onClick={save} disabled={!dirty || busy}>
{busy ? "Saving…" : "Save and send to TVs"}
</button>
{dirty && (
<button type="button" className={ghostButton} onClick={() => apply(view)}>
Discard
</button>
)}
</div>
{message && <p className={`text-sm ${message.tone === "ok" ? "text-emerald-300" : "text-rose-300"}`}>{message.text}</p>}
</div>
);
}
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 (
<div className="space-y-6">
<form onSubmit={change} className="max-w-sm space-y-3 rounded-xl border border-white/10 bg-[#0c121d] p-4">
<h2 className="text-sm font-bold">Change password</h2>
<input className={inputClass} type="password" autoComplete="current-password" placeholder="Current password" value={current} onChange={(event) => setCurrent(event.target.value)} />
<input className={inputClass} type="password" autoComplete="new-password" placeholder="New password (6+ characters)" value={next} onChange={(event) => setNext(event.target.value)} />
<button className={primaryButton} disabled={!current || next.length < 6}>
Change password
</button>
{message && <p className={`text-sm ${message.tone === "ok" ? "text-emerald-300" : "text-rose-300"}`}>{message.text}</p>}
</form>
<button type="button" className={ghostButton} onClick={signOut}>
Sign out
</button>
<p className="text-xs text-slate-500">
Forgot the password? On the server: <code>docker exec -u 10001 hobbs-family-organizer-organizer-1 node container/reset-admin.mjs</code>
</p>
</div>
);
}
export default function AdminPage() {
const [state, setState] = useState<"loading" | "setup" | "login" | "ready" | "blocked">("loading");
const [tab, setTab] = useState<Tab>("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 (
<div className="fixed inset-0 overflow-y-auto bg-[#070b12] text-slate-100">
<main className="mx-auto max-w-2xl px-4 py-6">
{state === "loading" && <p className="text-sm text-slate-400">Loading…</p>}
{state === "blocked" && <p className="mt-16 text-center text-sm text-slate-400">Admin is only available on the home network.</p>}
{(state === "setup" || state === "login") && <PasswordForm mode={state} onDone={() => setState("ready")} />}
{state === "ready" && (
<>
<header className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">Organizer admin</h1>
<Link href="/" className="text-xs text-cyan-300 hover:underline">
← Open the dashboard
</Link>
</div>
</header>
<nav className="mb-6 flex gap-1 rounded-xl border border-white/10 bg-white/5 p-1">
{(Object.keys(TAB_LABELS) as Tab[]).map((name) => (
<button
key={name}
type="button"
onClick={() => selectTab(name)}
className={`flex-1 rounded-lg px-3 py-2 text-sm font-semibold ${tab === name ? "bg-cyan-300 text-slate-950" : "text-slate-300 hover:bg-white/5"}`}
>
{TAB_LABELS[name]}
</button>
))}
</nav>
{tab === "calendars" && <CalendarsTab />}
{tab === "photos" && <PhotosTab />}
{tab === "tvs" && <TvsTab />}
{tab === "account" && <AccountTab onSignedOut={() => setState("login")} />}
</>
)}
</main>
</div>
);
}