feat: increase gallery grid from 4 to 5 columns per row on desktopfeat: increase gallery grid from 4 to 5 columns per row on desktop
This commit is contained in:
159
resources/js/components/viewer/ArtworkNavigator.jsx
Normal file
159
resources/js/components/viewer/ArtworkNavigator.jsx
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user