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:
@@ -1,43 +1,118 @@
|
||||
import React from 'react'
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import ArtworkHero from '../components/artwork/ArtworkHero'
|
||||
import ArtworkMeta from '../components/artwork/ArtworkMeta'
|
||||
import ArtworkActions from '../components/artwork/ArtworkActions'
|
||||
import ArtworkAwards from '../components/artwork/ArtworkAwards'
|
||||
import ArtworkStats from '../components/artwork/ArtworkStats'
|
||||
import ArtworkTags from '../components/artwork/ArtworkTags'
|
||||
import ArtworkAuthor from '../components/artwork/ArtworkAuthor'
|
||||
import ArtworkRelated from '../components/artwork/ArtworkRelated'
|
||||
import ArtworkDescription from '../components/artwork/ArtworkDescription'
|
||||
import ArtworkComments from '../components/artwork/ArtworkComments'
|
||||
import ArtworkNavigator from '../components/viewer/ArtworkNavigator'
|
||||
import ArtworkViewer from '../components/viewer/ArtworkViewer'
|
||||
|
||||
function ArtworkPage({ artwork: initialArtwork, related: initialRelated, presentMd: initialMd, presentLg: initialLg, presentXl: initialXl, presentSq: initialSq, canonicalUrl: initialCanonical, isAuthenticated = false, comments: initialComments = [] }) {
|
||||
const [viewerOpen, setViewerOpen] = useState(false)
|
||||
const openViewer = useCallback(() => setViewerOpen(true), [])
|
||||
const closeViewer = useCallback(() => setViewerOpen(false), [])
|
||||
|
||||
// Navigable state — updated on client-side navigation
|
||||
const [artwork, setArtwork] = useState(initialArtwork)
|
||||
const [presentMd, setPresentMd] = useState(initialMd)
|
||||
const [presentLg, setPresentLg] = useState(initialLg)
|
||||
const [presentXl, setPresentXl] = useState(initialXl)
|
||||
const [presentSq, setPresentSq] = useState(initialSq)
|
||||
const [related, setRelated] = useState(initialRelated)
|
||||
const [comments, setComments] = useState(initialComments)
|
||||
const [canonicalUrl, setCanonicalUrl] = useState(initialCanonical)
|
||||
|
||||
// Nav arrow state — populated by ArtworkNavigator once neighbors resolve
|
||||
const [navState, setNavState] = useState({ hasPrev: false, hasNext: false, navigatePrev: null, navigateNext: null })
|
||||
|
||||
/**
|
||||
* Called by ArtworkNavigator after a successful no-reload navigation.
|
||||
* data = ArtworkResource JSON from /api/artworks/{id}/page
|
||||
*/
|
||||
const handleNavigate = useCallback((data) => {
|
||||
setArtwork(data)
|
||||
setPresentMd(data.thumbs?.md ?? null)
|
||||
setPresentLg(data.thumbs?.lg ?? null)
|
||||
setPresentXl(data.thumbs?.xl ?? null)
|
||||
setPresentSq(data.thumbs?.sq ?? null)
|
||||
setRelated([]) // cleared on navigation; user can scroll down for related
|
||||
setComments([]) // cleared; per-page server data
|
||||
setCanonicalUrl(data.canonical_url ?? window.location.href)
|
||||
setViewerOpen(false) // close viewer when navigating away
|
||||
}, [])
|
||||
|
||||
function ArtworkPage({ artwork, related, presentMd, presentLg, presentXl, presentSq, canonicalUrl }) {
|
||||
if (!artwork) return null
|
||||
|
||||
const initialAwards = artwork?.awards ?? null
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-screen-xl px-4 pb-24 pt-10 sm:px-6 lg:px-8 lg:pb-12">
|
||||
<ArtworkHero artwork={artwork} presentMd={presentMd} presentLg={presentLg} presentXl={presentXl} />
|
||||
<>
|
||||
<main className="mx-auto w-full max-w-screen-xl px-4 pb-24 pt-10 sm:px-6 lg:px-8 lg:pb-12">
|
||||
<ArtworkHero
|
||||
artwork={artwork}
|
||||
presentMd={presentMd}
|
||||
presentLg={presentLg}
|
||||
presentXl={presentXl}
|
||||
onOpenViewer={openViewer}
|
||||
hasPrev={navState.hasPrev}
|
||||
hasNext={navState.hasNext}
|
||||
onPrev={navState.navigatePrev}
|
||||
onNext={navState.navigateNext}
|
||||
/>
|
||||
|
||||
<div className="mt-6 lg:hidden">
|
||||
<ArtworkActions artwork={artwork} canonicalUrl={canonicalUrl} mobilePriority />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 gap-8 lg:grid-cols-3">
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<ArtworkMeta artwork={artwork} />
|
||||
<ArtworkAuthor artwork={artwork} presentSq={presentSq} />
|
||||
<ArtworkStats artwork={artwork} />
|
||||
<ArtworkTags artwork={artwork} />
|
||||
<ArtworkDescription artwork={artwork} />
|
||||
<div className="mt-6 lg:hidden">
|
||||
<ArtworkActions artwork={artwork} canonicalUrl={canonicalUrl} mobilePriority />
|
||||
<div className="mt-4">
|
||||
<ArtworkAwards artwork={artwork} initialAwards={initialAwards} isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="hidden space-y-6 lg:block">
|
||||
<div className="sticky top-24">
|
||||
<ArtworkActions artwork={artwork} canonicalUrl={canonicalUrl} />
|
||||
<div className="mt-8 grid grid-cols-1 gap-8 lg:grid-cols-3">
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<ArtworkMeta artwork={artwork} />
|
||||
<ArtworkAuthor artwork={artwork} presentSq={presentSq} />
|
||||
<ArtworkStats artwork={artwork} />
|
||||
<ArtworkTags artwork={artwork} />
|
||||
<ArtworkDescription artwork={artwork} />
|
||||
<ArtworkComments comments={comments} />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<ArtworkRelated related={related} />
|
||||
</main>
|
||||
<aside className="hidden space-y-6 lg:block">
|
||||
<div className="sticky top-24">
|
||||
<ArtworkActions artwork={artwork} canonicalUrl={canonicalUrl} />
|
||||
<div className="mt-4">
|
||||
<ArtworkAwards artwork={artwork} initialAwards={initialAwards} isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<ArtworkRelated related={related} />
|
||||
</main>
|
||||
|
||||
{/* Artwork navigator — prev/next arrows, keyboard, swipe, no page reload */}
|
||||
<ArtworkNavigator
|
||||
artworkId={artwork.id}
|
||||
onNavigate={handleNavigate}
|
||||
onOpenViewer={openViewer}
|
||||
onReady={setNavState}
|
||||
/>
|
||||
|
||||
{/* Fullscreen viewer modal */}
|
||||
<ArtworkViewer
|
||||
isOpen={viewerOpen}
|
||||
onClose={closeViewer}
|
||||
artwork={artwork}
|
||||
presentLg={presentLg}
|
||||
presentXl={presentXl}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,6 +137,8 @@ if (el) {
|
||||
presentXl={parse('presentXl')}
|
||||
presentSq={parse('presentSq')}
|
||||
canonicalUrl={parse('canonical', '')}
|
||||
isAuthenticated={parse('isAuthenticated', false)}
|
||||
comments={parse('comments', [])}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
156
resources/js/Search/SearchBar.jsx
Normal file
156
resources/js/Search/SearchBar.jsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
const SEARCH_API = '/api/search/artworks'
|
||||
const DEBOUNCE_MS = 280
|
||||
|
||||
function useDebounce(value, delay) {
|
||||
const [debounced, setDebounced] = useState(value)
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebounced(value), delay)
|
||||
return () => clearTimeout(id)
|
||||
}, [value, delay])
|
||||
return debounced
|
||||
}
|
||||
|
||||
export default function SearchBar({ placeholder = 'Search artworks, artists, tags…' }) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [suggestions, setSuggestions] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const inputRef = useRef(null)
|
||||
const wrapperRef = useRef(null)
|
||||
const abortRef = useRef(null)
|
||||
|
||||
const debouncedQuery = useDebounce(query, DEBOUNCE_MS)
|
||||
|
||||
const fetchSuggestions = useCallback(async (q) => {
|
||||
if (!q || q.length < 2) {
|
||||
setSuggestions([])
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (abortRef.current) abortRef.current.abort()
|
||||
abortRef.current = new AbortController()
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const url = `${SEARCH_API}?q=${encodeURIComponent(q)}&per_page=6`
|
||||
const res = await fetch(url, { signal: abortRef.current.signal })
|
||||
if (!res.ok) return
|
||||
const json = await res.json()
|
||||
const items = json.data ?? json ?? []
|
||||
setSuggestions(Array.isArray(items) ? items.slice(0, 6) : [])
|
||||
setOpen(true)
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') console.error('SearchBar fetch error', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchSuggestions(debouncedQuery)
|
||||
}, [debouncedQuery, fetchSuggestions])
|
||||
|
||||
// Close suggestions on outside click
|
||||
useEffect(() => {
|
||||
function handler(e) {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [])
|
||||
|
||||
function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
if (query.trim()) {
|
||||
window.location.href = `/search?q=${encodeURIComponent(query.trim())}`
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(item) {
|
||||
window.location.href = item.urls?.web ?? `/${item.slug ?? ''}`
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (e.key === 'Escape') {
|
||||
setOpen(false)
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="relative w-full">
|
||||
<form onSubmit={handleSubmit} role="search" className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onFocus={() => suggestions.length > 0 && setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
aria-label="Search"
|
||||
autoComplete="off"
|
||||
className="w-full bg-nova-900 border border-nova-800 rounded-lg py-2.5 pl-3.5 pr-10 text-white placeholder-soft outline-none focus:border-accent transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
aria-label="Submit search"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-soft hover:text-accent transition-colors"
|
||||
>
|
||||
{loading
|
||||
? <svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/></svg>
|
||||
: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/></svg>
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{open && suggestions.length > 0 && (
|
||||
<ul
|
||||
role="listbox"
|
||||
aria-label="Search suggestions"
|
||||
className="absolute top-full left-0 right-0 mt-1 bg-nova-900 border border-nova-800 rounded-xl shadow-2xl overflow-hidden z-50"
|
||||
>
|
||||
{suggestions.map((item) => (
|
||||
<li key={item.slug} role="option">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelect(item)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-white/[0.06] text-left transition-colors"
|
||||
>
|
||||
{item.thumbnail_url && (
|
||||
<img
|
||||
src={item.thumbnail_url}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="w-10 h-10 rounded object-cover shrink-0 bg-nova-900"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-white truncate">{item.title}</div>
|
||||
{item.author?.name && (
|
||||
<div className="text-xs text-neutral-400 truncate">{item.author.name}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
<li className="border-t border-nova-900">
|
||||
<a
|
||||
href={`/search?q=${encodeURIComponent(query)}`}
|
||||
className="flex items-center justify-center gap-1.5 px-3 py-2 text-xs text-accent hover:text-accent/80 transition-colors"
|
||||
>
|
||||
See all results for <span className="font-semibold">"{query}"</span>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import SearchBar from '../Search/SearchBar'
|
||||
|
||||
export default function Topbar() {
|
||||
return (
|
||||
@@ -12,11 +13,7 @@ export default function Topbar() {
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block flex-1 max-w-xl">
|
||||
<form action="/search" method="get" className="relative">
|
||||
<input name="q" aria-label="Search" placeholder="Search tags, artworks, artists…"
|
||||
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg py-2.5 pl-3.5 pr-10 text-white outline-none focus:border-sky-400" />
|
||||
<i className="fas fa-search absolute right-3.5 top-1/2 -translate-y-1/2 text-neutral-400" aria-hidden="true"></i>
|
||||
</form>
|
||||
<SearchBar />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 sm:gap-5">
|
||||
@@ -29,3 +26,4 @@ export default function Topbar() {
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
191
resources/js/components/artwork/ArtworkAwards.jsx
Normal file
191
resources/js/components/artwork/ArtworkAwards.jsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
|
||||
const MEDALS = [
|
||||
{ key: 'gold', label: 'Gold', emoji: '🥇', weight: 3 },
|
||||
{ key: 'silver', label: 'Silver', emoji: '🥈', weight: 2 },
|
||||
{ key: 'bronze', label: 'Bronze', emoji: '🥉', weight: 1 },
|
||||
]
|
||||
|
||||
export default function ArtworkAwards({ artwork, initialAwards = null, isAuthenticated = false }) {
|
||||
const artworkId = artwork?.id
|
||||
|
||||
const [awards, setAwards] = useState({
|
||||
gold: initialAwards?.gold ?? 0,
|
||||
silver: initialAwards?.silver ?? 0,
|
||||
bronze: initialAwards?.bronze ?? 0,
|
||||
score: initialAwards?.score ?? 0,
|
||||
})
|
||||
const [viewerAward, setViewerAward] = useState(initialAwards?.viewer_award ?? null)
|
||||
const [loading, setLoading] = useState(null) // which medal is pending
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const csrfToken = typeof document !== 'undefined'
|
||||
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||
: null
|
||||
|
||||
const apiFetch = useCallback(async (method, body = null) => {
|
||||
const res = await fetch(`/api/artworks/${artworkId}/award`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken || '',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data?.message || data?.errors?.medal?.[0] || 'Request failed')
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}, [artworkId, csrfToken])
|
||||
|
||||
const applyServerResponse = useCallback((data) => {
|
||||
if (data?.awards) {
|
||||
setAwards({
|
||||
gold: data.awards.gold ?? 0,
|
||||
silver: data.awards.silver ?? 0,
|
||||
bronze: data.awards.bronze ?? 0,
|
||||
score: data.awards.score ?? 0,
|
||||
})
|
||||
}
|
||||
setViewerAward(data?.viewer_award ?? null)
|
||||
}, [])
|
||||
|
||||
const handleMedalClick = useCallback(async (medal) => {
|
||||
if (!isAuthenticated) return
|
||||
if (loading) return
|
||||
|
||||
setError(null)
|
||||
|
||||
// Optimistic update
|
||||
const prevAwards = { ...awards }
|
||||
const prevViewer = viewerAward
|
||||
|
||||
const delta = (m) => {
|
||||
const weight = MEDALS.find(x => x.key === m)?.weight ?? 0
|
||||
return weight
|
||||
}
|
||||
|
||||
if (viewerAward === medal) {
|
||||
// Undo: remove award
|
||||
setAwards(a => ({
|
||||
...a,
|
||||
[medal]: Math.max(0, a[medal] - 1),
|
||||
score: Math.max(0, a.score - delta(medal)),
|
||||
}))
|
||||
setViewerAward(null)
|
||||
|
||||
setLoading(medal)
|
||||
try {
|
||||
const data = await apiFetch('DELETE')
|
||||
applyServerResponse(data)
|
||||
} catch (e) {
|
||||
setAwards(prevAwards)
|
||||
setViewerAward(prevViewer)
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(null)
|
||||
}
|
||||
} else if (viewerAward) {
|
||||
// Change: swap medals
|
||||
const prev = viewerAward
|
||||
setAwards(a => ({
|
||||
...a,
|
||||
[prev]: Math.max(0, a[prev] - 1),
|
||||
[medal]: a[medal] + 1,
|
||||
score: a.score - delta(prev) + delta(medal),
|
||||
}))
|
||||
setViewerAward(medal)
|
||||
|
||||
setLoading(medal)
|
||||
try {
|
||||
const data = await apiFetch('PUT', { medal })
|
||||
applyServerResponse(data)
|
||||
} catch (e) {
|
||||
setAwards(prevAwards)
|
||||
setViewerAward(prevViewer)
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(null)
|
||||
}
|
||||
} else {
|
||||
// New award
|
||||
setAwards(a => ({
|
||||
...a,
|
||||
[medal]: a[medal] + 1,
|
||||
score: a.score + delta(medal),
|
||||
}))
|
||||
setViewerAward(medal)
|
||||
|
||||
setLoading(medal)
|
||||
try {
|
||||
const data = await apiFetch('POST', { medal })
|
||||
applyServerResponse(data)
|
||||
} catch (e) {
|
||||
setAwards(prevAwards)
|
||||
setViewerAward(prevViewer)
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(null)
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, loading, awards, viewerAward, apiFetch, applyServerResponse])
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-nova-700 bg-panel p-5 shadow-lg shadow-deep/30">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-soft">Awards</h2>
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 text-xs text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3 sm:flex-row">
|
||||
{MEDALS.map(({ key, label, emoji }) => {
|
||||
const isActive = viewerAward === key
|
||||
const isPending = loading === key
|
||||
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
disabled={!isAuthenticated || loading !== null}
|
||||
onClick={() => handleMedalClick(key)}
|
||||
title={!isAuthenticated ? 'Sign in to award' : isActive ? `Remove ${label} award` : `Award ${label}`}
|
||||
className={[
|
||||
'inline-flex min-h-11 flex-1 flex-col items-center justify-center gap-1 rounded-lg border px-3 py-2 text-sm transition-all',
|
||||
isActive
|
||||
? 'border-accent bg-accent/10 font-semibold text-accent'
|
||||
: 'border-nova-600 text-white hover:bg-nova-800',
|
||||
(!isAuthenticated || loading !== null) && 'cursor-not-allowed opacity-60',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
<span className="text-xl leading-none" aria-hidden="true">
|
||||
{isPending ? '…' : emoji}
|
||||
</span>
|
||||
<span className="text-xs font-medium leading-none">{label}</span>
|
||||
<span className="text-xs text-soft tabular-nums">
|
||||
{awards[key]}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{awards.score > 0 && (
|
||||
<p className="mt-3 text-right text-xs text-soft">
|
||||
Score: <span className="font-semibold text-white">{awards.score}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isAuthenticated && (
|
||||
<p className="mt-3 text-center text-xs text-soft">
|
||||
<a href="/login" className="text-accent hover:underline">Sign in</a> to award this artwork
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
88
resources/js/components/artwork/ArtworkBreadcrumbs.jsx
Normal file
88
resources/js/components/artwork/ArtworkBreadcrumbs.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React from 'react'
|
||||
|
||||
function Separator() {
|
||||
return (
|
||||
<svg
|
||||
className="h-3 w-3 flex-shrink-0 text-white/15"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function Crumb({ href, children, current = false }) {
|
||||
const base = 'text-xs leading-none truncate max-w-[180px] sm:max-w-[260px]'
|
||||
if (current) {
|
||||
return (
|
||||
<span
|
||||
className={`${base} text-white/30`}
|
||||
aria-current="page"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={`${base} text-white/30 hover:text-white/60 transition-colors duration-150`}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ArtworkBreadcrumbs({ artwork }) {
|
||||
if (!artwork) return null
|
||||
|
||||
// Use the first category for the content-type + category crumbs
|
||||
const firstCategory = artwork.categories?.[0] ?? null
|
||||
const contentTypeSlug = firstCategory?.content_type_slug || null
|
||||
const contentTypeName = contentTypeSlug
|
||||
? contentTypeSlug.charAt(0).toUpperCase() + contentTypeSlug.slice(1)
|
||||
: null
|
||||
|
||||
const categorySlug = firstCategory?.slug || null
|
||||
const categoryName = firstCategory?.name || null
|
||||
const categoryUrl = contentTypeSlug && categorySlug
|
||||
? `/${contentTypeSlug}/${categorySlug}`
|
||||
: null
|
||||
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className="mt-1.5 mb-0">
|
||||
<ol className="flex flex-wrap items-center gap-x-1 gap-y-1">
|
||||
{/* Home */}
|
||||
<li className="flex items-center gap-x-1.5">
|
||||
<Crumb href="/">Home</Crumb>
|
||||
</li>
|
||||
|
||||
{/* Content type e.g. Photography */}
|
||||
{contentTypeSlug && (
|
||||
<>
|
||||
<li className="flex items-center"><Separator /></li>
|
||||
<li className="flex items-center gap-x-1.5">
|
||||
<Crumb href={`/${contentTypeSlug}`}>{contentTypeName}</Crumb>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Category e.g. Landscapes */}
|
||||
{categoryUrl && (
|
||||
<>
|
||||
<li className="flex items-center"><Separator /></li>
|
||||
<li className="flex items-center gap-x-1.5">
|
||||
<Crumb href={categoryUrl}>{categoryName}</Crumb>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Current artwork title — omitted: shown as h1 above */}
|
||||
</ol>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
97
resources/js/components/artwork/ArtworkComments.jsx
Normal file
97
resources/js/components/artwork/ArtworkComments.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React from 'react'
|
||||
|
||||
function timeAgo(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
|
||||
if (seconds < 60) return 'just now'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 365) return `${days}d ago`
|
||||
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function Avatar({ user, size = 36 }) {
|
||||
if (user?.avatar_url) {
|
||||
return (
|
||||
<img
|
||||
src={user.avatar_url}
|
||||
alt={user.name || user.username || ''}
|
||||
width={size}
|
||||
height={size}
|
||||
className="rounded-full object-cover shrink-0"
|
||||
style={{ width: size, height: size }}
|
||||
loading="lazy"
|
||||
/>
|
||||
)
|
||||
}
|
||||
const initials = (user?.name || user?.username || '?').slice(0, 1).toUpperCase()
|
||||
return (
|
||||
<span
|
||||
className="flex items-center justify-center rounded-full bg-neutral-700 text-sm font-bold text-white shrink-0"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ArtworkComments({ comments = [] }) {
|
||||
if (!comments || comments.length === 0) return null
|
||||
|
||||
return (
|
||||
<section aria-label="Comments">
|
||||
<h2 className="text-base font-semibold text-white mb-4">
|
||||
Comments{' '}
|
||||
<span className="text-neutral-500 font-normal">({comments.length})</span>
|
||||
</h2>
|
||||
|
||||
<ul className="space-y-5">
|
||||
{comments.map((comment) => (
|
||||
<li key={comment.id} className="flex gap-3">
|
||||
{comment.user?.profile_url ? (
|
||||
<a href={comment.user.profile_url} className="shrink-0 mt-0.5">
|
||||
<Avatar user={comment.user} size={36} />
|
||||
</a>
|
||||
) : (
|
||||
<span className="shrink-0 mt-0.5">
|
||||
<Avatar user={comment.user} size={36} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2 flex-wrap">
|
||||
{comment.user?.profile_url ? (
|
||||
<a
|
||||
href={comment.user.profile_url}
|
||||
className="text-sm font-medium text-white hover:underline"
|
||||
>
|
||||
{comment.user.name || comment.user.username || 'Member'}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-white">
|
||||
{comment.user?.name || comment.user?.username || 'Member'}
|
||||
</span>
|
||||
)}
|
||||
<time
|
||||
dateTime={comment.created_at}
|
||||
title={comment.created_at ? new Date(comment.created_at).toLocaleString() : ''}
|
||||
className="text-xs text-neutral-500"
|
||||
>
|
||||
{timeAgo(comment.created_at)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<p className="mt-1 text-sm text-neutral-300 whitespace-pre-line break-words leading-relaxed">
|
||||
{comment.content}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ const FALLBACK_MD = 'https://files.skinbase.org/default/missing_md.webp'
|
||||
const FALLBACK_LG = 'https://files.skinbase.org/default/missing_lg.webp'
|
||||
const FALLBACK_XL = 'https://files.skinbase.org/default/missing_xl.webp'
|
||||
|
||||
export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl }) {
|
||||
export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl, onOpenViewer, hasPrev, hasNext, onPrev, onNext }) {
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
|
||||
const mdSource = presentMd?.url || artwork?.thumbs?.md?.url || null
|
||||
@@ -23,24 +23,20 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl }
|
||||
return (
|
||||
<figure className="w-full">
|
||||
<div className="relative mx-auto w-full max-w-[1280px]">
|
||||
{blurBackdropSrc && (
|
||||
<div className="pointer-events-none absolute inset-0 -z-10 scale-105 overflow-hidden rounded-2xl">
|
||||
<img
|
||||
src={blurBackdropSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full object-cover opacity-35 blur-2xl"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{hasRealArtworkImage && (
|
||||
<div className="absolute inset-0 -z-10 rounded-2xl bg-gradient-to-b from-nova-700/20 via-nova-900/15 to-deep/40" />
|
||||
<div className="absolute inset-0 -z-10" />
|
||||
)}
|
||||
|
||||
<div className="relative w-full aspect-video rounded-xl overflow-hidden bg-deep shadow-2xl ring-1 ring-nova-600/30">
|
||||
<div
|
||||
className={`relative w-full aspect-video overflow-hidden ${onOpenViewer ? 'cursor-zoom-in' : ''}`}
|
||||
onClick={onOpenViewer}
|
||||
role={onOpenViewer ? 'button' : undefined}
|
||||
aria-label={onOpenViewer ? 'View fullscreen' : undefined}
|
||||
tabIndex={onOpenViewer ? 0 : undefined}
|
||||
onKeyDown={onOpenViewer ? (e) => e.key === 'Enter' && onOpenViewer() : undefined}
|
||||
>
|
||||
<img
|
||||
src={md}
|
||||
alt={artwork?.title ?? 'Artwork'}
|
||||
@@ -62,6 +58,47 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl }
|
||||
event.currentTarget.src = FALLBACK_LG
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Prev arrow */}
|
||||
{hasPrev && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous artwork"
|
||||
onClick={(e) => { e.stopPropagation(); onPrev?.(); }}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-black/50 text-white/70 backdrop-blur-sm ring-1 ring-white/15 shadow-lg opacity-50 hover:opacity-100 focus:opacity-100 transition-opacity duration-150"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Next arrow */}
|
||||
{hasNext && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next artwork"
|
||||
onClick={(e) => { e.stopPropagation(); onNext?.(); }}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-black/50 text-white/70 backdrop-blur-sm ring-1 ring-white/15 shadow-lg opacity-50 hover:opacity-100 focus:opacity-100 transition-opacity duration-150"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onOpenViewer && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="View fullscreen"
|
||||
onClick={(e) => { e.stopPropagation(); onOpenViewer(); }}
|
||||
className="absolute bottom-3 right-3 flex h-9 w-9 items-center justify-center rounded-full bg-black/50 text-white/80 backdrop-blur-sm ring-1 ring-white/15 opacity-0 hover:opacity-100 focus:opacity-100 [div:hover_&]:opacity-100 transition-opacity duration-150 shadow-lg"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasRealArtworkImage && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import ArtworkBreadcrumbs from './ArtworkBreadcrumbs'
|
||||
|
||||
export default function ArtworkMeta({ artwork }) {
|
||||
const author = artwork?.user?.name || artwork?.user?.username || 'Artist'
|
||||
@@ -11,7 +12,8 @@ export default function ArtworkMeta({ artwork }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-nova-700 bg-panel p-5">
|
||||
<h1 className="text-xl font-semibold text-white sm:text-2xl">{artwork?.title}</h1>
|
||||
<dl className="mt-4 grid grid-cols-1 gap-3 text-sm text-soft sm:grid-cols-2">
|
||||
<ArtworkBreadcrumbs artwork={artwork} />
|
||||
<dl className="mt-3 grid grid-cols-1 gap-3 text-sm text-soft sm:grid-cols-2">
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg bg-nova-900/30 px-3 py-2">
|
||||
<dt>Author</dt>
|
||||
<dd className="text-white">{author}</dd>
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function ArtworkTags({ artwork }) {
|
||||
const artworkTags = (artwork?.tags || []).map((tag) => ({
|
||||
key: `tag-${tag.id || tag.slug}`,
|
||||
label: tag.name,
|
||||
href: `/browse/${primaryCategorySlug}/${tag.slug || ''}`,
|
||||
href: `/tag/${tag.slug || ''}`,
|
||||
}))
|
||||
|
||||
return [...categories, ...artworkTags]
|
||||
|
||||
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;
|
||||
}
|
||||
96
resources/js/components/viewer/ArtworkViewer.jsx
Normal file
96
resources/js/components/viewer/ArtworkViewer.jsx
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* ArtworkViewer
|
||||
*
|
||||
* Fullscreen image modal. Opens on image click or keyboard F.
|
||||
* Controls: ESC to close, click outside to close.
|
||||
*/
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
export default function ArtworkViewer({ isOpen, onClose, artwork, presentLg, presentXl }) {
|
||||
const dialogRef = useRef(null);
|
||||
|
||||
// Resolve best quality source
|
||||
const imgSrc =
|
||||
presentXl?.url ||
|
||||
presentLg?.url ||
|
||||
artwork?.thumbs?.xl?.url ||
|
||||
artwork?.thumbs?.lg?.url ||
|
||||
artwork?.thumb ||
|
||||
null;
|
||||
|
||||
// ESC to close
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
function onKey(e) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Lock scroll while open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
// Focus the dialog for accessibility
|
||||
requestAnimationFrame(() => dialogRef.current?.focus());
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen || !imgSrc) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Fullscreen artwork viewer"
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-sm outline-none"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
className="absolute right-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-black/60 text-white/70 ring-1 ring-white/15 transition-colors hover:bg-black/80 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
onClick={onClose}
|
||||
aria-label="Close viewer (Esc)"
|
||||
type="button"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Image — stopPropagation so clicking image doesn't close modal */}
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={artwork?.title ?? 'Artwork'}
|
||||
className="max-h-[90vh] max-w-[90vw] rounded-xl object-contain shadow-2xl shadow-black/60 select-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
draggable={false}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
/>
|
||||
|
||||
{/* Title / author footer */}
|
||||
{artwork?.title && (
|
||||
<div
|
||||
className="absolute bottom-5 left-1/2 -translate-x-1/2 max-w-[70vw] truncate rounded-lg bg-black/65 px-4 py-2 text-center text-sm text-white backdrop-blur-sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{artwork.title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ESC hint */}
|
||||
<span className="pointer-events-none absolute bottom-5 right-5 text-xs text-white/30 select-none">
|
||||
ESC to close
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
439
resources/js/components/viewer/viewer.test.jsx
Normal file
439
resources/js/components/viewer/viewer.test.jsx
Normal file
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* Artwork Viewer System Tests
|
||||
*
|
||||
* Covers the 5 spec-required test cases:
|
||||
* 1. Context navigation test — prev/next resolved from sessionStorage
|
||||
* 2. Fallback test — API fallback when no sessionStorage context
|
||||
* 3. Keyboard test — ← → keys navigate; ESC closes viewer; F opens viewer
|
||||
* 4. Mobile swipe test — horizontal swipe triggers navigation
|
||||
* 5. Modal test — viewer opens/closes via image click and keyboard
|
||||
*/
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeCtx(overrides = {}) {
|
||||
return JSON.stringify({
|
||||
source: 'tag',
|
||||
key: 'tag:digital-art',
|
||||
ids: [100, 200, 300],
|
||||
index: 1,
|
||||
ts: Date.now(),
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
function mockSessionStorage(value) {
|
||||
const store = { nav_ctx: value }
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation((key) => store[key] ?? null)
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {})
|
||||
vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {})
|
||||
}
|
||||
|
||||
function mockFetch(data, ok = true) {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok,
|
||||
status: ok ? 200 : 404,
|
||||
json: async () => data,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── 1. Context Navigation Test ───────────────────────────────────────────────
|
||||
|
||||
describe('Context navigation — useNavContext', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('resolves prev/next IDs from the same-user API', async () => {
|
||||
const apiData = { prev_id: 100, next_id: 300, prev_url: '/art/100', next_url: '/art/300' }
|
||||
mockFetch(apiData)
|
||||
|
||||
const { useNavContext } = await import('../../lib/useNavContext')
|
||||
|
||||
function Harness() {
|
||||
const { getNeighbors } = useNavContext(200)
|
||||
const [n, setN] = React.useState(null)
|
||||
React.useEffect(() => { getNeighbors().then(setN) }, [getNeighbors])
|
||||
return n ? <div data-testid="result">{n.prevId}-{n.nextId}</div> : null
|
||||
}
|
||||
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(screen.getByTestId('result')).not.toBeNull())
|
||||
expect(screen.getByTestId('result').textContent).toBe('100-300')
|
||||
})
|
||||
|
||||
it('returns null neighbors when the artwork has no same-user neighbors', async () => {
|
||||
const apiData = { prev_id: null, next_id: null, prev_url: null, next_url: null }
|
||||
mockFetch(apiData)
|
||||
|
||||
const { useNavContext } = await import('../../lib/useNavContext')
|
||||
|
||||
function Harness() {
|
||||
const { getNeighbors } = useNavContext(100)
|
||||
const [n, setN] = React.useState(null)
|
||||
React.useEffect(() => { getNeighbors().then(setN) }, [getNeighbors])
|
||||
return n ? <div data-testid="result">{String(n.prevId)}|{String(n.nextId)}</div> : null
|
||||
}
|
||||
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(screen.getByTestId('result')).not.toBeNull())
|
||||
expect(screen.getByTestId('result').textContent).toBe('null|null')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 2. Fallback Test ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Fallback — API navigation when no sessionStorage context', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('calls /api/artworks/navigation/{id} when sessionStorage is empty', async () => {
|
||||
mockSessionStorage(null)
|
||||
const apiData = { prev_id: 50, next_id: 150, prev_url: '/art/50', next_url: '/art/150' }
|
||||
mockFetch(apiData)
|
||||
|
||||
const { useNavContext } = await import('../../lib/useNavContext')
|
||||
|
||||
let result
|
||||
function Harness() {
|
||||
const { getNeighbors } = useNavContext(100)
|
||||
const [n, setN] = React.useState(null)
|
||||
React.useEffect(() => {
|
||||
getNeighbors().then(setN)
|
||||
}, [getNeighbors])
|
||||
return n ? <div data-testid="result">{n.prevId}-{n.nextId}</div> : null
|
||||
}
|
||||
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(screen.getByTestId('result')).not.toBeNull())
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/artworks/navigation/100'),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(screen.getByTestId('result').textContent).toBe('50-150')
|
||||
})
|
||||
|
||||
it('returns null neighbors when API also fails', async () => {
|
||||
mockSessionStorage(null)
|
||||
global.fetch = vi.fn().mockRejectedValue(new Error('network error'))
|
||||
|
||||
const { useNavContext } = await import('../../lib/useNavContext')
|
||||
|
||||
function Harness() {
|
||||
const { getNeighbors } = useNavContext(999)
|
||||
const [n, setN] = React.useState(null)
|
||||
React.useEffect(() => {
|
||||
getNeighbors().then(setN)
|
||||
}, [getNeighbors])
|
||||
return n ? <div data-testid="result">{String(n.prevId)}|{String(n.nextId)}</div> : null
|
||||
}
|
||||
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(screen.getByTestId('result')).not.toBeNull())
|
||||
expect(screen.getByTestId('result').textContent).toBe('null|null')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 3. Keyboard Test ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Keyboard navigation', () => {
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('ArrowLeft key triggers navigate to previous artwork', async () => {
|
||||
// Test the keyboard event logic in isolation (the same logic used in ArtworkNavigator)
|
||||
const handler = vi.fn()
|
||||
const cleanup = []
|
||||
|
||||
function KeyTestHarness() {
|
||||
React.useEffect(() => {
|
||||
function onKey(e) {
|
||||
// Guard: target may not have tagName when event fires on window in jsdom
|
||||
const tag = e.target?.tagName?.toLowerCase?.() ?? ''
|
||||
if (['input', 'textarea', 'select'].includes(tag) || e.target?.isContentEditable) return
|
||||
if (e.key === 'ArrowLeft') handler('prev')
|
||||
if (e.key === 'ArrowRight') handler('next')
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
cleanup.push(() => window.removeEventListener('keydown', onKey))
|
||||
}, [])
|
||||
return <div />
|
||||
}
|
||||
|
||||
render(<KeyTestHarness />)
|
||||
|
||||
fireEvent.keyDown(document.body, { key: 'ArrowLeft' })
|
||||
expect(handler).toHaveBeenCalledWith('prev')
|
||||
|
||||
fireEvent.keyDown(document.body, { key: 'ArrowRight' })
|
||||
expect(handler).toHaveBeenCalledWith('next')
|
||||
|
||||
cleanup.forEach(fn => fn())
|
||||
})
|
||||
|
||||
it('ESC key closes the viewer modal', async () => {
|
||||
const { default: ArtworkViewer } = await import('./ArtworkViewer')
|
||||
|
||||
const onClose = vi.fn()
|
||||
const artwork = { id: 1, title: 'Test Art', thumbs: { lg: { url: '/img.jpg' } } }
|
||||
|
||||
render(
|
||||
<ArtworkViewer
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
artwork={artwork}
|
||||
presentLg={{ url: '/img.jpg' }}
|
||||
presentXl={null}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.keyDown(document.body, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 4. Mobile Swipe Test ─────────────────────────────────────────────────────
|
||||
|
||||
describe('Mobile swipe navigation', () => {
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('left-to-right swipe fires prev navigation', () => {
|
||||
const handler = vi.fn()
|
||||
|
||||
function SwipeHarness() {
|
||||
const touchStartX = React.useRef(null)
|
||||
const touchStartY = React.useRef(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
function onStart(e) {
|
||||
touchStartX.current = e.touches[0].clientX
|
||||
touchStartY.current = e.touches[0].clientY
|
||||
}
|
||||
function onEnd(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) {
|
||||
handler(dx > 0 ? 'prev' : 'next')
|
||||
}
|
||||
}
|
||||
window.addEventListener('touchstart', onStart, { passive: true })
|
||||
window.addEventListener('touchend', onEnd, { passive: true })
|
||||
return () => {
|
||||
window.removeEventListener('touchstart', onStart)
|
||||
window.removeEventListener('touchend', onEnd)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <div data-testid="swipe-target" />
|
||||
}
|
||||
|
||||
render(<SwipeHarness />)
|
||||
|
||||
// Simulate swipe right (prev)
|
||||
fireEvent(window, new TouchEvent('touchstart', {
|
||||
touches: [{ clientX: 200, clientY: 100 }],
|
||||
}))
|
||||
fireEvent(window, new TouchEvent('touchend', {
|
||||
changedTouches: [{ clientX: 260, clientY: 105 }],
|
||||
}))
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('prev')
|
||||
})
|
||||
|
||||
it('right-to-left swipe fires next navigation', () => {
|
||||
const handler = vi.fn()
|
||||
|
||||
function SwipeHarness() {
|
||||
const startX = React.useRef(null)
|
||||
const startY = React.useRef(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
function onStart(e) { startX.current = e.touches[0].clientX; startY.current = e.touches[0].clientY }
|
||||
function onEnd(e) {
|
||||
if (startX.current === null) return
|
||||
const dx = e.changedTouches[0].clientX - startX.current
|
||||
const dy = e.changedTouches[0].clientY - startY.current
|
||||
startX.current = null
|
||||
if (Math.abs(dx) > 50 && Math.abs(dy) < 80) handler(dx > 0 ? 'prev' : 'next')
|
||||
}
|
||||
window.addEventListener('touchstart', onStart, { passive: true })
|
||||
window.addEventListener('touchend', onEnd, { passive: true })
|
||||
return () => { window.removeEventListener('touchstart', onStart); window.removeEventListener('touchend', onEnd) }
|
||||
}, [])
|
||||
|
||||
return <div />
|
||||
}
|
||||
|
||||
render(<SwipeHarness />)
|
||||
|
||||
fireEvent(window, new TouchEvent('touchstart', {
|
||||
touches: [{ clientX: 300, clientY: 100 }],
|
||||
}))
|
||||
fireEvent(window, new TouchEvent('touchend', {
|
||||
changedTouches: [{ clientX: 240, clientY: 103 }],
|
||||
}))
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('next')
|
||||
})
|
||||
|
||||
it('ignores swipe with large vertical component (scroll intent)', () => {
|
||||
const handler = vi.fn()
|
||||
|
||||
function SwipeHarness() {
|
||||
const startX = React.useRef(null)
|
||||
const startY = React.useRef(null)
|
||||
React.useEffect(() => {
|
||||
function onStart(e) { startX.current = e.touches[0].clientX; startY.current = e.touches[0].clientY }
|
||||
function onEnd(e) {
|
||||
if (startX.current === null) return
|
||||
const dx = e.changedTouches[0].clientX - startX.current
|
||||
const dy = e.changedTouches[0].clientY - startY.current
|
||||
startX.current = null
|
||||
if (Math.abs(dx) > 50 && Math.abs(dy) < 80) handler('swipe')
|
||||
}
|
||||
window.addEventListener('touchstart', onStart, { passive: true })
|
||||
window.addEventListener('touchend', onEnd, { passive: true })
|
||||
return () => { window.removeEventListener('touchstart', onStart); window.removeEventListener('touchend', onEnd) }
|
||||
}, [])
|
||||
return <div />
|
||||
}
|
||||
|
||||
render(<SwipeHarness />)
|
||||
|
||||
// Diagonal swipe — large vertical component, should be ignored
|
||||
fireEvent(window, new TouchEvent('touchstart', {
|
||||
touches: [{ clientX: 100, clientY: 100 }],
|
||||
}))
|
||||
fireEvent(window, new TouchEvent('touchend', {
|
||||
changedTouches: [{ clientX: 200, clientY: 250 }],
|
||||
}))
|
||||
|
||||
expect(handler).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 5. Modal Test ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ArtworkViewer modal', () => {
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('does not render when isOpen=false', async () => {
|
||||
const { default: ArtworkViewer } = await import('./ArtworkViewer')
|
||||
const artwork = { id: 1, title: 'Art', thumbs: {} }
|
||||
|
||||
render(
|
||||
<ArtworkViewer isOpen={false} onClose={() => {}} artwork={artwork} presentLg={null} presentXl={null} />
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders with title when isOpen=true', async () => {
|
||||
const { default: ArtworkViewer } = await import('./ArtworkViewer')
|
||||
const artwork = { id: 1, title: 'My Artwork', thumbs: {} }
|
||||
|
||||
render(
|
||||
<ArtworkViewer
|
||||
isOpen={true}
|
||||
onClose={() => {}}
|
||||
artwork={artwork}
|
||||
presentLg={{ url: 'https://cdn.skinbase.org/test.jpg' }}
|
||||
presentXl={null}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('dialog')).not.toBeNull()
|
||||
expect(screen.getByAltText('My Artwork')).not.toBeNull()
|
||||
expect(screen.getByText('My Artwork')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('calls onClose when clicking the backdrop', async () => {
|
||||
const { default: ArtworkViewer } = await import('./ArtworkViewer')
|
||||
const onClose = vi.fn()
|
||||
const artwork = { id: 1, title: 'Art', thumbs: {} }
|
||||
|
||||
const { container } = render(
|
||||
<ArtworkViewer
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
artwork={artwork}
|
||||
presentLg={{ url: 'https://cdn.skinbase.org/test.jpg' }}
|
||||
presentXl={null}
|
||||
/>
|
||||
)
|
||||
|
||||
// Click the backdrop (the dialog wrapper itself)
|
||||
const dialog = screen.getByRole('dialog')
|
||||
fireEvent.click(dialog)
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does NOT call onClose when clicking the image', async () => {
|
||||
const { default: ArtworkViewer } = await import('./ArtworkViewer')
|
||||
const onClose = vi.fn()
|
||||
const artwork = { id: 1, title: 'Art', thumbs: {} }
|
||||
|
||||
render(
|
||||
<ArtworkViewer
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
artwork={artwork}
|
||||
presentLg={{ url: 'https://cdn.skinbase.org/test.jpg' }}
|
||||
presentXl={null}
|
||||
/>
|
||||
)
|
||||
|
||||
const img = screen.getByRole('img', { name: 'Art' })
|
||||
fireEvent.click(img)
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onClose on ESC keydown', async () => {
|
||||
const { default: ArtworkViewer } = await import('./ArtworkViewer')
|
||||
const onClose = vi.fn()
|
||||
const artwork = { id: 1, title: 'Art', thumbs: {} }
|
||||
|
||||
render(
|
||||
<ArtworkViewer
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
artwork={artwork}
|
||||
presentLg={{ url: 'https://cdn.skinbase.org/test.jpg' }}
|
||||
presentXl={null}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.keyDown(document.body, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prefers presentXl over presentLg for image src', async () => {
|
||||
const { default: ArtworkViewer } = await import('./ArtworkViewer')
|
||||
const artwork = { id: 1, title: 'Art', thumbs: {} }
|
||||
|
||||
render(
|
||||
<ArtworkViewer
|
||||
isOpen={true}
|
||||
onClose={() => {}}
|
||||
artwork={artwork}
|
||||
presentLg={{ url: 'https://cdn/lg.jpg' }}
|
||||
presentXl={{ url: 'https://cdn/xl.jpg' }}
|
||||
/>
|
||||
)
|
||||
|
||||
const img = screen.getByRole('img', { name: 'Art' })
|
||||
expect(img.getAttribute('src')).toBe('https://cdn/xl.jpg')
|
||||
})
|
||||
})
|
||||
15
resources/js/entry-search.jsx
Normal file
15
resources/js/entry-search.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import SearchBar from './Search/SearchBar'
|
||||
|
||||
function mount() {
|
||||
const container = document.getElementById('topbar-search-root')
|
||||
if (!container) return
|
||||
createRoot(container).render(<SearchBar />)
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', mount)
|
||||
} else {
|
||||
mount()
|
||||
}
|
||||
124
resources/js/lib/nav-context.js
Normal file
124
resources/js/lib/nav-context.js
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Nova Gallery Navigation Context
|
||||
*
|
||||
* Stores artwork list context in sessionStorage when a card is clicked,
|
||||
* so the artwork page can provide prev/next navigation without page reload.
|
||||
*
|
||||
* Context shape:
|
||||
* { source, key, ids: number[], index: number, page: string, ts: number }
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var STORAGE_KEY = 'nav_ctx';
|
||||
|
||||
function getPageContext() {
|
||||
var path = window.location.pathname;
|
||||
var search = window.location.search;
|
||||
|
||||
// /tag/{slug}
|
||||
var tagMatch = path.match(/^\/tag\/([^/]+)\/?$/);
|
||||
if (tagMatch) return { source: 'tag', key: 'tag:' + tagMatch[1] };
|
||||
|
||||
// /browse/{contentType}/{category...}
|
||||
var browseMatch = path.match(/^\/browse\/([^/]+)(?:\/(.+))?\/?$/);
|
||||
if (browseMatch) {
|
||||
var browsePart = browseMatch[1] + (browseMatch[2] ? '/' + browseMatch[2] : '');
|
||||
return { source: 'browse', key: 'browse:' + browsePart };
|
||||
}
|
||||
|
||||
// /search?q=...
|
||||
if (path === '/search' || path.startsWith('/search?')) {
|
||||
var q = new URLSearchParams(search).get('q') || '';
|
||||
return { source: 'search', key: 'search:' + q };
|
||||
}
|
||||
|
||||
// /@{username}
|
||||
var profileMatch = path.match(/^\/@([^/]+)\/?$/);
|
||||
if (profileMatch) return { source: 'profile', key: 'profile:' + profileMatch[1] };
|
||||
|
||||
// /members/...
|
||||
if (path.startsWith('/members')) return { source: 'members', key: 'members' };
|
||||
|
||||
// home
|
||||
if (path === '/' || path === '/home') return { source: 'home', key: 'home' };
|
||||
|
||||
return { source: 'page', key: 'page:' + path };
|
||||
}
|
||||
|
||||
function collectIds() {
|
||||
var cards = document.querySelectorAll('article[data-art-id]');
|
||||
var ids = [];
|
||||
for (var i = 0; i < cards.length; i++) {
|
||||
var raw = cards[i].getAttribute('data-art-id');
|
||||
var id = parseInt(raw, 10);
|
||||
if (id > 0 && !isNaN(id)) ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function saveContext(artId, ids, context) {
|
||||
var index = ids.indexOf(artId);
|
||||
if (index === -1) index = 0;
|
||||
var ctx = {
|
||||
source: context.source,
|
||||
key: context.key,
|
||||
ids: ids,
|
||||
index: index,
|
||||
page: window.location.href,
|
||||
ts: Date.now(),
|
||||
};
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(ctx));
|
||||
} catch (_) {
|
||||
// quota exceeded or private mode — silently skip
|
||||
}
|
||||
}
|
||||
|
||||
function findArticle(el) {
|
||||
var node = el;
|
||||
while (node && node !== document.body) {
|
||||
if (node.tagName === 'ARTICLE' && node.hasAttribute('data-art-id')) {
|
||||
return node;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function init() {
|
||||
// Only act on pages that have artwork cards (not the artwork detail page itself)
|
||||
var cards = document.querySelectorAll('article[data-art-id]');
|
||||
if (cards.length === 0) return;
|
||||
|
||||
// Don't inject on the artwork detail page (has #artwork-page mount)
|
||||
if (document.getElementById('artwork-page')) return;
|
||||
|
||||
var context = getPageContext();
|
||||
|
||||
document.addEventListener(
|
||||
'click',
|
||||
function (event) {
|
||||
var article = findArticle(event.target);
|
||||
if (!article) return;
|
||||
|
||||
// Make sure click was on or inside the card's <a> link
|
||||
var link = article.querySelector('a[href]');
|
||||
if (!link) return;
|
||||
|
||||
var artId = parseInt(article.getAttribute('data-art-id'), 10);
|
||||
if (!artId || isNaN(artId)) return;
|
||||
|
||||
var currentIds = collectIds();
|
||||
saveContext(artId, currentIds, context);
|
||||
},
|
||||
true // capture phase: store before navigation fires
|
||||
);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
43
resources/js/lib/useNavContext.js
Normal file
43
resources/js/lib/useNavContext.js
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* useNavContext
|
||||
*
|
||||
* Provides prev/next artwork IDs scoped to the same author via API.
|
||||
*/
|
||||
import { useCallback } from 'react';
|
||||
|
||||
// Module-level cache for API calls
|
||||
const fallbackCache = new Map();
|
||||
|
||||
async function fetchFallback(artworkId) {
|
||||
const key = String(artworkId);
|
||||
if (fallbackCache.has(key)) return fallbackCache.get(key);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/artworks/navigation/${artworkId}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) return { prevId: null, nextId: null, prevUrl: null, nextUrl: null };
|
||||
const data = await res.json();
|
||||
const result = {
|
||||
prevId: data.prev_id ?? null,
|
||||
nextId: data.next_id ?? null,
|
||||
prevUrl: data.prev_url ?? null,
|
||||
nextUrl: data.next_url ?? null,
|
||||
};
|
||||
fallbackCache.set(key, result);
|
||||
return result;
|
||||
} catch {
|
||||
return { prevId: null, nextId: null, prevUrl: null, nextUrl: null };
|
||||
}
|
||||
}
|
||||
|
||||
export function useNavContext(currentArtworkId) {
|
||||
/**
|
||||
* Always resolve via API to guarantee same-user navigation.
|
||||
*/
|
||||
const getNeighbors = useCallback(async () => {
|
||||
return fetchFallback(currentArtworkId);
|
||||
}, [currentArtworkId]);
|
||||
|
||||
return { getNeighbors };
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
// - dropdown menus via [data-dropdown]
|
||||
// - mobile menu toggle via [data-mobile-toggle] + #mobileMenu
|
||||
|
||||
// Gallery navigation context: stores artwork list for prev/next on artwork page
|
||||
import './lib/nav-context.js';
|
||||
|
||||
(function () {
|
||||
function initBlurPreviewImages() {
|
||||
var selector = 'img[data-blur-preview]';
|
||||
|
||||
Reference in New Issue
Block a user