Save workspace changes
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
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'
|
||||
|
||||
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 }) {
|
||||
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')
|
||||
|
||||
// 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)
|
||||
setGroupSummary(data.group_summary ?? publisherToGroupSummary(data.publisher))
|
||||
setSelectedMediaId('cover')
|
||||
setViewerOpen(false) // close viewer when navigating away
|
||||
setShowMatureArtwork(false)
|
||||
}, [])
|
||||
|
||||
if (!artwork) return null
|
||||
|
||||
const requiresInterstitial = Boolean(artwork?.maturity?.requires_interstitial) && !showMatureArtwork
|
||||
|
||||
if (requiresInterstitial) {
|
||||
return (
|
||||
<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>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">{artwork?.maturity?.warning_title || 'Mature content warning'}</h1>
|
||||
<p className="mt-3 text-sm leading-relaxed text-slate-200/90">{artwork?.maturity?.warning_message || 'This artwork may contain mature material. Continue only if you want to view it.'}</p>
|
||||
<div className="mt-5 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-4 text-sm text-slate-300">
|
||||
<div className="font-semibold text-white">{artwork.title}</div>
|
||||
<div className="mt-1">by {artwork?.publisher?.name || artwork?.user?.name || 'Artist'}</div>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMatureArtwork(true)}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-amber-300/25 bg-amber-400/12 px-5 py-3 text-sm font-semibold text-amber-100 transition hover:bg-amber-400/18"
|
||||
>
|
||||
<i className="fa-solid fa-eye" />
|
||||
Show artwork
|
||||
</button>
|
||||
<a
|
||||
href={artwork?.publisher?.profile_url || '/discover/trending'}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-left" />
|
||||
Leave this page
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const coverItem = {
|
||||
id: 'cover',
|
||||
label: 'Cover art',
|
||||
thumbUrl: presentSq?.url || presentMd?.url || presentLg?.url || artwork?.thumbs?.sq?.url || artwork?.thumbs?.md?.url || null,
|
||||
mdUrl: presentMd?.url || artwork?.thumbs?.md?.url || null,
|
||||
lgUrl: presentLg?.url || artwork?.thumbs?.lg?.url || null,
|
||||
xlUrl: presentXl?.url || artwork?.thumbs?.xl?.url || null,
|
||||
width: Number(artwork?.dimensions?.width || artwork?.width || 0) || null,
|
||||
height: Number(artwork?.dimensions?.height || artwork?.height || 0) || null,
|
||||
}
|
||||
|
||||
const screenshotItems = Array.isArray(artwork?.screenshots)
|
||||
? artwork.screenshots.map((item, index) => ({
|
||||
id: item.id || `shot-${index + 1}`,
|
||||
label: item.label || `Screenshot ${index + 1}`,
|
||||
thumbUrl: item.thumb_url || item.url || null,
|
||||
mdUrl: item.url || item.thumb_url || null,
|
||||
lgUrl: item.url || item.thumb_url || null,
|
||||
xlUrl: item.url || item.thumb_url || null,
|
||||
width: null,
|
||||
height: null,
|
||||
}))
|
||||
: []
|
||||
|
||||
const mediaItems = [coverItem, ...screenshotItems].filter((item) => Boolean(item.thumbUrl || item.lgUrl || item.xlUrl))
|
||||
|
||||
const selectedMedia = mediaItems.find((item) => item.id === selectedMediaId) || mediaItems[0] || null
|
||||
|
||||
const initialAwards = artwork?.medals ?? 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={selectedMedia?.mdUrl ? { url: selectedMedia.mdUrl } : presentMd}
|
||||
presentLg={selectedMedia?.lgUrl ? { url: selectedMedia.lgUrl } : presentLg}
|
||||
presentXl={selectedMedia?.xlUrl ? { url: selectedMedia.xlUrl } : presentXl}
|
||||
mediaWidth={selectedMedia?.width ?? null}
|
||||
mediaHeight={selectedMedia?.height ?? null}
|
||||
mediaKey={selectedMedia?.id || 'cover'}
|
||||
onOpenViewer={openViewer}
|
||||
hasPrev={navState.hasPrev}
|
||||
hasNext={navState.hasNext}
|
||||
onPrev={navState.navigatePrev}
|
||||
onNext={navState.navigateNext}
|
||||
/>
|
||||
|
||||
<ArtworkMediaStrip
|
||||
items={mediaItems}
|
||||
selectedId={selectedMedia?.id || 'cover'}
|
||||
onSelect={setSelectedMediaId}
|
||||
/>
|
||||
</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 evolution */}
|
||||
<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">
|
||||
<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>
|
||||
<h2 className="mt-2 text-xl font-semibold tracking-[-0.02em] text-white">Make this artwork feel alive</h2>
|
||||
<p className="mt-1 text-sm leading-6 text-white/55">Drop a reaction so other people instantly see whether this piece hits with love, fire, wow, or a quick clap.</p>
|
||||
</div>
|
||||
|
||||
<div className="sm:shrink-0">
|
||||
<ReactionBar
|
||||
entityType="artwork"
|
||||
entityId={artwork.id}
|
||||
initialTotals={reactionTotals}
|
||||
isLoggedIn={isAuthenticated}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 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} />
|
||||
|
||||
{groupSummary ? <GroupSummaryPanel group={groupSummary} artwork={artwork} /> : null}
|
||||
|
||||
{/* Details (collapsible) */}
|
||||
<ArtworkDetailsPanel artwork={artwork} stats={liveStats} />
|
||||
|
||||
{/* Medals */}
|
||||
<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={selectedMedia?.lgUrl ? { url: selectedMedia.lgUrl } : presentLg}
|
||||
presentXl={selectedMedia?.xlUrl ? { url: selectedMedia.xlUrl } : 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)}
|
||||
groupSummary={parse('groupSummary')}
|
||||
comments={parse('comments', [])}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
export default ArtworkPage
|
||||
Reference in New Issue
Block a user