/** * ArtworkNavigator * * Behavior-only: prev/next navigation WITHOUT page reload. * Features: fetch + history.pushState, Image() preloading, keyboard (← →/F), touch swipe. * UI arrows are rendered by ArtworkHero via onReady callback. */ import { useState, useEffect, useCallback, useRef } from 'react'; import { useNavContext } from '../../lib/useNavContext'; const preloadCache = new Set(); function preloadImage(src) { if (!src || preloadCache.has(src)) return; preloadCache.add(src); const img = new Image(); img.src = src; } export default function ArtworkNavigator({ artworkId, onNavigate, onOpenViewer, onReady }) { const { getNeighbors } = useNavContext(artworkId); const [neighbors, setNeighbors] = useState({ prevId: null, nextId: null, prevUrl: null, nextUrl: null }); // Refs so navigate/keyboard/swipe callbacks are stable (no dep on state values) const navigatingRef = useRef(false); const neighborsRef = useRef(neighbors); const onNavigateRef = useRef(onNavigate); const onOpenViewerRef = useRef(onOpenViewer); const onReadyRef = useRef(onReady); // Keep refs in sync with latest props/state useEffect(() => { neighborsRef.current = neighbors; }, [neighbors]); useEffect(() => { onNavigateRef.current = onNavigate; }, [onNavigate]); useEffect(() => { onOpenViewerRef.current = onOpenViewer; }, [onOpenViewer]); useEffect(() => { onReadyRef.current = onReady; }, [onReady]); const touchStartX = useRef(null); const touchStartY = useRef(null); // Resolve neighbors on mount / artworkId change useEffect(() => { let cancelled = false; getNeighbors().then((n) => { if (cancelled) return; setNeighbors(n); [n.prevId, n.nextId].forEach((id) => { if (!id) return; fetch(`/api/artworks/${id}/page`, { headers: { Accept: 'application/json' } }) .then((r) => r.ok ? r.json() : null) .then((data) => { if (!data) return; const imgUrl = data.thumbs?.lg?.url || data.thumbs?.md?.url; if (imgUrl) preloadImage(imgUrl); }) .catch(() => {}); }); }); return () => { cancelled = true; }; }, [artworkId, getNeighbors]); // Stable navigate — reads state via refs, never recreated const navigate = useCallback(async (targetId, targetUrl) => { if (!targetId && !targetUrl) return; if (navigatingRef.current) return; const fallbackUrl = targetUrl || `/art/${targetId}`; const currentOnNavigate = onNavigateRef.current; if (!currentOnNavigate || !targetId) { window.location.href = fallbackUrl; return; } navigatingRef.current = true; try { const res = await fetch(`/api/artworks/${targetId}/page`, { headers: { Accept: 'application/json' }, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); const canonicalSlug = (data.slug || data.title || String(data.id)) .toString() .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, '') || String(data.id); history.pushState({ artworkId: data.id }, '', `/art/${data.id}/${canonicalSlug}`); document.title = `${data.title} | Skinbase`; currentOnNavigate(data); } catch { window.location.href = fallbackUrl; } finally { navigatingRef.current = false; } }, []); // stable — accesses everything via refs // Notify parent whenever neighbors change useEffect(() => { const hasPrev = Boolean(neighbors.prevId || neighbors.prevUrl); const hasNext = Boolean(neighbors.nextId || neighbors.nextUrl); onReadyRef.current?.({ hasPrev, hasNext, navigatePrev: hasPrev ? () => navigate(neighbors.prevId, neighbors.prevUrl) : null, navigateNext: hasNext ? () => navigate(neighbors.nextId, neighbors.nextUrl) : null, }); }, [neighbors, navigate]); // Sync browser back/forward useEffect(() => { function onPop() { window.location.reload(); } window.addEventListener('popstate', onPop); return () => window.removeEventListener('popstate', onPop); }, []); // Keyboard: ← → navigate, F fullscreen useEffect(() => { function onKey(e) { const tag = e.target?.tagName?.toLowerCase?.() ?? ''; if (['input', 'textarea', 'select'].includes(tag) || e.target?.isContentEditable) return; const n = neighborsRef.current; if (e.key === 'ArrowLeft') { e.preventDefault(); navigate(n.prevId, n.prevUrl); } else if (e.key === 'ArrowRight') { e.preventDefault(); navigate(n.nextId, n.nextUrl); } else if ((e.key === 'f' || e.key === 'F') && !e.ctrlKey && !e.metaKey) { onOpenViewerRef.current?.(); } } window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [navigate]); // navigate is stable so this only runs once // Touch swipe useEffect(() => { function onTouchStart(e) { touchStartX.current = e.touches[0].clientX; touchStartY.current = e.touches[0].clientY; } function onTouchEnd(e) { if (touchStartX.current === null) return; const dx = e.changedTouches[0].clientX - touchStartX.current; const dy = e.changedTouches[0].clientY - touchStartY.current; touchStartX.current = null; if (Math.abs(dx) > 50 && Math.abs(dy) < 80) { const n = neighborsRef.current; if (dx > 0) navigate(n.prevId, n.prevUrl); else navigate(n.nextId, n.nextUrl); } } window.addEventListener('touchstart', onTouchStart, { passive: true }); window.addEventListener('touchend', onTouchEnd, { passive: true }); return () => { window.removeEventListener('touchstart', onTouchStart); window.removeEventListener('touchend', onTouchEnd); }; }, [navigate]); // stable return null; }