Files
jeremy c2e5c1d9dc 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
2026-11-04 19:18:02 -06:00

163 lines
6.1 KiB
TypeScript

/* eslint-disable @next/next/no-img-element -- plain <img> is intended: same-origin cached JPEGs, no optimizer */
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
type SlidePhoto = { src: string; thumb: string; takenAt: number | null; width: number; height: number };
type Display = { intervalSeconds: number; shuffle: boolean; showDate: boolean; fit: "contain-blur" | "cover"; includeUploads: boolean };
type Slideshow = { display: Display; photos: SlidePhoto[]; version: string };
type Layer = { photo: SlidePhoto; id: number };
const POLL_MS = 30_000;
const FADE_MS = 1_400;
const FULL_RELOAD_MS = 12 * 60 * 60 * 1000;
const TIME_ZONE = "America/Chicago";
function shuffled<T>(values: T[]) {
const copy = [...values];
for (let index = copy.length - 1; index > 0; index -= 1) {
const other = Math.floor(Math.random() * (index + 1));
[copy[index], copy[other]] = [copy[other], copy[index]];
}
return copy;
}
function formatTaken(takenAt: number | null) {
if (!takenAt) return "";
return new Intl.DateTimeFormat("en-US", { timeZone: TIME_ZONE, month: "long", day: "numeric", year: "numeric" }).format(new Date(takenAt));
}
function preload(src: string) {
return new Promise<void>((resolve) => {
const image = new Image();
image.onload = () => resolve();
image.onerror = () => resolve();
image.src = src;
});
}
export default function PhotoFrame() {
const [show, setShow] = useState<Slideshow | null>(null);
const [layers, setLayers] = useState<Layer[]>([]);
const [failed, setFailed] = useState(false);
const queue = useRef<SlidePhoto[]>([]);
const versionRef = useRef("");
const showRef = useRef<Slideshow | null>(null);
const layerId = useRef(0);
// The newest layer's src, readable from callbacks without depending on layers state.
const topSrc = useRef("");
const refill = useCallback((photos: SlidePhoto[], shuffle: boolean, avoid?: string) => {
let next = shuffle ? shuffled(photos) : [...photos];
if (next.length > 1 && next[0].src === avoid) next = [...next.slice(1), next[0]];
queue.current = next;
}, []);
const advance = useCallback(async () => {
const current = showRef.current;
if (!current || !current.photos.length) return;
if (!queue.current.length) refill(current.photos, current.display.shuffle, topSrc.current);
const photo = queue.current.shift();
if (!photo) return;
await preload(photo.src);
layerId.current += 1;
const id = layerId.current;
setLayers((existing) => [...existing.slice(-1), { photo, id }]);
}, [refill]);
useEffect(() => {
topSrc.current = layers[layers.length - 1]?.photo.src ?? "";
}, [layers]);
// Poll settings + photo list; rebuild the queue only when something changed.
useEffect(() => {
let cancelled = false;
const poll = async () => {
try {
const response = await fetch("/api/photos", { cache: "no-store" });
if (!response.ok) throw new Error(String(response.status));
const next = (await response.json()) as Slideshow;
if (cancelled) return;
setFailed(false);
if (next.version === versionRef.current) return;
const first = !versionRef.current;
versionRef.current = next.version;
showRef.current = next;
setShow(next);
refill(next.photos, next.display.shuffle, topSrc.current);
if (first || !topSrc.current || !next.photos.some((photo) => photo.src === topSrc.current)) void advance();
} catch {
if (!cancelled) setFailed(true);
}
};
void poll();
const timer = window.setInterval(poll, POLL_MS);
const reload = window.setTimeout(() => window.location.reload(), FULL_RELOAD_MS);
return () => {
cancelled = true;
window.clearInterval(timer);
window.clearTimeout(reload);
};
}, [advance, refill]);
const intervalSeconds = show?.display.intervalSeconds ?? 20;
const photoCount = show?.photos.length ?? 0;
useEffect(() => {
if (photoCount < 2) return;
const timer = window.setInterval(() => void advance(), intervalSeconds * 1000);
return () => window.clearInterval(timer);
}, [advance, intervalSeconds, photoCount]);
const fit = show?.display.fit ?? "contain-blur";
const showDate = show?.display.showDate ?? true;
const top = layers[layers.length - 1];
return (
<div className="fixed inset-0 overflow-hidden bg-black" style={{ cursor: "none" }}>
{layers.map((layer) => (
<div
key={layer.id}
className="absolute inset-0"
style={{ animation: `photo-fade-in ${FADE_MS}ms ease-in-out both` }}
>
{fit === "contain-blur" && (
<img
src={layer.photo.thumb}
alt=""
aria-hidden
className="absolute inset-0 h-full w-full object-cover"
style={{ filter: "blur(28px) brightness(0.55) saturate(1.2)", transform: "scale(1.15)" }}
/>
)}
<img
src={layer.photo.src}
alt=""
className={`absolute inset-0 h-full w-full ${fit === "cover" ? "object-cover" : "object-contain"}`}
/>
</div>
))}
{showDate && top?.photo.takenAt ? (
<div
key={`date-${top.id}`}
className="absolute bottom-6 right-8 rounded-full bg-black/35 px-4 py-1.5 text-lg font-semibold tracking-wide text-white/85"
style={{ animation: `photo-fade-in ${FADE_MS}ms ease-in-out both`, textShadow: "0 1px 4px rgb(0 0 0 / 60%)" }}
>
{formatTaken(top.photo.takenAt)}
</div>
) : null}
{show && !show.photos.length && (
<div className="absolute inset-0 flex items-center justify-center text-center text-2xl text-white/60">
No photos yet. Add a Google Photos album in the admin screen.
</div>
)}
{failed && !layers.length && (
<div className="absolute inset-0 flex items-center justify-center text-2xl text-white/60">Can&apos;t reach the photo server…</div>
)}
<style>{`@keyframes photo-fade-in { from { opacity: 0 } to { opacity: 1 } }`}</style>
</div>
);
}