Files
SkinbaseNova/resources/js/components/viewer/ArtworkNavigator.jsx

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;
}