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:
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Hobbs Family Photos",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function PhotoLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/* 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't reach the photo server…</div>
|
||||
)}
|
||||
|
||||
<style>{`@keyframes photo-fade-in { from { opacity: 0 } to { opacity: 1 } }`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user