optimizations
This commit is contained in:
959
resources/js/Pages/Collection/CollectionShow.jsx
Normal file
959
resources/js/Pages/Collection/CollectionShow.jsx
Normal file
@@ -0,0 +1,959 @@
|
||||
import React from 'react'
|
||||
import { Head, usePage } from '@inertiajs/react'
|
||||
import ArtworkGallery from '../../components/artwork/ArtworkGallery'
|
||||
import CollectionCard from '../../components/profile/collections/CollectionCard'
|
||||
import CollectionVisibilityBadge from '../../components/profile/collections/CollectionVisibilityBadge'
|
||||
import CommentForm from '../../components/social/CommentForm'
|
||||
import CommentList from '../../components/social/CommentList'
|
||||
import useWebShare from '../../hooks/useWebShare'
|
||||
|
||||
function getCsrfToken() {
|
||||
if (typeof document === 'undefined') return ''
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}
|
||||
|
||||
async function requestJson(url, { method = 'POST', body } = {}) {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || 'Request failed.')
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
function TypeBadge({ collection }) {
|
||||
const label = collection?.type === 'editorial'
|
||||
? 'Editorial'
|
||||
: collection?.type === 'community'
|
||||
? 'Community'
|
||||
: 'Personal'
|
||||
|
||||
return <span className="inline-flex items-center rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100">{label}</span>
|
||||
}
|
||||
|
||||
function CollaboratorCard({ member }) {
|
||||
return (
|
||||
<a href={member?.user?.profile_url || '#'} className="flex items-center gap-3 rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-4 transition hover:bg-white/[0.07]">
|
||||
<img src={member?.user?.avatar_url} alt={member?.user?.name || member?.user?.username} className="h-12 w-12 rounded-2xl object-cover ring-1 ring-white/10" />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-white">{member?.user?.name || member?.user?.username}</div>
|
||||
<div className="truncate text-xs uppercase tracking-[0.16em] text-slate-400">{member?.role} {member?.status === 'pending' ? '• invited' : ''}</div>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function SubmissionCard({ submission, onApprove, onReject, onWithdraw, onReport }) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{submission?.artwork?.thumb ? <img src={submission.artwork.thumb} alt={submission.artwork.title} className="h-20 w-20 rounded-2xl object-cover" /> : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="truncate text-sm font-semibold text-white">{submission?.artwork?.title || 'Artwork submission'}</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-300">{submission?.status}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-400">Submitted by @{submission?.user?.username}</p>
|
||||
{submission?.message ? <p className="mt-2 text-sm text-slate-300">{submission.message}</p> : null}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{submission?.can_review ? <button type="button" onClick={() => onApprove?.(submission)} className="rounded-full border border-emerald-400/20 bg-emerald-400/10 px-3 py-2 text-xs font-semibold text-emerald-100">Approve</button> : null}
|
||||
{submission?.can_review ? <button type="button" onClick={() => onReject?.(submission)} className="rounded-full border border-rose-400/20 bg-rose-400/10 px-3 py-2 text-xs font-semibold text-rose-100">Reject</button> : null}
|
||||
{submission?.can_withdraw ? <button type="button" onClick={() => onWithdraw?.(submission)} className="rounded-full border border-white/12 bg-white/[0.05] px-3 py-2 text-xs font-semibold text-white">Withdraw</button> : null}
|
||||
{submission?.can_report ? <button type="button" onClick={() => onReport?.(submission)} className="rounded-full border border-white/12 bg-white/[0.05] px-3 py-2 text-xs font-semibold text-white">Report</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MetaRow({ icon, label, value, compact = false }) {
|
||||
const title = `${label}: ${value}`
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
className="flex min-w-0 flex-col items-center rounded-2xl border border-white/10 bg-white/[0.05] px-3 py-4 text-center"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
>
|
||||
<i className={`fa-solid ${icon} text-base text-slate-300`} />
|
||||
<div className="mt-3 text-xl font-semibold text-white">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-3" title={title}>
|
||||
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
<i className={`fa-solid ${icon} text-[10px]`} />
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-1 text-lg font-semibold text-white">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getSpotlightClasses(style) {
|
||||
switch (style) {
|
||||
case 'editorial':
|
||||
return 'border-amber-300/20 bg-[linear-gradient(135deg,rgba(120,53,15,0.45),rgba(2,6,23,0.82))] text-amber-50'
|
||||
case 'seasonal':
|
||||
return 'border-emerald-300/20 bg-[linear-gradient(135deg,rgba(6,78,59,0.5),rgba(2,6,23,0.82))] text-emerald-50'
|
||||
case 'challenge':
|
||||
return 'border-fuchsia-300/20 bg-[linear-gradient(135deg,rgba(112,26,117,0.48),rgba(2,6,23,0.82))] text-fuchsia-50'
|
||||
case 'community':
|
||||
return 'border-sky-300/20 bg-[linear-gradient(135deg,rgba(3,105,161,0.45),rgba(2,6,23,0.82))] text-sky-50'
|
||||
default:
|
||||
return 'border-white/10 bg-[linear-gradient(135deg,rgba(15,23,42,0.9),rgba(30,41,59,0.72))] text-white'
|
||||
}
|
||||
}
|
||||
|
||||
function EmptyCollectionState({ isOwner, smart = false }) {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] px-6 py-14 text-center">
|
||||
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-[24px] border border-white/12 bg-white/[0.06] text-slate-400">
|
||||
<i className={`fa-solid ${smart ? 'fa-wand-magic-sparkles' : 'fa-images'} text-3xl`} />
|
||||
</div>
|
||||
<h2 className="mt-5 text-2xl font-semibold text-white">This collection is still taking shape</h2>
|
||||
<p className="mx-auto mt-3 max-w-lg text-sm leading-relaxed text-slate-300">
|
||||
{isOwner
|
||||
? (smart ? 'Adjust the smart rules to broaden the match or publish more artworks that fit this set.' : 'Add artworks to start building the visual rhythm, cover image, and sequence for this showcase.')
|
||||
: (smart ? 'This smart collection does not have visible matches right now.' : 'There are no visible artworks in this collection right now.')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OwnerCard({ owner, collectionType }) {
|
||||
const label = owner?.is_system
|
||||
? 'Editorial Owner'
|
||||
: collectionType === 'editorial'
|
||||
? 'Editorial Curator'
|
||||
: 'Curator'
|
||||
|
||||
const body = (
|
||||
<>
|
||||
{owner?.avatar_url ? (
|
||||
<img src={owner.avatar_url} alt={owner?.name || owner?.username} className="h-14 w-14 rounded-2xl object-cover ring-1 ring-white/10" />
|
||||
) : (
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-slate-400">
|
||||
<i className="fa-solid fa-user-astronaut" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{label}</div>
|
||||
<div className="mt-1 text-lg font-semibold text-white">{owner?.name || owner?.username || 'Skinbase Curator'}</div>
|
||||
{owner?.username ? <div className="text-sm text-slate-400">@{owner.username}</div> : null}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
if (owner?.profile_url) {
|
||||
return <a href={owner.profile_url} className="mt-6 inline-flex items-center gap-4 rounded-[24px] border border-white/10 bg-white/[0.04] px-4 py-4 transition hover:bg-white/[0.07]">{body}</a>
|
||||
}
|
||||
|
||||
return <div className="mt-6 inline-flex items-center gap-4 rounded-[24px] border border-white/10 bg-white/[0.04] px-4 py-4">{body}</div>
|
||||
}
|
||||
|
||||
function PageSection({ eyebrow, title, count, children }) {
|
||||
return (
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">{eyebrow}</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">{title}</h2>
|
||||
</div>
|
||||
{count !== undefined ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{count}</span> : null}
|
||||
</div>
|
||||
<div className="mt-5">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function EntityLinkCard({ item }) {
|
||||
return (
|
||||
<a href={item?.url || '#'} className="flex h-full flex-col gap-4 rounded-[24px] border border-white/10 bg-white/[0.04] p-5 transition hover:bg-white/[0.07]">
|
||||
<div className="flex items-start gap-4">
|
||||
{item?.image_url ? <img src={item.image_url} alt={item?.title} className="h-16 w-16 rounded-[18px] object-cover ring-1 ring-white/10" /> : <div className="flex h-16 w-16 items-center justify-center rounded-[18px] border border-white/10 bg-white/[0.05] text-slate-400"><i className="fa-solid fa-diagram-project" /></div>}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="truncate text-lg font-semibold text-white">{item?.title}</h3>
|
||||
{item?.meta ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{item.meta}</span> : null}
|
||||
</div>
|
||||
{item?.subtitle ? <p className="mt-1 text-sm text-slate-400">{item.subtitle}</p> : null}
|
||||
{item?.relationship_type ? <p className="mt-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-200/80">{item.relationship_type}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
{item?.description ? <p className="text-sm leading-relaxed text-slate-300">{item.description}</p> : null}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function humanizeToken(value) {
|
||||
return String(value || '')
|
||||
.replaceAll('_', ' ')
|
||||
.replaceAll('-', ' ')
|
||||
.replace(/\b\w/g, (match) => match.toUpperCase())
|
||||
}
|
||||
|
||||
function groupEntityLinks(items) {
|
||||
return (Array.isArray(items) ? items : []).reduce((groups, item) => {
|
||||
const key = item?.linked_type || 'other'
|
||||
if (!groups[key]) groups[key] = []
|
||||
groups[key].push(item)
|
||||
return groups
|
||||
}, {})
|
||||
}
|
||||
|
||||
function recommendationReasons(currentCollection, candidate) {
|
||||
const reasons = []
|
||||
|
||||
if (candidate?.event_key && currentCollection?.event_key && candidate.event_key === currentCollection.event_key) {
|
||||
reasons.push('Same event context')
|
||||
}
|
||||
|
||||
if (candidate?.campaign_key && currentCollection?.campaign_key && candidate.campaign_key === currentCollection.campaign_key) {
|
||||
reasons.push('Same campaign')
|
||||
}
|
||||
|
||||
if (candidate?.theme_token && currentCollection?.theme_token && candidate.theme_token === currentCollection.theme_token) {
|
||||
reasons.push('Shared theme')
|
||||
}
|
||||
|
||||
if (candidate?.type && currentCollection?.type && candidate.type === currentCollection.type) {
|
||||
reasons.push(`${humanizeToken(candidate.type)} collection`)
|
||||
}
|
||||
|
||||
if (candidate?.owner?.id && currentCollection?.owner?.id && candidate.owner.id === currentCollection.owner.id) {
|
||||
reasons.push('Same curator')
|
||||
}
|
||||
|
||||
if (candidate?.trust_tier && currentCollection?.trust_tier && candidate.trust_tier === currentCollection.trust_tier) {
|
||||
reasons.push(`${humanizeToken(candidate.trust_tier)} trust tier`)
|
||||
}
|
||||
|
||||
return reasons.slice(0, 3)
|
||||
}
|
||||
|
||||
function ContextSignalCard({ item }) {
|
||||
const wrapperClassName = 'flex h-full flex-col gap-3 rounded-[24px] border border-white/10 bg-white/[0.04] p-5 transition hover:bg-white/[0.07]'
|
||||
|
||||
const body = (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{item.meta}</span>
|
||||
{item.kicker ? <span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-200/80">{item.kicker}</span> : null}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">{item.title}</h3>
|
||||
{item.subtitle ? <p className="mt-1 text-sm text-slate-400">{item.subtitle}</p> : null}
|
||||
</div>
|
||||
{item.description ? <p className="text-sm leading-relaxed text-slate-300">{item.description}</p> : null}
|
||||
</>
|
||||
)
|
||||
|
||||
if (item.url) {
|
||||
return <a href={item.url} className={wrapperClassName}>{body}</a>
|
||||
}
|
||||
|
||||
return <div className={wrapperClassName}>{body}</div>
|
||||
}
|
||||
|
||||
export default function CollectionShow() {
|
||||
const { props } = usePage()
|
||||
const {
|
||||
collection: initialCollection,
|
||||
artworks,
|
||||
owner,
|
||||
isOwner,
|
||||
manageUrl,
|
||||
editUrl,
|
||||
analyticsUrl,
|
||||
historyUrl,
|
||||
profileCollectionsUrl,
|
||||
featuredCollectionsUrl,
|
||||
engagement,
|
||||
seo,
|
||||
members: initialMembers,
|
||||
comments: initialComments,
|
||||
submissions: initialSubmissions,
|
||||
entityLinks,
|
||||
relatedCollections,
|
||||
commentsEndpoint,
|
||||
submitEndpoint,
|
||||
submissionArtworkOptions,
|
||||
seriesContext,
|
||||
canSubmit,
|
||||
canComment,
|
||||
reportEndpoint,
|
||||
} = props
|
||||
const [collection, setCollection] = React.useState(initialCollection)
|
||||
const [comments, setComments] = React.useState(initialComments || [])
|
||||
const [submissions, setSubmissions] = React.useState(initialSubmissions || [])
|
||||
const [selectedArtworkId, setSelectedArtworkId] = React.useState(submissionArtworkOptions?.[0]?.id || '')
|
||||
const [state, setState] = React.useState({
|
||||
liked: Boolean(engagement?.liked),
|
||||
following: Boolean(engagement?.following),
|
||||
saved: Boolean(engagement?.saved),
|
||||
notice: '',
|
||||
busy: false,
|
||||
})
|
||||
const artworkItems = artworks?.data ?? []
|
||||
const creatorIds = Array.from(new Set(artworkItems.map((artwork) => artwork?.author?.id).filter(Boolean)))
|
||||
const featuringCreatorsCount = creatorIds.length
|
||||
const showArtworkAuthors = collection?.type !== 'personal' || featuringCreatorsCount > 1
|
||||
const enabledModules = Array.isArray(collection?.layout_modules)
|
||||
? collection.layout_modules.filter((module) => module?.enabled !== false)
|
||||
: []
|
||||
const enabledModuleKeys = new Set(enabledModules.map((module) => module?.key).filter(Boolean))
|
||||
const showIntroBlock = enabledModuleKeys.size === 0 || enabledModuleKeys.has('intro_block')
|
||||
const metaOwnerName = owner?.name || owner?.username || collection?.owner?.name || 'Skinbase Curator'
|
||||
const metaTitle = seo?.title || `${collection?.title} — Skinbase Nova`
|
||||
const metaDescription = seo?.description || collection?.summary || collection?.description || ''
|
||||
const collectionSchema = seo?.canonical ? {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: collection?.title,
|
||||
description: metaDescription,
|
||||
url: seo.canonical,
|
||||
image: seo?.og_image || collection?.cover_image || undefined,
|
||||
isPartOf: {
|
||||
'@type': 'WebSite',
|
||||
name: 'Skinbase Nova',
|
||||
url: typeof window !== 'undefined' ? window.location.origin : undefined,
|
||||
},
|
||||
author: owner ? {
|
||||
'@type': 'Person',
|
||||
name: metaOwnerName,
|
||||
url: owner.profile_url,
|
||||
} : undefined,
|
||||
keywords: [collection?.type, collection?.mode, collection?.badge_label].filter(Boolean).join(', ') || undefined,
|
||||
mainEntity: {
|
||||
'@type': 'ItemList',
|
||||
numberOfItems: collection?.artworks_count || artworkItems.length || 0,
|
||||
itemListElement: artworkItems.slice(0, 12).map((artwork, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: artwork.url,
|
||||
name: artwork.title,
|
||||
})),
|
||||
},
|
||||
} : null
|
||||
const [members] = React.useState(initialMembers || [])
|
||||
const spotlightClasses = getSpotlightClasses(collection?.spotlight_style)
|
||||
const groupedEntityLinks = groupEntityLinks(entityLinks)
|
||||
const storyLinks = groupedEntityLinks.story || []
|
||||
const taxonomyLinks = [...(groupedEntityLinks.category || []), ...(groupedEntityLinks.tag || [])]
|
||||
const contributorLinks = [...(groupedEntityLinks.creator || []), ...(groupedEntityLinks.artwork || [])]
|
||||
const linkedContextSignals = [
|
||||
...(groupedEntityLinks.campaign || []).map((item) => ({
|
||||
meta: item.meta || 'Campaign',
|
||||
kicker: item.relationship_type || 'Linked campaign',
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
description: item.description,
|
||||
url: item.url,
|
||||
})),
|
||||
...(groupedEntityLinks.event || []).map((item) => ({
|
||||
meta: item.meta || 'Event',
|
||||
kicker: item.relationship_type || 'Linked event',
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
description: item.description,
|
||||
url: item.url,
|
||||
})),
|
||||
]
|
||||
const contextSignals = [
|
||||
collection?.campaign_key ? {
|
||||
meta: 'Campaign',
|
||||
kicker: 'Discover surface',
|
||||
title: collection.campaign_label || humanizeToken(collection.campaign_key),
|
||||
subtitle: collection.campaign_key,
|
||||
description: 'This collection is programmed into a campaign surface and can be explored alongside other campaign-ready sets.',
|
||||
url: `/collections/campaigns/${encodeURIComponent(collection.campaign_key)}`,
|
||||
} : null,
|
||||
collection?.program_key ? {
|
||||
meta: 'Program',
|
||||
kicker: 'Partner context',
|
||||
title: humanizeToken(collection.program_key),
|
||||
subtitle: collection.partner_label || collection.partner_key || 'Collection program',
|
||||
description: 'This collection is attached to a program or partner-ready surface, which affects how it is grouped and surfaced.',
|
||||
url: `/collections/program/${encodeURIComponent(collection.program_key)}`,
|
||||
} : null,
|
||||
(collection?.event_label || collection?.event_key) ? {
|
||||
meta: 'Event',
|
||||
kicker: 'Seasonal context',
|
||||
title: collection.event_label || humanizeToken(collection.event_key),
|
||||
subtitle: collection.season_key ? `Season ${humanizeToken(collection.season_key)}` : 'Event-linked collection',
|
||||
description: 'This collection is tied to an event or seasonal programming window, so related recommendations favor matching event context.',
|
||||
url: null,
|
||||
} : null,
|
||||
collection?.theme_token ? {
|
||||
meta: 'Theme',
|
||||
kicker: 'Visual language',
|
||||
title: humanizeToken(collection.theme_token),
|
||||
subtitle: collection.presentation_style ? `Presentation ${humanizeToken(collection.presentation_style)}` : null,
|
||||
description: 'Theme and presentation signals help similar collections cluster together in discovery and recommendation surfaces.',
|
||||
url: `/collections/search?theme=${encodeURIComponent(collection.theme_token)}`,
|
||||
} : null,
|
||||
collection?.trust_tier ? {
|
||||
meta: 'Quality Tier',
|
||||
kicker: 'Placement signal',
|
||||
title: humanizeToken(collection.trust_tier),
|
||||
subtitle: collection?.quality_score != null ? `Quality score ${Number(collection.quality_score).toFixed(1)}` : null,
|
||||
description: 'Trust tier and quality score shape how aggressively this collection can be used in premium or partner-facing placements.',
|
||||
url: `/collections/search?quality_tier=${encodeURIComponent(collection.trust_tier)}`,
|
||||
} : null,
|
||||
...linkedContextSignals,
|
||||
].filter(Boolean).filter((item, index, items) => {
|
||||
const key = `${item.meta}:${item.title}:${item.subtitle || ''}`
|
||||
return items.findIndex((candidate) => `${candidate.meta}:${candidate.title}:${candidate.subtitle || ''}` === key) === index
|
||||
})
|
||||
|
||||
const { share } = useWebShare({
|
||||
onFallback: async ({ url }) => {
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(url)
|
||||
setState((current) => ({ ...current, notice: 'Collection link copied.' }))
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
async function handleLike() {
|
||||
if (!engagement?.can_interact) {
|
||||
if (engagement?.login_url) window.location.assign(engagement.login_url)
|
||||
return
|
||||
}
|
||||
|
||||
setState((current) => ({ ...current, busy: true, notice: '' }))
|
||||
try {
|
||||
const payload = await requestJson(state.liked ? engagement.unlike_url : engagement.like_url, {
|
||||
method: state.liked ? 'DELETE' : 'POST',
|
||||
})
|
||||
setState((current) => ({ ...current, liked: Boolean(payload?.liked), busy: false }))
|
||||
setCollection((current) => ({ ...current, likes_count: payload?.likes_count ?? current.likes_count }))
|
||||
} catch (error) {
|
||||
setState((current) => ({ ...current, busy: false, notice: error.message }))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFollow() {
|
||||
if (!engagement?.can_interact) {
|
||||
if (engagement?.login_url) window.location.assign(engagement.login_url)
|
||||
return
|
||||
}
|
||||
|
||||
setState((current) => ({ ...current, busy: true, notice: '' }))
|
||||
try {
|
||||
const payload = await requestJson(state.following ? engagement.unfollow_url : engagement.follow_url, {
|
||||
method: state.following ? 'DELETE' : 'POST',
|
||||
})
|
||||
setState((current) => ({ ...current, following: Boolean(payload?.following), busy: false }))
|
||||
setCollection((current) => ({ ...current, followers_count: payload?.followers_count ?? current.followers_count }))
|
||||
} catch (error) {
|
||||
setState((current) => ({ ...current, busy: false, notice: error.message }))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleShare() {
|
||||
try {
|
||||
const payload = await requestJson(engagement?.share_url, { method: 'POST' })
|
||||
setCollection((current) => ({ ...current, shares_count: payload?.shares_count ?? current.shares_count }))
|
||||
await share({
|
||||
title: collection?.title,
|
||||
text: collection?.summary || collection?.description || `Explore ${collection?.title} on Skinbase Nova.`,
|
||||
url: collection?.public_url,
|
||||
})
|
||||
} catch (error) {
|
||||
setState((current) => ({ ...current, notice: error.message }))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!engagement?.save_url) {
|
||||
if (engagement?.login_url) window.location.assign(engagement.login_url)
|
||||
return
|
||||
}
|
||||
|
||||
setState((current) => ({ ...current, busy: true, notice: '' }))
|
||||
try {
|
||||
const payload = await requestJson(state.saved ? engagement.unsave_url : engagement.save_url, {
|
||||
method: state.saved ? 'DELETE' : 'POST',
|
||||
body: state.saved ? undefined : {
|
||||
context: 'collection_detail',
|
||||
context_meta: {
|
||||
collection_type: collection?.type || null,
|
||||
collection_mode: collection?.mode || null,
|
||||
},
|
||||
},
|
||||
})
|
||||
setState((current) => ({ ...current, saved: Boolean(payload?.saved), busy: false }))
|
||||
setCollection((current) => ({ ...current, saves_count: payload?.saves_count ?? current.saves_count }))
|
||||
} catch (error) {
|
||||
setState((current) => ({ ...current, busy: false, notice: error.message }))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCommentSubmit(body) {
|
||||
const payload = await requestJson(commentsEndpoint, {
|
||||
method: 'POST',
|
||||
body: { body },
|
||||
})
|
||||
setComments(payload?.comments || [])
|
||||
setCollection((current) => ({ ...current, comments_count: payload?.comments_count ?? current.comments_count }))
|
||||
}
|
||||
|
||||
async function handleDeleteComment(commentId) {
|
||||
const payload = await requestJson(`${commentsEndpoint}/${commentId}`, { method: 'DELETE' })
|
||||
setComments(payload?.comments || [])
|
||||
setCollection((current) => ({ ...current, comments_count: payload?.comments_count ?? current.comments_count }))
|
||||
}
|
||||
|
||||
async function handleSubmitArtwork() {
|
||||
if (!submitEndpoint || !selectedArtworkId) return
|
||||
|
||||
try {
|
||||
const payload = await requestJson(submitEndpoint, {
|
||||
method: 'POST',
|
||||
body: { artwork_id: selectedArtworkId },
|
||||
})
|
||||
setSubmissions(payload?.submissions || [])
|
||||
setState((current) => ({ ...current, notice: 'Artwork submitted for review.' }))
|
||||
} catch (error) {
|
||||
setState((current) => ({ ...current, notice: error.message }))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmissionAction(submission, action) {
|
||||
const url = action === 'approve'
|
||||
? `/collections/submissions/${submission.id}/approve`
|
||||
: action === 'reject'
|
||||
? `/collections/submissions/${submission.id}/reject`
|
||||
: `/collections/submissions/${submission.id}`
|
||||
|
||||
const payload = await requestJson(url, {
|
||||
method: action === 'withdraw' ? 'DELETE' : 'POST',
|
||||
})
|
||||
|
||||
setSubmissions(payload?.submissions || [])
|
||||
if (action === 'approve') {
|
||||
setCollection((current) => ({ ...current, artworks_count: (current?.artworks_count ?? 0) + 1 }))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReport(targetType, targetId) {
|
||||
if (!reportEndpoint) {
|
||||
if (engagement?.login_url) window.location.assign(engagement.login_url)
|
||||
return
|
||||
}
|
||||
|
||||
const reason = window.prompt('Why are you reporting this? (required)')
|
||||
if (!reason || !reason.trim()) return
|
||||
|
||||
try {
|
||||
await requestJson(reportEndpoint, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
target_type: targetType,
|
||||
target_id: targetId,
|
||||
reason: reason.trim(),
|
||||
},
|
||||
})
|
||||
setState((current) => ({ ...current, notice: 'Report submitted. Thank you.' }))
|
||||
} catch (error) {
|
||||
setState((current) => ({ ...current, notice: error.message }))
|
||||
}
|
||||
}
|
||||
|
||||
function renderModule(module) {
|
||||
if (!module?.key) return null
|
||||
|
||||
if (module.key === 'intro_block') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (module.key === 'featured_artworks') {
|
||||
if (!artworkItems.length) return null
|
||||
|
||||
return (
|
||||
<PageSection eyebrow="Highlights" title="Featured artworks" count={Math.min(artworkItems.length, 3)}>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm leading-relaxed text-slate-300">
|
||||
Start with the standout pieces from this collection before diving into the full sequence.
|
||||
</p>
|
||||
<ArtworkGallery items={artworkItems.slice(0, 3)} showAuthor={showArtworkAuthors} className="grid-cols-1 gap-5 md:grid-cols-3" />
|
||||
</div>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
|
||||
if (module.key === 'editorial_note') {
|
||||
if (collection?.type !== 'editorial') return null
|
||||
|
||||
return (
|
||||
<PageSection eyebrow="Editorial" title="Editorial context">
|
||||
<div className="space-y-3 text-sm leading-relaxed text-slate-300">
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] px-4 py-4">
|
||||
{collection?.description || 'A staff-curated collection prepared for premium discovery placement.'}
|
||||
</div>
|
||||
{(collection?.event_label || collection?.badge_label) ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{collection?.event_label ? <span className="inline-flex items-center rounded-full border border-white/10 bg-white/[0.05] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white">{collection.event_label}</span> : null}
|
||||
{collection?.badge_label ? <span className="inline-flex items-center rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-sky-100">{collection.badge_label}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
|
||||
if (module.key === 'artwork_grid') {
|
||||
return (
|
||||
<section>
|
||||
{artworkItems.length ? <ArtworkGallery items={artworkItems} showAuthor={showArtworkAuthors} className="grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" /> : <EmptyCollectionState isOwner={isOwner} smart={collection?.mode === 'smart'} />}
|
||||
|
||||
{(artworks?.links?.prev || artworks?.links?.next) ? (
|
||||
<div className="mt-8 flex items-center justify-between gap-4">
|
||||
<a href={artworks?.links?.prev || '#'} className={`inline-flex items-center gap-2 rounded-2xl border px-4 py-3 text-sm font-semibold transition ${artworks?.links?.prev ? 'border-white/12 bg-white/[0.05] text-white hover:bg-white/[0.08]' : 'pointer-events-none border-white/8 bg-white/[0.02] text-slate-500'}`}><i className="fa-solid fa-arrow-left fa-fw" />Previous</a>
|
||||
<a href={artworks?.links?.next || '#'} className={`inline-flex items-center gap-2 rounded-2xl border px-4 py-3 text-sm font-semibold transition ${artworks?.links?.next ? 'border-white/12 bg-white/[0.05] text-white hover:bg-white/[0.08]' : 'pointer-events-none border-white/8 bg-white/[0.02] text-slate-500'}`}>Next<i className="fa-solid fa-arrow-right fa-fw" /></a>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (module.key === 'discussion') {
|
||||
if (!collection?.allow_comments) return null
|
||||
|
||||
return (
|
||||
<PageSection eyebrow="Discussion" title="Collection comments" count={(collection?.comments_count ?? comments.length).toLocaleString()}>
|
||||
{canComment ? <div className="rounded-2xl border border-white/10 bg-white/[0.04] p-4"><CommentForm onSubmit={handleCommentSubmit} placeholder="Talk about the curation, mood, or standout pieces…" submitLabel="Post comment" /></div> : null}
|
||||
<div className={canComment ? 'mt-5' : ''}>
|
||||
<CommentList comments={comments} canReply={false} onDelete={handleDeleteComment} onReport={(comment) => handleReport('collection_comment', comment.id)} emptyMessage="No comments yet." />
|
||||
</div>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
|
||||
if (module.key === 'related_collections') {
|
||||
if (!Array.isArray(relatedCollections) || !relatedCollections.length) return null
|
||||
|
||||
return (
|
||||
<PageSection eyebrow="More to Explore" title="Related collections">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{relatedCollections.map((item) => (
|
||||
<div key={item.id} className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{recommendationReasons(collection, item).map((reason) => (
|
||||
<span key={`${item.id}-${reason}`} className="inline-flex items-center rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold text-sky-100">
|
||||
{reason}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<CollectionCard collection={item} isOwner={false} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
|
||||
if (module.key === 'collaborators') {
|
||||
return (
|
||||
<PageSection eyebrow="Contributors" title="Curation team">
|
||||
<div className="space-y-3">
|
||||
{members.length ? members.filter((member) => member?.status === 'active').map((member) => <CollaboratorCard key={member.id} member={member} />) : <p className="text-sm text-slate-400">This collection is curated by a single owner right now.</p>}
|
||||
</div>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
|
||||
if (module.key === 'submissions') {
|
||||
if (!collection?.allow_submissions) return null
|
||||
|
||||
return (
|
||||
<PageSection eyebrow="Submissions" title="Submit to this collection">
|
||||
{canSubmit && submissionArtworkOptions?.length ? (
|
||||
<div className="space-y-3">
|
||||
<select value={selectedArtworkId} onChange={(event) => setSelectedArtworkId(event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
|
||||
{submissionArtworkOptions.map((artwork) => (
|
||||
<option key={artwork.id} value={artwork.id}>{artwork.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" onClick={handleSubmitArtwork} className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/25 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15"><i className="fa-solid fa-paper-plane fa-fw" />Submit artwork</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-400">Sign in with at least one artwork on your account to submit here.</p>
|
||||
)}
|
||||
<div className="mt-5 space-y-3">
|
||||
{submissions.length ? submissions.map((submission) => (
|
||||
<SubmissionCard
|
||||
key={submission.id}
|
||||
submission={submission}
|
||||
onApprove={(item) => handleSubmissionAction(item, 'approve')}
|
||||
onReject={(item) => handleSubmissionAction(item, 'reject')}
|
||||
onWithdraw={(item) => handleSubmissionAction(item, 'withdraw')}
|
||||
onReport={(item) => handleReport('collection_submission', item.id)}
|
||||
/>
|
||||
)) : <p className="text-sm text-slate-400">No submissions yet.</p>}
|
||||
</div>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const renderedFullModules = enabledModules.filter((module) => module.slot === 'full').map(renderModule).filter(Boolean)
|
||||
const renderedMainModules = enabledModules.filter((module) => module.slot === 'main').map(renderModule).filter(Boolean)
|
||||
const renderedSidebarModules = enabledModules.filter((module) => module.slot === 'sidebar').map(renderModule).filter(Boolean)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{metaTitle}</title>
|
||||
{metaDescription ? <meta name="description" content={metaDescription} /> : null}
|
||||
{seo?.canonical ? <link rel="canonical" href={seo.canonical} /> : null}
|
||||
{seo?.robots ? <meta name="robots" content={seo.robots} /> : null}
|
||||
<meta property="og:title" content={metaTitle} />
|
||||
{metaDescription ? <meta property="og:description" content={metaDescription} /> : null}
|
||||
{seo?.og_image ? <meta property="og:image" content={seo.og_image} /> : null}
|
||||
{seo?.canonical ? <meta property="og:url" content={seo.canonical} /> : null}
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={metaTitle} />
|
||||
{metaDescription ? <meta name="twitter:description" content={metaDescription} /> : null}
|
||||
{seo?.og_image ? <meta name="twitter:image" content={seo.og_image} /> : null}
|
||||
{collectionSchema ? <script type="application/ld+json">{JSON.stringify(collectionSchema)}</script> : null}
|
||||
</Head>
|
||||
|
||||
<div className="relative min-h-screen overflow-hidden pb-16">
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[36rem] opacity-95" style={{ background: 'radial-gradient(circle at top left, rgba(56,189,248,0.18), transparent 32%), radial-gradient(circle at 82% 10%, rgba(249,115,22,0.18), transparent 26%), linear-gradient(180deg, #07101d 0%, #0a1220 42%, #08111f 100%)' }} />
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 opacity-[0.05]" style={{ backgroundImage: 'url(/gfx/noise.png)', backgroundSize: '180px' }} />
|
||||
|
||||
<div className="mx-auto max-w-7xl px-4 pt-8 md:px-6">
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-slate-300">
|
||||
<a href={profileCollectionsUrl} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white">
|
||||
<i className="fa-solid fa-arrow-left fa-fw text-[11px]" />
|
||||
Back to collections
|
||||
</a>
|
||||
{isOwner && manageUrl ? <a href={manageUrl} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 font-semibold text-sky-100 transition hover:bg-sky-400/15"><i className="fa-solid fa-grip fa-fw text-[11px]" />Manage artworks</a> : null}
|
||||
{isOwner && editUrl ? <a href={editUrl} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white"><i className="fa-solid fa-pen-to-square fa-fw text-[11px]" />Edit details</a> : null}
|
||||
{isOwner && analyticsUrl ? <a href={analyticsUrl} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white"><i className="fa-solid fa-chart-column fa-fw text-[11px]" />Analytics</a> : null}
|
||||
{isOwner && historyUrl ? <a href={historyUrl} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white"><i className="fa-solid fa-timeline fa-fw text-[11px]" />History</a> : null}
|
||||
</div>
|
||||
|
||||
<section className="mt-6 overflow-hidden rounded-[34px] border border-white/10 bg-white/[0.04] shadow-[0_30px_90px_rgba(2,6,23,0.28)] backdrop-blur-sm">
|
||||
<div className="grid gap-6 p-5 md:p-7 xl:grid-cols-[minmax(0,1.2fr)_420px]">
|
||||
<div className="relative overflow-hidden rounded-[28px] border border-white/10 bg-slate-950/60">
|
||||
{collection?.cover_image ? <img src={collection.cover_image} alt={collection.title} className="aspect-[16/10] h-full w-full object-cover" /> : <div className="flex aspect-[16/10] items-center justify-center bg-[linear-gradient(135deg,#08111f,#0f172a,#08111f)] text-slate-500"><i className="fa-solid fa-layer-group text-5xl" /></div>}
|
||||
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_top,rgba(2,6,23,0.8),rgba(2,6,23,0.08))]" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-between">
|
||||
<div>
|
||||
{collection?.banner_text ? (
|
||||
<div className={`mb-4 inline-flex max-w-full items-center gap-2 rounded-[22px] border px-4 py-3 text-sm font-medium shadow-[0_18px_40px_rgba(2,6,23,0.2)] ${spotlightClasses}`}>
|
||||
<i className="fa-solid fa-sparkles text-[12px]" />
|
||||
<span className="truncate">{collection.banner_text}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{collection?.is_featured ? <span className="inline-flex items-center rounded-full border border-amber-300/25 bg-amber-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100">Featured Collection</span> : null}
|
||||
{collection?.mode === 'smart' ? <span className="inline-flex items-center rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100">Smart Collection</span> : null}
|
||||
<TypeBadge collection={collection} />
|
||||
{collection?.event_label ? <span className="inline-flex items-center rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white">{collection.event_label}</span> : null}
|
||||
{collection?.campaign_label ? <span className="inline-flex items-center rounded-full border border-amber-300/20 bg-amber-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100">{collection.campaign_label}</span> : null}
|
||||
{collection?.badge_label ? <span className="inline-flex items-center rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white">{collection.badge_label}</span> : null}
|
||||
{collection?.series_key ? <span className="inline-flex items-center rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white">Series {collection.series_order ? `#${collection.series_order}` : ''}</span> : null}
|
||||
{isOwner ? <CollectionVisibilityBadge visibility={collection?.visibility} /> : null}
|
||||
</div>
|
||||
<h1 className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white md:text-5xl">{collection?.title}</h1>
|
||||
{showIntroBlock ? (
|
||||
<>
|
||||
{collection?.subtitle ? <p className="mt-3 text-base text-slate-300">{collection.subtitle}</p> : null}
|
||||
{collection?.description ? <p className="mt-4 max-w-2xl text-sm leading-relaxed text-slate-300 md:text-[15px]">{collection.description}</p> : <p className="mt-4 max-w-2xl text-sm leading-relaxed text-slate-400 md:text-[15px]">A curated selection from @{owner?.username}, assembled as a focused gallery rather than a simple archive.</p>}
|
||||
{collection?.smart_summary ? <p className="mt-3 max-w-2xl text-sm leading-relaxed text-sky-100/90">{collection.smart_summary}</p> : null}
|
||||
{featuringCreatorsCount > 1 ? <p className="mt-3 text-sm text-slate-300">Featuring artworks by {featuringCreatorsCount} creators.</p> : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<button type="button" onClick={handleLike} disabled={state.busy || !engagement?.like_url} className={`inline-flex items-center gap-2 rounded-full border px-4 py-2.5 text-sm font-semibold transition ${state.liked ? 'border-rose-400/20 bg-rose-400/10 text-rose-100' : 'border-white/12 bg-white/[0.05] text-white hover:bg-white/[0.08]'}`}><i className={`fa-solid ${state.liked ? 'fa-heart' : 'fa-heart-circle-plus'} fa-fw`} />{state.liked ? 'Liked' : 'Like Collection'}</button>
|
||||
<button type="button" onClick={handleFollow} disabled={state.busy || !engagement?.follow_url} className={`inline-flex items-center gap-2 rounded-full border px-4 py-2.5 text-sm font-semibold transition ${state.following ? 'border-emerald-400/20 bg-emerald-400/10 text-emerald-100' : 'border-white/12 bg-white/[0.05] text-white hover:bg-white/[0.08]'}`}><i className={`fa-solid ${state.following ? 'fa-bell' : 'fa-bell-concierge'} fa-fw`} />{state.following ? 'Following' : 'Follow Collection'}</button>
|
||||
<button type="button" onClick={handleSave} disabled={state.busy || (!engagement?.save_url && !engagement?.unsave_url)} className={`inline-flex items-center gap-2 rounded-full border px-4 py-2.5 text-sm font-semibold transition ${state.saved ? 'border-violet-300/20 bg-violet-400/10 text-violet-100' : 'border-white/12 bg-white/[0.05] text-white hover:bg-white/[0.08]'}`}><i className={`fa-solid ${state.saved ? 'fa-bookmark' : 'fa-bookmark-circle'} fa-fw`} />{state.saved ? 'Saved' : 'Save Collection'}</button>
|
||||
<button type="button" onClick={handleShare} className="inline-flex items-center gap-2 rounded-full border border-white/12 bg-white/[0.05] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]"><i className="fa-solid fa-share-nodes fa-fw" />Share</button>
|
||||
{reportEndpoint && !isOwner ? <button type="button" onClick={() => handleReport('collection', collection?.id)} className="inline-flex items-center gap-2 rounded-full border border-white/12 bg-white/[0.05] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]"><i className="fa-solid fa-flag fa-fw" />Report</button> : null}
|
||||
{featuredCollectionsUrl ? <a href={featuredCollectionsUrl} className="inline-flex items-center gap-2 rounded-full border border-white/12 bg-white/[0.05] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]"><i className="fa-solid fa-compass fa-fw" />Featured Collections</a> : null}
|
||||
</div>
|
||||
|
||||
{state.notice ? <p className="mt-3 text-sm text-sky-100">{state.notice}</p> : null}
|
||||
|
||||
<div className="mt-6 grid gap-3 sm:grid-cols-3 xl:grid-cols-5">
|
||||
<MetaRow compact icon="fa-images" label="Artworks" value={(collection?.artworks_count ?? 0).toLocaleString()} />
|
||||
<MetaRow compact icon="fa-heart" label="Likes" value={(collection?.likes_count ?? 0).toLocaleString()} />
|
||||
<MetaRow compact icon="fa-bell" label="Followers" value={(collection?.followers_count ?? 0).toLocaleString()} />
|
||||
<MetaRow compact icon="fa-eye" label="Views" value={(collection?.views_count ?? 0).toLocaleString()} />
|
||||
<MetaRow compact icon="fa-bookmark" label="Saves" value={(collection?.saves_count ?? 0).toLocaleString()} />
|
||||
</div>
|
||||
|
||||
{(collection?.presentation_style && collection.presentation_style !== 'standard') || collection?.quality_score != null || collection?.ranking_score != null || collection?.campaign_key ? (
|
||||
<div className="mt-6 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{collection?.presentation_style && collection.presentation_style !== 'standard' ? <MetaRow icon="fa-panorama" label="Presentation" value={String(collection.presentation_style).replace(/_/g, ' ')} /> : null}
|
||||
{collection?.quality_score != null ? <MetaRow icon="fa-gauge-high" label="Quality" value={Number(collection.quality_score).toFixed(1)} /> : null}
|
||||
{collection?.ranking_score != null ? <MetaRow icon="fa-ranking-star" label="Ranking" value={Number(collection.ranking_score).toFixed(1)} /> : null}
|
||||
{collection?.campaign_key ? <MetaRow icon="fa-bullhorn" label="Campaign" value={collection.campaign_label || collection.campaign_key} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<OwnerCard owner={owner} collectionType={collection?.type} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{(seriesContext?.url || seriesContext?.previous || seriesContext?.next || (Array.isArray(seriesContext?.siblings) && seriesContext.siblings.length)) ? (
|
||||
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Series</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">{seriesContext?.title || 'Connected collection sequence'}</h2>
|
||||
{seriesContext?.description ? <p className="mt-3 max-w-3xl text-sm leading-relaxed text-slate-300">{seriesContext.description}</p> : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{collection?.series_key ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{collection.series_key}</span> : null}
|
||||
{seriesContext?.url ? <a href={seriesContext.url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-list fa-fw text-[10px]" />View full series</a> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
{seriesContext?.previous ? (
|
||||
<a href={seriesContext.previous.url} className="flex items-center justify-between rounded-[24px] border border-white/10 bg-white/[0.04] px-5 py-4 transition hover:bg-white/[0.07]">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Previous</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{seriesContext.previous.title}</div>
|
||||
</div>
|
||||
<i className="fa-solid fa-arrow-left text-slate-500" />
|
||||
</a>
|
||||
) : null}
|
||||
{seriesContext?.next ? (
|
||||
<a href={seriesContext.next.url} className="flex items-center justify-between rounded-[24px] border border-white/10 bg-white/[0.04] px-5 py-4 transition hover:bg-white/[0.07]">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Next</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{seriesContext.next.title}</div>
|
||||
</div>
|
||||
<i className="fa-solid fa-arrow-right text-slate-500" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{Array.isArray(seriesContext?.siblings) && seriesContext.siblings.length ? (
|
||||
<div className="grid gap-4">
|
||||
{seriesContext.siblings.slice(0, 2).map((item) => (
|
||||
<CollectionCard key={item.id} collection={item} isOwner={false} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{contextSignals.length ? (
|
||||
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80">Related Context</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Campaign, event, and quality context</h2>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{contextSignals.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{contextSignals.map((item) => (
|
||||
<ContextSignalCard key={`${item.meta}-${item.title}`} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{storyLinks.length ? (
|
||||
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-lime-200/80">Stories</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Stories and editorial references linked to this collection</h2>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{storyLinks.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{storyLinks.map((item) => (
|
||||
<EntityLinkCard key={`${item.linked_type}-${item.linked_id}-${item.id}`} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{taxonomyLinks.length ? (
|
||||
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-violet-200/80">Browse The Theme</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Categories and tags that anchor this collection</h2>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{taxonomyLinks.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{taxonomyLinks.map((item) => (
|
||||
<EntityLinkCard key={`${item.linked_type}-${item.linked_id}-${item.id}`} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{contributorLinks.length ? (
|
||||
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Connected Creators</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Creators and artworks that give the set its shape</h2>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{contributorLinks.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{contributorLinks.map((item) => (
|
||||
<EntityLinkCard key={`${item.linked_type}-${item.linked_id}-${item.id}`} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{renderedFullModules.length ? <div className="mt-8 space-y-6">{renderedFullModules}</div> : null}
|
||||
|
||||
{(renderedMainModules.length || renderedSidebarModules.length) ? (
|
||||
<section className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="space-y-6">{renderedMainModules}</div>
|
||||
<div className="space-y-6">{renderedSidebarModules}</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user