Wire admin studio SSR and search infrastructure
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { Head } from '@inertiajs/react'
|
||||
import axios from 'axios'
|
||||
import ArtworkHero from '../components/artwork/ArtworkHero'
|
||||
import ArtworkMediaStrip from '../components/artwork/ArtworkMediaStrip'
|
||||
@@ -17,6 +17,7 @@ import ArtworkNavigator from '../components/viewer/ArtworkNavigator'
|
||||
import ArtworkViewer from '../components/viewer/ArtworkViewer'
|
||||
import ReactionBar from '../components/comments/ReactionBar'
|
||||
import GroupSummaryPanel from '../components/groups/GroupSummaryPanel'
|
||||
import SeoHead from '../components/seo/SeoHead'
|
||||
|
||||
function publisherToGroupSummary(publisher) {
|
||||
if (!publisher || publisher.type !== 'group') return null
|
||||
@@ -41,7 +42,7 @@ function publisherToGroupSummary(publisher) {
|
||||
}
|
||||
}
|
||||
|
||||
function ArtworkPage({ artwork: initialArtwork, related: initialRelated, presentMd: initialMd, presentLg: initialLg, presentXl: initialXl, presentSq: initialSq, canonicalUrl: initialCanonical, isAuthenticated = false, comments: initialComments = [], groupSummary: initialGroupSummary = null }) {
|
||||
function ArtworkPage({ artwork: initialArtwork, related: initialRelated, presentMd: initialMd, presentLg: initialLg, presentXl: initialXl, presentSq: initialSq, canonicalUrl: initialCanonical, isAuthenticated = false, comments: initialComments = [], groupSummary: initialGroupSummary = null, reactionTotals: initialReactionTotals = {}, seo = null }) {
|
||||
const [viewerOpen, setViewerOpen] = useState(false)
|
||||
const [showMatureArtwork, setShowMatureArtwork] = useState(false)
|
||||
const openViewer = useCallback(() => setViewerOpen(true), [])
|
||||
@@ -69,20 +70,85 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
|
||||
const [canonicalUrl, setCanonicalUrl] = useState(initialCanonical)
|
||||
const [groupSummary, setGroupSummary] = useState(initialGroupSummary || publisherToGroupSummary(initialArtwork?.publisher))
|
||||
const [selectedMediaId, setSelectedMediaId] = useState('cover')
|
||||
const [similarRecommendations, setSimilarRecommendations] = useState([])
|
||||
const [trendingRecommendations, setTrendingRecommendations] = useState([])
|
||||
|
||||
// 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)
|
||||
// Artwork-level reactions — initialised from SSR props; re-fetched on client-side navigation
|
||||
const initialArtworkIdRef = useRef(initialArtwork?.id)
|
||||
const [reactionTotals, setReactionTotals] = useState(initialReactionTotals ?? {})
|
||||
useEffect(() => {
|
||||
if (!artwork?.id) return
|
||||
// Skip the fetch on first load — we already have fresh data from the server
|
||||
if (artwork.id === initialArtworkIdRef.current) return
|
||||
axios
|
||||
.get(`/api/artworks/${artwork.id}/reactions`)
|
||||
.then(({ data }) => setReactionTotals(data.totals ?? {}))
|
||||
.catch(() => setReactionTotals({}))
|
||||
}, [artwork?.id])
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
|
||||
const loadSimilarRecommendations = async () => {
|
||||
if (!artwork?.id) {
|
||||
setSimilarRecommendations([])
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/art/${artwork.id}/similar-ai`, { credentials: 'same-origin' })
|
||||
if (!response.ok) throw new Error('similar fetch failed')
|
||||
const payload = await response.json()
|
||||
if (!isCancelled) setSimilarRecommendations(payload?.data || [])
|
||||
} catch {
|
||||
if (!isCancelled) setSimilarRecommendations([])
|
||||
}
|
||||
}
|
||||
|
||||
loadSimilarRecommendations()
|
||||
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [artwork?.id])
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
|
||||
const loadTrendingRecommendations = async () => {
|
||||
const categoryId = artwork?.categories?.[0]?.id
|
||||
const endpoints = categoryId
|
||||
? [`/api/rank/category/${categoryId}?type=trending`, '/api/rank/global?type=trending']
|
||||
: ['/api/rank/global?type=trending']
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
try {
|
||||
const response = await fetch(endpoint, { credentials: 'same-origin' })
|
||||
if (!response.ok) continue
|
||||
const payload = await response.json()
|
||||
const items = Array.isArray(payload?.data) ? payload.data : []
|
||||
if (items.length > 0) {
|
||||
if (!isCancelled) setTrendingRecommendations(items)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Try the next fallback endpoint.
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCancelled) setTrendingRecommendations([])
|
||||
}
|
||||
|
||||
loadTrendingRecommendations()
|
||||
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [artwork?.categories])
|
||||
|
||||
/**
|
||||
* Called by ArtworkNavigator after a successful no-reload navigation.
|
||||
* data = ArtworkResource JSON from /api/artworks/{id}/page
|
||||
@@ -99,6 +165,8 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
|
||||
setCanonicalUrl(data.canonical_url ?? window.location.href)
|
||||
setGroupSummary(data.group_summary ?? publisherToGroupSummary(data.publisher))
|
||||
setSelectedMediaId('cover')
|
||||
setSimilarRecommendations([])
|
||||
setTrendingRecommendations([])
|
||||
setViewerOpen(false) // close viewer when navigating away
|
||||
setShowMatureArtwork(false)
|
||||
}, [])
|
||||
@@ -107,9 +175,15 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
|
||||
|
||||
const requiresInterstitial = Boolean(artwork?.maturity?.requires_interstitial) && !showMatureArtwork
|
||||
|
||||
const preloadSrcset = [presentMd?.url && `${presentMd.url} 640w`, presentLg?.url && `${presentLg.url} 1280w`, presentXl?.url && `${presentXl.url} 1920w`].filter(Boolean).join(', ')
|
||||
const heroImageSizes = '(min-width: 1536px) 1400px, (min-width: 1024px) 92vw, 100vw'
|
||||
const heroPreloadHref = presentLg?.url || presentMd?.url || null
|
||||
|
||||
if (requiresInterstitial) {
|
||||
return (
|
||||
<main className="pb-24 pt-8 lg:pb-12 lg:pt-10">
|
||||
<>
|
||||
<SeoHead seo={seo} />
|
||||
<main className="pb-24 pt-8 lg:pb-12 lg:pt-10">
|
||||
<div className="mx-auto w-full max-w-3xl px-4 sm:px-6 lg:px-8">
|
||||
<section className="rounded-[32px] border border-amber-300/20 bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.18),transparent_32%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.34)] md:p-8">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-amber-200/80">Content warning</p>
|
||||
@@ -139,6 +213,7 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -174,6 +249,30 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
|
||||
|
||||
return (
|
||||
<>
|
||||
<SeoHead seo={seo} />
|
||||
{heroPreloadHref ? (
|
||||
<Head>
|
||||
<link
|
||||
head-key="artwork-hero-preload"
|
||||
rel="preload"
|
||||
as="image"
|
||||
href={heroPreloadHref}
|
||||
imagesrcset={preloadSrcset || undefined}
|
||||
imagesizes={preloadSrcset ? heroImageSizes : undefined}
|
||||
fetchPriority="high"
|
||||
/>
|
||||
{/* Dedicated preload for the backdrop (LCP element on mobile) which always loads the md-sized URL */}
|
||||
{presentMd?.url ? (
|
||||
<link
|
||||
head-key="artwork-backdrop-preload"
|
||||
rel="preload"
|
||||
as="image"
|
||||
href={presentMd.url}
|
||||
fetchPriority="high"
|
||||
/>
|
||||
) : null}
|
||||
</Head>
|
||||
) : null}
|
||||
<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">
|
||||
@@ -224,8 +323,7 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
|
||||
<ArtworkEvolutionPanel evolution={artwork?.evolution} />
|
||||
|
||||
{/* Artwork reactions */}
|
||||
{reactionTotals !== null && (
|
||||
<section className="relative z-20 overflow-visible rounded-[28px] border border-white/[0.08] bg-[radial-gradient(circle_at_top_left,rgba(245,158,11,0.14),transparent_42%),linear-gradient(180deg,rgba(255,255,255,0.06),rgba(255,255,255,0.02))] px-5 py-5 shadow-[0_22px_55px_rgba(0,0,0,0.26)] backdrop-blur-xl sm:px-6">
|
||||
<section className="relative z-20 overflow-visible rounded-[28px] border border-white/[0.08] bg-[radial-gradient(circle_at_top_left,rgba(245,158,11,0.14),transparent_42%),linear-gradient(180deg,rgba(255,255,255,0.06),rgba(255,255,255,0.02))] px-5 py-5 shadow-[0_22px_55px_rgba(0,0,0,0.26)] backdrop-blur-xl sm:px-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="max-w-xl">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent/80">Artwork Reactions</div>
|
||||
@@ -243,7 +341,6 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Tags & categories */}
|
||||
<ArtworkTags artwork={artwork} />
|
||||
@@ -274,8 +371,13 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
|
||||
</div>
|
||||
|
||||
{/* ── Full-width recommendation rails ─────────────────────────── */}
|
||||
<div className="mt-14 w-full max-w-screen-2xl mx-auto">
|
||||
<ArtworkRecommendationsRails artwork={artwork} related={related} />
|
||||
<div className="mt-14 w-full max-w-screen-2xl mx-auto min-h-[640px]">
|
||||
<ArtworkRecommendationsRails
|
||||
artwork={artwork}
|
||||
related={related}
|
||||
similarApiData={similarRecommendations}
|
||||
trendingData={trendingRecommendations}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -299,32 +401,4 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
|
||||
)
|
||||
}
|
||||
|
||||
// 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)}
|
||||
groupSummary={parse('groupSummary')}
|
||||
comments={parse('comments', [])}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
export default ArtworkPage
|
||||
|
||||
Reference in New Issue
Block a user