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:
@@ -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'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't in a Google album. They'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")} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user