feat(hobbs-organizer): v1.2.0 photo frame (/photo) with Google Photos shared albums

- /photo full-screen slideshow (crossfade, blurred backdrop or fill, shuffle, date)
- admin Photos tab: add shared-album links (check, sync now, status/stale warnings),
  slideshow options, browser-resized uploads
- 6-hourly album sync scraping the share page (Library API no longer exposes
  user albums), caches 1920px + 64px copies in /data/photos
- /api/health/photos -> 503 when an album has not synced for 48h
- history/restore covers photo settings; min admin password length 6

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3
This commit is contained in:
2026-11-04 19:18:02 -06:00
parent 8715589cbb
commit c2e5c1d9dc
24 changed files with 1215 additions and 27 deletions
@@ -26,7 +26,7 @@ type DraftCalendar = {
replacing: boolean;
test?: TestResult | "running";
};
type HistoryEntry = { id: string; savedAt: string; calendarCount: number; note: string };
type HistoryEntry = { id: string; savedAt: string; calendarCount: number; albumCount: number; note: string };
type Tab = "calendars" | "photos" | "account";
async function api<T>(path: string, init?: RequestInit & { body?: string }) {
@@ -331,7 +331,7 @@ function CalendarsTab() {
</section>
<section className="space-y-2">
<h2 className="text-sm font-bold uppercase tracking-wider text-slate-400">History</h2>
<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>
) : (
@@ -339,7 +339,7 @@ function CalendarsTab() {
{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)</span>
{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
@@ -353,6 +353,344 @@ function CalendarsTab() {
);
}
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 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 thumb = await encode(64, 0.7);
bitmap.close();
return { photo, 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, thumb } = await resizeForUpload(file);
const form = new FormData();
form.append("photo", photo.blob, "photo.jpg");
form.append("thumb", thumb.blob, "thumb.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>
);
}
function AccountTab({ onSignedOut }: { onSignedOut: () => void }) {
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
@@ -381,8 +719,8 @@ function AccountTab({ onSignedOut }: { onSignedOut: () => void }) {
<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 (8+ characters)" value={next} onChange={(event) => setNext(event.target.value)} />
<button className={primaryButton} disabled={!current || next.length < 8}>
<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>}
@@ -449,11 +787,7 @@ export default function AdminPage() {
))}
</nav>
{tab === "calendars" && <CalendarsTab />}
{tab === "photos" && (
<p className="rounded-xl border border-white/10 bg-[#0c121d] p-4 text-sm text-slate-400">
Photo albums arrive in the next update.
</p>
)}
{tab === "photos" && <PhotosTab />}
{tab === "account" && <AccountTab onSignedOut={() => setState("login")} />}
</>
)}