172 lines
6.1 KiB
JavaScript
172 lines
6.1 KiB
JavaScript
/**
|
|
* 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);
|
|
const touchIgnoreSwipe = useRef(false);
|
|
|
|
// 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) {
|
|
touchIgnoreSwipe.current = Boolean(e.target?.closest?.('[data-nav-swipe-ignore="1"]'));
|
|
if (touchIgnoreSwipe.current) {
|
|
touchStartX.current = null;
|
|
touchStartY.current = null;
|
|
return;
|
|
}
|
|
touchStartX.current = e.touches[0].clientX;
|
|
touchStartY.current = e.touches[0].clientY;
|
|
}
|
|
function onTouchEnd(e) {
|
|
if (touchIgnoreSwipe.current) {
|
|
touchIgnoreSwipe.current = false;
|
|
return;
|
|
}
|
|
if (touchStartX.current === null) return;
|
|
const dx = e.changedTouches[0].clientX - touchStartX.current;
|
|
const dy = e.changedTouches[0].clientY - touchStartY.current;
|
|
touchStartX.current = null;
|
|
touchStartY.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;
|
|
}
|