Files
SkinbaseNova/resources/js/Pages/ArtworkPage.jsx
Gregor Klevze eee7df1f8c feat: artwork page carousels, recommendations, avatars & fixes
- Infinite loop carousels for Similar Artworks & Trending rails
- Mouse wheel horizontal scrolling on both carousels
- Author avatar shown on hover in RailCard (similar + trending)
- Removed "View" badge from RailCard hover overlay
- Added `id` to Meilisearch filterable attributes
- Auto-prepend Scout prefix in meilisearch:configure-index command
- Added author name + avatar to Similar Artworks API response
- Added avatar_url to ArtworkListResource author object
- Added direct /art/{id}/{slug} URL to ArtworkListResource
- Fixed race condition: Similar Artworks no longer briefly shows trending items
- Fixed user_profiles eager load (user_id primary key, not id)
- Bumped /api/art/{id}/similar rate limit to 300/min
- Removed decorative heart icons from tag pills
- Moved ReactionBar under artwork description
2026-02-28 14:05:39 +01:00

207 lines
8.0 KiB
JavaScript

import React, { useState, useCallback, useEffect } from 'react'
import { createRoot } from 'react-dom/client'
import axios from 'axios'
import ArtworkHero from '../components/artwork/ArtworkHero'
import ArtworkMeta from '../components/artwork/ArtworkMeta'
import ArtworkAwards from '../components/artwork/ArtworkAwards'
import ArtworkTags from '../components/artwork/ArtworkTags'
import ArtworkDescription from '../components/artwork/ArtworkDescription'
import ArtworkComments from '../components/artwork/ArtworkComments'
import ArtworkActionBar from '../components/artwork/ArtworkActionBar'
import ArtworkDetailsPanel from '../components/artwork/ArtworkDetailsPanel'
import CreatorSpotlight from '../components/artwork/CreatorSpotlight'
import ArtworkRecommendationsRails from '../components/artwork/ArtworkRecommendationsRails'
import ArtworkNavigator from '../components/viewer/ArtworkNavigator'
import ArtworkViewer from '../components/viewer/ArtworkViewer'
import ReactionBar from '../components/comments/ReactionBar'
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 [liveStats, setLiveStats] = useState(initialArtwork?.stats || {})
const handleStatsChange = useCallback((delta) => {
setLiveStats(prev => {
const next = { ...prev }
Object.entries(delta).forEach(([key, val]) => {
next[key] = Math.max(0, (Number(next[key]) || 0) + val)
})
return next
})
}, [])
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 })
// Artwork-level reactions
const [reactionTotals, setReactionTotals] = useState(null)
useEffect(() => {
if (!artwork?.id) return
axios
.get(`/api/artworks/${artwork.id}/reactions`)
.then(({ data }) => setReactionTotals(data.totals ?? {}))
.catch(() => setReactionTotals({}))
}, [artwork?.id])
/**
* Called by ArtworkNavigator after a successful no-reload navigation.
* data = ArtworkResource JSON from /api/artworks/{id}/page
*/
const handleNavigate = useCallback((data) => {
setArtwork(data)
setLiveStats(data.stats || {})
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
}, [])
if (!artwork) return null
const initialAwards = artwork?.awards ?? null
return (
<>
<main className="pb-24 pt-6 lg:pb-12 lg:pt-8">
{/* ── Hero ────────────────────────────────────────────────────── */}
<div id="artwork-hero-anchor" className="mx-auto w-full max-w-screen-2xl px-3 sm:px-6 lg:px-8">
<ArtworkHero
artwork={artwork}
presentMd={presentMd}
presentLg={presentLg}
presentXl={presentXl}
onOpenViewer={openViewer}
hasPrev={navState.hasPrev}
hasNext={navState.hasNext}
onPrev={navState.navigatePrev}
onNext={navState.navigateNext}
/>
</div>
{/* ── Centered action bar with stat counts ────────────────────── */}
<div className="mx-auto mt-5 w-full max-w-screen-xl px-4 sm:px-6 lg:px-8">
<ArtworkActionBar
artwork={artwork}
stats={liveStats}
canonicalUrl={canonicalUrl}
onStatsChange={handleStatsChange}
/>
</div>
{/* ── Two-column content ──────────────────────────────────────── */}
<div className="mx-auto mt-8 w-full max-w-screen-xl px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_340px]">
{/* LEFT COLUMN — main content */}
<div className="relative z-10 min-w-0 space-y-5">
{/* Title + author + breadcrumbs */}
<ArtworkMeta artwork={artwork} />
{/* Description */}
<ArtworkDescription artwork={artwork} />
{/* Artwork reactions */}
{reactionTotals !== null && (
<ReactionBar
entityType="artwork"
entityId={artwork.id}
initialTotals={reactionTotals}
isLoggedIn={isAuthenticated}
/>
)}
{/* Tags & categories */}
<ArtworkTags artwork={artwork} />
{/* Comments */}
<ArtworkComments
artworkId={artwork.id}
comments={comments}
isLoggedIn={isAuthenticated}
loginUrl="/login"
/>
</div>
{/* RIGHT COLUMN — sidebar */}
<aside className="space-y-5 lg:sticky lg:top-6 lg:self-start">
{/* Creator card */}
<CreatorSpotlight artwork={artwork} presentSq={presentSq} related={related} />
{/* Details (collapsible) */}
<ArtworkDetailsPanel artwork={artwork} stats={liveStats} />
{/* Awards */}
<ArtworkAwards artwork={artwork} initialAwards={initialAwards} isAuthenticated={isAuthenticated} />
</aside>
</div>
</div>
{/* ── Full-width recommendation rails ─────────────────────────── */}
<div className="mt-14 w-full max-w-screen-2xl mx-auto">
<ArtworkRecommendationsRails artwork={artwork} related={related} />
</div>
</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}
/>
</>
)
}
// Auto-mount if the Blade view provided data attributes
const el = document.getElementById('artwork-page')
if (el) {
const parse = (key, fallback = null) => {
try {
return JSON.parse(el.dataset[key] || 'null') ?? fallback
} catch {
return fallback
}
}
const root = createRoot(el)
root.render(
<ArtworkPage
artwork={parse('artwork')}
related={parse('related', [])}
presentMd={parse('presentMd')}
presentLg={parse('presentLg')}
presentXl={parse('presentXl')}
presentSq={parse('presentSq')}
canonicalUrl={parse('canonical', '')}
isAuthenticated={parse('isAuthenticated', false)}
comments={parse('comments', [])}
/>,
)
}
export default ArtworkPage