Content warning
{artwork?.maturity?.warning_title || 'Mature content warning'}
{artwork?.maturity?.warning_message || 'This artwork may contain mature material. Continue only if you want to view it.'}
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'
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 ArtworkEvolutionPanel from '../components/artwork/ArtworkEvolutionPanel'
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'
import GroupSummaryPanel from '../components/groups/GroupSummaryPanel'
import SeoHead from '../components/seo/SeoHead'
function publisherToGroupSummary(publisher) {
if (!publisher || publisher.type !== 'group') return null
return {
id: publisher.id,
name: publisher.name,
slug: publisher.slug,
headline: publisher.headline,
avatar_url: publisher.avatar_url,
counts: {
followers: publisher.followers_count || 0,
artworks: 0,
members: 0,
},
trust_signals: [],
urls: {
public: publisher.profile_url,
follow: publisher.follow_url,
unfollow: publisher.unfollow_url,
},
}
}
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), [])
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)
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 — 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
*/
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)
setGroupSummary(data.group_summary ?? publisherToGroupSummary(data.publisher))
setSelectedMediaId('cover')
setSimilarRecommendations([])
setTrendingRecommendations([])
setViewerOpen(false) // close viewer when navigating away
setShowMatureArtwork(false)
}, [])
if (!artwork) return null
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 (
<>
Content warning {artwork?.maturity?.warning_message || 'This artwork may contain mature material. Continue only if you want to view it.'}{artwork?.maturity?.warning_title || 'Mature content warning'}
Drop a reaction so other people instantly see whether this piece hits with love, fire, wow, or a quick clap.