photos phase 3
Some checks failed
Build and Push Docker Image / build (push) Failing after 10s

This commit is contained in:
jason
2026-03-30 10:45:16 -05:00
parent 5431177b7a
commit 4672f70a60
2 changed files with 290 additions and 0 deletions

View File

@@ -8,6 +8,7 @@ import {
Image, Menu, X, ChevronRight,
} from 'lucide-react';
import { ThemeToggle } from '@/components/ui/ThemeToggle';
import { Screensaver } from '@/components/screensaver/Screensaver';
interface NavItem {
to: string;
@@ -192,6 +193,9 @@ export function AppShell({ children }: AppShellProps) {
</AnimatePresence>
</main>
</div>
{/* ── Screensaver (fixed overlay, activates on idle) ─────────────── */}
<Screensaver />
</div>
);
}

View File

@@ -0,0 +1,286 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'framer-motion';
import { format } from 'date-fns';
import { api, type AppSettings } from '@/lib/api';
// ── Types ──────────────────────────────────────────────────────────────────
interface Photo {
name: string;
rel: string;
url: string;
}
// ── Ken Burns presets ──────────────────────────────────────────────────────
// Scale always ≥ 1.08 so panning never reveals black edges.
// Alternates between zoom-in and zoom-out for variety.
const KB_PRESETS = [
{ i: { scale: 1.08, x: '-3%', y: '-2%' }, a: { scale: 1.18, x: '3%', y: '2%' } }, // zoom in → bottom-right
{ i: { scale: 1.18, x: '3%', y: '2%' }, a: { scale: 1.08, x: '-3%', y: '-2%' } }, // zoom out → top-left
{ i: { scale: 1.08, x: '3%', y: '-2%' }, a: { scale: 1.18, x: '-3%', y: '2%' } }, // zoom in → bottom-left
{ i: { scale: 1.18, x: '-3%', y: '2%' }, a: { scale: 1.08, x: '3%', y: '-2%' } }, // zoom out → top-right
{ i: { scale: 1.08, x: '0%', y: '-3%' }, a: { scale: 1.18, x: '0%', y: '3%' } }, // zoom in → pan down
{ i: { scale: 1.18, x: '0%', y: '3%' }, a: { scale: 1.08, x: '0%', y: '-3%' } }, // zoom out → pan up
{ i: { scale: 1.08, x: '-3%', y: '0%' }, a: { scale: 1.18, x: '3%', y: '0%' } }, // zoom in → pan right
{ i: { scale: 1.18, x: '3%', y: '0%' }, a: { scale: 1.08, x: '-3%', y: '0%' } }, // zoom out → pan left
] as const;
// ── Helpers ────────────────────────────────────────────────────────────────
function shuffleArray<T>(arr: T[]): T[] {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function orderPhotos(photos: Photo[], order: string): Photo[] {
if (order === 'random') return shuffleArray(photos);
if (order === 'newest') return [...photos].reverse();
return [...photos]; // sequential
}
// Pick a random Ken Burns preset that isn't the one we just used
function pickKb(prevIdx: number, total = KB_PRESETS.length): number {
if (total === 1) return 0;
let next: number;
do { next = Math.floor(Math.random() * total); } while (next === prevIdx);
return next;
}
// ── Component ──────────────────────────────────────────────────────────────
export function Screensaver() {
const [active, setActive] = useState(false);
const [photoIdx, setPhotoIdx] = useState(0);
const [kbIdx, setKbIdx] = useState(0);
const [clock, setClock] = useState(new Date());
const [hintVisible, setHintVisible] = useState(false);
const [orderedPhotos, setOrderedPhotos] = useState<Photo[]>([]);
// Refs to avoid stale closures in event handlers
const activeRef = useRef(false);
const idleTimerRef = useRef<ReturnType<typeof setTimeout>>();
const photoTimerRef = useRef<ReturnType<typeof setInterval>>();
const hintTimerRef = useRef<ReturnType<typeof setTimeout>>();
// Keep ref in sync with state
useEffect(() => { activeRef.current = active; }, [active]);
// ── Fetch settings ───────────────────────────────────────────────────────
const { data: settings } = useQuery<AppSettings>({
queryKey: ['settings'],
queryFn: () => api.get('/settings').then((r) => r.data),
staleTime: 60_000,
});
// ── Fetch photos for slideshow ────────────────────────────────────────────
const { data: slideshowData } = useQuery<{ count: number; photos: Photo[] }>({
queryKey: ['photos-slideshow'],
queryFn: () => api.get('/photos/slideshow').then((r) => r.data),
staleTime: 60_000,
});
const idleTimeoutMs = parseInt(settings?.idle_timeout ?? '120000', 10);
const slideshowSpeed = parseInt(settings?.slideshow_speed ?? '6000', 10);
const slideshowOrder = settings?.slideshow_order ?? 'random';
const timeFormat = settings?.time_format ?? '12h';
const allPhotos = slideshowData?.photos ?? [];
// ── Clock tick ────────────────────────────────────────────────────────────
useEffect(() => {
const tick = setInterval(() => setClock(new Date()), 1000);
return () => clearInterval(tick);
}, []);
// ── Activate ──────────────────────────────────────────────────────────────
const activate = useCallback(() => {
const ordered = orderPhotos(allPhotos, slideshowOrder);
setOrderedPhotos(ordered);
setPhotoIdx(0);
setKbIdx(Math.floor(Math.random() * KB_PRESETS.length));
setActive(true);
setHintVisible(true);
clearTimeout(hintTimerRef.current);
hintTimerRef.current = setTimeout(() => setHintVisible(false), 3500);
}, [allPhotos, slideshowOrder]);
// ── Deactivate ────────────────────────────────────────────────────────────
const deactivate = useCallback(() => {
setActive(false);
clearTimeout(hintTimerRef.current);
setHintVisible(false);
}, []);
// ── Idle detection ────────────────────────────────────────────────────────
useEffect(() => {
if (idleTimeoutMs === 0) return; // disabled in settings
const startIdleTimer = () => {
clearTimeout(idleTimerRef.current);
idleTimerRef.current = setTimeout(activate, idleTimeoutMs);
};
const onActivity = () => {
if (activeRef.current) return; // screensaver handles its own dismissal
startIdleTimer();
};
const EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll'] as const;
EVENTS.forEach((e) => document.addEventListener(e, onActivity, { passive: true }));
startIdleTimer(); // kick off on mount / settings change
return () => {
EVENTS.forEach((e) => document.removeEventListener(e, onActivity));
clearTimeout(idleTimerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activate, idleTimeoutMs]); // intentionally omit `active` — we read it via ref
// ── Dismiss on any keypress while screensaver is active ───────────────────
useEffect(() => {
if (!active) return;
const onKey = () => deactivate();
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [active, deactivate]);
// ── Photo advance timer ───────────────────────────────────────────────────
useEffect(() => {
if (!active || orderedPhotos.length === 0) return;
photoTimerRef.current = setInterval(() => {
setPhotoIdx((prev) => {
const next = prev + 1;
if (next >= orderedPhotos.length) {
// Re-order for next cycle (reshuffle random)
if (slideshowOrder === 'random') {
setOrderedPhotos(shuffleArray(allPhotos));
}
return 0;
}
return next;
});
setKbIdx((prev) => pickKb(prev));
}, slideshowSpeed);
return () => clearInterval(photoTimerRef.current);
}, [active, orderedPhotos.length, slideshowSpeed, slideshowOrder, allPhotos]);
// ── Derived values ────────────────────────────────────────────────────────
const currentPhoto = orderedPhotos[photoIdx] ?? null;
const nextPhoto = orderedPhotos[(photoIdx + 1) % Math.max(orderedPhotos.length, 1)] ?? null;
const kb = KB_PRESETS[kbIdx];
const timeStr = timeFormat === '24h' ? format(clock, 'H:mm') : format(clock, 'h:mm');
const periodStr = timeFormat === '12h' ? format(clock, 'a') : '';
const dateStr = format(clock, 'EEEE, MMMM d');
// ── Render ────────────────────────────────────────────────────────────────
if (!active) return null;
return (
<motion.div
className="fixed inset-0 z-[200] overflow-hidden bg-black cursor-pointer select-none"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
onClick={deactivate}
>
{/* ── Photo layer ───────────────────────────────────────────────────── */}
<AnimatePresence mode="sync">
{currentPhoto ? (
<motion.div
key={`photo-${photoIdx}-${currentPhoto.rel}`}
className="absolute inset-0"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 1.2, ease: 'easeInOut' }}
>
{/* Ken Burns motion layer */}
<motion.div
className="absolute inset-0"
initial={kb.i}
animate={kb.a}
transition={{ duration: slideshowSpeed / 1000, ease: 'linear' }}
>
<div
className="absolute inset-0"
style={{
backgroundImage: `url(${currentPhoto.url})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
</motion.div>
</motion.div>
) : (
/* No photos: animated dark gradient */
<motion.div
key="no-photo"
className="absolute inset-0 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
)}
</AnimatePresence>
{/* Preload next photo (hidden) */}
{nextPhoto && nextPhoto.rel !== currentPhoto?.rel && (
<img src={nextPhoto.url} className="hidden" alt="" aria-hidden="true" />
)}
{/* ── Gradient scrim (bottom-heavy for clock legibility) ─────────── */}
<div className="absolute inset-0 pointer-events-none"
style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.75) 0%, rgba(0,0,0,0.15) 40%, rgba(0,0,0,0.10) 100%)' }}
/>
{/* ── Clock ─────────────────────────────────────────────────────────── */}
<div className="absolute bottom-12 left-14 pointer-events-none">
{/* Time row */}
<div className="flex items-end gap-3">
<span
className="text-white font-thin tabular-nums leading-none"
style={{ fontSize: 'clamp(5rem, 11vw, 10rem)' }}
>
{timeStr}
</span>
{periodStr && (
<span
className="text-white/75 font-light leading-none mb-3"
style={{ fontSize: 'clamp(1.75rem, 3.5vw, 3.25rem)' }}
>
{periodStr}
</span>
)}
</div>
{/* Date */}
<p
className="text-white/65 font-light tracking-wide mt-2"
style={{ fontSize: 'clamp(1rem, 2vw, 1.75rem)' }}
>
{dateStr}
</p>
</div>
{/* ── "Tap to dismiss" hint ─────────────────────────────────────────── */}
<AnimatePresence>
{hintVisible && (
<motion.div
className="absolute top-6 left-1/2 -translate-x-1/2 px-4 py-2 rounded-full bg-white/10 backdrop-blur-md text-white/75 text-sm pointer-events-none whitespace-nowrap"
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.4 }}
>
Tap anywhere to dismiss
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}