optimizations

This commit is contained in:
2026-03-28 19:15:39 +01:00
parent 0b25d9570a
commit cab4fbd83e
509 changed files with 1016804 additions and 1605 deletions

View File

@@ -4,6 +4,7 @@ import { Link, usePage } from '@inertiajs/react'
const navItems = [
{ label: 'Overview', href: '/studio', icon: 'fa-solid fa-chart-line' },
{ label: 'Artworks', href: '/studio/artworks', icon: 'fa-solid fa-images' },
{ label: 'Cards', href: '/studio/cards', icon: 'fa-solid fa-rectangle-history-circle-user' },
{ label: 'Drafts', href: '/studio/artworks/drafts', icon: 'fa-solid fa-file-pen' },
{ label: 'Archived', href: '/studio/artworks/archived', icon: 'fa-solid fa-box-archive' },
{ label: 'Analytics', href: '/studio/analytics', icon: 'fa-solid fa-chart-pie' },

View File

@@ -116,12 +116,24 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
{/* Artwork reactions */}
{reactionTotals !== null && (
<ReactionBar
entityType="artwork"
entityId={artwork.id}
initialTotals={reactionTotals}
isLoggedIn={isAuthenticated}
/>
<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 */}

View File

@@ -0,0 +1,143 @@
import React from 'react'
import { Head, usePage } from '@inertiajs/react'
function MetricCard({ label, value, delta, icon }) {
return (
<div className="rounded-[26px] border border-white/10 bg-white/[0.04] p-5 backdrop-blur-sm">
<div className="flex items-center justify-between gap-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">{label}</div>
<i className={`fa-solid ${icon} text-slate-500`} />
</div>
<div className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">{Number(value || 0).toLocaleString()}</div>
<div className="mt-2 text-sm text-slate-300">{Number(delta || 0).toLocaleString()} in the selected range</div>
</div>
)
}
function TimelineChart({ timeline }) {
const safeTimeline = Array.isArray(timeline) ? timeline : []
const maxValue = safeTimeline.reduce((largest, item) => Math.max(largest, item.views || 0, item.likes || 0, item.saves || 0), 0) || 1
return (
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Timeline</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Engagement trend</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">{safeTimeline.length} days</span>
</div>
{safeTimeline.length ? (
<div className="mt-6 flex h-64 items-end gap-2 overflow-x-auto rounded-[24px] border border-white/10 bg-slate-950/40 px-4 py-5">
{safeTimeline.map((point) => (
<div key={point.date} className="flex min-w-[32px] flex-1 flex-col items-center justify-end gap-2">
<div className="flex h-full w-full items-end gap-1">
<div className="w-1/3 rounded-t-full bg-sky-300/80" style={{ height: `${Math.max(6, ((point.views || 0) / maxValue) * 100)}%` }} title={`Views: ${point.views || 0}`} />
<div className="w-1/3 rounded-t-full bg-emerald-300/75" style={{ height: `${Math.max(6, ((point.likes || 0) / maxValue) * 100)}%` }} title={`Likes: ${point.likes || 0}`} />
<div className="w-1/3 rounded-t-full bg-amber-300/75" style={{ height: `${Math.max(6, ((point.saves || 0) / maxValue) * 100)}%` }} title={`Saves: ${point.saves || 0}`} />
</div>
<div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">{String(point.date || '').slice(5)}</div>
</div>
))}
</div>
) : (
<div className="mt-6 rounded-[24px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-12 text-sm text-slate-300">Analytics are enabled, but there are not enough daily snapshots yet to render a timeline.</div>
)}
<div className="mt-4 flex flex-wrap gap-4 text-xs font-semibold uppercase tracking-[0.16em] text-slate-400">
<span className="inline-flex items-center gap-2"><span className="h-2.5 w-2.5 rounded-full bg-sky-300/80" />Views</span>
<span className="inline-flex items-center gap-2"><span className="h-2.5 w-2.5 rounded-full bg-emerald-300/75" />Likes</span>
<span className="inline-flex items-center gap-2"><span className="h-2.5 w-2.5 rounded-full bg-amber-300/75" />Saves</span>
</div>
</section>
)
}
export default function CollectionAnalytics() {
const { props } = usePage()
const collection = props.collection || {}
const analytics = props.analytics || {}
const totals = analytics.totals || {}
const range = analytics.range || {}
const topArtworks = Array.isArray(analytics.top_artworks) ? analytics.top_artworks : []
const seo = props.seo || {}
return (
<>
<Head>
<title>{seo.title || `${collection.title || 'Collection'} Analytics — Skinbase Nova`}</title>
<meta name="description" content={seo.description || 'Collection analytics overview.'} />
{seo.canonical ? <link rel="canonical" href={seo.canonical} /> : null}
<meta name="robots" content={seo.robots || 'noindex,follow'} />
</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-[32rem] opacity-95" style={{ background: 'radial-gradient(circle at 14% 14%, rgba(56,189,248,0.18), transparent 26%), radial-gradient(circle at 86% 18%, rgba(16,185,129,0.16), transparent 24%), 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">
{props.dashboardUrl ? <a href={props.dashboardUrl} 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]" />Dashboard</a> : null}
{props.historyUrl ? <a href={props.historyUrl} 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-timeline fa-fw text-[11px]" />History</a> : null}
{collection.manage_url ? <a href={collection.manage_url} 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]" />Manage</a> : null}
</div>
<section className="mt-6 rounded-[34px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_30px_90px_rgba(2,6,23,0.28)] backdrop-blur-sm md:p-8">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Performance</p>
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">{collection.title || 'Collection analytics'}</h1>
<p className="mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 md:text-[15px]">
Review activity velocity, audience response, and the artworks carrying the most discovery value over the last {range.days || 30} days.
</p>
</section>
<section className="mt-8 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
<MetricCard label="Views" value={totals.views} delta={range.views_delta} icon="fa-eye" />
<MetricCard label="Likes" value={totals.likes} delta={range.likes_delta} icon="fa-heart" />
<MetricCard label="Follows" value={totals.follows} delta={range.follows_delta} icon="fa-bell" />
<MetricCard label="Saves" value={totals.saves} delta={range.saves_delta} icon="fa-bookmark" />
<MetricCard label="Comments" value={totals.comments} delta={range.comments_delta} icon="fa-comments" />
<MetricCard label="Submissions" value={totals.submissions} delta={totals.submissions} icon="fa-inbox" />
</section>
<div className="mt-8 space-y-6">
<TimelineChart timeline={analytics.timeline} />
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Artworks</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Top artwork drivers</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">{topArtworks.length}</span>
</div>
{topArtworks.length ? (
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{topArtworks.map((artwork) => (
<div key={artwork.id} className="overflow-hidden rounded-[24px] border border-white/10 bg-slate-950/40">
<div className="aspect-square bg-slate-950/60">
{artwork.thumb ? <img src={artwork.thumb} alt={artwork.title} className="h-full w-full object-cover" /> : <div className="flex h-full w-full items-center justify-center text-slate-500"><i className="fa-solid fa-image text-3xl" /></div>}
</div>
<div className="space-y-2 p-4">
<div className="truncate text-sm font-semibold text-white">{artwork.title}</div>
<div className="grid grid-cols-2 gap-2 text-xs text-slate-300">
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">Views: {Number(artwork.views || 0).toLocaleString()}</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">Favs: {Number(artwork.favourites || 0).toLocaleString()}</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">Shares: {Number(artwork.shares || 0).toLocaleString()}</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">Rank: {Number(artwork.ranking_score || 0).toFixed(1)}</div>
</div>
</div>
</div>
))}
</div>
) : (
<div className="mt-6 rounded-[24px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-12 text-sm text-slate-300">Attach or publish more artworks before artwork-level ranking can be surfaced here.</div>
)}
</section>
</div>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,674 @@
import React from 'react'
import { Head, usePage } from '@inertiajs/react'
import CollectionCard from '../../components/profile/collections/CollectionCard'
const DEFAULT_SEARCH_FILTERS = {
q: '',
type: '',
visibility: '',
lifecycle_state: '',
workflow_state: '',
health_state: '',
placement_eligibility: '',
}
const DEFAULT_BULK_FORM = {
action: 'archive',
campaign_key: '',
campaign_label: '',
lifecycle_state: 'archived',
}
function getCsrfToken() {
if (typeof document === 'undefined') return ''
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
function titleize(value) {
return String(value || '')
.split('_')
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
function buildSearchUrl(baseUrl, filters) {
const url = new URL(baseUrl, window.location.origin)
Object.entries(filters || {}).forEach(([key, value]) => {
if (value === '' || value === null || value === undefined) {
return
}
url.searchParams.set(key, String(value))
})
return url.toString()
}
async function fetchSearchResults(baseUrl, filters) {
const response = await fetch(buildSearchUrl(baseUrl, filters), {
method: 'GET',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || 'Search failed.')
}
return payload
}
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 SummaryCard({ label, value, icon, tone = 'sky' }) {
const toneClasses = {
sky: 'border-sky-300/15 bg-sky-400/10 text-sky-100',
amber: 'border-amber-300/15 bg-amber-400/10 text-amber-100',
rose: 'border-rose-300/15 bg-rose-400/10 text-rose-100',
emerald: 'border-emerald-300/15 bg-emerald-400/10 text-emerald-100',
}
return (
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 backdrop-blur-sm">
<div className={`inline-flex h-12 w-12 items-center justify-center rounded-2xl border ${toneClasses[tone] || toneClasses.sky}`}>
<i className={`fa-solid ${icon}`} />
</div>
<div className="mt-4 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">{label}</div>
<div className="mt-2 text-3xl font-semibold tracking-[-0.04em] text-white">{value}</div>
</div>
)
}
function CollectionStrip({ title, eyebrow, collections, emptyLabel, endpoints }) {
function resolve(pattern, collectionId) {
return pattern ? pattern.replace('__COLLECTION__', String(collectionId)) : null
}
return (
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_24px_80px_rgba(2,6,23,0.24)] backdrop-blur-sm md:p-7">
<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">{eyebrow}</p>
<h2 className="mt-2 text-2xl font-semibold text-white">{title}</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">{collections.length}</span>
</div>
{collections.length ? (
<div className="mt-6 grid gap-5 xl:grid-cols-2">
{collections.map((collection) => (
<div key={collection.id} className="space-y-3">
<CollectionCard collection={collection} isOwner />
<div className="flex flex-wrap gap-2">
{resolve(endpoints?.managePattern, collection.id) ? (
<a href={resolve(endpoints.managePattern, collection.id)} 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 text-white transition hover:bg-white/[0.07]">
<i className="fa-solid fa-pen-to-square fa-fw text-[10px]" />Manage
</a>
) : null}
{resolve(endpoints?.analyticsPattern, collection.id) ? (
<a href={resolve(endpoints.analyticsPattern, collection.id)} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-xs font-semibold text-sky-100 transition hover:bg-sky-400/15">
<i className="fa-solid fa-chart-column fa-fw text-[10px]" />Analytics
</a>
) : null}
{resolve(endpoints?.historyPattern, collection.id) ? (
<a href={resolve(endpoints.historyPattern, collection.id)} 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 text-white transition hover:bg-white/[0.07]">
<i className="fa-solid fa-timeline fa-fw text-[10px]" />History
</a>
) : null}
</div>
</div>
))}
</div>
) : (
<div className="mt-6 rounded-[26px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-12 text-sm text-slate-300">{emptyLabel}</div>
)}
</section>
)
}
function WarningList({ warnings, endpoints }) {
function resolve(pattern, collectionId) {
return pattern ? pattern.replace('__COLLECTION__', String(collectionId)) : null
}
if (!warnings.length) {
return null
}
return (
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_24px_80px_rgba(2,6,23,0.24)] backdrop-blur-sm md:p-7">
<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">Health</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Warnings and blockers</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">{warnings.length}</span>
</div>
<div className="mt-6 grid gap-4 xl:grid-cols-2">
{warnings.map((warning) => (
<div key={warning.collection_id} className="rounded-[24px] border border-white/10 bg-[#0d1726] p-5">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-lg font-semibold text-white">{warning.title}</p>
<p className="mt-2 text-sm text-slate-300">State: {warning.health?.health_state || 'unknown'}</p>
</div>
<span className="rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-xs font-semibold text-amber-100">
{warning.health?.health_score ?? 'n/a'}
</span>
</div>
{Array.isArray(warning.health?.flags) && warning.health.flags.length ? (
<div className="mt-3 flex flex-wrap gap-2">
{warning.health.flags.map((flag) => (
<span key={flag} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-300">{flag}</span>
))}
</div>
) : null}
<div className="mt-4 flex flex-wrap gap-2">
{resolve(endpoints?.managePattern, warning.collection_id) ? <a href={resolve(endpoints.managePattern, warning.collection_id)} 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 text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-pen-to-square fa-fw text-[10px]" />Manage</a> : null}
{resolve(endpoints?.healthPattern, warning.collection_id) ? <a href={resolve(endpoints.healthPattern, warning.collection_id)} className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-400/10 px-4 py-2 text-xs font-semibold text-amber-100 transition hover:bg-amber-400/15"><i className="fa-solid fa-shield-heart fa-fw text-[10px]" />Health JSON</a> : null}
</div>
</div>
))}
</div>
</section>
)
}
function SearchField({ label, value, onChange, children }) {
return (
<label className="block space-y-2">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{label}</span>
{children || (
<input
value={value}
onChange={onChange}
className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-sky-300/40"
/>
)}
</label>
)
}
function BulkActionsPanel({
selectedCount,
totalCount,
form,
onFormChange,
onApply,
onClear,
onToggleAll,
busy,
error,
notice,
}) {
if (!selectedCount && !notice && !error) {
return null
}
return (
<div className="mt-6 rounded-[26px] border border-white/10 bg-[#0d1726] p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Bulk actions</p>
<h3 className="mt-1 text-lg font-semibold text-white">Apply safe actions to selected collections</h3>
</div>
<div className="flex flex-wrap gap-2 text-xs font-semibold">
<button type="button" onClick={onToggleAll} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-slate-200 transition hover:bg-white/[0.07]">
{selectedCount === totalCount && totalCount > 0 ? 'Clear visible' : 'Select visible'}
</button>
{selectedCount ? (
<button type="button" onClick={onClear} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-slate-200 transition hover:bg-white/[0.07]">
Clear selection
</button>
) : null}
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2 text-xs font-semibold uppercase tracking-[0.12em] text-slate-400">
<span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-sky-100">{selectedCount} selected</span>
</div>
{(notice || error) ? (
<div className={`mt-4 rounded-2xl px-4 py-3 text-sm ${error ? 'border border-rose-300/20 bg-rose-400/10 text-rose-100' : 'border border-emerald-300/20 bg-emerald-400/10 text-emerald-100'}`}>
{error || notice}
</div>
) : null}
<div className="mt-4 grid gap-4 lg:grid-cols-4">
<SearchField label="Action">
<select value={form.action} onChange={(event) => onFormChange('action', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40">
<option value="archive">Archive</option>
<option value="assign_campaign">Assign campaign</option>
<option value="update_lifecycle">Update lifecycle</option>
<option value="request_ai_review">Request AI review</option>
<option value="mark_editorial_review">Mark editorial review</option>
</select>
</SearchField>
{form.action === 'assign_campaign' ? (
<>
<SearchField label="Campaign key">
<input value={form.campaign_key} onChange={(event) => onFormChange('campaign_key', event.target.value)} placeholder="spring-launch" className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-sky-300/40" />
</SearchField>
<SearchField label="Campaign label">
<input value={form.campaign_label} onChange={(event) => onFormChange('campaign_label', event.target.value)} placeholder="Spring Launch" className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-sky-300/40" />
</SearchField>
</>
) : null}
{form.action === 'update_lifecycle' ? (
<SearchField label="Lifecycle state">
<select value={form.lifecycle_state} onChange={(event) => onFormChange('lifecycle_state', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40">
<option value="draft">Draft</option>
<option value="published">Published</option>
<option value="archived">Archived</option>
</select>
</SearchField>
) : null}
<div className="flex items-end">
<button type="button" onClick={onApply} disabled={busy || !selectedCount} className="inline-flex w-full items-center justify-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:cursor-not-allowed disabled:opacity-60">
<i className="fa-solid fa-wand-magic-sparkles fa-fw text-[12px]" />Apply action
</button>
</div>
</div>
</div>
)
}
function SearchResults({ state, endpoints, selectedIds, onToggleSelected }) {
function resolve(pattern, collectionId) {
return pattern ? pattern.replace('__COLLECTION__', String(collectionId)) : null
}
if (!state.hasSearched) {
return (
<div className="mt-6 rounded-[26px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-12 text-sm text-slate-300">
Run a search to slice your collection library by workflow, health, visibility, and placement readiness.
</div>
)
}
if (state.error) {
return <div className="mt-6 rounded-[26px] border border-rose-300/20 bg-rose-400/10 px-6 py-4 text-sm text-rose-100">{state.error}</div>
}
if (!state.collections.length) {
return (
<div className="mt-6 rounded-[26px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-12 text-sm text-slate-300">
No collections matched the current filters.
</div>
)
}
return (
<div className="mt-6 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
{Object.entries(state.filters || {}).filter(([, value]) => value !== '' && value !== null && value !== undefined).map(([key, value]) => (
<span key={key} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-300">
{titleize(key)}: {value === '1' ? 'Eligible' : value === '0' ? 'Blocked' : titleize(value)}
</span>
))}
</div>
{state.meta ? <span className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">{state.meta.total || state.collections.length} results</span> : null}
</div>
<div className="grid gap-5 xl:grid-cols-2">
{state.collections.map((collection) => (
<div key={collection.id} className="space-y-3 rounded-[28px] border border-white/10 bg-[#0d1726] p-5">
<div className="flex items-center justify-between gap-3">
<label className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.12em] text-slate-400">
<input
type="checkbox"
checked={selectedIds.includes(collection.id)}
onChange={() => onToggleSelected(collection.id)}
className="h-4 w-4 rounded border-white/20 bg-[#09111d] text-sky-400 focus:ring-sky-300/30"
/>
Select
</label>
</div>
<CollectionCard collection={collection} isOwner />
<div className="flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-400">
{collection.workflow_state ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">Workflow: {titleize(collection.workflow_state)}</span> : null}
{collection.health_state ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">Health: {titleize(collection.health_state)}</span> : null}
{collection.placement_eligibility === true ? <span className="rounded-full border border-emerald-300/20 bg-emerald-400/10 px-3 py-1 text-emerald-100">Placement Eligible</span> : null}
{collection.placement_eligibility === false ? <span className="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-1 text-rose-100">Placement Blocked</span> : null}
</div>
<div className="flex flex-wrap gap-2">
{resolve(endpoints?.managePattern, collection.id) ? (
<a href={resolve(endpoints.managePattern, collection.id)} 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 text-white transition hover:bg-white/[0.07]">
<i className="fa-solid fa-pen-to-square fa-fw text-[10px]" />Manage
</a>
) : null}
{resolve(endpoints?.analyticsPattern, collection.id) ? (
<a href={resolve(endpoints.analyticsPattern, collection.id)} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-xs font-semibold text-sky-100 transition hover:bg-sky-400/15">
<i className="fa-solid fa-chart-column fa-fw text-[10px]" />Analytics
</a>
) : null}
{resolve(endpoints?.historyPattern, collection.id) ? (
<a href={resolve(endpoints.historyPattern, collection.id)} 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 text-white transition hover:bg-white/[0.07]">
<i className="fa-solid fa-timeline fa-fw text-[10px]" />History
</a>
) : null}
{resolve(endpoints?.healthPattern, collection.id) ? (
<a href={resolve(endpoints.healthPattern, collection.id)} className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-400/10 px-4 py-2 text-xs font-semibold text-amber-100 transition hover:bg-amber-400/15">
<i className="fa-solid fa-shield-heart fa-fw text-[10px]" />Health
</a>
) : null}
</div>
</div>
))}
</div>
</div>
)
}
export default function CollectionDashboard() {
const { props } = usePage()
const [summary, setSummary] = React.useState(props.summary || {})
const topPerforming = Array.isArray(props.topPerforming) ? props.topPerforming : []
const needsAttention = Array.isArray(props.needsAttention) ? props.needsAttention : []
const expiringCampaigns = Array.isArray(props.expiringCampaigns) ? props.expiringCampaigns : []
const healthWarnings = Array.isArray(props.healthWarnings) ? props.healthWarnings : []
const filterOptions = props.filterOptions || {}
const endpoints = props.endpoints || {}
const seo = props.seo || {}
const [searchFilters, setSearchFilters] = React.useState(DEFAULT_SEARCH_FILTERS)
const [searchState, setSearchState] = React.useState({
busy: false,
error: '',
collections: [],
meta: null,
filters: null,
hasSearched: false,
})
const [selectedIds, setSelectedIds] = React.useState([])
const [bulkForm, setBulkForm] = React.useState(DEFAULT_BULK_FORM)
const [bulkState, setBulkState] = React.useState({ busy: false, error: '', notice: '' })
React.useEffect(() => {
setSummary(props.summary || {})
}, [props.summary])
React.useEffect(() => {
const visibleIds = new Set((searchState.collections || []).map((collection) => Number(collection.id)))
setSelectedIds((current) => current.filter((id) => visibleIds.has(Number(id))))
}, [searchState.collections])
function updateFilter(key, value) {
setSearchFilters((current) => ({
...current,
[key]: value,
}))
}
async function handleSearch(event) {
event.preventDefault()
if (!endpoints.search) {
setSearchState({ busy: false, error: 'Search endpoint is unavailable.', collections: [], meta: null, filters: null, hasSearched: true })
return
}
setSearchState((current) => ({ ...current, busy: true, error: '', hasSearched: true }))
try {
const payload = await fetchSearchResults(endpoints.search, searchFilters)
setSearchState({
busy: false,
error: '',
collections: Array.isArray(payload.collections) ? payload.collections : [],
meta: payload.meta || null,
filters: payload.filters || { ...searchFilters },
hasSearched: true,
})
} catch (error) {
setSearchState({ busy: false, error: error.message || 'Search failed.', collections: [], meta: null, filters: null, hasSearched: true })
}
}
function resetSearch() {
setSearchFilters(DEFAULT_SEARCH_FILTERS)
setSearchState({ busy: false, error: '', collections: [], meta: null, filters: null, hasSearched: false })
setSelectedIds([])
}
function updateBulkForm(key, value) {
setBulkForm((current) => ({
...current,
[key]: value,
}))
}
function toggleSelected(collectionId) {
setSelectedIds((current) => (
current.includes(collectionId)
? current.filter((id) => id !== collectionId)
: [...current, collectionId]
))
}
function toggleSelectAllVisible() {
const visibleIds = (searchState.collections || []).map((collection) => Number(collection.id))
if (!visibleIds.length) {
return
}
setSelectedIds((current) => (
current.length === visibleIds.length && visibleIds.every((id) => current.includes(id))
? []
: visibleIds
))
}
async function applyBulkAction() {
if (!selectedIds.length) {
return
}
if (!endpoints.bulkActions) {
setBulkState({ busy: false, error: 'Bulk action endpoint is unavailable.', notice: '' })
return
}
if (bulkForm.action === 'archive' && !window.confirm(`Archive ${selectedIds.length} selected collection${selectedIds.length === 1 ? '' : 's'}?`)) {
return
}
const payload = {
action: bulkForm.action,
collection_ids: selectedIds,
}
if (bulkForm.action === 'assign_campaign') {
payload.campaign_key = bulkForm.campaign_key
payload.campaign_label = bulkForm.campaign_label
}
if (bulkForm.action === 'update_lifecycle') {
payload.lifecycle_state = bulkForm.lifecycle_state
}
setBulkState({ busy: true, error: '', notice: '' })
try {
const response = await requestJson(endpoints.bulkActions, { method: 'POST', body: payload })
const updates = new Map((Array.isArray(response.collections) ? response.collections : []).map((collection) => [Number(collection.id), collection]))
setSearchState((current) => ({
...current,
collections: (current.collections || []).map((collection) => updates.get(Number(collection.id)) || collection),
}))
setSummary(response.summary || summary)
setSelectedIds([])
setBulkState({ busy: false, error: '', notice: response.message || 'Bulk action applied.' })
} catch (error) {
setBulkState({ busy: false, error: error.message || 'Bulk action failed.', notice: '' })
}
}
return (
<>
<Head>
<title>{seo.title || 'Collections Dashboard — Skinbase Nova'}</title>
<meta name="description" content={seo.description || 'Collection lifecycle and performance dashboard.'} />
{seo.canonical ? <link rel="canonical" href={seo.canonical} /> : null}
<meta name="robots" content={seo.robots || 'noindex,follow'} />
</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-[34rem] opacity-95" style={{ background: 'radial-gradient(circle at 12% 15%, rgba(56,189,248,0.18), transparent 28%), radial-gradient(circle at 84% 14%, rgba(245,158,11,0.16), 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">
<section className="overflow-hidden rounded-[34px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_30px_90px_rgba(2,6,23,0.28)] backdrop-blur-sm md:p-8">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Operations</p>
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">Collections dashboard</h1>
<p className="mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 md:text-[15px]">
A working view of collection health across lifecycle, submissions, quality, and campaign timing. Use it to decide what to publish, repair, archive, or promote next.
</p>
</section>
<section className="mt-8 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
<SummaryCard label="Total Collections" value={summary.total ?? 0} icon="fa-layer-group" tone="sky" />
<SummaryCard label="Drafts" value={summary.drafts ?? 0} icon="fa-file" tone="amber" />
<SummaryCard label="Scheduled" value={summary.scheduled ?? 0} icon="fa-calendar-days" tone="emerald" />
<SummaryCard label="Published" value={summary.published ?? 0} icon="fa-globe" tone="sky" />
<SummaryCard label="Archived" value={summary.archived ?? 0} icon="fa-box-archive" tone="rose" />
<SummaryCard label="Pending Submissions" value={summary.pending_submissions ?? 0} icon="fa-inbox" tone="amber" />
<SummaryCard label="Needs Review" value={summary.needs_review ?? 0} icon="fa-triangle-exclamation" tone="amber" />
<SummaryCard label="Duplicate Risk" value={summary.duplicate_risk ?? 0} icon="fa-clone" tone="rose" />
<SummaryCard label="Placement Blocked" value={summary.placement_blocked ?? 0} icon="fa-ban" tone="rose" />
</section>
<div className="mt-8 space-y-6">
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_24px_80px_rgba(2,6,23,0.24)] backdrop-blur-sm md:p-7">
<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">Search</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Find the exact collections that need action</h2>
</div>
{searchState.busy ? <span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-xs font-semibold text-sky-100">Searching...</span> : null}
</div>
<form onSubmit={handleSearch} className="mt-6 grid gap-4 lg:grid-cols-2 xl:grid-cols-4">
<SearchField label="Query" value={searchFilters.q} onChange={(event) => updateFilter('q', event.target.value)}>
<input value={searchFilters.q} onChange={(event) => updateFilter('q', event.target.value)} placeholder="Title, slug, or campaign" className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-sky-300/40" />
</SearchField>
<SearchField label="Type">
<select value={searchFilters.type} onChange={(event) => updateFilter('type', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40">
<option value="">Any type</option>
{(Array.isArray(filterOptions.types) ? filterOptions.types : []).map((option) => (
<option key={option} value={option}>{titleize(option)}</option>
))}
</select>
</SearchField>
<SearchField label="Visibility">
<select value={searchFilters.visibility} onChange={(event) => updateFilter('visibility', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40">
<option value="">Any visibility</option>
{(Array.isArray(filterOptions.visibilities) ? filterOptions.visibilities : []).map((option) => (
<option key={option} value={option}>{titleize(option)}</option>
))}
</select>
</SearchField>
<SearchField label="Lifecycle">
<select value={searchFilters.lifecycle_state} onChange={(event) => updateFilter('lifecycle_state', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40">
<option value="">Any lifecycle</option>
{(Array.isArray(filterOptions.lifecycleStates) ? filterOptions.lifecycleStates : []).map((option) => (
<option key={option} value={option}>{titleize(option)}</option>
))}
</select>
</SearchField>
<SearchField label="Workflow">
<select value={searchFilters.workflow_state} onChange={(event) => updateFilter('workflow_state', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40">
<option value="">Any workflow</option>
{(Array.isArray(filterOptions.workflowStates) ? filterOptions.workflowStates : []).map((option) => (
<option key={option} value={option}>{titleize(option)}</option>
))}
</select>
</SearchField>
<SearchField label="Health">
<select value={searchFilters.health_state} onChange={(event) => updateFilter('health_state', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40">
<option value="">Any health state</option>
{(Array.isArray(filterOptions.healthStates) ? filterOptions.healthStates : []).map((option) => (
<option key={option} value={option}>{titleize(option)}</option>
))}
</select>
</SearchField>
<SearchField label="Placement">
<select value={searchFilters.placement_eligibility} onChange={(event) => updateFilter('placement_eligibility', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40">
<option value="">Any placement state</option>
<option value="1">Eligible</option>
<option value="0">Blocked</option>
</select>
</SearchField>
<div className="flex items-end gap-3 xl:col-span-1">
<button type="submit" disabled={searchState.busy} className="inline-flex flex-1 items-center justify-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:cursor-not-allowed disabled:opacity-60">
<i className="fa-solid fa-magnifying-glass fa-fw text-[12px]" />Search
</button>
<button type="button" onClick={resetSearch} disabled={searchState.busy} className="inline-flex items-center justify-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.07] disabled:cursor-not-allowed disabled:opacity-60">
Reset
</button>
</div>
</form>
<BulkActionsPanel
selectedCount={selectedIds.length}
totalCount={(searchState.collections || []).length}
form={bulkForm}
onFormChange={updateBulkForm}
onApply={applyBulkAction}
onClear={() => setSelectedIds([])}
onToggleAll={toggleSelectAllVisible}
busy={bulkState.busy}
error={bulkState.error}
notice={bulkState.notice}
/>
<SearchResults state={searchState} endpoints={endpoints} selectedIds={selectedIds} onToggleSelected={toggleSelected} />
</section>
<WarningList warnings={healthWarnings} endpoints={endpoints} />
<CollectionStrip title="Top Performing" eyebrow="Momentum" collections={topPerforming} emptyLabel="No collections have enough activity yet to rank here." endpoints={endpoints} />
<CollectionStrip title="Needs Attention" eyebrow="Quality" collections={needsAttention} emptyLabel="No collections currently need manual intervention." endpoints={endpoints} />
<CollectionStrip title="Expiring Campaigns" eyebrow="Timing" collections={expiringCampaigns} emptyLabel="No campaigns are approaching their sunset window." endpoints={endpoints} />
</div>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,489 @@
import React from 'react'
import { Head, usePage } from '@inertiajs/react'
import CollectionCard from '../../components/profile/collections/CollectionCard'
const SEARCH_SELECT_OPTIONS = {
type: [
{ value: 'personal', label: 'Personal' },
{ value: 'community', label: 'Community' },
{ value: 'editorial', label: 'Editorial' },
],
mode: [
{ value: 'manual', label: 'Manual' },
{ value: 'smart', label: 'Smart' },
],
lifecycle_state: [
{ value: 'published', label: 'Published' },
{ value: 'featured', label: 'Featured' },
{ value: 'archived', label: 'Archived' },
],
health_state: [
{ value: 'healthy', label: 'Healthy' },
{ value: 'needs_metadata', label: 'Needs metadata' },
{ value: 'stale', label: 'Stale' },
{ value: 'low_content', label: 'Low content' },
{ value: 'broken_items', label: 'Broken items' },
{ value: 'weak_cover', label: 'Weak cover' },
{ value: 'low_engagement', label: 'Low engagement' },
{ value: 'duplicate_risk', label: 'Duplicate risk' },
{ value: 'merge_candidate', label: 'Merge candidate' },
],
sort: [
{ value: 'trending', label: 'Trending' },
{ value: 'recent', label: 'Recent' },
{ value: 'quality', label: 'Quality' },
{ value: 'evergreen', label: 'Evergreen' },
],
}
function humanizeToken(value) {
return String(value || '')
.replaceAll('_', ' ')
.replaceAll('-', ' ')
.replace(/\b\w/g, (match) => match.toUpperCase())
}
function searchChipLabel(key, value) {
if (!value) return null
const option = SEARCH_SELECT_OPTIONS[key]?.find((item) => item.value === value)
const displayValue = option?.label || humanizeToken(value)
return key === 'q'
? `Query: ${value}`
: key === 'campaign_key'
? `Campaign: ${displayValue}`
: key === 'program_key'
? `Program: ${displayValue}`
: key === 'quality_tier'
? `Quality Tier: ${displayValue}`
: key === 'sort'
? `Sort: ${displayValue}`
: `${humanizeToken(key)}: ${displayValue}`
}
function buildSearchHref(filters, omitKey = null) {
const params = new URLSearchParams()
Object.entries(filters || {}).forEach(([key, value]) => {
if (key === omitKey) return
if (value === null || value === undefined || value === '') return
params.set(key, value)
})
const query = params.toString()
return query ? `/collections/search?${query}` : '/collections/search'
}
function activeSearchChips(filters) {
return Object.entries(filters || {})
.filter(([, value]) => value !== null && value !== undefined && value !== '')
.map(([key, value]) => ({
key,
label: searchChipLabel(key, value),
href: buildSearchHref(filters, key),
}))
.filter((chip) => chip.label)
}
function primarySaveContext({ search, campaign, program, title, eyebrow }) {
if (search) {
return {
context: 'collection_search',
meta: {
query: search?.filters?.q || null,
surface_label: 'collection search',
},
}
}
if (campaign) {
return {
context: 'campaign_landing',
meta: {
campaign_key: campaign.key,
campaign_label: campaign.label,
surface_label: campaign.label || 'campaign landing',
},
}
}
if (program) {
return {
context: 'program_landing',
meta: {
program_key: program.key,
program_label: program.label,
surface_label: program.label || 'program landing',
},
}
}
if (eyebrow === 'Trending') return { context: 'trending_landing', meta: { surface_label: 'trending collections' } }
if (eyebrow === 'Editorial') return { context: 'editorial_landing', meta: { surface_label: 'editorial collections' } }
if (eyebrow === 'Community') return { context: 'community_landing', meta: { surface_label: 'community collections' } }
if (eyebrow === 'Seasonal') return { context: 'seasonal_landing', meta: { surface_label: 'seasonal collections' } }
if (title === 'Recommended collections' || title === 'Collections worth exploring') return { context: 'recommended_landing', meta: { surface_label: 'recommended collections' } }
return {
context: 'featured_landing',
meta: { surface_label: 'featured collections' },
}
}
function HeroStat({ icon, label, value }) {
return (
<div className="rounded-[22px] border border-white/10 bg-white/[0.05] px-4 py-4">
<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-2 text-2xl font-semibold tracking-[-0.03em] text-white">{value}</div>
</div>
)
}
function EmptyState() {
return (
<div className="rounded-[32px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-16 text-center">
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-[24px] border border-white/12 bg-white/[0.05] text-slate-400">
<i className="fa-solid fa-compass text-3xl" />
</div>
<h2 className="mt-5 text-2xl font-semibold text-white">No featured collections yet</h2>
<p className="mx-auto mt-3 max-w-xl text-sm leading-relaxed text-slate-300">
Featured placement is reserved for public collections with a strong visual point of view. Check back once creators start pinning their best showcases.
</p>
<div className="mt-6 flex justify-center">
<a
href="/"
className="inline-flex items-center gap-2 rounded-2xl border border-white/12 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]"
>
<i className="fa-solid fa-house fa-fw" />
Back to home
</a>
</div>
</div>
)
}
function SearchPanel({ search }) {
if (!search) return null
const filters = search.filters || {}
const options = search.options || {}
const chips = activeSearchChips(filters)
return (
<section className="mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
<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">Filters</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Search collections</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">{search?.meta?.total ?? 0} results</span>
</div>
<form method="GET" action="/collections/search" className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<input name="q" defaultValue={filters.q || ''} placeholder="Search title or summary" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35 xl:col-span-2" />
<select name="type" defaultValue={filters.type || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
<option value="">All types</option>
<option value="personal">Personal</option>
<option value="community">Community</option>
<option value="editorial">Editorial</option>
</select>
<select name="sort" defaultValue={filters.sort || 'trending'} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
<option value="trending">Trending</option>
<option value="recent">Recent</option>
<option value="quality">Quality</option>
<option value="evergreen">Evergreen</option>
</select>
<select name="category" defaultValue={filters.category || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
<option value="">Any category</option>
{(options.category || []).map((item) => (
<option key={`category-${item.value}`} value={item.value}>{item.label}</option>
))}
</select>
<select name="mode" defaultValue={filters.mode || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
<option value="">Any curation mode</option>
<option value="manual">Manual</option>
<option value="smart">Smart</option>
</select>
<select name="style" defaultValue={filters.style || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
<option value="">Any style signal</option>
{(options.style || []).map((item) => (
<option key={`style-${item.value}`} value={item.value}>{item.label}</option>
))}
</select>
<select name="lifecycle_state" defaultValue={filters.lifecycle_state || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
<option value="">Any lifecycle</option>
<option value="published">Published</option>
<option value="featured">Featured</option>
<option value="archived">Archived</option>
</select>
<select name="theme" defaultValue={filters.theme || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
<option value="">Any theme</option>
{(options.theme || []).map((item) => (
<option key={`theme-${item.value}`} value={item.value}>{item.label}</option>
))}
</select>
<select name="health_state" defaultValue={filters.health_state || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
<option value="">Any quality state</option>
<option value="healthy">Healthy</option>
<option value="needs_metadata">Needs metadata</option>
<option value="stale">Stale</option>
<option value="low_content">Low content</option>
<option value="broken_items">Broken items</option>
<option value="weak_cover">Weak cover</option>
<option value="low_engagement">Low engagement</option>
<option value="duplicate_risk">Duplicate risk</option>
<option value="merge_candidate">Merge candidate</option>
</select>
<select name="color" defaultValue={filters.color || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
<option value="">Any color palette</option>
{(options.color || []).map((item) => (
<option key={`color-${item.value}`} value={item.value}>{item.label}</option>
))}
</select>
<input name="campaign_key" defaultValue={filters.campaign_key || ''} placeholder="Campaign key" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35" />
<input name="program_key" defaultValue={filters.program_key || ''} placeholder="Program key" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35" />
<select name="quality_tier" defaultValue={filters.quality_tier || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
<option value="">Any quality tier</option>
{(options.quality_tier || []).map((item) => (
<option key={`quality-tier-${item.value}`} value={item.value}>{item.label}</option>
))}
</select>
<div className="md:col-span-2 xl:col-span-4 flex flex-wrap gap-3">
<button type="submit" className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15"><i className="fa-solid fa-magnifying-glass fa-fw" />Apply filters</button>
<a href="/collections/search" 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.07]"><i className="fa-solid fa-rotate-left fa-fw" />Reset</a>
</div>
</form>
{chips.length ? (
<div className="mt-5 flex flex-wrap gap-3">
{chips.map((chip) => (
<a key={chip.key} href={chip.href} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-3 py-1.5 text-xs font-semibold text-slate-200 transition hover:bg-white/[0.08]">
<span>{chip.label}</span>
<i className="fa-solid fa-xmark text-[10px] text-slate-400" />
</a>
))}
</div>
) : null}
{(search?.links?.prev || search?.links?.next) ? (
<div className="mt-5 flex flex-wrap gap-3 text-sm">
{search.links.prev ? <a href={search.links.prev} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-arrow-left fa-fw text-[10px]" />Previous</a> : null}
{search.links.next ? <a href={search.links.next} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-white transition hover:bg-white/[0.07]">Next<i className="fa-solid fa-arrow-right fa-fw text-[10px]" /></a> : null}
</div>
) : null}
</section>
)
}
export default function CollectionFeaturedIndex() {
const { props } = usePage()
const seo = props.seo || {}
const eyebrow = props.eyebrow || 'Discovery'
const title = props.title || 'Featured collections'
const description = props.description || 'A rotating set of standout galleries from across Skinbase Nova. Some are meticulously hand-sequenced. Others are smart collections that stay fresh as the creator publishes new work.'
const collections = Array.isArray(props.collections) ? props.collections : []
const communityCollections = Array.isArray(props.communityCollections) ? props.communityCollections : []
const editorialCollections = Array.isArray(props.editorialCollections) ? props.editorialCollections : []
const recentCollections = Array.isArray(props.recentCollections) ? props.recentCollections : []
const trendingCollections = Array.isArray(props.trendingCollections) ? props.trendingCollections : []
const seasonalCollections = Array.isArray(props.seasonalCollections) ? props.seasonalCollections : []
const campaign = props.campaign || null
const program = props.program || null
const search = props.search || null
const smartCount = collections.filter((collection) => collection?.mode === 'smart').length
const totalArtworks = collections.reduce((sum, collection) => sum + (collection?.artworks_count || 0), 0)
const mainSave = primarySaveContext({ search, campaign, program, title, eyebrow })
const listSchema = seo?.canonical ? {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: title,
description,
url: seo.canonical,
mainEntity: {
'@type': 'ItemList',
numberOfItems: collections.length,
itemListElement: collections.slice(0, 18).map((collection, index) => ({
'@type': 'ListItem',
position: index + 1,
url: collection.url,
name: collection.title,
})),
},
} : null
return (
<>
<Head>
<title>{seo?.title || `${title} — Skinbase Nova`}</title>
<meta name="description" content={seo?.description || description} />
{seo?.canonical ? <link rel="canonical" href={seo.canonical} /> : null}
<meta name="robots" content={seo?.robots || 'index,follow'} />
<meta property="og:title" content={seo?.title || `${title} — Skinbase Nova`} />
<meta property="og:description" content={seo?.description || description} />
{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={seo?.title || `${title} — Skinbase Nova`} />
<meta name="twitter:description" content={seo?.description || description} />
{listSchema ? <script type="application/ld+json">{JSON.stringify(listSchema)}</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-[38rem] opacity-95"
style={{
background: 'radial-gradient(circle at 12% 14%, rgba(56,189,248,0.18), transparent 28%), radial-gradient(circle at 88% 16%, 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="/" 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 home
</a>
<a href="/collections/featured" 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">Featured</a>
<a href="/collections/trending" 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">Trending</a>
<a href="/collections/community" 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">Community</a>
<a href="/collections/editorial" 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">Editorial</a>
<a href="/collections/seasonal" 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">Seasonal</a>
</div>
<section className="mt-6 overflow-hidden rounded-[34px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_30px_90px_rgba(2,6,23,0.28)] backdrop-blur-sm md:p-8">
<div className="grid gap-8 xl:grid-cols-[minmax(0,1.18fr)_400px] xl:items-end">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">{eyebrow}</p>
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">{title}</h1>
{campaign?.badge_label ? (
<div className="mt-4 inline-flex items-center rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100">
{campaign.badge_label}
</div>
) : program?.promotion_tier ? (
<div className="mt-4 inline-flex items-center rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100">
Promotion tier: {program.promotion_tier}
</div>
) : null}
<p className="mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 md:text-[15px]">
{description}
</p>
{campaign ? (
<div className="mt-5 flex flex-wrap gap-3 text-xs text-slate-300">
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Campaign key: {campaign.key}</span>
{campaign.event_label ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Event: {campaign.event_label}</span> : null}
{campaign.season_key ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Season: {campaign.season_key}</span> : null}
{Array.isArray(campaign.active_surface_keys) && campaign.active_surface_keys.length ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Surfaces: {campaign.active_surface_keys.join(', ')}</span> : null}
</div>
) : program ? (
<div className="mt-5 flex flex-wrap gap-3 text-xs text-slate-300">
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Program key: {program.key}</span>
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Collections: {program.collections_count ?? collections.length}</span>
{program.trust_tier ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Trust: {program.trust_tier}</span> : null}
{Array.isArray(program.partner_labels) && program.partner_labels.length ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Partners: {program.partner_labels.join(', ')}</span> : null}
{Array.isArray(program.sponsorship_labels) && program.sponsorship_labels.length ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Sponsors: {program.sponsorship_labels.join(', ')}</span> : null}
</div>
) : null}
</div>
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
<HeroStat icon="fa-layer-group" label="Collections" value={collections.length.toLocaleString()} />
<HeroStat icon="fa-wand-magic-sparkles" label="Smart" value={smartCount.toLocaleString()} />
<HeroStat icon="fa-images" label="Artworks" value={totalArtworks.toLocaleString()} />
</div>
</div>
</section>
<section className="mt-8">
<SearchPanel search={search} />
</section>
<section className="mt-8">
{collections.length ? (
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
{collections.map((collection) => (
<CollectionCard key={collection.id} collection={collection} isOwner={false} saveContext={mainSave.context} saveContextMeta={mainSave.meta} />
))}
</div>
) : (
<EmptyState />
)}
</section>
{communityCollections.length ? (
<section className="mt-10">
<div className="flex items-end justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Community</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Collaborative picks</h2>
</div>
</div>
<div className="mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
{communityCollections.map((collection) => (
<CollectionCard key={collection.id} collection={collection} isOwner={false} saveContext="community_row" saveContextMeta={{ surface_label: 'community collections' }} />
))}
</div>
</section>
) : null}
{trendingCollections.length ? (
<section className="mt-10">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Trending</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Momentum right now</h2>
</div>
<div className="mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
{trendingCollections.map((collection) => (
<CollectionCard key={collection.id} collection={collection} isOwner={false} saveContext="trending_row" saveContextMeta={{ surface_label: 'trending collections' }} />
))}
</div>
</section>
) : null}
{editorialCollections.length ? (
<section className="mt-10">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Editorial</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Staff and campaign collections</h2>
</div>
<div className="mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
{editorialCollections.map((collection) => (
<CollectionCard key={collection.id} collection={collection} isOwner={false} saveContext="editorial_row" saveContextMeta={{ surface_label: 'editorial collections' }} />
))}
</div>
</section>
) : null}
{seasonalCollections.length ? (
<section className="mt-10">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Seasonal</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Campaign and event spotlights</h2>
</div>
<div className="mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
{seasonalCollections.map((collection) => (
<CollectionCard key={collection.id} collection={collection} isOwner={false} saveContext="seasonal_row" saveContextMeta={{ surface_label: 'seasonal collections' }} />
))}
</div>
</section>
) : null}
{recentCollections.length ? (
<section className="mt-10 pb-8">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Recent</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Freshly published collections</h2>
</div>
<div className="mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
{recentCollections.map((collection) => (
<CollectionCard key={collection.id} collection={collection} isOwner={false} saveContext="recent_row" saveContextMeta={{ surface_label: 'recent collections' }} />
))}
</div>
</section>
) : null}
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,169 @@
import React from 'react'
import { Head, usePage } from '@inertiajs/react'
function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
function formatDateTime(value) {
if (!value) return 'Unknown time'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return 'Unknown time'
return date.toLocaleString()
}
function FieldChanges({ label, value }) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
const entries = Object.entries(value).slice(0, 8)
if (!entries.length) return null
return (
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{label}</div>
<div className="mt-3 space-y-2 text-sm text-slate-300">
{entries.map(([key, fieldValue]) => (
<div key={key} className="flex items-start justify-between gap-4 border-b border-white/5 pb-2 last:border-b-0 last:pb-0">
<span className="font-medium text-white">{key}</span>
<span className="max-w-[60%] truncate text-right">{Array.isArray(fieldValue) ? `${fieldValue.length} items` : String(fieldValue)}</span>
</div>
))}
</div>
</div>
)
}
function buildPageUrl(pageNumber) {
if (typeof window === 'undefined') return '#'
const url = new URL(window.location.href)
url.searchParams.set('page', String(pageNumber))
return url.toString()
}
export default function CollectionHistory() {
const { props } = usePage()
const collection = props.collection || {}
const history = props.history || {}
const entries = Array.isArray(history.data) ? history.data : []
const meta = history.meta || {}
const seo = props.seo || {}
const [busyId, setBusyId] = React.useState(null)
const [notice, setNotice] = React.useState('')
async function handleRestore(entry) {
if (!props.restorePattern || !entry?.can_restore) return
const confirmed = window.confirm(`Restore this collection state from history entry #${entry.id}?`)
if (!confirmed) return
setBusyId(entry.id)
setNotice('')
try {
const response = await fetch(props.restorePattern.replace('__HISTORY__', String(entry.id)), {
method: 'POST',
headers: {
Accept: 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
},
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || 'Unable to restore this history entry right now.')
}
window.location.reload()
} catch (error) {
setNotice(error?.message || 'Unable to restore this history entry right now.')
} finally {
setBusyId(null)
}
}
return (
<>
<Head>
<title>{seo.title || `${collection.title || 'Collection'} History — Skinbase Nova`}</title>
<meta name="description" content={seo.description || 'Collection audit history.'} />
{seo.canonical ? <link rel="canonical" href={seo.canonical} /> : null}
<meta name="robots" content={seo.robots || 'noindex,follow'} />
</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-[32rem] opacity-95" style={{ background: 'radial-gradient(circle at 14% 14%, rgba(56,189,248,0.16), transparent 26%), radial-gradient(circle at 84% 20%, rgba(244,63,94,0.14), transparent 24%), 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-6xl px-4 pt-8 md:px-6">
<div className="flex flex-wrap items-center gap-3 text-sm text-slate-300">
{props.dashboardUrl ? <a href={props.dashboardUrl} 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]" />Dashboard</a> : null}
{props.analyticsUrl ? <a href={props.analyticsUrl} 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-chart-column fa-fw text-[11px]" />Analytics</a> : null}
{collection.manage_url ? <a href={collection.manage_url} 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]" />Manage</a> : null}
</div>
<section className="mt-6 rounded-[34px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_30px_90px_rgba(2,6,23,0.28)] backdrop-blur-sm md:p-8">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Audit</p>
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">{collection.title || 'Collection history'}</h1>
<p className="mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 md:text-[15px]">
A chronological log of lifecycle transitions, editorial changes, artwork operations, and moderation-adjacent actions for this collection.
</p>
</section>
<section className="mt-8 space-y-4">
{notice ? <div className="rounded-[24px] border border-rose-300/20 bg-rose-500/10 px-5 py-4 text-sm text-rose-100">{notice}</div> : null}
{entries.length ? entries.map((entry) => (
<article key={entry.id} className="rounded-[30px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="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">{String(entry.action_type || 'updated').replace(/_/g, ' ')}</span>
{entry.actor?.username ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300">@{entry.actor.username}</span> : <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">System</span>}
{entry.can_restore ? <span className="rounded-full border border-emerald-300/20 bg-emerald-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-emerald-100">Restorable</span> : null}
</div>
<h2 className="mt-4 text-xl font-semibold text-white">{entry.summary || 'Collection updated'}</h2>
{entry.can_restore && Array.isArray(entry.restore_fields) && entry.restore_fields.length ? <p className="mt-3 text-xs uppercase tracking-[0.18em] text-slate-400">Restores: {entry.restore_fields.join(', ')}</p> : null}
</div>
<div className="flex flex-col items-end gap-3">
<div className="text-sm text-slate-400">{formatDateTime(entry.created_at)}</div>
{props.canRestoreHistory && entry.can_restore ? (
<button
type="button"
onClick={() => handleRestore(entry)}
disabled={busyId === entry.id}
className="inline-flex items-center gap-2 rounded-full border border-emerald-300/20 bg-emerald-400/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-emerald-100 transition hover:bg-emerald-400/15 disabled:cursor-not-allowed disabled:opacity-60"
>
<i className="fa-solid fa-rotate-left fa-fw text-[10px]" />
{busyId === entry.id ? 'Restoring…' : 'Restore'}
</button>
) : null}
</div>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<FieldChanges label="Before" value={entry.before} />
<FieldChanges label="After" value={entry.after} />
</div>
</article>
)) : (
<div className="rounded-[30px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-14 text-sm text-slate-300">No audit entries have been recorded for this collection yet.</div>
)}
</section>
{Number(meta.last_page || 1) > 1 ? (
<div className="mt-8 flex flex-wrap items-center justify-between gap-3 rounded-[28px] border border-white/10 bg-white/[0.04] px-5 py-4 text-sm text-slate-300">
<div>Page {meta.current_page || 1} of {meta.last_page || 1}</div>
<div className="flex gap-2">
{(meta.current_page || 1) > 1 ? <a href={buildPageUrl((meta.current_page || 1) - 1)} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-arrow-left fa-fw text-[10px]" />Previous</a> : null}
{(meta.current_page || 1) < (meta.last_page || 1) ? <a href={buildPageUrl((meta.current_page || 1) + 1)} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 font-semibold text-white transition hover:bg-white/[0.07]">Next<i className="fa-solid fa-arrow-right fa-fw text-[10px]" /></a> : null}
</div>
</div>
) : null}
</div>
</div>
</>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,98 @@
import React from 'react'
import { Head, usePage } from '@inertiajs/react'
import CollectionCard from '../../components/profile/collections/CollectionCard'
function StatCard({ icon, label, value }) {
return (
<div className="rounded-[22px] border border-white/10 bg-white/[0.05] px-4 py-4">
<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-2 text-2xl font-semibold tracking-[-0.03em] text-white">{value}</div>
</div>
)
}
export default function CollectionSeriesShow() {
const { props } = usePage()
const seo = props.seo || {}
const title = props.title || `Collection Series: ${props.seriesKey || ''}`
const description = props.description || 'A connected sequence of public collections on Skinbase Nova.'
const collections = Array.isArray(props.collections) ? props.collections : []
const leadCollection = props.leadCollection || null
const stats = props.stats || {}
return (
<>
<Head>
<title>{seo.title || `${title} — Skinbase Nova`}</title>
<meta name="description" content={seo.description || description} />
{seo.canonical ? <link rel="canonical" href={seo.canonical} /> : null}
<meta name="robots" content={seo.robots || 'index,follow'} />
</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 10% 15%, rgba(59,130,246,0.18), transparent 28%), radial-gradient(circle at 84% 18%, rgba(34,197,94,0.16), transparent 24%), 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="/collections/featured" 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>
{leadCollection?.url ? <a href={leadCollection.url} 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">Lead collection</a> : null}
</div>
<section className="mt-6 overflow-hidden rounded-[34px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_30px_90px_rgba(2,6,23,0.28)] backdrop-blur-sm md:p-8">
<div className="grid gap-8 xl:grid-cols-[minmax(0,1.15fr)_400px] xl:items-end">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Series</p>
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">{title}</h1>
<p className="mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 md:text-[15px]">{description}</p>
{props.seriesKey ? <div className="mt-5 inline-flex rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-300">{props.seriesKey}</div> : null}
</div>
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
<StatCard icon="fa-layer-group" label="Collections" value={Number(stats.collections || collections.length).toLocaleString()} />
<StatCard icon="fa-user-group" label="Creators" value={Number(stats.owners || 0).toLocaleString()} />
<StatCard icon="fa-images" label="Artworks" value={Number(stats.artworks || 0).toLocaleString()} />
</div>
</div>
</section>
{leadCollection ? (
<section className="mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Lead Entry</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Start with the opening collection</h2>
</div>
{stats.latest_activity_at ? <div className="text-xs uppercase tracking-[0.16em] text-slate-400">Latest activity {new Date(stats.latest_activity_at).toLocaleDateString()}</div> : null}
</div>
<div className="mt-5 max-w-xl">
<CollectionCard collection={leadCollection} isOwner={false} />
</div>
</section>
) : null}
<section className="mt-8 pb-8">
<div className="flex items-end justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Sequence</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Public collections in order</h2>
</div>
</div>
<div className="mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
{collections.map((collection) => (
<CollectionCard key={collection.id} collection={collection} isOwner={false} />
))}
</div>
</section>
</div>
</div>
</>
)
}

View 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>
</>
)
}

View File

@@ -0,0 +1,963 @@
import React from 'react'
import { Head, usePage } from '@inertiajs/react'
import CollectionCard from '../../components/profile/collections/CollectionCard'
import ShareToast from '../../components/ui/ShareToast'
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 isoToLocalInput(value) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
const local = new Date(date.getTime() - (date.getTimezoneOffset() * 60000))
return local.toISOString().slice(0, 16)
}
function titleize(value) {
return String(value || '')
.split('_')
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
function Field({ label, help, children }) {
return (
<label className="block space-y-2">
<span className="text-sm font-semibold text-white">{label}</span>
{children}
{help ? <span className="block text-xs leading-relaxed text-slate-400">{help}</span> : null}
</label>
)
}
function StatCard({ label, value, tone = 'sky' }) {
const toneClasses = {
sky: 'border-sky-300/15 bg-sky-400/10 text-sky-100',
amber: 'border-amber-300/15 bg-amber-400/10 text-amber-100',
emerald: 'border-emerald-300/15 bg-emerald-400/10 text-emerald-100',
}
return (
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5 backdrop-blur-sm">
<div className={`inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${toneClasses[tone] || toneClasses.sky}`}>{label}</div>
<div className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white">{value}</div>
</div>
)
}
function sortAssignments(items) {
return [...items].sort((left, right) => {
const keyCompare = String(left.program_key || '').localeCompare(String(right.program_key || ''))
if (keyCompare !== 0) return keyCompare
return (Number(right.priority) || 0) - (Number(left.priority) || 0)
})
}
function buildHooksForm(collection) {
return {
experiment_key: collection?.experiment_key || '',
experiment_treatment: collection?.experiment_treatment || '',
placement_variant: collection?.placement_variant || '',
ranking_mode_variant: collection?.ranking_mode_variant || '',
collection_pool_version: collection?.collection_pool_version || '',
test_label: collection?.test_label || '',
promotion_tier: collection?.promotion_tier || '',
partner_key: collection?.partner_key || '',
trust_tier: collection?.trust_tier || '',
sponsorship_state: collection?.sponsorship_state || '',
ownership_domain: collection?.ownership_domain || '',
commercial_review_state: collection?.commercial_review_state || '',
legal_review_state: collection?.legal_review_state || '',
placement_eligibility: Boolean(collection?.placement_eligibility),
}
}
function buildDiagnostics(collection) {
if (!collection) {
return null
}
return {
collection_id: Number(collection.id),
workflow_state: collection.workflow_state || null,
health_state: collection.health_state || null,
placement_eligibility: Boolean(collection.placement_eligibility),
experiment_key: collection.experiment_key || null,
experiment_treatment: collection.experiment_treatment || null,
placement_variant: collection.placement_variant || null,
ranking_mode_variant: collection.ranking_mode_variant || null,
collection_pool_version: collection.collection_pool_version || null,
test_label: collection.test_label || null,
partner_key: collection.partner_key || null,
trust_tier: collection.trust_tier || null,
promotion_tier: collection.promotion_tier || null,
sponsorship_state: collection.sponsorship_state || null,
ownership_domain: collection.ownership_domain || null,
commercial_review_state: collection.commercial_review_state || null,
legal_review_state: collection.legal_review_state || null,
ranking_bucket: collection.ranking_bucket || null,
recommendation_tier: collection.recommendation_tier || null,
last_health_check_at: collection.last_health_check_at || null,
last_recommendation_refresh_at: collection.last_recommendation_refresh_at || null,
}
}
function buildProgramUrl(pattern, programKey) {
if (!pattern || !programKey) return null
return pattern.replace('__PROGRAM__', String(programKey))
}
export default function CollectionStaffProgramming() {
const { props } = usePage()
const initialCollectionOptions = Array.isArray(props.collectionOptions) ? props.collectionOptions : []
const initialAssignments = Array.isArray(props.assignments) ? props.assignments : []
const baseProgramKeys = Array.isArray(props.programKeyOptions) ? props.programKeyOptions : []
const initialMergeQueue = props.mergeQueue || { summary: {}, pending: [], recent: [] }
const observabilitySummary = props.observabilitySummary || { counts: {}, watchlist: [], generated_at: null }
const endpoints = props.endpoints || {}
const historyPattern = props.historyPattern || ''
const seo = props.seo || {}
const viewer = props.viewer || {}
const [assignments, setAssignments] = React.useState(sortAssignments(initialAssignments))
const [collectionOverrides, setCollectionOverrides] = React.useState({})
const collectionOptions = React.useMemo(() => initialCollectionOptions.map((collection) => collectionOverrides[collection.id] || collection), [collectionOverrides, initialCollectionOptions])
const [mergeQueue, setMergeQueue] = React.useState(initialMergeQueue)
const [previewCollections, setPreviewCollections] = React.useState([])
const [diagnostics, setDiagnostics] = React.useState({
eligibility: null,
duplicates: null,
recommendations: null,
})
const [notice, setNotice] = React.useState('')
const [toast, setToast] = React.useState({ id: 0, visible: false, message: '', variant: 'success' })
const [busy, setBusy] = React.useState('')
const [queueBusy, setQueueBusy] = React.useState({})
const [selectedCollectionId, setSelectedCollectionId] = React.useState(initialCollectionOptions[0]?.id || '')
const [previewForm, setPreviewForm] = React.useState({
program_key: baseProgramKeys[0] || '',
limit: 8,
})
const [assignmentForm, setAssignmentForm] = React.useState({
id: null,
collection_id: collectionOptions[0]?.id || '',
program_key: baseProgramKeys[0] || '',
campaign_key: '',
placement_scope: '',
starts_at: '',
ends_at: '',
priority: 0,
notes: '',
})
const selectedCollection = React.useMemo(() => collectionOptions.find((collection) => String(collection.id) === String(selectedCollectionId)) || null, [collectionOptions, selectedCollectionId])
const [hooksForm, setHooksForm] = React.useState(buildHooksForm(initialCollectionOptions[0] || null))
const [hooksDiagnostics, setHooksDiagnostics] = React.useState(buildDiagnostics(initialCollectionOptions[0] || null))
const programKeyOptions = React.useMemo(() => {
return Array.from(new Set([
...baseProgramKeys,
...assignments.map((assignment) => assignment.program_key).filter(Boolean),
...collectionOptions.map((collection) => collection.program_key).filter(Boolean),
assignmentForm.program_key || null,
previewForm.program_key || null,
].filter(Boolean))).sort((left, right) => String(left).localeCompare(String(right)))
}, [assignmentForm.program_key, assignments, baseProgramKeys, collectionOptions, previewForm.program_key])
React.useEffect(() => {
if (!assignmentForm.collection_id && collectionOptions[0]?.id) {
setAssignmentForm((current) => ({ ...current, collection_id: collectionOptions[0].id }))
}
if (!selectedCollectionId && collectionOptions[0]?.id) {
setSelectedCollectionId(collectionOptions[0].id)
}
}, [assignmentForm.collection_id, collectionOptions, selectedCollectionId])
React.useEffect(() => {
if (!assignmentForm.program_key && programKeyOptions[0]) {
setAssignmentForm((current) => ({ ...current, program_key: programKeyOptions[0] }))
}
if (!previewForm.program_key && programKeyOptions[0]) {
setPreviewForm((current) => ({ ...current, program_key: programKeyOptions[0] }))
}
}, [assignmentForm.program_key, previewForm.program_key, programKeyOptions])
React.useEffect(() => {
setMergeQueue(initialMergeQueue)
}, [initialMergeQueue])
React.useEffect(() => {
setHooksForm(buildHooksForm(selectedCollection))
setHooksDiagnostics(buildDiagnostics(selectedCollection))
}, [selectedCollection])
function showToast(message, variant = 'success') {
setToast({
id: Date.now() + Math.random(),
visible: true,
message,
variant,
})
}
function resetAssignmentForm() {
setAssignmentForm({
id: null,
collection_id: collectionOptions[0]?.id || '',
program_key: programKeyOptions[0] || '',
campaign_key: '',
placement_scope: '',
starts_at: '',
ends_at: '',
priority: 0,
notes: '',
})
}
function hydrateAssignment(assignment) {
setAssignmentForm({
id: assignment.id,
collection_id: assignment.collection?.id || '',
program_key: assignment.program_key || '',
campaign_key: assignment.campaign_key || '',
placement_scope: assignment.placement_scope || '',
starts_at: isoToLocalInput(assignment.starts_at),
ends_at: isoToLocalInput(assignment.ends_at),
priority: assignment.priority || 0,
notes: assignment.notes || '',
})
}
async function handleAssignmentSubmit(event) {
event.preventDefault()
setBusy('assignment')
setNotice('')
try {
const url = assignmentForm.id
? endpoints.updatePattern?.replace('__PROGRAM__', String(assignmentForm.id))
: endpoints.store
const payload = await requestJson(url, {
method: assignmentForm.id ? 'PATCH' : 'POST',
body: {
collection_id: Number(assignmentForm.collection_id),
program_key: assignmentForm.program_key,
campaign_key: assignmentForm.campaign_key || null,
placement_scope: assignmentForm.placement_scope || null,
starts_at: assignmentForm.starts_at ? new Date(assignmentForm.starts_at).toISOString() : null,
ends_at: assignmentForm.ends_at ? new Date(assignmentForm.ends_at).toISOString() : null,
priority: Number(assignmentForm.priority || 0),
notes: assignmentForm.notes || null,
},
})
setAssignments((current) => sortAssignments([...current.filter((item) => item.id !== payload.assignment.id), payload.assignment]))
setNotice(assignmentForm.id ? 'Programming assignment updated.' : 'Programming assignment created.')
resetAssignmentForm()
} catch (error) {
setNotice(error.message || 'Failed to save programming assignment.')
} finally {
setBusy('')
}
}
async function handlePreview(event) {
event.preventDefault()
setBusy('preview')
setNotice('')
try {
const payload = await requestJson(endpoints.preview, {
method: 'POST',
body: {
program_key: previewForm.program_key,
limit: Number(previewForm.limit || 8),
},
})
setPreviewCollections(Array.isArray(payload.collections) ? payload.collections : [])
setNotice('Preview refreshed.')
} catch (error) {
setNotice(error.message || 'Failed to preview this program key.')
} finally {
setBusy('')
}
}
async function runDiagnostic(kind) {
const endpointMap = {
eligibility: endpoints.refreshEligibility,
duplicates: endpoints.duplicateScan,
recommendations: endpoints.refreshRecommendations,
}
const url = endpointMap[kind]
if (!url) {
return
}
setBusy(kind)
setNotice('')
try {
const payload = await requestJson(url, {
method: 'POST',
body: {
collection_id: selectedCollectionId ? Number(selectedCollectionId) : null,
},
})
setDiagnostics((current) => ({ ...current, [kind]: payload.result || null }))
setNotice(payload?.result?.message || `${titleize(kind)} queued.`)
} catch (error) {
setNotice(error.message || `Failed to run ${kind}.`)
} finally {
setBusy('')
}
}
async function handleQueueAction(kind, item) {
const sourceId = item?.source?.id
const targetId = item?.target?.id
if (!sourceId || !targetId) {
return
}
const endpointMap = {
canonicalize: endpoints.canonicalizeCandidate,
merge: endpoints.mergeCandidate,
reject: endpoints.rejectCandidate,
}
const confirmationMap = {
canonicalize: `Designate "${item.target?.title || 'this collection'}" as the canonical target for "${item.source?.title || 'this collection'}"?`,
merge: `Merge "${item.source?.title || 'this collection'}" into "${item.target?.title || 'this collection'}" from the staff queue?`,
reject: `Mark "${item.target?.title || 'this collection'}" as not a duplicate of "${item.source?.title || 'this collection'}"?`,
}
const url = endpointMap[kind]
if (!url) {
return
}
if (!window.confirm(confirmationMap[kind] || 'Continue?')) {
return
}
setQueueBusy((current) => ({ ...current, [item.id]: kind }))
try {
const payload = await requestJson(url, {
method: 'POST',
body: {
source_collection_id: Number(sourceId),
target_collection_id: Number(targetId),
},
})
if (payload?.mergeQueue) {
setMergeQueue(payload.mergeQueue)
}
showToast(payload?.message || `${titleize(kind)} action completed.`, 'success')
} catch (error) {
showToast(error.message || `Failed to ${kind} this queue item.`, 'error')
} finally {
setQueueBusy((current) => {
const next = { ...current }
delete next[item.id]
return next
})
}
}
async function handleHooksSubmit(event) {
event.preventDefault()
if (!selectedCollectionId || !endpoints.metadataUpdate) {
return
}
setBusy('hooks')
setNotice('')
try {
const payload = await requestJson(endpoints.metadataUpdate, {
method: 'POST',
body: {
collection_id: Number(selectedCollectionId),
experiment_key: hooksForm.experiment_key || null,
experiment_treatment: hooksForm.experiment_treatment || null,
placement_variant: hooksForm.placement_variant || null,
ranking_mode_variant: hooksForm.ranking_mode_variant || null,
collection_pool_version: hooksForm.collection_pool_version || null,
test_label: hooksForm.test_label || null,
promotion_tier: hooksForm.promotion_tier || null,
partner_key: viewer.isAdmin ? (hooksForm.partner_key || null) : undefined,
trust_tier: viewer.isAdmin ? (hooksForm.trust_tier || null) : undefined,
sponsorship_state: viewer.isAdmin ? (hooksForm.sponsorship_state || null) : undefined,
ownership_domain: viewer.isAdmin ? (hooksForm.ownership_domain || null) : undefined,
commercial_review_state: viewer.isAdmin ? (hooksForm.commercial_review_state || null) : undefined,
legal_review_state: viewer.isAdmin ? (hooksForm.legal_review_state || null) : undefined,
placement_eligibility: Boolean(hooksForm.placement_eligibility),
},
})
if (payload?.collection?.id) {
setCollectionOverrides((current) => ({
...current,
[payload.collection.id]: payload.collection,
}))
}
setHooksDiagnostics(payload?.diagnostics || buildDiagnostics(payload?.collection || selectedCollection))
setHooksForm(buildHooksForm(payload?.collection || selectedCollection))
setNotice(payload?.message || 'Experiment and program governance hooks updated.')
} catch (error) {
setNotice(error.message || 'Failed to update experiment and program governance hooks.')
} finally {
setBusy('')
}
}
const totalPrograms = Array.from(new Set(assignments.map((assignment) => assignment.program_key).filter(Boolean))).length
const eligibleAssignments = assignments.filter((assignment) => assignment.collection?.placement_eligibility === true).length
return (
<>
<Head>
<title>{seo.title || 'Collection Programming — Skinbase Nova'}</title>
<meta name="description" content={seo.description || 'Staff programming tools for collections.'} />
{seo.canonical ? <link rel="canonical" href={seo.canonical} /> : null}
<meta name="robots" content={seo.robots || 'noindex,follow'} />
</Head>
<ShareToast
key={toast.id}
message={toast.message}
visible={toast.visible}
variant={toast.variant}
duration={toast.variant === 'error' ? 3200 : 2200}
onHide={() => setToast((current) => ({ ...current, visible: false }))}
/>
<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-[34rem] opacity-95" style={{ background: 'radial-gradient(circle at 18% 15%, rgba(56,189,248,0.18), transparent 28%), radial-gradient(circle at 85% 14%, rgba(132,204,22,0.16), 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">
<section className="rounded-[34px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_30px_90px_rgba(2,6,23,0.28)] backdrop-blur-sm md:p-8">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-lime-200/80">Staff Programming</p>
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">Collections programming studio</h1>
<p className="mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 md:text-[15px]">
Manage program assignments, preview live pools, and run targeted diagnostics before collections hit discovery surfaces.
</p>
{notice ? <p className="mt-4 text-sm text-sky-100">{notice}</p> : null}
</div>
{endpoints.surfaces ? <a href={endpoints.surfaces} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-thumbtack fa-fw text-[11px]" />Open placement studio</a> : null}
</div>
</section>
<section className="mt-8 grid gap-5 md:grid-cols-3">
<StatCard label="Assignments" value={assignments.length} tone="sky" />
<StatCard label="Program Keys" value={totalPrograms} tone="amber" />
<StatCard label="Eligible" value={eligibleAssignments} tone="emerald" />
</section>
<section className="mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-rose-200/80">Merge Queue</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Pending duplicates and recent decisions</h2>
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-slate-300">
Review what still needs merge attention and what staff already resolved. Each row links back into the collection studio for full compare-and-confirm actions.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<StatCard label="Pending" value={mergeQueue?.summary?.pending || 0} tone="amber" />
<StatCard label="Approved" value={mergeQueue?.summary?.approved || 0} tone="sky" />
<StatCard label="Rejected" value={mergeQueue?.summary?.rejected || 0} tone="emerald" />
<StatCard label="Merged" value={mergeQueue?.summary?.completed || 0} tone="sky" />
</div>
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-2">
<div className="rounded-[28px] border border-white/10 bg-slate-950/40 p-5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-rose-200/80">Needs Review</p>
<h3 className="mt-2 text-xl font-semibold text-white">Suggested duplicate pairs</h3>
</div>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{mergeQueue?.pending?.length || 0}</span>
</div>
<div className="mt-5 space-y-4">
{(mergeQueue?.pending || []).length ? mergeQueue.pending.map((item) => {
const activeQueueAction = queueBusy[item.id] || ''
const cardBusy = Boolean(activeQueueAction)
return (
<div key={`merge-pending-${item.id}`} className={`rounded-[24px] border border-white/10 bg-white/[0.04] p-4 transition ${cardBusy ? 'ring-1 ring-sky-300/25' : ''}`}>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
{(item.comparison?.match_reasons || []).map((reason) => (
<span key={reason} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-300">{titleize(reason)}</span>
))}
</div>
{cardBusy ? <span className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-sky-100"><i className="fa-solid fa-circle-notch fa-spin fa-fw text-[10px]" />Processing {titleize(activeQueueAction)}</span> : null}
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div>
<p className="mb-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Source</p>
{item.source ? <CollectionCard collection={item.source} isOwner /> : null}
</div>
<div>
<p className="mb-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Candidate</p>
{item.target ? <CollectionCard collection={item.target} isOwner /> : null}
</div>
</div>
<div className="mt-4 grid gap-2 md:grid-cols-3 text-xs text-slate-400">
<div className="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2">Shared artworks: <span className="font-semibold text-white">{item.comparison?.shared_artworks_count ?? 0}</span></div>
<div className="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2">Source count: <span className="font-semibold text-white">{item.comparison?.source_artworks_count ?? 0}</span></div>
<div className="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2">Target count: <span className="font-semibold text-white">{item.comparison?.target_artworks_count ?? 0}</span></div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{item.source?.manage_url ? <a href={item.source.manage_url} className="inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-xs font-semibold text-rose-100 transition hover:bg-rose-400/15"><i className="fa-solid fa-code-compare fa-fw text-[10px]" />Review source</a> : null}
{item.target?.manage_url ? <a href={item.target.manage_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 text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-arrow-up-right-from-square fa-fw text-[10px]" />Open target</a> : null}
{item.source?.id && historyPattern ? <a href={historyPattern.replace('__COLLECTION__', String(item.source.id))} 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 text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-timeline fa-fw text-[10px]" />History</a> : null}
<button type="button" onClick={() => handleQueueAction('canonicalize', item)} disabled={cardBusy} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-xs font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60"><i className={`fa-solid ${activeQueueAction === 'canonicalize' ? 'fa-circle-notch fa-spin' : 'fa-badge-check'} fa-fw text-[10px]`} />{activeQueueAction === 'canonicalize' ? 'Canonicalizing...' : 'Canonicalize'}</button>
<button type="button" onClick={() => handleQueueAction('merge', item)} disabled={cardBusy} className="inline-flex items-center gap-2 rounded-full border border-emerald-300/20 bg-emerald-400/10 px-4 py-2 text-xs font-semibold text-emerald-100 transition hover:bg-emerald-400/15 disabled:opacity-60"><i className={`fa-solid ${activeQueueAction === 'merge' ? 'fa-circle-notch fa-spin' : 'fa-code-merge'} fa-fw text-[10px]`} />{activeQueueAction === 'merge' ? 'Merging...' : 'Merge now'}</button>
<button type="button" onClick={() => handleQueueAction('reject', item)} disabled={cardBusy} className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-400/10 px-4 py-2 text-xs font-semibold text-amber-100 transition hover:bg-amber-400/15 disabled:opacity-60"><i className={`fa-solid ${activeQueueAction === 'reject' ? 'fa-circle-notch fa-spin' : 'fa-ban'} fa-fw text-[10px]`} />{activeQueueAction === 'reject' ? 'Rejecting...' : 'Reject'}</button>
</div>
</div>
)}) : <div className="rounded-[24px] border border-dashed border-white/12 bg-white/[0.03] px-5 py-10 text-sm text-slate-300">No pending merge candidates right now.</div>}
</div>
</div>
<div className="rounded-[28px] border border-white/10 bg-slate-950/40 p-5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80">Recent Decisions</p>
<h3 className="mt-2 text-xl font-semibold text-white">Canonical, reject, and merge history</h3>
</div>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{mergeQueue?.recent?.length || 0}</span>
</div>
<div className="mt-5 space-y-4">
{(mergeQueue?.recent || []).length ? mergeQueue.recent.map((item) => (
<div key={`merge-recent-${item.id}`} className="rounded-[24px] border border-white/10 bg-white/[0.04] p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<span className="inline-flex rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{titleize(item.action_type)}</span>
{item.summary ? <p className="mt-2 text-sm text-slate-300">{item.summary}</p> : null}
<p className="mt-2 text-xs text-slate-500">{item.updated_at ? new Date(item.updated_at).toLocaleString() : 'Unknown time'}{item.actor?.username ? ` • @${item.actor.username}` : ''}</p>
</div>
</div>
<div className="mt-4 grid gap-3 lg:grid-cols-2 text-sm text-slate-300">
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Source</div>
<div className="mt-1 font-semibold text-white">{item.source?.title || 'Collection'}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Target</div>
<div className="mt-1 font-semibold text-white">{item.target?.title || 'Collection'}</div>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{item.source?.manage_url ? <a href={item.source.manage_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 text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-pen-to-square fa-fw text-[10px]" />Open source</a> : null}
{item.target?.manage_url ? <a href={item.target.manage_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 text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-arrow-up-right-from-square fa-fw text-[10px]" />Open target</a> : null}
</div>
</div>
)) : <div className="rounded-[24px] border border-dashed border-white/12 bg-white/[0.03] px-5 py-10 text-sm text-slate-300">No recent merge decisions yet.</div>}
</div>
</div>
</div>
</section>
<section className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
<form onSubmit={handleAssignmentSubmit} className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Assignment</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Program key and scope</h2>
</div>
<div className="mt-6 grid gap-4 md:grid-cols-2">
<Field label="Collection">
<select value={assignmentForm.collection_id} onChange={(event) => setAssignmentForm((current) => ({ ...current, collection_id: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none">
{collectionOptions.map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
</Field>
<Field label="Program Key" help="Use stable internal names like discover-spring or homepage-hero.">
<input list="program-key-options" value={assignmentForm.program_key} onChange={(event) => setAssignmentForm((current) => ({ ...current, program_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} />
</Field>
<Field label="Placement Scope" help="Optional placement scope such as homepage.hero or discover.rail.">
<input value={assignmentForm.placement_scope} onChange={(event) => setAssignmentForm((current) => ({ ...current, placement_scope: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} />
</Field>
<Field label="Campaign Key">
<input value={assignmentForm.campaign_key} onChange={(event) => setAssignmentForm((current) => ({ ...current, campaign_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} />
</Field>
<Field label="Priority">
<input type="number" min="-100" max="100" value={assignmentForm.priority} onChange={(event) => setAssignmentForm((current) => ({ ...current, priority: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" />
</Field>
<Field label="Starts At"><input type="datetime-local" value={assignmentForm.starts_at} onChange={(event) => setAssignmentForm((current) => ({ ...current, starts_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
<Field label="Ends At"><input type="datetime-local" value={assignmentForm.ends_at} onChange={(event) => setAssignmentForm((current) => ({ ...current, ends_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
</div>
<Field label="Notes" help="Operational note for launch timing, overrides, or review context."><textarea value={assignmentForm.notes} onChange={(event) => setAssignmentForm((current) => ({ ...current, notes: event.target.value }))} className="mt-4 min-h-[120px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={1000} /></Field>
<div className="mt-5 flex flex-wrap gap-3">
<button type="submit" disabled={busy === 'assignment'} className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'assignment' ? 'fa-circle-notch fa-spin' : 'fa-sliders'} fa-fw`} />{assignmentForm.id ? 'Update Assignment' : 'Save Assignment'}</button>
{assignmentForm.id ? <button type="button" onClick={resetAssignmentForm} 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.07]"><i className="fa-solid fa-rotate-left fa-fw" />Cancel Edit</button> : null}
</div>
<datalist id="program-key-options">
{programKeyOptions.map((option) => <option key={option} value={option} />)}
</datalist>
</form>
<div className="space-y-6">
<form onSubmit={handlePreview} className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80">Preview</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Inspect a live program pool</h2>
</div>
<div className="mt-6 grid gap-4 md:grid-cols-[minmax(0,1fr)_140px_auto]">
<Field label="Program Key"><input list="program-key-options" value={previewForm.program_key} onChange={(event) => setPreviewForm((current) => ({ ...current, program_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} /></Field>
<Field label="Limit"><input type="number" min="1" max="24" value={previewForm.limit} onChange={(event) => setPreviewForm((current) => ({ ...current, limit: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
<div className="flex items-end"><button type="submit" disabled={busy === 'preview'} className="inline-flex h-[50px] w-full items-center justify-center gap-2 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-5 text-sm font-semibold text-amber-100 transition hover:bg-amber-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'preview' ? 'fa-circle-notch fa-spin' : 'fa-binoculars'} fa-fw`} />Preview</button></div>
</div>
{previewCollections.length ? (
<div className="mt-6 grid gap-4 xl:grid-cols-2">
{previewCollections.map((collection) => (
<div key={collection.id} className="rounded-[24px] border border-white/10 bg-slate-950/40 p-4">
<CollectionCard collection={collection} isOwner />
</div>
))}
</div>
) : (
<div className="mt-6 rounded-[24px] border border-dashed border-white/12 bg-white/[0.03] px-5 py-8 text-sm text-slate-300">Run a preview to inspect which collections currently qualify for a given program key.</div>
)}
</form>
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-lime-200/80">Diagnostics</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Eligibility, duplicate risk, and ranking refresh</h2>
</div>
<div className="mt-6 grid gap-4 xl:grid-cols-[320px_minmax(0,1fr)]">
<div className="rounded-[24px] border border-white/10 bg-slate-950/40 p-4 text-sm text-slate-300">
<p className="font-semibold text-white">Operations summary</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">
<div className="text-[11px] uppercase tracking-[0.16em] text-slate-400">Stale health</div>
<div className="mt-1 text-lg font-semibold text-white">{Number(observabilitySummary?.counts?.stale_health || 0)}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">
<div className="text-[11px] uppercase tracking-[0.16em] text-slate-400">Stale recommendations</div>
<div className="mt-1 text-lg font-semibold text-white">{Number(observabilitySummary?.counts?.stale_recommendations || 0)}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">
<div className="text-[11px] uppercase tracking-[0.16em] text-slate-400">Placement blocked</div>
<div className="mt-1 text-lg font-semibold text-white">{Number(observabilitySummary?.counts?.placement_blocked || 0)}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">
<div className="text-[11px] uppercase tracking-[0.16em] text-slate-400">Duplicate risk</div>
<div className="mt-1 text-lg font-semibold text-white">{Number(observabilitySummary?.counts?.duplicate_risk || 0)}</div>
</div>
</div>
{observabilitySummary?.generated_at ? <p className="mt-4 text-xs text-slate-400">Generated {new Date(observabilitySummary.generated_at).toLocaleString()}</p> : null}
</div>
<div className="rounded-[24px] border border-white/10 bg-slate-950/40 p-4 text-sm text-slate-300">
<p className="font-semibold text-white">Watchlist</p>
{Array.isArray(observabilitySummary?.watchlist) && observabilitySummary.watchlist.length ? (
<div className="mt-4 grid gap-4 xl:grid-cols-2">
{observabilitySummary.watchlist.map((collection) => (
<div key={`watch-${collection.id}`} className="rounded-[20px] border border-white/10 bg-white/[0.04] p-3">
<CollectionCard collection={collection} isOwner />
</div>
))}
</div>
) : <p className="mt-3">No watchlist items are currently flagged.</p>}
</div>
</div>
<div className="mt-6 grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]">
<Field label="Target Collection" help="Leave a selection in place to inspect one collection. Change it any time before running a diagnostic.">
<select value={selectedCollectionId} onChange={(event) => setSelectedCollectionId(event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none">
{collectionOptions.map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
</select>
</Field>
<div className="flex items-end gap-3">
<button type="button" onClick={() => runDiagnostic('eligibility')} disabled={busy !== ''} className="inline-flex items-center gap-2 rounded-2xl border border-lime-300/20 bg-lime-400/10 px-4 py-3 text-sm font-semibold text-lime-100 transition hover:bg-lime-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'eligibility' ? 'fa-circle-notch fa-spin' : 'fa-shield-check'} fa-fw`} />Eligibility</button>
<button type="button" onClick={() => runDiagnostic('duplicates')} disabled={busy !== ''} className="inline-flex items-center gap-2 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100 transition hover:bg-rose-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'duplicates' ? 'fa-circle-notch fa-spin' : 'fa-clone'} fa-fw`} />Duplicates</button>
<button type="button" onClick={() => runDiagnostic('recommendations')} disabled={busy !== ''} className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'recommendations' ? 'fa-circle-notch fa-spin' : 'fa-arrows-rotate'} fa-fw`} />Refresh</button>
</div>
</div>
<div className="mt-6 grid gap-4 lg:grid-cols-3">
<div className="rounded-[24px] border border-white/10 bg-slate-950/40 p-4 text-sm text-slate-300">
<p className="font-semibold text-white">Eligibility</p>
{diagnostics.eligibility ? (
<div className="mt-3 space-y-2">
<p>{diagnostics.eligibility.status === 'queued' ? `${diagnostics.eligibility.count} collection(s) queued.` : `${diagnostics.eligibility.count} collection(s) evaluated.`}</p>
{diagnostics.eligibility.message ? <div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">{diagnostics.eligibility.message}</div> : null}
{(diagnostics.eligibility.items || []).map((item) => <div key={item.collection_id} className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">{item.health_state || 'unknown'} · {item.readiness_state || 'unknown'} · {item.placement_eligibility ? 'eligible' : 'blocked'}</div>)}
</div>
) : <p className="mt-3">Run an eligibility refresh to verify readiness and public placement safety.</p>}
</div>
<div className="rounded-[24px] border border-white/10 bg-slate-950/40 p-4 text-sm text-slate-300">
<p className="font-semibold text-white">Duplicate candidates</p>
{diagnostics.duplicates ? (
<div className="mt-3 space-y-2">
<p>{diagnostics.duplicates.status === 'queued' ? `${diagnostics.duplicates.count} collection(s) queued.` : `${diagnostics.duplicates.count} collection(s) with candidates.`}</p>
{diagnostics.duplicates.message ? <div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">{diagnostics.duplicates.message}</div> : null}
{(diagnostics.duplicates.items || []).map((item) => (
<div key={item.collection_id} className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">
{item.candidates?.length ? item.candidates.map((candidate) => candidate.title).join(', ') : 'No candidates'}
</div>
))}
</div>
) : <p className="mt-3">Run duplicate scan to surface overlap before programming a collection widely.</p>}
</div>
<div className="rounded-[24px] border border-white/10 bg-slate-950/40 p-4 text-sm text-slate-300">
<p className="font-semibold text-white">Recommendation refresh</p>
{diagnostics.recommendations ? (
<div className="mt-3 space-y-2">
<p>{diagnostics.recommendations.status === 'queued' ? `${diagnostics.recommendations.count} collection(s) queued.` : `${diagnostics.recommendations.count} collection(s) refreshed.`}</p>
{diagnostics.recommendations.message ? <div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">{diagnostics.recommendations.message}</div> : null}
{(diagnostics.recommendations.items || []).map((item) => <div key={item.collection_id} className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">{titleize(item.recommendation_tier || 'unknown')} · {titleize(item.ranking_bucket || 'unknown')} · {titleize(item.search_boost_tier || 'unknown')}</div>)}
</div>
) : <p className="mt-3">Run a recommendation refresh to update ranking and search tiers for this collection.</p>}
</div>
</div>
</section>
<form onSubmit={handleHooksSubmit} className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-fuchsia-200/80">Hooks</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Experiment and program governance</h2>
<p className="mt-3 text-sm leading-relaxed text-slate-300">Control experiment keys, promotion tiers, and staff-only program governance hooks for the selected collection without leaving the programming studio.</p>
</div>
{selectedCollection?.program_key && endpoints.publicProgramPattern ? (
<a href={buildProgramUrl(endpoints.publicProgramPattern, selectedCollection.program_key)} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/[0.07]">
<i className="fa-solid fa-arrow-up-right-from-square fa-fw text-[11px]" />Open public program landing
</a>
) : null}
</div>
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Field label="Experiment Key" help="Internal test or treatment key for cross-surface collection experiments.">
<input value={hooksForm.experiment_key} onChange={(event) => setHooksForm((current) => ({ ...current, experiment_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} />
</Field>
<Field label="Treatment" help="Variant or treatment label tied to the experiment key.">
<input value={hooksForm.experiment_treatment} onChange={(event) => setHooksForm((current) => ({ ...current, experiment_treatment: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} />
</Field>
<Field label="Placement Variant" help="Surface-specific placement variant such as homepage_a or search_dense.">
<input value={hooksForm.placement_variant} onChange={(event) => setHooksForm((current) => ({ ...current, placement_variant: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} />
</Field>
<Field label="Ranking Variant" help="Override or annotate ranking mode experiments without changing the live pool logic.">
<input value={hooksForm.ranking_mode_variant} onChange={(event) => setHooksForm((current) => ({ ...current, ranking_mode_variant: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} />
</Field>
<Field label="Pool Version" help="Snapshot or rollout version for the collection pool definition.">
<input value={hooksForm.collection_pool_version} onChange={(event) => setHooksForm((current) => ({ ...current, collection_pool_version: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} />
</Field>
<Field label="Test Label" help="Human-readable campaign or experiment label for operations and diagnostics.">
<input value={hooksForm.test_label} onChange={(event) => setHooksForm((current) => ({ ...current, test_label: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={120} />
</Field>
<Field label="Promotion Tier" help="Optional internal tier for elevated or restrained programming treatment.">
<input value={hooksForm.promotion_tier} onChange={(event) => setHooksForm((current) => ({ ...current, promotion_tier: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={40} />
</Field>
{viewer.isAdmin ? (
<>
<Field label="Partner Key" help="Admin-only internal key for trusted partner or program ownership.">
<input value={hooksForm.partner_key} onChange={(event) => setHooksForm((current) => ({ ...current, partner_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} />
</Field>
<Field label="Trust Tier" help="Admin-only trust marker used for internal partner/program review logic.">
<input value={hooksForm.trust_tier} onChange={(event) => setHooksForm((current) => ({ ...current, trust_tier: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={40} />
</Field>
<Field label="Sponsorship State" help="Admin-only state for sponsored, pending, or cleared program treatment.">
<input value={hooksForm.sponsorship_state} onChange={(event) => setHooksForm((current) => ({ ...current, sponsorship_state: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={40} />
</Field>
<Field label="Ownership Domain" help="Admin-only internal ownership domain such as editorial, partner, creator_program, or events.">
<input value={hooksForm.ownership_domain} onChange={(event) => setHooksForm((current) => ({ ...current, ownership_domain: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} />
</Field>
<Field label="Commercial Review" help="Admin-only commercial review status for future partner and sponsor programs.">
<input value={hooksForm.commercial_review_state} onChange={(event) => setHooksForm((current) => ({ ...current, commercial_review_state: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={40} />
</Field>
<Field label="Legal Review" help="Admin-only legal review status when collections need compliance approval before wider promotion.">
<input value={hooksForm.legal_review_state} onChange={(event) => setHooksForm((current) => ({ ...current, legal_review_state: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={40} />
</Field>
</>
) : <div className="rounded-[24px] border border-dashed border-white/12 bg-white/[0.03] px-4 py-4 text-sm text-slate-300 md:col-span-2 xl:col-span-3">Partner, sponsorship, ownership, and review metadata remain admin-only. Moderators can still manage experiment and promotion hooks here.</div>}
</div>
<label className="mt-4 flex items-center gap-3 rounded-[20px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-200">
<input type="checkbox" checked={hooksForm.placement_eligibility} onChange={(event) => setHooksForm((current) => ({ ...current, placement_eligibility: event.target.checked }))} className="h-4 w-4 rounded border-white/20 bg-white/[0.04] text-sky-400 focus:ring-sky-300/40" />
Placement eligible override
</label>
<div className="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Experiment</div>
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.experiment_key || selectedCollection?.experiment_key || 'Not set'}</div>
</div>
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Treatment</div>
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.experiment_treatment || selectedCollection?.experiment_treatment || 'Not set'}</div>
</div>
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Placement Variant</div>
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.placement_variant || selectedCollection?.placement_variant || 'Not set'}</div>
</div>
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Workflow</div>
<div className="mt-1 font-semibold text-white">{titleize(hooksDiagnostics?.workflow_state || selectedCollection?.workflow_state || 'unknown')}</div>
</div>
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Health</div>
<div className="mt-1 font-semibold text-white">{titleize(hooksDiagnostics?.health_state || selectedCollection?.health_state || 'unknown')}</div>
</div>
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Recommendation Tier</div>
<div className="mt-1 font-semibold text-white">{titleize(hooksDiagnostics?.recommendation_tier || selectedCollection?.recommendation_tier || 'unknown')}</div>
</div>
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Ranking Bucket</div>
<div className="mt-1 font-semibold text-white">{titleize(hooksDiagnostics?.ranking_bucket || selectedCollection?.ranking_bucket || 'unknown')}</div>
</div>
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Ranking Variant</div>
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.ranking_mode_variant || selectedCollection?.ranking_mode_variant || 'Not set'}</div>
</div>
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Pool Version</div>
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.collection_pool_version || selectedCollection?.collection_pool_version || 'Not set'}</div>
</div>
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Test Label</div>
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.test_label || selectedCollection?.test_label || 'Not set'}</div>
</div>
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Promotion Tier</div>
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.promotion_tier || selectedCollection?.promotion_tier || 'Not set'}</div>
</div>
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Partner Key</div>
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.partner_key || selectedCollection?.partner_key || 'Not set'}</div>
</div>
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Trust Tier</div>
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.trust_tier || selectedCollection?.trust_tier || 'Not set'}</div>
</div>
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Sponsorship State</div>
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.sponsorship_state || selectedCollection?.sponsorship_state || 'Not set'}</div>
</div>
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Ownership Domain</div>
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.ownership_domain || selectedCollection?.ownership_domain || 'Not set'}</div>
</div>
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Commercial Review</div>
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.commercial_review_state || selectedCollection?.commercial_review_state || 'Not set'}</div>
</div>
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Legal Review</div>
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.legal_review_state || selectedCollection?.legal_review_state || 'Not set'}</div>
</div>
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Last Health Check</div>
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.last_health_check_at ? new Date(hooksDiagnostics.last_health_check_at).toLocaleString() : 'Not yet'}</div>
</div>
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Last Recommendation Refresh</div>
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.last_recommendation_refresh_at ? new Date(hooksDiagnostics.last_recommendation_refresh_at).toLocaleString() : 'Not yet'}</div>
</div>
</div>
<div className="mt-5 flex flex-wrap gap-3">
<button type="submit" disabled={busy === 'hooks' || !selectedCollectionId} className="inline-flex items-center gap-2 rounded-2xl border border-fuchsia-300/20 bg-fuchsia-400/10 px-5 py-3 text-sm font-semibold text-fuchsia-100 transition hover:bg-fuchsia-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'hooks' ? 'fa-circle-notch fa-spin' : 'fa-flask-vial'} fa-fw`} />Save Hooks</button>
{selectedCollection?.manage_url ? <a href={selectedCollection.manage_url} 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.07]"><i className="fa-solid fa-arrow-up-right-from-square fa-fw" />Open collection</a> : null}
</div>
</form>
</div>
</section>
<section className="mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
<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">Assignments</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Current programming inventory</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">{assignments.length}</span>
</div>
<div className="mt-6 space-y-5">
{assignments.length ? assignments.map((assignment) => (
<div key={assignment.id} className="rounded-[28px] border border-white/10 bg-slate-950/40 p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<span className="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">{assignment.program_key}</span>
{assignment.placement_scope ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300">{assignment.placement_scope}</span> : null}
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300">priority {assignment.priority}</span>
</div>
<div className="flex flex-wrap gap-2">
<button type="button" onClick={() => hydrateAssignment(assignment)} 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 text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-pen fa-fw text-[10px]" />Edit</button>
{endpoints.managePattern ? <a href={endpoints.managePattern.replace('__COLLECTION__', String(assignment.collection?.id || ''))} 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 text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-arrow-up-right-from-square fa-fw text-[10px]" />Manage</a> : null}
</div>
</div>
<div className="mt-4 grid gap-5 xl:grid-cols-[minmax(0,1fr)_280px]">
<div>
{assignment.collection ? <CollectionCard collection={assignment.collection} isOwner /> : null}
</div>
<div className="space-y-3 text-sm text-slate-300">
<div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3">Campaign: {assignment.campaign_key || 'None'}</div>
<div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3">Starts: {assignment.starts_at ? new Date(assignment.starts_at).toLocaleString() : 'Immediate'}</div>
<div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3">Ends: {assignment.ends_at ? new Date(assignment.ends_at).toLocaleString() : 'Open-ended'}</div>
<div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3">Placement: {assignment.collection?.placement_eligibility ? 'Eligible' : 'Blocked'}</div>
{assignment.notes ? <div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3">{assignment.notes}</div> : null}
</div>
</div>
</div>
)) : <div className="rounded-[26px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-12 text-sm text-slate-300">No programming assignments yet. Create the first one above.</div>}
</div>
</section>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,644 @@
import React from 'react'
import { Head, usePage } from '@inertiajs/react'
import CollectionCard from '../../components/profile/collections/CollectionCard'
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 isoToLocalInput(value) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
const local = new Date(date.getTime() - (date.getTimezoneOffset() * 60000))
return local.toISOString().slice(0, 16)
}
function rulesJsonToText(rulesJson) {
if (!rulesJson) return '{\n "campaign_key": "",\n "owner_username": "",\n "presentation_style": "hero_grid",\n "min_quality_score": 80\n}'
try {
return JSON.stringify(rulesJson, null, 2)
} catch {
return '{\n "campaign_key": "",\n "owner_username": "",\n "presentation_style": "hero_grid",\n "min_quality_score": 80\n}'
}
}
function Field({ label, help, children }) {
return (
<label className="block space-y-2">
<span className="text-sm font-semibold text-white">{label}</span>
{children}
{help ? <span className="block text-xs leading-relaxed text-slate-400">{help}</span> : null}
</label>
)
}
export default function CollectionStaffSurfaces() {
const { props } = usePage()
const collectionOptions = Array.isArray(props.collectionOptions) ? props.collectionOptions : []
const [definitions, setDefinitions] = React.useState(Array.isArray(props.definitions) ? props.definitions : [])
const [placements, setPlacements] = React.useState(Array.isArray(props.placements) ? props.placements : [])
const [conflicts, setConflicts] = React.useState(Array.isArray(props.conflicts) ? props.conflicts : [])
const [definitionForm, setDefinitionForm] = React.useState({
id: null,
surface_key: '',
title: '',
description: '',
mode: 'manual',
ranking_mode: 'ranking_score',
max_items: 12,
is_active: true,
starts_at: '',
ends_at: '',
fallback_surface_key: '',
rules_json: '{\n "campaign_key": "",\n "owner_username": "",\n "presentation_style": "hero_grid",\n "min_quality_score": 80\n}',
})
const [placementForm, setPlacementForm] = React.useState({
id: null,
surface_key: props.surfaceKeyOptions?.[0] || '',
collection_id: collectionOptions[0]?.id || '',
placement_type: 'manual',
priority: 0,
starts_at: '',
ends_at: '',
is_active: true,
campaign_key: '',
notes: '',
})
const [batchForm, setBatchForm] = React.useState({
collection_ids: [],
campaign_key: '',
campaign_label: '',
event_label: '',
season_key: '',
editorial_notes: '',
surface_key: props.surfaceKeyOptions?.[0] || '',
placement_type: 'campaign',
priority: 0,
starts_at: '',
ends_at: '',
is_active: true,
notes: '',
})
const [batchResult, setBatchResult] = React.useState(null)
const [notice, setNotice] = React.useState('')
const [busy, setBusy] = React.useState('')
const seo = props.seo || {}
const surfaceKeyOptions = React.useMemo(() => {
const keys = definitions.map((definition) => definition.surface_key).filter(Boolean)
return Array.from(new Set(keys)).sort((left, right) => String(left).localeCompare(String(right)))
}, [definitions])
const conflictPlacementIds = React.useMemo(() => {
return new Set(conflicts.flatMap((conflict) => Array.isArray(conflict.placement_ids) ? conflict.placement_ids : []))
}, [conflicts])
React.useEffect(() => {
setPlacementForm((current) => {
if (current.surface_key && surfaceKeyOptions.includes(current.surface_key)) {
return current
}
return {
...current,
surface_key: surfaceKeyOptions[0] || '',
}
})
setBatchForm((current) => {
if (!current.surface_key || surfaceKeyOptions.includes(current.surface_key)) {
return current
}
return {
...current,
surface_key: surfaceKeyOptions[0] || '',
}
})
}, [surfaceKeyOptions])
function resetDefinitionForm() {
setDefinitionForm({
id: null,
surface_key: '',
title: '',
description: '',
mode: 'manual',
ranking_mode: 'ranking_score',
max_items: 12,
is_active: true,
starts_at: '',
ends_at: '',
fallback_surface_key: '',
rules_json: '{\n "campaign_key": "",\n "owner_username": "",\n "presentation_style": "hero_grid",\n "min_quality_score": 80\n}',
})
}
function resetPlacementForm() {
setPlacementForm({
id: null,
surface_key: surfaceKeyOptions[0] || '',
collection_id: collectionOptions[0]?.id || '',
placement_type: 'manual',
priority: 0,
starts_at: '',
ends_at: '',
is_active: true,
campaign_key: '',
notes: '',
})
}
function toggleBatchCollection(collectionId) {
setBatchForm((current) => {
const currentIds = Array.isArray(current.collection_ids) ? current.collection_ids : []
const nextIds = currentIds.includes(collectionId)
? currentIds.filter((id) => id !== collectionId)
: [...currentIds, collectionId]
return {
...current,
collection_ids: nextIds,
}
})
}
async function handleDefinitionSubmit(event) {
event.preventDefault()
setBusy('definition')
setNotice('')
try {
const rulesJson = definitionForm.rules_json.trim() ? JSON.parse(definitionForm.rules_json) : null
const url = definitionForm.id
? props.endpoints?.definitionsUpdatePattern?.replace('__DEFINITION__', String(definitionForm.id))
: props.endpoints?.definitionsStore
const payload = await requestJson(url, {
method: definitionForm.id ? 'PATCH' : 'POST',
body: {
...definitionForm,
max_items: Number(definitionForm.max_items || 12),
starts_at: definitionForm.starts_at ? new Date(definitionForm.starts_at).toISOString() : null,
ends_at: definitionForm.ends_at ? new Date(definitionForm.ends_at).toISOString() : null,
fallback_surface_key: definitionForm.fallback_surface_key || null,
rules_json: rulesJson,
},
})
setDefinitions((current) => {
const next = current.filter((definition) => definition.id !== payload.definition.id)
return [...next, payload.definition].sort((left, right) => String(left.surface_key).localeCompare(String(right.surface_key)))
})
setNotice(definitionForm.id ? 'Surface definition updated.' : 'Surface definition saved.')
resetDefinitionForm()
} catch (error) {
setNotice(error.message || 'Failed to save definition.')
} finally {
setBusy('')
}
}
async function handlePlacementSubmit(event) {
event.preventDefault()
setBusy('placement')
setNotice('')
try {
const url = placementForm.id
? props.endpoints?.placementsUpdatePattern?.replace('__PLACEMENT__', String(placementForm.id))
: props.endpoints?.placementsStore
const payload = await requestJson(url, {
method: placementForm.id ? 'PATCH' : 'POST',
body: {
...placementForm,
collection_id: Number(placementForm.collection_id),
priority: Number(placementForm.priority || 0),
starts_at: placementForm.starts_at ? new Date(placementForm.starts_at).toISOString() : null,
ends_at: placementForm.ends_at ? new Date(placementForm.ends_at).toISOString() : null,
},
})
setPlacements((current) => {
const next = current.filter((placement) => placement.id !== payload.placement.id)
return [...next, payload.placement].sort((left, right) => {
if (left.surface_key === right.surface_key) return (right.priority || 0) - (left.priority || 0)
return String(left.surface_key).localeCompare(String(right.surface_key))
})
})
setConflicts(Array.isArray(payload.conflicts) ? payload.conflicts : [])
setNotice(placementForm.id ? 'Surface placement updated.' : 'Surface placement saved.')
resetPlacementForm()
} catch (error) {
setNotice(error.message || 'Failed to save placement.')
} finally {
setBusy('')
}
}
async function handleBatchEditorial(mode) {
setBusy(`batch-${mode}`)
setNotice('')
try {
const payload = await requestJson(props.endpoints?.batchEditorial, {
method: 'POST',
body: {
...batchForm,
starts_at: batchForm.starts_at ? new Date(batchForm.starts_at).toISOString() : null,
ends_at: batchForm.ends_at ? new Date(batchForm.ends_at).toISOString() : null,
collection_ids: (batchForm.collection_ids || []).map((id) => Number(id)),
priority: Number(batchForm.priority || 0),
surface_key: batchForm.surface_key || null,
apply: mode === 'apply',
},
})
setBatchResult(payload.plan || null)
if (mode === 'apply') {
setPlacements(Array.isArray(payload.placements) ? payload.placements : [])
setConflicts(Array.isArray(payload.conflicts) ? payload.conflicts : [])
setNotice('Batch editorial changes applied.')
} else {
setNotice('Batch editorial preview generated.')
}
} catch (error) {
setNotice(error.message || 'Batch editorial tools failed.')
} finally {
setBusy('')
}
}
function hydrateDefinition(definition) {
setDefinitionForm({
id: definition.id,
surface_key: definition.surface_key || '',
title: definition.title || '',
description: definition.description || '',
mode: definition.mode || 'manual',
ranking_mode: definition.ranking_mode || 'ranking_score',
max_items: definition.max_items || 12,
is_active: definition.is_active !== false,
starts_at: isoToLocalInput(definition.starts_at),
ends_at: isoToLocalInput(definition.ends_at),
fallback_surface_key: definition.fallback_surface_key || '',
rules_json: rulesJsonToText(definition.rules_json),
})
}
function hydratePlacement(placement) {
setPlacementForm({
id: placement.id,
surface_key: placement.surface_key || '',
collection_id: placement.collection?.id || '',
placement_type: placement.placement_type || 'manual',
priority: placement.priority || 0,
starts_at: isoToLocalInput(placement.starts_at),
ends_at: isoToLocalInput(placement.ends_at),
is_active: placement.is_active !== false,
campaign_key: placement.campaign_key || '',
notes: placement.notes || '',
})
}
async function handleDeleteDefinition(definition) {
if (!window.confirm(`Delete surface definition "${definition.surface_key}"?`)) return
setBusy(`delete-definition-${definition.id}`)
setNotice('')
try {
const url = props.endpoints?.definitionsDeletePattern?.replace('__DEFINITION__', String(definition.id))
await requestJson(url, { method: 'DELETE' })
setDefinitions((current) => current.filter((item) => item.id !== definition.id))
if (definitionForm.id === definition.id) {
resetDefinitionForm()
}
setNotice('Surface definition deleted.')
} catch (error) {
setNotice(error.message || 'Failed to delete definition.')
} finally {
setBusy('')
}
}
async function handleDeletePlacement(placement) {
if (!window.confirm(`Delete placement for "${placement.collection?.title || 'this collection'}" on ${placement.surface_key}?`)) return
setBusy(`delete-placement-${placement.id}`)
setNotice('')
try {
const url = props.endpoints?.placementsDeletePattern?.replace('__PLACEMENT__', String(placement.id))
const payload = await requestJson(url, { method: 'DELETE' })
setPlacements((current) => current.filter((item) => item.id !== placement.id))
setConflicts(Array.isArray(payload.conflicts) ? payload.conflicts : [])
if (placementForm.id === placement.id) {
resetPlacementForm()
}
setNotice('Surface placement deleted.')
} catch (error) {
setNotice(error.message || 'Failed to delete placement.')
} finally {
setBusy('')
}
}
return (
<>
<Head>
<title>{seo.title || 'Collection Surfaces — Skinbase Nova'}</title>
<meta name="description" content={seo.description || 'Staff tools for collection surfaces.'} />
{seo.canonical ? <link rel="canonical" href={seo.canonical} /> : null}
<meta name="robots" content={seo.robots || 'noindex,follow'} />
</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-[34rem] opacity-95" style={{ background: 'radial-gradient(circle at 15% 14%, rgba(245,158,11,0.16), transparent 26%), radial-gradient(circle at 82% 18%, rgba(56,189,248,0.16), transparent 24%), 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">
<section className="rounded-[34px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_30px_90px_rgba(2,6,23,0.28)] backdrop-blur-sm md:p-8">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80">Staff Surfaces</p>
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">Collections placement studio</h1>
<p className="mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 md:text-[15px]">
Define reusable discovery surfaces, then place eligible public collections into manual or campaign-specific slots with clear timing and notes.
</p>
{notice ? <p className="mt-4 text-sm text-sky-100">{notice}</p> : null}
</section>
<section className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
<form onSubmit={handleDefinitionSubmit} className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Surface Definition</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Rules and ranking</h2>
</div>
{definitionForm.id ? <p className="mt-3 text-sm text-slate-300">Editing <span className="font-semibold text-white">{definitionForm.surface_key}</span></p> : null}
<div className="mt-6 grid gap-4 md:grid-cols-2">
<Field label="Surface Key" help={definitionForm.id ? 'Surface keys stay stable during edits so existing placements remain attached.' : null}><input value={definitionForm.surface_key} onChange={(event) => setDefinitionForm((current) => ({ ...current, surface_key: event.target.value }))} disabled={Boolean(definitionForm.id)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none disabled:cursor-not-allowed disabled:opacity-60" maxLength={120} /></Field>
<Field label="Title"><input value={definitionForm.title} onChange={(event) => setDefinitionForm((current) => ({ ...current, title: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={160} /></Field>
<Field label="Mode"><select value={definitionForm.mode} onChange={(event) => setDefinitionForm((current) => ({ ...current, mode: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none"><option value="manual">Manual</option><option value="automatic">Automatic</option><option value="hybrid">Hybrid</option></select></Field>
<Field label="Ranking"><select value={definitionForm.ranking_mode} onChange={(event) => setDefinitionForm((current) => ({ ...current, ranking_mode: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none"><option value="ranking_score">Ranking score</option><option value="recent_activity">Recent activity</option><option value="quality_score">Quality score</option></select></Field>
<Field label="Max Items"><input type="number" min="1" max="24" value={definitionForm.max_items} onChange={(event) => setDefinitionForm((current) => ({ ...current, max_items: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
<Field label="Starts At" help="Optional activation window for the full surface definition."><input type="datetime-local" value={definitionForm.starts_at} onChange={(event) => setDefinitionForm((current) => ({ ...current, starts_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
<Field label="Ends At" help="Leave blank when the surface should stay live until staff changes it."><input type="datetime-local" value={definitionForm.ends_at} onChange={(event) => setDefinitionForm((current) => ({ ...current, ends_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
<Field label="Fallback Surface Key" help="Optional fallback when this definition is inactive, scheduled out, or resolves no items."><input value={definitionForm.fallback_surface_key} onChange={(event) => setDefinitionForm((current) => ({ ...current, fallback_surface_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={120} /></Field>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white"><input type="checkbox" checked={definitionForm.is_active} onChange={(event) => setDefinitionForm((current) => ({ ...current, is_active: event.target.checked }))} />Active</label>
</div>
<Field label="Description" help="Operational note for staff browsing this surface later."><textarea value={definitionForm.description} onChange={(event) => setDefinitionForm((current) => ({ ...current, description: event.target.value }))} className="mt-4 min-h-[96px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={400} /></Field>
<Field label="Rules JSON" help="Supported filters include campaign, event, season, type, presentation_style, theme_token, collaboration_mode, owner_username or owner_usernames, commercial_eligible_only, analytics_enabled_only, min_quality_score, min_ranking_score, include_collection_ids, exclude_collection_ids, and featured_only."><textarea value={definitionForm.rules_json} onChange={(event) => setDefinitionForm((current) => ({ ...current, rules_json: event.target.value }))} className="mt-4 min-h-[160px] w-full rounded-2xl border border-white/10 bg-slate-950/50 px-4 py-3 font-mono text-sm text-white outline-none" spellCheck={false} /></Field>
<div className="mt-5 flex flex-wrap items-center gap-3">
<button type="submit" disabled={busy === 'definition'} className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'definition' ? 'fa-circle-notch fa-spin' : 'fa-layer-group'} fa-fw`} />{definitionForm.id ? 'Update Definition' : 'Save Definition'}</button>
{definitionForm.id ? <button type="button" onClick={resetDefinitionForm} 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.07]"><i className="fa-solid fa-rotate-left fa-fw" />Cancel Edit</button> : null}
</div>
</form>
<form onSubmit={handlePlacementSubmit} className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80">Surface Placement</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Manual and campaign slots</h2>
</div>
<div className="mt-6 grid gap-4 md:grid-cols-2">
<Field label="Surface"><select value={placementForm.surface_key} onChange={(event) => setPlacementForm((current) => ({ ...current, surface_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none">{surfaceKeyOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select></Field>
<Field label="Collection"><select value={placementForm.collection_id} onChange={(event) => setPlacementForm((current) => ({ ...current, collection_id: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none">{collectionOptions.map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}</select></Field>
<Field label="Placement Type"><select value={placementForm.placement_type} onChange={(event) => setPlacementForm((current) => ({ ...current, placement_type: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none"><option value="manual">Manual</option><option value="campaign">Campaign</option><option value="scheduled_override">Scheduled override</option></select></Field>
<Field label="Priority"><input type="number" min="-100" max="100" value={placementForm.priority} onChange={(event) => setPlacementForm((current) => ({ ...current, priority: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
<Field label="Starts At"><input type="datetime-local" value={placementForm.starts_at} onChange={(event) => setPlacementForm((current) => ({ ...current, starts_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
<Field label="Ends At"><input type="datetime-local" value={placementForm.ends_at} onChange={(event) => setPlacementForm((current) => ({ ...current, ends_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
<Field label="Campaign Key" help="Optional campaign label for reporting and grouped overrides."><input value={placementForm.campaign_key} onChange={(event) => setPlacementForm((current) => ({ ...current, campaign_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} /></Field>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white"><input type="checkbox" checked={placementForm.is_active} onChange={(event) => setPlacementForm((current) => ({ ...current, is_active: event.target.checked }))} />Active placement</label>
</div>
<Field label="Notes" help="Internal note for why this collection owns the slot."><textarea value={placementForm.notes} onChange={(event) => setPlacementForm((current) => ({ ...current, notes: event.target.value }))} className="mt-4 min-h-[110px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={1000} /></Field>
<div className="mt-5 flex flex-wrap items-center gap-3">
<button type="submit" disabled={busy === 'placement'} className="inline-flex items-center gap-2 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-5 py-3 text-sm font-semibold text-amber-100 transition hover:bg-amber-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'placement' ? 'fa-circle-notch fa-spin' : 'fa-thumbtack'} fa-fw`} />{placementForm.id ? 'Update Placement' : 'Save Placement'}</button>
{placementForm.id ? <button type="button" onClick={resetPlacementForm} 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.07]"><i className="fa-solid fa-rotate-left fa-fw" />Cancel Edit</button> : null}
</div>
</form>
</section>
<section className="mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
<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">Batch Editorial Tools</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Campaign planning in one pass</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">{batchForm.collection_ids.length} selected</span>
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
<div className="space-y-4">
<div className="rounded-[26px] border border-white/10 bg-slate-950/40 p-5">
<p className="text-sm font-semibold text-white">Choose collections</p>
<p className="mt-2 text-sm text-slate-300">The selector uses current public discovery candidates so staff can quickly prepare a seasonal or editorial run.</p>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{collectionOptions.map((option) => {
const checked = batchForm.collection_ids.includes(option.id)
return (
<label key={option.id} className={`flex cursor-pointer items-start gap-3 rounded-[22px] border px-4 py-3 transition ${checked ? 'border-lime-300/30 bg-lime-400/10' : 'border-white/10 bg-white/[0.04] hover:bg-white/[0.07]'}`}>
<input type="checkbox" checked={checked} onChange={() => toggleBatchCollection(option.id)} className="mt-1" />
<span className="min-w-0">
<span className="block truncate text-sm font-semibold text-white">{option.title}</span>
<span className="mt-1 block text-xs text-slate-400">{option.type || 'collection'} · {option.visibility || 'public'}</span>
</span>
</label>
)
})}
</div>
</div>
<div className="rounded-[26px] border border-white/10 bg-slate-950/40 p-5">
<p className="text-sm font-semibold text-white">Campaign metadata</p>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<Field label="Campaign Key"><input value={batchForm.campaign_key} onChange={(event) => setBatchForm((current) => ({ ...current, campaign_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} /></Field>
<Field label="Campaign Label"><input value={batchForm.campaign_label} onChange={(event) => setBatchForm((current) => ({ ...current, campaign_label: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={120} /></Field>
<Field label="Event Label"><input value={batchForm.event_label} onChange={(event) => setBatchForm((current) => ({ ...current, event_label: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={120} /></Field>
<Field label="Season Key"><input value={batchForm.season_key} onChange={(event) => setBatchForm((current) => ({ ...current, season_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} /></Field>
</div>
<Field label="Editorial Notes" help="Shared context recorded on each selected collection."><textarea value={batchForm.editorial_notes} onChange={(event) => setBatchForm((current) => ({ ...current, editorial_notes: event.target.value }))} className="mt-4 min-h-[120px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={4000} /></Field>
</div>
</div>
<div className="space-y-4">
<div className="rounded-[26px] border border-white/10 bg-slate-950/40 p-5">
<p className="text-sm font-semibold text-white">Optional placement plan</p>
<p className="mt-2 text-sm text-slate-300">If you set a surface, the preview shows which collections can safely be placed and which ones will be skipped.</p>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<Field label="Surface"><select value={batchForm.surface_key} onChange={(event) => setBatchForm((current) => ({ ...current, surface_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none"><option value="">No placement</option>{surfaceKeyOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select></Field>
<Field label="Placement Type"><select value={batchForm.placement_type} onChange={(event) => setBatchForm((current) => ({ ...current, placement_type: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none"><option value="campaign">Campaign</option><option value="manual">Manual</option><option value="scheduled_override">Scheduled override</option></select></Field>
<Field label="Priority"><input type="number" min="-100" max="100" value={batchForm.priority} onChange={(event) => setBatchForm((current) => ({ ...current, priority: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white"><input type="checkbox" checked={batchForm.is_active} onChange={(event) => setBatchForm((current) => ({ ...current, is_active: event.target.checked }))} />Active placement</label>
<Field label="Starts At"><input type="datetime-local" value={batchForm.starts_at} onChange={(event) => setBatchForm((current) => ({ ...current, starts_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
<Field label="Ends At"><input type="datetime-local" value={batchForm.ends_at} onChange={(event) => setBatchForm((current) => ({ ...current, ends_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
</div>
<Field label="Placement Notes"><textarea value={batchForm.notes} onChange={(event) => setBatchForm((current) => ({ ...current, notes: event.target.value }))} className="mt-4 min-h-[110px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={1000} /></Field>
<div className="mt-5 flex flex-wrap gap-3">
<button type="button" onClick={() => handleBatchEditorial('preview')} disabled={busy === 'batch-preview'} className="inline-flex items-center gap-2 rounded-2xl border border-lime-300/20 bg-lime-400/10 px-5 py-3 text-sm font-semibold text-lime-100 transition hover:bg-lime-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'batch-preview' ? 'fa-circle-notch fa-spin' : 'fa-flask'} fa-fw`} />Preview Batch</button>
<button type="button" onClick={() => handleBatchEditorial('apply')} disabled={busy === 'batch-apply'} className="inline-flex items-center gap-2 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-5 py-3 text-sm font-semibold text-amber-100 transition hover:bg-amber-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'batch-apply' ? 'fa-circle-notch fa-spin' : 'fa-wand-magic-sparkles'} fa-fw`} />Apply Batch</button>
</div>
</div>
{batchResult ? (
<div className="rounded-[26px] border border-white/10 bg-slate-950/40 p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-white">Preview results</p>
<p className="mt-1 text-sm text-slate-300">{batchResult.collections_count} collections reviewed, {batchResult.placement_eligible_count} placement-ready.</p>
</div>
</div>
<div className="mt-4 space-y-3">
{(batchResult.items || []).map((item) => (
<div key={item.collection?.id} className="rounded-[22px] border border-white/10 bg-white/[0.04] p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-white">{item.collection?.title}</p>
<p className="mt-1 text-xs text-slate-400">{item.collection?.visibility} · {item.collection?.lifecycle_state} · {item.collection?.moderation_status}</p>
</div>
{item.placement ? (
<span className={`rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${item.placement.eligible ? 'border-lime-300/20 bg-lime-400/10 text-lime-100' : 'border-rose-300/20 bg-rose-400/10 text-rose-100'}`}>
{item.placement.eligible ? `ready for ${item.placement.surface_key}` : 'placement skipped'}
</span>
) : <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300">metadata only</span>}
</div>
{item.eligibility?.reasons?.length ? <p className="mt-3 text-xs text-amber-100/80">Campaign readiness: {item.eligibility.reasons.join(' ')}</p> : null}
{item.placement?.reasons?.length ? <p className="mt-2 text-xs text-rose-100/80">Placement: {item.placement.reasons.join(' ')}</p> : null}
</div>
))}
</div>
</div>
) : null}
</div>
</div>
</section>
<section className="mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
<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">Definitions</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Registered surfaces</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">{definitions.length}</span>
</div>
<div className="mt-6 grid gap-4 lg:grid-cols-2">
{definitions.map((definition) => (
<div key={definition.id} className="rounded-[24px] border border-white/10 bg-slate-950/40 p-5">
<div className="flex flex-wrap items-center gap-2">
<span className="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">{definition.surface_key}</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300">{definition.mode}</span>
</div>
<h3 className="mt-4 text-lg font-semibold text-white">{definition.title}</h3>
{definition.description ? <p className="mt-2 text-sm text-slate-300">{definition.description}</p> : null}
<div className="mt-4 flex flex-wrap gap-2 text-xs text-slate-400">
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">{definition.ranking_mode}</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">max {definition.max_items}</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">{definition.is_active ? 'active' : 'inactive'}</span>
{definition.starts_at ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">starts {new Date(definition.starts_at).toLocaleString()}</span> : null}
{definition.ends_at ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">ends {new Date(definition.ends_at).toLocaleString()}</span> : null}
{definition.fallback_surface_key ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">fallback {definition.fallback_surface_key}</span> : null}
</div>
<div className="mt-4">
<div className="flex flex-wrap gap-2">
<button type="button" onClick={() => hydrateDefinition(definition)} 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 text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-pen fa-fw text-[10px]" />Edit Definition</button>
<button type="button" onClick={() => handleDeleteDefinition(definition)} disabled={busy === `delete-definition-${definition.id}`} className="inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-xs font-semibold text-rose-100 transition hover:bg-rose-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === `delete-definition-${definition.id}` ? 'fa-circle-notch fa-spin' : 'fa-trash'} fa-fw text-[10px]`} />Delete</button>
</div>
</div>
</div>
))}
</div>
</section>
{conflicts.length ? (
<section className="mt-8 rounded-[32px] border border-rose-300/20 bg-rose-500/10 p-6 backdrop-blur-sm md:p-7">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-rose-100/80">Conflicts</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Schedule overlaps need review</h2>
</div>
<span className="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-1 text-xs font-semibold text-rose-100">{conflicts.length}</span>
</div>
<div className="mt-6 grid gap-4 lg:grid-cols-2">
{conflicts.map((conflict, index) => (
<div key={`${conflict.surface_key}-${index}`} className="rounded-[24px] border border-rose-300/20 bg-slate-950/40 p-5">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-rose-100">{conflict.surface_key}</span>
</div>
<p className="mt-4 text-sm text-rose-50">{conflict.summary}</p>
<p className="mt-3 text-xs text-rose-100/70">
Window: {conflict.window?.starts_at ? new Date(conflict.window.starts_at).toLocaleString() : 'Immediate'} to {conflict.window?.ends_at ? new Date(conflict.window.ends_at).toLocaleString() : 'Open-ended'}
</p>
</div>
))}
</div>
</section>
) : null}
<section className="mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
<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">Placements</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Active and scheduled slots</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">{placements.length}</span>
</div>
<div className="mt-6 space-y-5">
{placements.map((placement) => (
<div key={placement.id} className="rounded-[28px] border border-white/10 bg-slate-950/40 p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100">{placement.surface_key}</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300">{placement.placement_type}</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300">priority {placement.priority}</span>
{conflictPlacementIds.has(placement.id) || placement.has_conflict ? <span className="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-rose-100">conflict</span> : null}
</div>
<div className="flex flex-wrap gap-2">
<button type="button" onClick={() => hydratePlacement(placement)} 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 text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-pen fa-fw text-[10px]" />Edit</button>
<button type="button" onClick={() => handleDeletePlacement(placement)} disabled={busy === `delete-placement-${placement.id}`} className="inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-xs font-semibold text-rose-100 transition hover:bg-rose-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === `delete-placement-${placement.id}` ? 'fa-circle-notch fa-spin' : 'fa-trash'} fa-fw text-[10px]`} />Delete</button>
</div>
</div>
<div className="mt-4 grid gap-5 xl:grid-cols-[minmax(0,1fr)_280px]">
<div>
{placement.collection ? <CollectionCard collection={placement.collection} isOwner /> : null}
</div>
<div className="space-y-3 text-sm text-slate-300">
<div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3">Starts: {placement.starts_at ? new Date(placement.starts_at).toLocaleString() : 'Immediate'}</div>
<div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3">Ends: {placement.ends_at ? new Date(placement.ends_at).toLocaleString() : 'Open-ended'}</div>
<div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3">Campaign: {placement.campaign_key || 'None'}</div>
<div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3">Status: {placement.is_active ? 'Active' : 'Inactive'}</div>
{placement.notes ? <div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3 text-slate-300">{placement.notes}</div> : null}
</div>
</div>
</div>
))}
</div>
</section>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,638 @@
import React from 'react'
import { Head, Link, usePage } from '@inertiajs/react'
import NovaCardCanvasPreview from '../../components/nova-cards/NovaCardCanvasPreview'
function requestJson(url, { method = 'GET', body } = {}) {
return fetch(url, {
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
'X-Requested-With': 'XMLHttpRequest',
},
body: body ? JSON.stringify(body) : undefined,
}).then(async (response) => {
const payload = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(payload?.message || 'Request failed')
return payload
})
}
function renderOverrideHistoryItems(items, prefix) {
return (items || []).slice(0, 3).map((entry, index) => (
<div key={`${prefix}-${index}-${entry.updated_at || entry.source || entry.moderation_status || 'override'}`} className="rounded-2xl border border-white/10 bg-black/10 px-3 py-3">
<div className="flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-sky-100/75">
<span>{entry.moderation_status || 'unknown status'}</span>
{entry.disposition_label ? <span>{entry.disposition_label}</span> : null}
{entry.actor_username ? <span>@{entry.actor_username}</span> : null}
</div>
<div className="mt-1 flex flex-wrap gap-2 text-[11px] uppercase tracking-[0.14em] text-sky-100/55">
{entry.source ? <span>{String(entry.source).replaceAll('_', ' ')}</span> : null}
{entry.updated_at ? <span>{new Date(entry.updated_at).toLocaleString()}</span> : null}
</div>
{entry.note ? <div className="mt-2 text-sm leading-6 text-sky-50">{entry.note}</div> : null}
</div>
))
}
export default function NovaCardsAdminIndex() {
const { props } = usePage()
const [cards, setCards] = React.useState(props.cards?.data || [])
const [featuredCreators, setFeaturedCreators] = React.useState(props.featuredCreators || [])
const [categories, setCategories] = React.useState(props.categories || [])
const [reportStatus, setReportStatus] = React.useState('open')
const [reports, setReports] = React.useState([])
const [reportsMeta, setReportsMeta] = React.useState({ total: 0 })
const [reportCounts, setReportCounts] = React.useState(props.reportingQueue?.statuses || { open: 0, reviewing: 0, closed: 0 })
const [reportNotes, setReportNotes] = React.useState({})
const [reportBusy, setReportBusy] = React.useState({})
const [reportsLoading, setReportsLoading] = React.useState(false)
const [reportsError, setReportsError] = React.useState('')
const [cardDispositions, setCardDispositions] = React.useState(() => Object.fromEntries((props.cards?.data || []).map((card) => [card.id, card.moderation_override?.disposition || ''])))
const [reportDispositions, setReportDispositions] = React.useState({})
const [newCategory, setNewCategory] = React.useState({ slug: '', name: '', description: '', active: true, order_num: categories.length })
const endpoints = props.endpoints || {}
const stats = props.stats || {}
const reportingQueue = props.reportingQueue || {}
const moderationDispositionOptions = props.moderationDispositionOptions || {}
function dispositionOptionsForStatus(status) {
return moderationDispositionOptions?.[status] || []
}
function preferredDisposition(status, currentValue) {
const options = dispositionOptionsForStatus(status)
if (currentValue && options.some((option) => option.value === currentValue)) {
return currentValue
}
return options[0]?.value || ''
}
React.useEffect(() => {
let active = true
async function loadReports() {
if (!endpoints.reportsQueue) {
return
}
setReportsLoading(true)
setReportsError('')
try {
const separator = String(endpoints.reportsQueue).includes('?') ? '&' : '?'
const response = await requestJson(`${endpoints.reportsQueue}${separator}status=${reportStatus}`)
if (!active) return
setReports(response.data || [])
setReportsMeta(response.meta || { total: 0 })
setReportDispositions((current) => {
const next = { ...current }
;(response.data || []).forEach((report) => {
const target = report?.target?.moderation_target
if (target?.card_id && !(report.id in next)) {
next[report.id] = preferredDisposition(target.moderation_status, target.moderation_override?.disposition)
}
})
return next
})
setReportNotes((current) => {
const next = { ...current }
;(response.data || []).forEach((report) => {
if (!(report.id in next)) {
next[report.id] = report.moderator_note || ''
}
})
return next
})
} catch (error) {
if (!active) return
setReportsError(error.message)
setReports([])
} finally {
if (active) {
setReportsLoading(false)
}
}
}
loadReports()
return () => {
active = false
}
}, [endpoints.reportsQueue, reportStatus])
async function updateCard(cardId, patch) {
const response = await requestJson(String(endpoints.updateCardPattern || '').replace('__CARD__', String(cardId)), {
method: 'PATCH',
body: patch,
})
setCardDispositions((current) => ({
...current,
[cardId]: preferredDisposition(response.card?.moderation_status, response.card?.moderation_override?.disposition),
}))
setCards((current) => current.map((card) => (card.id === cardId ? response.card : card)))
}
async function updateCreator(creatorId, patch) {
const response = await requestJson(String(endpoints.updateCreatorPattern || '').replace('__CREATOR__', String(creatorId)), {
method: 'PATCH',
body: patch,
})
setFeaturedCreators((current) => current.map((creator) => (creator.id === creatorId ? response.creator : creator)))
}
function syncReportCounts(previousStatus, nextStatus) {
if (!previousStatus || !nextStatus || previousStatus === nextStatus) {
return
}
setReportCounts((current) => ({
...current,
[previousStatus]: Math.max(0, Number(current?.[previousStatus] || 0) - 1),
[nextStatus]: Number(current?.[nextStatus] || 0) + 1,
}))
}
function mergeUpdatedReport(updatedReport, previousStatus) {
setReportNotes((current) => ({ ...current, [updatedReport.id]: updatedReport.moderator_note || '' }))
setReportDispositions((current) => ({
...current,
[updatedReport.id]: preferredDisposition(updatedReport?.target?.moderation_target?.moderation_status, updatedReport?.target?.moderation_target?.moderation_override?.disposition),
}))
if (previousStatus && previousStatus !== updatedReport.status) {
syncReportCounts(previousStatus, updatedReport.status)
}
setReports((current) => {
if (previousStatus && previousStatus !== updatedReport.status) {
return current.filter((report) => report.id !== updatedReport.id)
}
return current.map((report) => (report.id === updatedReport.id ? updatedReport : report))
})
}
async function saveCategory(category) {
const isExisting = Boolean(category.id)
const url = isExisting
? String(endpoints.updateCategoryPattern || '').replace('__CATEGORY__', String(category.id))
: endpoints.storeCategory
const response = await requestJson(url, {
method: isExisting ? 'PATCH' : 'POST',
body: category,
})
setCategories((current) => {
if (isExisting) {
return current.map((item) => (item.id === category.id ? { ...item, ...response.category } : item))
}
return [...current, { ...response.category, cards_count: 0 }]
})
if (!isExisting) {
setNewCategory({ slug: '', name: '', description: '', active: true, order_num: categories.length + 1 })
}
}
async function updateReport(reportId, patch) {
const currentReport = reports.find((report) => report.id === reportId)
if (!currentReport) {
return
}
setReportBusy((current) => ({ ...current, [reportId]: true }))
try {
const response = await requestJson(String(endpoints.updateReportPattern || '').replace('__REPORT__', String(reportId)), {
method: 'PATCH',
body: patch,
})
mergeUpdatedReport(response.report, currentReport.status)
} finally {
setReportBusy((current) => ({ ...current, [reportId]: false }))
}
}
async function moderateReportTarget(reportId, action) {
const currentReport = reports.find((report) => report.id === reportId)
if (!currentReport) {
return
}
setReportBusy((current) => ({ ...current, [reportId]: true }))
try {
const response = await requestJson(String(endpoints.moderateReportTargetPattern || '').replace('__REPORT__', String(reportId)), {
method: 'POST',
body: { action, disposition: reportDispositions[reportId] || null },
})
mergeUpdatedReport(response.report, currentReport.status)
setCards((current) => current.map((card) => (card.id === response.report?.target?.moderation_target?.card_id
? {
...card,
moderation_status: response.report.target.moderation_target.moderation_status,
moderation_override: response.report.target.moderation_target.moderation_override,
moderation_override_history: response.report.target.moderation_target.moderation_override_history,
}
: card)))
} finally {
setReportBusy((current) => ({ ...current, [reportId]: false }))
}
}
return (
<div className="mx-auto max-w-7xl px-4 pb-20 pt-8 sm:px-6 lg:px-8">
<Head title="Nova Cards Moderation" />
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/75">Moderation surface</p>
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Nova Cards control panel</h1>
<p className="mt-3 max-w-3xl text-sm leading-7 text-slate-300">Review pending cards, feature standout work, and keep the starter category taxonomy healthy as Nova Cards launches.</p>
</div>
<div className="flex flex-wrap gap-3">
<Link href={endpoints.templates || '/cp/cards/templates'} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
<i className="fa-solid fa-swatchbook" />
Manage templates
</Link>
<Link href={endpoints.assetPacks || '/cp/cards/asset-packs'} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
<i className="fa-solid fa-shapes" />
Asset packs
</Link>
<Link href={endpoints.challenges || '/cp/cards/challenges'} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
<i className="fa-solid fa-trophy" />
Challenges
</Link>
</div>
</div>
</section>
<section className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{[
['Pending', stats.pending || 0, 'fa-clock'],
['Flagged', stats.flagged || 0, 'fa-flag'],
['Featured', stats.featured || 0, 'fa-star'],
['Published', stats.published || 0, 'fa-earth-americas'],
['Remixable', stats.remixable || 0, 'fa-code-branch'],
['Challenges', stats.challenges || 0, 'fa-trophy'],
].map(([label, value, icon]) => (
<div key={label} className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">{label}</div>
<div className="mt-3 flex items-center gap-3">
<span className="inline-flex h-12 w-12 items-center justify-center rounded-2xl border border-sky-300/20 bg-sky-400/10 text-sky-100"><i className={`fa-solid ${icon}`} /></span>
<span className="text-3xl font-semibold tracking-[-0.04em] text-white">{value}</span>
</div>
</div>
))}
</section>
<section className="mt-6 rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Reporting queue</div>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">{reportingQueue.label || 'Nova Cards report queue'}</h2>
<p className="mt-3 max-w-3xl text-sm leading-7 text-slate-300">{reportingQueue.description || 'Review reports targeting Nova Cards surfaces.'}</p>
</div>
<div className="rounded-[22px] border border-amber-300/20 bg-amber-400/10 px-5 py-4 text-right">
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100/75">Pending reports</div>
<div className="mt-2 text-3xl font-semibold tracking-[-0.04em] text-amber-50">{reportCounts.open || 0}</div>
<div className="mt-1 text-xs text-amber-100/70">{reportingQueue.enabled ? 'Connected to moderation pipeline' : 'Reporting disabled'}</div>
</div>
</div>
<div className="mt-5 flex flex-wrap gap-3">
{[
['open', reportCounts.open || 0],
['reviewing', reportCounts.reviewing || 0],
['closed', reportCounts.closed || 0],
].map(([status, count]) => (
<button
key={status}
type="button"
onClick={() => setReportStatus(status)}
className={`rounded-full border px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] transition ${reportStatus === status ? 'border-sky-300/30 bg-sky-400/12 text-sky-100' : 'border-white/10 bg-white/[0.03] text-slate-300 hover:bg-white/[0.06]'}`}
>
{status} {count}
</button>
))}
</div>
<div className="mt-5 rounded-[24px] border border-white/10 bg-[#08111f]/70 p-4">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">{reportStatus.charAt(0).toUpperCase() + reportStatus.slice(1)} reports</div>
<div className="text-xs uppercase tracking-[0.16em] text-slate-500">{reportsMeta.total || reports.length} total</div>
</div>
{reportsLoading ? <div className="mt-4 text-sm text-slate-400">Loading report queue</div> : null}
{reportsError ? <div className="mt-4 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{reportsError}</div> : null}
{!reportsLoading && !reportsError && !reports.length ? <div className="mt-4 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-4 text-sm text-slate-400">No reports in this state.</div> : null}
<div className="mt-4 space-y-3">
{reports.map((report) => (
<div key={report.id} className="rounded-[22px] border border-white/10 bg-white/[0.03] p-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300">{String(report.target?.type || report.target_type).replaceAll('_', ' ')}</span>
<span className="rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100">{report.status}</span>
</div>
<div className="mt-3 text-lg font-semibold text-white">{report.target?.label || `Target #${report.target_id}`}</div>
<div className="mt-1 text-sm text-slate-400">{report.target?.subtitle || 'No target details available.'}</div>
<div className="mt-3 text-sm font-semibold text-slate-200">{report.reason}</div>
{report.details ? <p className="mt-2 text-sm leading-6 text-slate-300">{report.details}</p> : null}
<div className="mt-3 text-xs uppercase tracking-[0.16em] text-slate-500">Reported by {report.reporter?.username ? `@${report.reporter.username}` : 'unknown'}{report.created_at ? `${new Date(report.created_at).toLocaleString()}` : ''}</div>
{report.last_moderated_by?.username || report.last_moderated_at ? (
<div className="mt-2 text-xs uppercase tracking-[0.16em] text-slate-500">
Last touched {report.last_moderated_by?.username ? `by @${report.last_moderated_by.username}` : 'by staff'}{report.last_moderated_at ? `${new Date(report.last_moderated_at).toLocaleString()}` : ''}
</div>
) : null}
{report.target?.moderation_target ? (
<div className="mt-4 rounded-2xl border border-amber-300/15 bg-amber-400/10 px-4 py-3 text-sm text-amber-50">
<div className="font-semibold uppercase tracking-[0.16em] text-amber-100/80">Card moderation target</div>
<div className="mt-2">{report.target.moderation_target.title}</div>
{report.target.moderation_target.context ? <div className="mt-1 text-xs uppercase tracking-[0.16em] text-amber-100/70">{report.target.moderation_target.context}</div> : null}
<div className="mt-2 flex flex-wrap gap-2 text-xs uppercase tracking-[0.14em] text-amber-100/80">
<span>Status {report.target.moderation_target.status}</span>
<span>Moderation {report.target.moderation_target.moderation_status}</span>
</div>
{report.target.moderation_target.moderation_reason_labels?.length ? (
<div className="mt-3 rounded-2xl border border-amber-200/15 bg-black/10 px-4 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/75">Heuristic flags</div>
<div className="mt-2 flex flex-wrap gap-2">
{report.target.moderation_target.moderation_reason_labels.map((label) => (
<span key={`${report.id}-${label}`} className="rounded-full border border-amber-200/20 bg-amber-50/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-amber-50">
{label}
</span>
))}
</div>
{report.target.moderation_target.moderation_source ? <div className="mt-2 text-[11px] uppercase tracking-[0.14em] text-amber-100/60">Source {String(report.target.moderation_target.moderation_source).replaceAll('_', ' ')}</div> : null}
</div>
) : null}
{report.target.moderation_target.moderation_override ? (
<div className="mt-3 rounded-2xl border border-sky-200/15 bg-sky-400/10 px-4 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-100/80">Latest staff override</div>
<div className="mt-2 flex flex-wrap gap-2 text-xs uppercase tracking-[0.14em] text-sky-100/80">
<span>Status {report.target.moderation_target.moderation_override.moderation_status}</span>
{report.target.moderation_target.moderation_override.disposition_label ? <span>{report.target.moderation_target.moderation_override.disposition_label}</span> : null}
{report.target.moderation_target.moderation_override.actor_username ? <span>@{report.target.moderation_target.moderation_override.actor_username}</span> : null}
{report.target.moderation_target.moderation_override.source ? <span>{String(report.target.moderation_target.moderation_override.source).replaceAll('_', ' ')}</span> : null}
</div>
{report.target.moderation_target.moderation_override.note ? <div className="mt-2 text-sm leading-6 text-sky-50">{report.target.moderation_target.moderation_override.note}</div> : null}
</div>
) : null}
{report.target.moderation_target.moderation_override_history?.length > 1 ? (
<div className="mt-3 rounded-2xl border border-sky-200/10 bg-sky-400/[0.08] px-4 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-100/75">Recent override history</div>
<div className="mt-3 space-y-2">
{renderOverrideHistoryItems(report.target.moderation_target.moderation_override_history, `report-${report.id}`)}
</div>
</div>
) : null}
<div className="mt-3 max-w-xs">
<label className="text-sm text-amber-50">
<span className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/75">Disposition</span>
<select
value={preferredDisposition(report.target.moderation_target.moderation_status, reportDispositions[report.id])}
onChange={(event) => setReportDispositions((current) => ({ ...current, [report.id]: event.target.value }))}
className="w-full rounded-2xl border border-amber-200/20 bg-[#0d1726] px-4 py-3 text-white"
>
{dispositionOptionsForStatus(report.target.moderation_target.moderation_status).map((option) => <option key={`${report.id}-disp-${option.value}`} value={option.value}>{option.label}</option>)}
</select>
</label>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{(report.target.moderation_target.available_actions || []).map((actionItem) => (
<button
key={`${report.id}-${actionItem.action}`}
type="button"
onClick={() => moderateReportTarget(report.id, actionItem.action)}
disabled={Boolean(reportBusy[report.id])}
className="inline-flex items-center gap-2 rounded-full border border-amber-200/20 bg-amber-50/10 px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-50 transition hover:bg-amber-50/15 disabled:cursor-not-allowed disabled:opacity-60"
>
{actionItem.label}
</button>
))}
</div>
</div>
) : null}
<div className="mt-4 rounded-2xl border border-white/10 bg-[#08111f]/70 p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Moderator note</div>
<textarea
value={reportNotes[report.id] ?? ''}
onChange={(event) => setReportNotes((current) => ({ ...current, [report.id]: event.target.value }))}
rows={3}
className="mt-3 w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white"
placeholder="Capture reviewer context, outcome, or escalation notes."
/>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => updateReport(report.id, { moderator_note: (reportNotes[report.id] || '').trim() || null })}
disabled={Boolean(reportBusy[report.id])}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.08] disabled:cursor-not-allowed disabled:opacity-60"
>
Save note
</button>
{['open', 'reviewing', 'closed'].filter((status) => status !== report.status).map((status) => (
<button
key={`${report.id}-${status}`}
type="button"
onClick={() => updateReport(report.id, { status, moderator_note: (reportNotes[report.id] || '').trim() || null })}
disabled={Boolean(reportBusy[report.id])}
className="inline-flex items-center gap-2 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 transition hover:bg-sky-400/15 disabled:cursor-not-allowed disabled:opacity-60"
>
Mark {status}
</button>
))}
</div>
</div>
<div className="mt-4 rounded-2xl border border-white/10 bg-[#08111f]/70 p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Audit trail</div>
{!report.history?.length ? <div className="mt-3 text-sm text-slate-400">No moderator actions recorded yet.</div> : null}
<div className="mt-3 space-y-3">
{(report.history || []).map((entry) => (
<div key={entry.id} className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
<div className="flex flex-wrap items-center gap-2 text-xs uppercase tracking-[0.16em] text-slate-500">
<span>{entry.summary || entry.action_type}</span>
<span>{entry.actor?.username ? `@${entry.actor.username}` : 'system'}</span>
<span>{entry.created_at ? new Date(entry.created_at).toLocaleString() : ''}</span>
</div>
{entry.note ? <div className="mt-2 text-sm leading-6 text-slate-300">{entry.note}</div> : null}
</div>
))}
</div>
</div>
</div>
<div className="flex flex-wrap gap-2 lg:max-w-[260px] lg:justify-end">
{report.target?.public_url ? <a href={report.target.public_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.08]">Open target</a> : null}
{report.target?.moderation_url ? <a href={report.target.moderation_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.08]">Moderate</a> : null}
</div>
</div>
</div>
))}
</div>
</div>
</section>
<section className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
<div className="space-y-5">
{cards.map((card) => (
<div key={card.id} className="rounded-[28px] border border-white/10 bg-white/[0.04] p-4 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
<div className="grid gap-4 lg:grid-cols-[260px_minmax(0,1fr)]">
<NovaCardCanvasPreview card={card} />
<div>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-xl font-semibold tracking-[-0.03em] text-white">{card.title}</div>
<div className="mt-1 text-sm text-slate-400">@{card.creator?.username} {card.category?.name || 'Uncategorized'}</div>
</div>
<a href={card.public_url} className="text-sm text-sky-300 transition hover:text-sky-200">Open public page</a>
</div>
<p className="mt-3 line-clamp-3 text-sm leading-7 text-slate-300">{card.quote_text}</p>
<div className="mt-4 grid gap-3 md:grid-cols-3">
<label className="text-sm text-slate-300">
<span className="mb-2 block">Status</span>
<select value={card.status} onChange={(event) => updateCard(card.id, { status: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
{['draft', 'processing', 'published', 'hidden', 'rejected'].map((item) => <option key={`${card.id}-${item}`} value={item}>{item}</option>)}
</select>
</label>
<label className="text-sm text-slate-300">
<span className="mb-2 block">Moderation</span>
<select value={card.moderation_status} onChange={(event) => updateCard(card.id, { moderation_status: event.target.value, disposition: preferredDisposition(event.target.value, cardDispositions[card.id]) })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
{['pending', 'approved', 'flagged', 'rejected'].map((item) => <option key={`${card.id}-mod-${item}`} value={item}>{item}</option>)}
</select>
</label>
<label className="text-sm text-slate-300">
<span className="mb-2 block">Disposition</span>
<select
value={preferredDisposition(card.moderation_status, cardDispositions[card.id])}
onChange={(event) => {
const disposition = event.target.value
setCardDispositions((current) => ({ ...current, [card.id]: disposition }))
updateCard(card.id, { moderation_status: card.moderation_status, disposition })
}}
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white"
>
{dispositionOptionsForStatus(card.moderation_status).map((option) => <option key={`${card.id}-disp-${option.value}`} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
<span>Featured</span>
<input type="checkbox" checked={Boolean(card.featured)} onChange={(event) => updateCard(card.id, { featured: event.target.checked })} className="h-4 w-4" />
</label>
<label className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
<span>Allow remix</span>
<input type="checkbox" checked={Boolean(card.allow_remix)} onChange={(event) => updateCard(card.id, { allow_remix: event.target.checked })} className="h-4 w-4" />
</label>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-4 text-xs text-slate-400">
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">{card.likes_count || 0} likes</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">{card.saves_count || 0} saves</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">{card.remixes_count || 0} remixes</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">{card.challenge_entries_count || 0} challenge entries</div>
</div>
{card.moderation_reason_labels?.length ? (
<div className="mt-4 rounded-2xl border border-amber-300/15 bg-amber-400/10 px-4 py-3 text-sm text-amber-50">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/80">Heuristic moderation flags</div>
<div className="mt-2 flex flex-wrap gap-2">
{card.moderation_reason_labels.map((label) => (
<span key={`${card.id}-${label}`} className="rounded-full border border-amber-200/20 bg-amber-50/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-amber-50">
{label}
</span>
))}
</div>
{card.moderation_source ? <div className="mt-2 text-[11px] uppercase tracking-[0.14em] text-amber-100/70">Source {String(card.moderation_source).replaceAll('_', ' ')}</div> : null}
</div>
) : null}
{card.moderation_override ? (
<div className="mt-4 rounded-2xl border border-sky-300/15 bg-sky-400/10 px-4 py-3 text-sm text-sky-50">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-100/80">Latest staff override</div>
<div className="mt-2 flex flex-wrap gap-2 text-xs uppercase tracking-[0.14em] text-sky-100/80">
<span>Status {card.moderation_override.moderation_status}</span>
{card.moderation_override.disposition_label ? <span>{card.moderation_override.disposition_label}</span> : null}
{card.moderation_override.actor_username ? <span>@{card.moderation_override.actor_username}</span> : null}
{card.moderation_override.source ? <span>{String(card.moderation_override.source).replaceAll('_', ' ')}</span> : null}
</div>
{card.moderation_override.note ? <div className="mt-2 text-sm leading-6 text-sky-50">{card.moderation_override.note}</div> : null}
</div>
) : null}
{card.moderation_override_history?.length > 1 ? (
<div className="mt-4 rounded-2xl border border-sky-300/10 bg-sky-400/[0.08] px-4 py-3 text-sm text-sky-50">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-100/75">Recent override history</div>
<div className="mt-3 space-y-2">
{renderOverrideHistoryItems(card.moderation_override_history, `card-${card.id}`)}
</div>
</div>
) : null}
</div>
</div>
</div>
))}
</div>
<div className="space-y-6">
<section className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Creator curation</div>
{!featuredCreators.length ? <div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-4 text-sm text-slate-400">No public Nova creators are available for curation yet.</div> : null}
<div className="space-y-3">
{featuredCreators.map((creator) => (
<div key={creator.id} className="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="font-semibold text-white">{creator.display_name}</div>
<div className="text-xs uppercase tracking-[0.18em] text-slate-500">@{creator.username}</div>
</div>
{creator.public_url ? <a href={creator.public_url} className="text-xs font-semibold uppercase tracking-[0.16em] text-sky-300 transition hover:text-sky-200">Open profile</a> : null}
</div>
<div className="mt-3 grid grid-cols-3 gap-2 text-xs text-slate-300">
<div className="rounded-2xl border border-white/10 bg-[#08111f]/70 px-3 py-3">{creator.public_cards_count || 0} public cards</div>
<div className="rounded-2xl border border-white/10 bg-[#08111f]/70 px-3 py-3">{creator.featured_cards_count || 0} featured</div>
<div className="rounded-2xl border border-white/10 bg-[#08111f]/70 px-3 py-3">{creator.total_views_count || 0} views</div>
</div>
<label className="mt-3 flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
<span>Feature on editorial page</span>
<input type="checkbox" checked={Boolean(creator.nova_featured_creator)} onChange={(event) => updateCreator(creator.id, { nova_featured_creator: event.target.checked })} className="h-4 w-4" />
</label>
</div>
))}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Categories</div>
<div className="space-y-3">
{categories.map((category) => (
<div key={category.id} className="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-semibold text-white">{category.name}</div>
<div className="text-xs uppercase tracking-[0.18em] text-slate-500">{category.slug} {category.cards_count} cards</div>
</div>
<button type="button" onClick={() => saveCategory(category)} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-white transition hover:bg-white/[0.08]">Save</button>
</div>
</div>
))}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Add category</div>
<div className="space-y-3">
<input value={newCategory.name} onChange={(event) => setNewCategory((current) => ({ ...current, name: event.target.value }))} placeholder="Name" className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
<input value={newCategory.slug} onChange={(event) => setNewCategory((current) => ({ ...current, slug: event.target.value }))} placeholder="Slug" className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
<textarea value={newCategory.description} onChange={(event) => setNewCategory((current) => ({ ...current, description: event.target.value }))} placeholder="Description" rows={3} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
<button type="button" onClick={() => saveCategory(newCategory)} className="w-full rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">Create category</button>
</div>
</section>
</div>
</section>
</div>
)
}

View File

@@ -0,0 +1,129 @@
import React from 'react'
import { Head, Link, usePage } from '@inertiajs/react'
function requestJson(url, { method = 'GET', body } = {}) {
return fetch(url, {
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
'X-Requested-With': 'XMLHttpRequest',
},
body: body ? JSON.stringify(body) : undefined,
}).then(async (response) => {
const payload = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(payload?.message || 'Request failed')
return payload
})
}
export default function NovaCardsAssetPackAdmin() {
const { props } = usePage()
const [packs, setPacks] = React.useState(props.packs || [])
const [selectedId, setSelectedId] = React.useState(null)
const [form, setForm] = React.useState({ slug: '', name: '', description: '', type: 'asset', preview_image: '', manifest_json: {}, official: true, active: true, order_num: 0 })
const endpoints = props.endpoints || {}
function loadPack(pack) {
setSelectedId(pack.id)
setForm({
slug: pack.slug,
name: pack.name,
description: pack.description || '',
type: pack.type || 'asset',
preview_image: pack.preview_image || '',
manifest_json: pack.manifest_json || {},
official: Boolean(pack.official),
active: Boolean(pack.active),
order_num: pack.order_num || 0,
})
}
function resetForm() {
setSelectedId(null)
setForm({ slug: '', name: '', description: '', type: 'asset', preview_image: '', manifest_json: {}, official: true, active: true, order_num: packs.length })
}
async function savePack() {
const isExisting = Boolean(selectedId)
const url = isExisting ? String(endpoints.updatePattern || '').replace('__PACK__', String(selectedId)) : endpoints.store
const response = await requestJson(url, { method: isExisting ? 'PATCH' : 'POST', body: form })
if (isExisting) {
setPacks((current) => current.map((pack) => (pack.id === selectedId ? response.pack : pack)))
} else {
setPacks((current) => [...current, response.pack])
setSelectedId(response.pack.id)
}
}
return (
<div className="mx-auto max-w-7xl px-4 pb-20 pt-8 sm:px-6 lg:px-8">
<Head title="Nova Cards Asset Packs" />
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/75">V2 pack system</p>
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Official asset and template packs</h1>
<p className="mt-3 max-w-3xl text-sm leading-7 text-slate-300">Control the official packs exposed in the v2 editor and public pack directories.</p>
</div>
<div className="flex gap-3">
<button type="button" onClick={resetForm} className="rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">New pack</button>
<Link href={endpoints.cards || '/cp/cards'} className="rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Back to cards</Link>
</div>
</div>
</section>
<div className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]">
<section className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Existing packs</div>
<div className="space-y-3">
{packs.map((pack) => (
<button key={pack.id} type="button" onClick={() => loadPack(pack)} className={`w-full rounded-[22px] border p-4 text-left transition ${selectedId === pack.id ? 'border-sky-300/35 bg-sky-400/10' : 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]'}`}>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-base font-semibold tracking-[-0.03em] text-white">{pack.name}</div>
<div className="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">{pack.slug}</div>
</div>
<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-200">{pack.type}</span>
</div>
{pack.description ? <div className="mt-2 text-sm text-slate-400">{pack.description}</div> : null}
</button>
))}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Pack editor</div>
<div className="grid gap-4 md:grid-cols-2">
<input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} placeholder="Pack name" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
<input value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} placeholder="Slug" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
<textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} placeholder="Description" rows={3} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white md:col-span-2" />
<label className="text-sm text-slate-300">
<span className="mb-2 block">Type</span>
<select value={form.type} onChange={(event) => setForm((current) => ({ ...current, type: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
<option value="asset">asset</option>
<option value="template">template</option>
</select>
</label>
<input value={form.preview_image} onChange={(event) => setForm((current) => ({ ...current, preview_image: event.target.value }))} placeholder="Preview image URL" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
<textarea value={JSON.stringify(form.manifest_json || {}, null, 2)} onChange={(event) => {
try {
setForm((current) => ({ ...current, manifest_json: JSON.parse(event.target.value || '{}') }))
} catch {
// ignore invalid json until user fixes it
}
}} rows={10} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 font-mono text-sm text-white md:col-span-2" />
</div>
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(form.active)} onChange={(event) => setForm((current) => ({ ...current, active: event.target.checked }))} className="h-4 w-4" /> Active</label>
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(form.official)} onChange={(event) => setForm((current) => ({ ...current, official: event.target.checked }))} className="h-4 w-4" /> Official</label>
</div>
<button type="button" onClick={savePack} className="mt-5 w-full rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">{selectedId ? 'Update pack' : 'Create pack'}</button>
</section>
</div>
</div>
)
}

View File

@@ -0,0 +1,146 @@
import React from 'react'
import { Head, Link, usePage } from '@inertiajs/react'
function requestJson(url, { method = 'GET', body } = {}) {
return fetch(url, {
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
'X-Requested-With': 'XMLHttpRequest',
},
body: body ? JSON.stringify(body) : undefined,
}).then(async (response) => {
const payload = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(payload?.message || 'Request failed')
return payload
})
}
export default function NovaCardsChallengeAdmin() {
const { props } = usePage()
const [challenges, setChallenges] = React.useState(props.challenges || [])
const [selectedId, setSelectedId] = React.useState(null)
const [form, setForm] = React.useState({ slug: '', title: '', description: '', prompt: '', rules_json: {}, status: 'draft', official: true, featured: false, winner_card_id: '', starts_at: '', ends_at: '' })
const endpoints = props.endpoints || {}
const cards = props.cards || []
function loadChallenge(challenge) {
setSelectedId(challenge.id)
setForm({
slug: challenge.slug,
title: challenge.title,
description: challenge.description || '',
prompt: challenge.prompt || '',
rules_json: challenge.rules_json || {},
status: challenge.status || 'draft',
official: Boolean(challenge.official),
featured: Boolean(challenge.featured),
winner_card_id: challenge.winner_card_id || '',
starts_at: challenge.starts_at || '',
ends_at: challenge.ends_at || '',
})
}
function resetForm() {
setSelectedId(null)
setForm({ slug: '', title: '', description: '', prompt: '', rules_json: {}, status: 'draft', official: true, featured: false, winner_card_id: '', starts_at: '', ends_at: '' })
}
async function saveChallenge() {
const isExisting = Boolean(selectedId)
const url = isExisting ? String(endpoints.updatePattern || '').replace('__CHALLENGE__', String(selectedId)) : endpoints.store
const response = await requestJson(url, { method: isExisting ? 'PATCH' : 'POST', body: { ...form, winner_card_id: form.winner_card_id || null } })
if (isExisting) {
setChallenges((current) => current.map((challenge) => (challenge.id === selectedId ? response.challenge : challenge)))
} else {
setChallenges((current) => [...current, response.challenge])
setSelectedId(response.challenge.id)
}
}
return (
<div className="mx-auto max-w-7xl px-4 pb-20 pt-8 sm:px-6 lg:px-8">
<Head title="Nova Cards Challenges" />
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/75">Challenge system</p>
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Nova Cards challenge programming</h1>
<p className="mt-3 max-w-3xl text-sm leading-7 text-slate-300">Program official challenge prompts, track featured runs, and connect winner cards.</p>
</div>
<div className="flex gap-3">
<button type="button" onClick={resetForm} className="rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">New challenge</button>
<Link href={endpoints.cards || '/cp/cards'} className="rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Back to cards</Link>
</div>
</div>
</section>
<div className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]">
<section className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Existing challenges</div>
<div className="space-y-3">
{challenges.map((challenge) => (
<button key={challenge.id} type="button" onClick={() => loadChallenge(challenge)} className={`w-full rounded-[22px] border p-4 text-left transition ${selectedId === challenge.id ? 'border-sky-300/35 bg-sky-400/10' : 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]'}`}>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-base font-semibold tracking-[-0.03em] text-white">{challenge.title}</div>
<div className="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">{challenge.slug}</div>
</div>
<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-200">{challenge.status}</span>
</div>
{challenge.description ? <div className="mt-2 text-sm text-slate-400">{challenge.description}</div> : null}
</button>
))}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Challenge editor</div>
<div className="grid gap-4 md:grid-cols-2">
<input value={form.title} onChange={(event) => setForm((current) => ({ ...current, title: event.target.value }))} placeholder="Title" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
<input value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} placeholder="Slug" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
<textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} placeholder="Description" rows={3} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white md:col-span-2" />
<textarea value={form.prompt} onChange={(event) => setForm((current) => ({ ...current, prompt: event.target.value }))} placeholder="Prompt" rows={4} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white md:col-span-2" />
<label className="text-sm text-slate-300">
<span className="mb-2 block">Status</span>
<select value={form.status} onChange={(event) => setForm((current) => ({ ...current, status: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
{['draft', 'active', 'completed', 'archived'].map((status) => <option key={status} value={status}>{status}</option>)}
</select>
</label>
<label className="text-sm text-slate-300">
<span className="mb-2 block">Winner card</span>
<select value={form.winner_card_id} onChange={(event) => setForm((current) => ({ ...current, winner_card_id: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
<option value="">No winner</option>
{cards.map((card) => <option key={card.id} value={card.id}>{card.title}</option>)}
</select>
</label>
<label className="text-sm text-slate-300">
<span className="mb-2 block">Starts at</span>
<input type="datetime-local" value={form.starts_at} onChange={(event) => setForm((current) => ({ ...current, starts_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
</label>
<label className="text-sm text-slate-300">
<span className="mb-2 block">Ends at</span>
<input type="datetime-local" value={form.ends_at} onChange={(event) => setForm((current) => ({ ...current, ends_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
</label>
<textarea value={JSON.stringify(form.rules_json || {}, null, 2)} onChange={(event) => {
try {
setForm((current) => ({ ...current, rules_json: JSON.parse(event.target.value || '{}') }))
} catch {
// ignore invalid json until fixed
}
}} rows={10} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 font-mono text-sm text-white md:col-span-2" />
</div>
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(form.official)} onChange={(event) => setForm((current) => ({ ...current, official: event.target.checked }))} className="h-4 w-4" /> Official</label>
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(form.featured)} onChange={(event) => setForm((current) => ({ ...current, featured: event.target.checked }))} className="h-4 w-4" /> Featured</label>
</div>
<button type="button" onClick={saveChallenge} className="mt-5 w-full rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">{selectedId ? 'Update challenge' : 'Create challenge'}</button>
</section>
</div>
</div>
)
}

View File

@@ -0,0 +1,196 @@
import React from 'react'
import { Head, Link, usePage } from '@inertiajs/react'
function requestJson(url, { method = 'GET', body } = {}) {
return fetch(url, {
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
'X-Requested-With': 'XMLHttpRequest',
},
body: body ? JSON.stringify(body) : undefined,
}).then(async (response) => {
const payload = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(payload?.message || 'Request failed')
return payload
})
}
export default function NovaCardsCollectionAdmin() {
const { props } = usePage()
const [collections, setCollections] = React.useState(props.collections || [])
const [selectedId, setSelectedId] = React.useState(props.collections?.[0]?.id || null)
const [cardId, setCardId] = React.useState('')
const [cardNote, setCardNote] = React.useState('')
const endpoints = props.endpoints || {}
const admins = props.admins || []
const cards = props.cards || []
const selected = React.useMemo(() => collections.find((entry) => entry.id === selectedId) || null, [collections, selectedId])
const [form, setForm] = React.useState(() => ({
user_id: admins[0]?.id || '',
slug: '',
name: '',
description: '',
visibility: 'public',
official: true,
featured: false,
}))
React.useEffect(() => {
if (!selected) {
setForm({ user_id: admins[0]?.id || '', slug: '', name: '', description: '', visibility: 'public', official: true, featured: false })
return
}
setForm({
user_id: selected.owner?.id || admins[0]?.id || '',
slug: selected.slug || '',
name: selected.name || '',
description: selected.description || '',
visibility: selected.visibility || 'public',
official: Boolean(selected.official),
featured: Boolean(selected.featured),
})
}, [admins, selected])
async function saveCollection() {
const isExisting = Boolean(selectedId)
const url = isExisting ? String(endpoints.updatePattern || '').replace('__COLLECTION__', String(selectedId)) : endpoints.store
const response = await requestJson(url, { method: isExisting ? 'PATCH' : 'POST', body: form })
if (isExisting) {
setCollections((current) => current.map((entry) => (entry.id === selectedId ? response.collection : entry)))
} else {
setCollections((current) => [response.collection, ...current])
setSelectedId(response.collection.id)
}
}
async function attachCard() {
if (!selectedId || !cardId) return
const response = await requestJson(String(endpoints.attachCardPattern || '').replace('__COLLECTION__', String(selectedId)), {
method: 'POST',
body: { card_id: Number(cardId), note: cardNote || null },
})
setCollections((current) => current.map((entry) => (entry.id === selectedId ? response.collection : entry)))
setCardId('')
setCardNote('')
}
async function detachCard(collectionId, currentCardId) {
const response = await requestJson(
String(endpoints.detachCardPattern || '')
.replace('__COLLECTION__', String(collectionId))
.replace('__CARD__', String(currentCardId)),
{ method: 'DELETE' },
)
setCollections((current) => current.map((entry) => (entry.id === collectionId ? response.collection : entry)))
}
return (
<div className="mx-auto max-w-7xl px-4 pb-20 pt-8 sm:px-6 lg:px-8">
<Head title="Nova Cards Collections" />
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/75">Editorial layer</p>
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Official and public card collections</h1>
<p className="mt-3 max-w-3xl text-sm leading-7 text-slate-300">Create editorial collections, assign owners, and curate the public card sets that the v2 browse surface links to.</p>
</div>
<div className="flex gap-3">
<button type="button" onClick={() => setSelectedId(null)} className="rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">New collection</button>
<Link href={endpoints.cards || '/cp/cards'} className="rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Back to cards</Link>
</div>
</div>
</section>
<div className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<section className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Collections</div>
<div className="space-y-3">
{collections.map((collection) => (
<button key={collection.id} type="button" onClick={() => setSelectedId(collection.id)} className={`w-full rounded-[22px] border p-4 text-left transition ${selectedId === collection.id ? 'border-sky-300/35 bg-sky-400/10' : 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]'}`}>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-base font-semibold tracking-[-0.03em] text-white">{collection.name}</div>
<div className="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">{collection.featured ? 'Featured • ' : ''}{collection.official ? 'Official' : '@' + (collection.owner?.username || 'creator')}</div>
</div>
<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-200">{collection.cards_count} cards</span>
</div>
{collection.description ? <div className="mt-2 text-sm text-slate-400">{collection.description}</div> : null}
</button>
))}
</div>
</section>
<section className="space-y-6">
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Collection editor</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="text-sm text-slate-300">
<span className="mb-2 block">Owner</span>
<select value={form.user_id} onChange={(event) => setForm((current) => ({ ...current, user_id: Number(event.target.value) }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
{admins.map((admin) => <option key={admin.id} value={admin.id}>{admin.name || admin.username}</option>)}
</select>
</label>
<label className="text-sm text-slate-300">
<span className="mb-2 block">Visibility</span>
<select value={form.visibility} onChange={(event) => setForm((current) => ({ ...current, visibility: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
<option value="public">public</option>
<option value="private">private</option>
</select>
</label>
<input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} placeholder="Collection name" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
<input value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} placeholder="Slug" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
<textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} placeholder="Description" rows={4} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white md:col-span-2" />
</div>
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
<div className="flex flex-wrap items-center gap-4">
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(form.official)} onChange={(event) => setForm((current) => ({ ...current, official: event.target.checked }))} className="h-4 w-4" /> Official collection</label>
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(form.featured)} onChange={(event) => setForm((current) => ({ ...current, featured: event.target.checked }))} className="h-4 w-4" /> Featured collection</label>
</div>
{selected?.public_url ? <a href={selected.public_url} className="text-sky-100 transition hover:text-white" target="_blank" rel="noreferrer">Open public page</a> : null}
</div>
<button type="button" onClick={saveCollection} className="mt-5 w-full rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">{selectedId ? 'Update collection' : 'Create collection'}</button>
</div>
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Curate cards</div>
{!selectedId ? (
<div className="rounded-2xl border border-dashed border-white/12 bg-white/[0.03] px-4 py-8 text-center text-sm text-slate-400">Create or select a collection first.</div>
) : (
<>
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
<select value={cardId} onChange={(event) => setCardId(event.target.value)} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
<option value="">Select a card</option>
{cards.map((card) => <option key={card.id} value={card.id}>{card.title}</option>)}
</select>
<input value={cardNote} onChange={(event) => setCardNote(event.target.value)} placeholder="Optional curator note" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
<button type="button" onClick={attachCard} className="rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">Add</button>
</div>
<div className="mt-5 space-y-3">
{(selected?.items || []).map((item) => (
<div key={item.id} className="flex items-start justify-between gap-4 rounded-[22px] border border-white/10 bg-white/[0.03] p-4">
<div>
<div className="text-base font-semibold text-white">{item.card?.title}</div>
<div className="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">#{item.sort_order} {item.card?.creator?.username ? `• @${item.card.creator.username}` : ''}</div>
{item.note ? <div className="mt-2 text-sm text-slate-400">{item.note}</div> : null}
</div>
<button type="button" onClick={() => detachCard(selectedId, item.card.id)} className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100 transition hover:bg-rose-400/15">Remove</button>
</div>
))}
</div>
</>
)}
</div>
</section>
</div>
</div>
)
}

View File

@@ -0,0 +1,210 @@
import React from 'react'
import { Head, Link, usePage } from '@inertiajs/react'
function requestJson(url, { method = 'GET', body } = {}) {
return fetch(url, {
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
'X-Requested-With': 'XMLHttpRequest',
},
body: body ? JSON.stringify(body) : undefined,
}).then(async (response) => {
const payload = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(payload?.message || 'Request failed')
return payload
})
}
export default function NovaCardsTemplateAdmin() {
const { props } = usePage()
const [templates, setTemplates] = React.useState(props.templates || [])
const [selectedId, setSelectedId] = React.useState(null)
const [form, setForm] = React.useState({
slug: '',
name: '',
description: '',
supported_formats: ['square'],
active: true,
official: true,
order_num: templates.length,
config_json: {
font_preset: 'modern-sans',
gradient_preset: 'midnight-nova',
text_align: 'center',
layout: 'quote_heavy',
text_color: '#ffffff',
overlay_style: 'dark-soft',
},
})
const endpoints = props.endpoints || {}
const formats = props.editorOptions?.formats || []
const fonts = props.editorOptions?.font_presets || []
const gradients = props.editorOptions?.gradient_presets || []
function loadTemplate(template) {
setSelectedId(template.id)
setForm({
slug: template.slug,
name: template.name,
description: template.description || '',
preview_image: template.preview_image || null,
supported_formats: template.supported_formats || [],
active: Boolean(template.active),
official: Boolean(template.official),
order_num: template.order_num || 0,
config_json: template.config_json || {},
})
}
function resetForm() {
setSelectedId(null)
setForm({
slug: '',
name: '',
description: '',
supported_formats: ['square'],
active: true,
official: true,
order_num: templates.length,
config_json: {
font_preset: 'modern-sans',
gradient_preset: 'midnight-nova',
text_align: 'center',
layout: 'quote_heavy',
text_color: '#ffffff',
overlay_style: 'dark-soft',
},
})
}
async function saveTemplate() {
const isExisting = Boolean(selectedId)
const url = isExisting
? String(endpoints.updatePattern || '').replace('__TEMPLATE__', String(selectedId))
: endpoints.store
const response = await requestJson(url, {
method: isExisting ? 'PATCH' : 'POST',
body: form,
})
if (isExisting) {
setTemplates((current) => current.map((template) => (template.id === selectedId ? response.template : template)))
} else {
setTemplates((current) => [...current, response.template])
setSelectedId(response.template.id)
}
}
function toggleFormat(key) {
setForm((current) => {
const exists = current.supported_formats.includes(key)
return {
...current,
supported_formats: exists ? current.supported_formats.filter((item) => item !== key) : [...current.supported_formats, key],
}
})
}
return (
<div className="mx-auto max-w-7xl px-4 pb-20 pt-8 sm:px-6 lg:px-8">
<Head title="Nova Cards Templates" />
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/75">Template system</p>
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Official Nova Cards templates</h1>
<p className="mt-3 max-w-3xl text-sm leading-7 text-slate-300">Keep starter templates config-driven so the editor and render pipeline stay aligned as new card styles ship.</p>
</div>
<div className="flex gap-3">
<button type="button" onClick={resetForm} className="rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">New template</button>
<Link href={endpoints.cards || '/cp/cards'} className="rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Back to cards</Link>
</div>
</div>
</section>
<div className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,1.4fr)]">
<section className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Existing templates</div>
<div className="space-y-3">
{templates.map((template) => (
<button key={template.id} type="button" onClick={() => loadTemplate(template)} className={`w-full rounded-[22px] border p-4 text-left transition ${selectedId === template.id ? 'border-sky-300/35 bg-sky-400/10' : 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]'}`}>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-base font-semibold tracking-[-0.03em] text-white">{template.name}</div>
<div className="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">{template.slug}</div>
</div>
<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-200">{template.supported_formats?.join(', ')}</span>
</div>
{template.description ? <div className="mt-2 text-sm text-slate-400">{template.description}</div> : null}
</button>
))}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Template editor</div>
<div className="grid gap-4 md:grid-cols-2">
<input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} placeholder="Template name" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
<input value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} placeholder="Slug" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
<textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} placeholder="Description" rows={3} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white md:col-span-2" />
<label className="text-sm text-slate-300">
<span className="mb-2 block">Font preset</span>
<select value={form.config_json?.font_preset || 'modern-sans'} onChange={(event) => setForm((current) => ({ ...current, config_json: { ...current.config_json, font_preset: event.target.value } }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
{fonts.map((font) => <option key={font.key} value={font.key}>{font.label}</option>)}
</select>
</label>
<label className="text-sm text-slate-300">
<span className="mb-2 block">Gradient preset</span>
<select value={form.config_json?.gradient_preset || 'midnight-nova'} onChange={(event) => setForm((current) => ({ ...current, config_json: { ...current.config_json, gradient_preset: event.target.value } }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
{gradients.map((gradient) => <option key={gradient.key} value={gradient.key}>{gradient.label}</option>)}
</select>
</label>
<label className="text-sm text-slate-300">
<span className="mb-2 block">Layout preset</span>
<select value={form.config_json?.layout || 'quote_heavy'} onChange={(event) => setForm((current) => ({ ...current, config_json: { ...current.config_json, layout: event.target.value } }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
{['quote_heavy', 'author_emphasis', 'centered', 'minimal'].map((value) => <option key={value} value={value}>{value}</option>)}
</select>
</label>
<label className="text-sm text-slate-300">
<span className="mb-2 block">Text alignment</span>
<select value={form.config_json?.text_align || 'center'} onChange={(event) => setForm((current) => ({ ...current, config_json: { ...current.config_json, text_align: event.target.value } }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
{['left', 'center', 'right'].map((value) => <option key={value} value={value}>{value}</option>)}
</select>
</label>
<label className="text-sm text-slate-300">
<span className="mb-2 block">Overlay style</span>
<select value={form.config_json?.overlay_style || 'dark-soft'} onChange={(event) => setForm((current) => ({ ...current, config_json: { ...current.config_json, overlay_style: event.target.value } }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
{['none', 'dark-soft', 'dark-strong', 'light-soft'].map((value) => <option key={value} value={value}>{value}</option>)}
</select>
</label>
<label className="text-sm text-slate-300">
<span className="mb-2 block">Text color</span>
<input type="color" value={form.config_json?.text_color || '#ffffff'} onChange={(event) => setForm((current) => ({ ...current, config_json: { ...current.config_json, text_color: event.target.value } }))} className="h-12 w-full rounded-2xl border border-white/10 bg-[#0d1726] p-2" />
</label>
</div>
<div className="mt-5">
<div className="mb-3 text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">Supported formats</div>
<div className="flex flex-wrap gap-3">
{formats.map((format) => (
<label key={format.key} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-2 text-sm text-slate-200">
<input type="checkbox" checked={form.supported_formats.includes(format.key)} onChange={() => toggleFormat(format.key)} className="h-4 w-4" />
{format.label}
</label>
))}
</div>
</div>
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(form.active)} onChange={(event) => setForm((current) => ({ ...current, active: event.target.checked }))} className="h-4 w-4" /> Active</label>
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(form.official)} onChange={(event) => setForm((current) => ({ ...current, official: event.target.checked }))} className="h-4 w-4" /> Official</label>
</div>
<button type="button" onClick={saveTemplate} className="mt-5 w-full rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">{selectedId ? 'Update template' : 'Create template'}</button>
</section>
</div>
</div>
)
}

View File

@@ -0,0 +1,542 @@
import React from 'react'
import { Head, usePage } from '@inertiajs/react'
import CollectionCard from '../../components/profile/collections/CollectionCard'
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 buildFilterUrl(next, baseUrl) {
if (typeof window === 'undefined' && !baseUrl) return '#'
const url = new URL(baseUrl || window.location.href, window.location.origin)
Object.entries(next).forEach(([key, value]) => {
if (value === null || value === undefined || value === '' || value === 'all') {
url.searchParams.delete(key)
} else {
url.searchParams.set(key, String(value))
}
})
return `${url.pathname}?${url.searchParams.toString()}`.replace(/\?$/, '')
}
function buildSearchActionUrl(baseUrl) {
if (typeof window === 'undefined' && !baseUrl) return '#'
const url = new URL(baseUrl || window.location.href, window.location.origin)
url.searchParams.delete('q')
return `${url.pathname}?${url.searchParams.toString()}`.replace(/\?$/, '')
}
function reorderCollectionIds(collections, collectionId, direction) {
const currentIndex = collections.findIndex((item) => Number(item.id) === Number(collectionId))
if (currentIndex === -1) return null
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1
if (targetIndex < 0 || targetIndex >= collections.length) return null
const nextCollections = [...collections]
const [movedCollection] = nextCollections.splice(currentIndex, 1)
nextCollections.splice(targetIndex, 0, movedCollection)
return nextCollections.map((item) => Number(item.id))
}
function EmptyState({ browseUrl }) {
return (
<div className="rounded-[32px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-16 text-center">
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-[24px] border border-white/12 bg-white/[0.05] text-slate-400">
<i className="fa-solid fa-bookmark text-3xl" />
</div>
<h2 className="mt-5 text-2xl font-semibold text-white">No saved collections yet</h2>
<p className="mx-auto mt-3 max-w-xl text-sm leading-relaxed text-slate-300">
Save collections to build a personal reference library for inspiration, campaigns, and creators you want to revisit.
</p>
<div className="mt-6 flex justify-center">
<a href={browseUrl} className="inline-flex items-center gap-2 rounded-2xl border border-white/12 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
<i className="fa-solid fa-compass fa-fw" />
Browse collections
</a>
</div>
</div>
)
}
function FilterLink({ href, active, children, count }) {
return (
<a href={href} className={`flex items-center justify-between rounded-2xl border px-4 py-3 text-sm transition ${active ? 'border-sky-300/20 bg-sky-400/10 text-sky-100' : 'border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.07]'}`}>
<span>{children}</span>
{typeof count === 'number' ? <span className={`text-xs ${active ? 'text-sky-100/80' : 'text-slate-400'}`}>{count}</span> : null}
</a>
)
}
function formatDateTime(value) {
if (!value) return null
const date = new Date(value)
if (Number.isNaN(date.getTime())) return null
return date.toLocaleString()
}
export default function SavedCollections() {
const { props } = usePage()
const seo = props.seo || {}
const initialCollections = Array.isArray(props.collections) ? props.collections : []
const recentlyRevisited = Array.isArray(props.recentlyRevisited) ? props.recentlyRevisited : []
const recommendedCollections = Array.isArray(props.recommendedCollections) ? props.recommendedCollections : []
const browseUrl = props.browseUrl || '/collections/featured'
const libraryUrl = props.libraryUrl || '/me/saved/collections'
const activeFilters = props.activeFilters || { q: '', filter: 'all', sort: 'saved_desc', list: null }
const activeList = props.activeList || null
const filterOptions = Array.isArray(props.filterOptions) ? props.filterOptions : []
const sortOptions = Array.isArray(props.sortOptions) ? props.sortOptions : []
const searchBaseUrl = activeList?.url || libraryUrl
const [collections, setCollections] = React.useState(initialCollections)
const [savedLists, setSavedLists] = React.useState(Array.isArray(props.savedLists) ? props.savedLists : [])
const [newListTitle, setNewListTitle] = React.useState('')
const [selectedLists, setSelectedLists] = React.useState({})
const [notes, setNotes] = React.useState(() => Object.fromEntries(initialCollections.map((collection) => [collection.id, collection.saved_note || ''])))
const [search, setSearch] = React.useState(activeFilters.q || '')
const [notice, setNotice] = React.useState('')
const [busy, setBusy] = React.useState('')
React.useEffect(() => {
setCollections(initialCollections)
setNotes(Object.fromEntries(initialCollections.map((collection) => [collection.id, collection.saved_note || ''])))
}, [initialCollections])
React.useEffect(() => {
setSearch(activeFilters.q || '')
}, [activeFilters.q])
React.useEffect(() => {
setSavedLists(Array.isArray(props.savedLists) ? props.savedLists : [])
}, [props.savedLists])
const listSchema = seo?.canonical ? {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: 'Saved collections',
description: seo?.description || 'Your saved collections on Skinbase Nova.',
url: seo.canonical,
mainEntity: {
'@type': 'ItemList',
numberOfItems: collections.length,
itemListElement: collections.slice(0, 18).map((collection, index) => ({
'@type': 'ListItem',
position: index + 1,
url: collection.url,
name: collection.title,
})),
},
} : null
async function handleCreateList(event) {
event.preventDefault()
if (!newListTitle.trim() || !props.endpoints?.createList) return
setBusy('create-list')
setNotice('')
try {
const payload = await requestJson(props.endpoints.createList, {
method: 'POST',
body: { title: newListTitle.trim() },
})
setSavedLists((current) => [...current, payload.list].sort((left, right) => String(left.title).localeCompare(String(right.title))))
setNewListTitle('')
setNotice('Saved list created.')
} catch (error) {
setNotice(error.message || 'Failed to create list.')
} finally {
setBusy('')
}
}
async function handleAddToList(collectionId) {
const listId = selectedLists[collectionId] || savedLists[0]?.id
if (!listId || !props.endpoints?.addToListPattern) return
setBusy(`list-${collectionId}`)
setNotice('')
try {
const payload = await requestJson(props.endpoints.addToListPattern.replace('__COLLECTION__', String(collectionId)), {
method: 'POST',
body: { saved_list_id: Number(listId) },
})
setSavedLists((current) => current.map((list) => (
Number(list.id) === Number(listId)
? { ...list, items_count: Number(payload?.list?.items_count || list.items_count || 0) }
: list
)))
setNotice(payload?.added ? 'Collection added to saved list.' : 'Collection already exists in that saved list.')
} catch (error) {
setNotice(error.message || 'Failed to add collection to list.')
} finally {
setBusy('')
}
}
async function handleUnsave(collection) {
if (!collection?.id || !props.endpoints?.unsavePattern) return
setBusy(`unsave-${collection.id}`)
setNotice('')
try {
await requestJson(props.endpoints.unsavePattern.replace('__COLLECTION__', String(collection.id)), {
method: 'DELETE',
})
setCollections((current) => current.filter((item) => Number(item.id) !== Number(collection.id)))
setSavedLists((current) => current.map((list) => {
const isMember = Array.isArray(collection.saved_list_ids) && collection.saved_list_ids.includes(Number(list.id))
return isMember
? { ...list, items_count: Math.max(0, Number(list.items_count || 0) - 1) }
: list
}))
setNotice('Collection removed from your saved library.')
} catch (error) {
setNotice(error.message || 'Failed to remove collection from your saved library.')
} finally {
setBusy('')
}
}
async function handleRemoveFromList(collection) {
if (!activeList?.id || !collection?.id || !props.endpoints?.removeFromListPattern) return
setBusy(`remove-${collection.id}`)
setNotice('')
try {
const payload = await requestJson(
props.endpoints.removeFromListPattern
.replace('__LIST__', String(activeList.id))
.replace('__COLLECTION__', String(collection.id)),
{ method: 'DELETE' },
)
setCollections((current) => current.filter((item) => Number(item.id) !== Number(collection.id)))
setSavedLists((current) => current.map((list) => (
Number(list.id) === Number(payload?.list?.id)
? { ...list, items_count: Number(payload?.list?.items_count || 0) }
: list
)))
setNotice('Collection removed from this saved list.')
} catch (error) {
setNotice(error.message || 'Failed to remove collection from this saved list.')
} finally {
setBusy('')
}
}
async function handleReorderCollection(collectionId, direction) {
if (!activeList?.id || !props.endpoints?.reorderItemsPattern) return
const reorderedCollectionIds = reorderCollectionIds(collections, collectionId, direction)
if (!reorderedCollectionIds) return
setBusy(`reorder-${collectionId}`)
setNotice('')
try {
await requestJson(
props.endpoints.reorderItemsPattern.replace('__LIST__', String(activeList.id)),
{
method: 'POST',
body: { collection_ids: reorderedCollectionIds },
},
)
setCollections((current) => {
const order = new Map(reorderedCollectionIds.map((id, index) => [Number(id), index]))
return [...current].sort((left, right) => {
const leftOrder = order.get(Number(left.id)) ?? Number.MAX_SAFE_INTEGER
const rightOrder = order.get(Number(right.id)) ?? Number.MAX_SAFE_INTEGER
return leftOrder - rightOrder
})
})
setNotice('Saved list order updated.')
} catch (error) {
setNotice(error.message || 'Failed to update saved list order.')
} finally {
setBusy('')
}
}
async function handleSaveNote(collectionId) {
if (!props.endpoints?.updateNotePattern) return
setBusy(`note-${collectionId}`)
setNotice('')
try {
const payload = await requestJson(
props.endpoints.updateNotePattern.replace('__COLLECTION__', String(collectionId)),
{
method: 'PATCH',
body: { note: notes[collectionId] || '' },
},
)
setCollections((current) => current.map((collection) => (
Number(collection.id) === Number(collectionId)
? { ...collection, saved_note: payload?.note?.note || null }
: collection
)))
setNotice(payload?.note ? 'Saved note updated.' : 'Saved note removed.')
} catch (error) {
setNotice(error.message || 'Failed to update saved note.')
} finally {
setBusy('')
}
}
return (
<>
<Head>
<title>{seo?.title || 'Saved Collections — Skinbase Nova'}</title>
<meta name="description" content={seo?.description || 'Your saved collections on Skinbase Nova.'} />
{seo?.canonical ? <link rel="canonical" href={seo.canonical} /> : null}
<meta name="robots" content={seo?.robots || 'noindex,follow'} />
<meta property="og:title" content={seo?.title || 'Saved Collections — Skinbase Nova'} />
<meta property="og:description" content={seo?.description || 'Your saved collections on Skinbase Nova.'} />
{seo?.canonical ? <meta property="og:url" content={seo.canonical} /> : null}
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content={seo?.title || 'Saved Collections — Skinbase Nova'} />
<meta name="twitter:description" content={seo?.description || 'Your saved collections on Skinbase Nova.'} />
{listSchema ? <script type="application/ld+json">{JSON.stringify(listSchema)}</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-[34rem] opacity-95" style={{ background: 'radial-gradient(circle at 15% 14%, rgba(245,158,11,0.16), transparent 26%), radial-gradient(circle at 82% 18%, rgba(56,189,248,0.16), transparent 24%), 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={browseUrl} 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]" />
Browse collections
</a>
</div>
<section className="mt-6 overflow-hidden rounded-[34px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_30px_90px_rgba(2,6,23,0.28)] backdrop-blur-sm md:p-8">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80">Library</p>
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">Saved collections</h1>
<p className="mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 md:text-[15px]">
A personal shortlist of collections worth revisiting. Organize them into saved lists, pivot by editorial or campaign relevance, and keep a working shelf of what should influence your next publish.
</p>
{activeList ? <p className="mt-4 inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100"><i className="fa-solid fa-folder-open fa-fw" />Viewing list: {activeList.title}</p> : null}
{activeFilters.q ? <p className="mt-4 inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-400/10 px-4 py-2 text-sm font-semibold text-amber-100"><i className="fa-solid fa-magnifying-glass fa-fw" />Search: {activeFilters.q}</p> : null}
{notice ? <p className="mt-4 text-sm text-sky-100">{notice}</p> : null}
</section>
<section className="mt-8 grid gap-6 xl:grid-cols-[320px_minmax(0,1fr)]">
<aside className="space-y-5">
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-5 backdrop-blur-sm">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Search</p>
<form method="GET" action={buildSearchActionUrl(searchBaseUrl)} className="mt-4 space-y-3">
<input name="q" value={search} onChange={(event) => setSearch(event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35" placeholder="Search titles, notes context, or curator" maxLength={120} />
{activeFilters.filter && activeFilters.filter !== 'all' ? <input type="hidden" name="filter" value={activeFilters.filter} /> : null}
{activeFilters.sort && activeFilters.sort !== 'saved_desc' ? <input type="hidden" name="sort" value={activeFilters.sort} /> : null}
<div className="flex gap-3">
<button type="submit" className="inline-flex flex-1 items-center justify-center gap-2 rounded-2xl border border-sky-300/20 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-magnifying-glass fa-fw" />Apply</button>
{(activeFilters.q || search) ? <a href={buildFilterUrl({ q: null }, searchBaseUrl)} className="inline-flex items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.07]">Clear</a> : null}
</div>
</form>
</section>
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-5 backdrop-blur-sm">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Filters</p>
<div className="mt-4 space-y-3">
{filterOptions.map((option) => (
<FilterLink
key={option.key}
href={buildFilterUrl({ q: activeFilters.q, filter: option.key, sort: activeFilters.sort }, searchBaseUrl)}
active={activeFilters.filter === option.key}
count={option.count}
>
{option.label}
</FilterLink>
))}
</div>
</section>
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-5 backdrop-blur-sm">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Sort</p>
<div className="mt-4 space-y-3">
{sortOptions.map((option) => (
<FilterLink
key={option.key}
href={buildFilterUrl({ q: activeFilters.q, filter: activeFilters.filter, sort: option.key }, searchBaseUrl)}
active={activeFilters.sort === option.key}
>
{option.label}
</FilterLink>
))}
</div>
</section>
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-5 backdrop-blur-sm">
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Saved Lists</p>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{savedLists.length}</span>
</div>
<div className="mt-4 space-y-3">
<FilterLink href={buildFilterUrl({ q: activeFilters.q, filter: activeFilters.filter, sort: activeFilters.sort }, libraryUrl)} active={!activeFilters.list}>All saved collections</FilterLink>
{savedLists.map((list) => (
<FilterLink key={list.id} href={buildFilterUrl({ q: activeFilters.q, filter: activeFilters.filter, sort: activeFilters.sort }, list.url || libraryUrl)} active={Number(activeFilters.list) === Number(list.id)} count={list.items_count}>{list.title}</FilterLink>
))}
</div>
<form onSubmit={handleCreateList} className="mt-5 space-y-3">
<input value={newListTitle} onChange={(event) => setNewListTitle(event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35" placeholder="Create a saved list" maxLength={120} />
<button type="submit" disabled={busy === 'create-list'} className="inline-flex w-full items-center justify-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'create-list' ? 'fa-circle-notch fa-spin' : 'fa-folder-plus'} fa-fw`} />Create list</button>
</form>
</section>
</aside>
<div className="space-y-8">
{recentlyRevisited.length ? (
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
<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">Recently Revisited</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Jump back into active references</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">{recentlyRevisited.length}</span>
</div>
<div className="mt-6 grid grid-cols-1 gap-5 xl:grid-cols-3">
{recentlyRevisited.map((collection) => (
<CollectionCard key={`revisited-${collection.id}`} collection={collection} isOwner={false} />
))}
</div>
</section>
) : null}
<section>
{collections.length ? (
<div className="grid grid-cols-1 gap-5 xl:grid-cols-2">
{collections.map((collection, index) => (
<div key={collection.id} className="space-y-3">
<CollectionCard collection={collection} isOwner={false} />
{(collection.saved_because || collection.last_viewed_at) ? (
<div className="flex flex-wrap items-center gap-3 rounded-[24px] border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-slate-300">
{collection.saved_because ? <span className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-2 text-xs font-semibold text-amber-100"><i className="fa-solid fa-lightbulb fa-fw" />{collection.saved_because}</span> : null}
{collection.last_viewed_at ? <span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-[#0d1726] px-3 py-2 text-xs font-semibold text-slate-200"><i className="fa-solid fa-clock-rotate-left fa-fw" />Last revisited {formatDateTime(collection.last_viewed_at)}</span> : null}
</div>
) : null}
<div className="flex flex-wrap items-center gap-3 rounded-[24px] border border-white/10 bg-white/[0.04] px-4 py-3">
<button type="button" onClick={() => handleUnsave(collection)} disabled={busy === `unsave-${collection.id}`} className="inline-flex items-center gap-2 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-2.5 text-sm font-semibold text-rose-100 transition hover:bg-rose-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === `unsave-${collection.id}` ? 'fa-circle-notch fa-spin' : 'fa-bookmark-slash'} fa-fw`} />Remove from saved</button>
{activeList ? <button type="button" onClick={() => handleRemoveFromList(collection)} disabled={busy === `remove-${collection.id}`} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.07] disabled:opacity-60"><i className={`fa-solid ${busy === `remove-${collection.id}` ? 'fa-circle-notch fa-spin' : 'fa-folder-minus'} fa-fw`} />Remove from list</button> : null}
{activeList && collections.length > 1 ? (
<div className="ml-auto inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-[#0d1726] p-1">
<button
type="button"
onClick={() => handleReorderCollection(collection.id, 'up')}
disabled={index === 0 || busy === `reorder-${collection.id}`}
className="inline-flex h-10 w-10 items-center justify-center rounded-xl text-sm text-white transition hover:bg-white/[0.08] disabled:cursor-not-allowed disabled:opacity-40"
aria-label={`Move ${collection.title} up`}
>
<i className={`fa-solid ${busy === `reorder-${collection.id}` ? 'fa-circle-notch fa-spin' : 'fa-arrow-up'} fa-fw`} />
</button>
<button
type="button"
onClick={() => handleReorderCollection(collection.id, 'down')}
disabled={index === collections.length - 1 || busy === `reorder-${collection.id}`}
className="inline-flex h-10 w-10 items-center justify-center rounded-xl text-sm text-white transition hover:bg-white/[0.08] disabled:cursor-not-allowed disabled:opacity-40"
aria-label={`Move ${collection.title} down`}
>
<i className={`fa-solid ${busy === `reorder-${collection.id}` ? 'fa-circle-notch fa-spin' : 'fa-arrow-down'} fa-fw`} />
</button>
</div>
) : null}
</div>
{savedLists.length ? (
<div className="flex flex-wrap items-center gap-3 rounded-[24px] border border-white/10 bg-white/[0.04] px-4 py-3">
<select value={selectedLists[collection.id] || savedLists[0]?.id || ''} onChange={(event) => setSelectedLists((current) => ({ ...current, [collection.id]: event.target.value }))} className="min-w-[180px] rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-2.5 text-sm text-white outline-none">
{savedLists.map((list) => <option key={list.id} value={list.id}>{list.title}</option>)}
</select>
<button type="button" onClick={() => handleAddToList(collection.id)} disabled={busy === `list-${collection.id}`} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.07] disabled:opacity-60"><i className={`fa-solid ${busy === `list-${collection.id}` ? 'fa-circle-notch fa-spin' : 'fa-folder-plus'} fa-fw`} />Add to list</button>
</div>
) : null}
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] px-4 py-4">
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Private Note</p>
<button type="button" onClick={() => handleSaveNote(collection.id)} disabled={busy === `note-${collection.id}`} className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-xs font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === `note-${collection.id}` ? 'fa-circle-notch fa-spin' : 'fa-note-sticky'} fa-fw`} />Save note</button>
</div>
<textarea
value={notes[collection.id] || ''}
onChange={(event) => setNotes((current) => ({ ...current, [collection.id]: event.target.value }))}
rows={3}
maxLength={1000}
placeholder="Why did you save this collection? Add campaign context, inspiration notes, or follow-up ideas."
className="mt-3 w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35"
/>
</div>
</div>
))}
</div>
) : (
activeFilters.q || activeFilters.filter !== 'all' || activeFilters.sort !== 'saved_desc' || activeFilters.list
? <div className="rounded-[32px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-16 text-center text-sm text-slate-300">No saved collections match the current search or filters.</div>
: <EmptyState browseUrl={browseUrl} />
)}
</section>
{recommendedCollections.length ? (
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
<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">Recommended Next</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Because of what you save</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">{recommendedCollections.length}</span>
</div>
<div className="mt-6 grid grid-cols-1 gap-5 xl:grid-cols-3">
{recommendedCollections.map((collection) => (
<CollectionCard key={collection.id} collection={collection} isOwner={false} />
))}
</div>
</section>
) : null}
</div>
</section>
</div>
</div>
</>
)
}

View File

@@ -56,6 +56,11 @@ export default function ForumThread({
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h1 className="text-2xl font-bold text-white leading-snug">{thread?.title}</h1>
{thread?.description ? (
<p className="mt-3 max-w-3xl text-sm leading-6 text-zinc-300 sm:text-[15px]">
{thread.description}
</p>
) : null}
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-zinc-500">
<span>By {author?.name ?? 'Unknown'}</span>
<span className="text-zinc-700"></span>

View File

@@ -0,0 +1,127 @@
import React from 'react'
import CollectionCard from '../../components/profile/collections/CollectionCard'
function normalizeItems(items) {
if (!Array.isArray(items)) return []
return items.filter((item) => item && typeof item === 'object')
}
function SectionHeader({ title, subtitle, href, ctaLabel = 'See all' }) {
return (
<div className="mb-5 flex items-end justify-between gap-4">
<div>
<h3 className="text-lg font-bold text-white">{title}</h3>
{subtitle ? <p className="mt-1 text-xs text-nova-400">{subtitle}</p> : null}
</div>
{href ? (
<a href={href} className="shrink-0 text-sm text-nova-300 transition hover:text-white">
{ctaLabel}
</a>
) : null}
</div>
)
}
function CollectionStrip({ items }) {
if (!items.length) return null
return (
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
{items.map((collection) => (
<CollectionCard key={collection.id} collection={collection} isOwner={false} />
))}
</div>
)
}
function CollectionSection({ title, subtitle, href, items, limit = 3, ctaLabel }) {
const normalized = normalizeItems(items).slice(0, limit)
if (!normalized.length) return null
return (
<section className="mt-10">
<SectionHeader title={title} subtitle={subtitle} href={href} ctaLabel={ctaLabel} />
<CollectionStrip items={normalized} />
</section>
)
}
export default function HomeCollections({
featured,
recent,
trending,
editorial,
community,
isLoggedIn = false,
}) {
const featuredItems = normalizeItems(featured)
const recentItems = normalizeItems(recent)
const trendingItems = normalizeItems(trending)
const editorialItems = normalizeItems(editorial)
const communityItems = normalizeItems(community)
if (!featuredItems.length && !recentItems.length && !trendingItems.length && !editorialItems.length && !communityItems.length) {
return null
}
return (
<section className="mt-14 px-4 sm:px-6 lg:px-8">
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
<div>
<h2 className="text-xl font-bold text-white">Curated Collections</h2>
<p className="mt-1 max-w-2xl text-sm text-nova-300">
Hand-built galleries, smart collections, and community showcases worth opening next.
</p>
</div>
<div className="flex flex-wrap gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-nova-400">
{isLoggedIn && recentItems.length ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">Recent</span> : null}
{featuredItems.length ? <span className="rounded-full border border-amber-300/20 bg-amber-300/10 px-3 py-1 text-amber-100">Featured</span> : null}
{communityItems.length ? <span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-sky-100">Community</span> : null}
</div>
</div>
<CollectionSection
title="Featured Collections"
subtitle="Standout galleries with strong sequencing, presentation, or curator voice."
href="/collections/featured"
items={featuredItems}
limit={3}
/>
{isLoggedIn ? (
<CollectionSection
title="Recently Active"
subtitle="Fresh collection activity from around the site, including new updates and resurfacing galleries."
href="/collections/trending"
items={recentItems}
limit={3}
/>
) : null}
<CollectionSection
title="Trending Collections"
subtitle="Collections getting the strongest mix of follows, saves, and engagement right now."
href="/collections/trending"
items={trendingItems}
limit={3}
/>
<CollectionSection
title="Editorial Picks"
subtitle="Staff and premium editorial showcases with stronger themes and presentation rules."
href="/collections/editorial"
items={editorialItems}
limit={3}
/>
<CollectionSection
title="Community Highlights"
subtitle="Collaborative and submission-friendly collections that spotlight multiple creators together."
href="/collections/community"
items={communityItems}
limit={3}
/>
</section>
)
}

View File

@@ -1,9 +1,6 @@
import React, { lazy, Suspense } from 'react'
import { createRoot } from 'react-dom/client'
// Above-fold — eager
import HomeHero from './HomeHero'
// Below-fold — lazy-loaded to keep initial bundle small
const HomeWelcomeRow = lazy(() => import('./HomeWelcomeRow'))
const HomeFromFollowing = lazy(() => import('./HomeFromFollowing'))
@@ -13,6 +10,7 @@ const HomeSuggestedCreators = lazy(() => import('./HomeSuggestedCreators'))
const HomeTrending = lazy(() => import('./HomeTrending'))
const HomeRising = lazy(() => import('./HomeRising'))
const HomeFresh = lazy(() => import('./HomeFresh'))
const HomeCollections = lazy(() => import('./HomeCollections'))
const HomeCategories = lazy(() => import('./HomeCategories'))
const HomeTags = lazy(() => import('./HomeTags'))
const HomeCreators = lazy(() => import('./HomeCreators'))
@@ -26,12 +24,10 @@ function SectionFallback() {
}
function GuestHomePage(props) {
const { hero, rising, trending, fresh, tags, creators, news } = props
const { rising, trending, fresh, tags, creators, news, collections_featured, collections_trending, collections_editorial, collections_community } = props
return (
<>
{/* 1. Hero */}
<HomeHero artwork={hero} isLoggedIn={false} />
<Suspense fallback={<SectionFallback />}>
<HomeRising items={rising} />
</Suspense>
@@ -44,6 +40,15 @@ function GuestHomePage(props) {
<HomeFresh items={fresh} />
</Suspense>
<Suspense fallback={<SectionFallback />}>
<HomeCollections
featured={collections_featured}
trending={collections_trending}
editorial={collections_editorial}
community={collections_community}
/>
</Suspense>
{/* 4. Explore Categories */}
<Suspense fallback={<SectionFallback />}>
<HomeCategories />
@@ -75,12 +80,16 @@ function GuestHomePage(props) {
function AuthHomePage(props) {
const {
user_data,
hero,
for_you,
from_following,
rising,
trending,
fresh,
by_tags,
collections_featured,
collections_recent,
collections_trending,
collections_editorial,
collections_community,
by_categories,
suggested_creators,
tags,
@@ -91,9 +100,6 @@ function AuthHomePage(props) {
return (
<>
{/* 1. Hero — flush to top */}
<HomeHero artwork={hero} isLoggedIn />
{/* P0. Welcome/status row — below hero so featured image sits at 0px */}
<Suspense fallback={null}>
<HomeWelcomeRow user_data={user_data} />
@@ -104,9 +110,9 @@ function AuthHomePage(props) {
<HomeFromFollowing items={from_following} />
</Suspense>
{/* P3. Trending For You (by_tags = Meilisearch tag overlap sorted by trending) */}
{/* P3. Personalized For You preview */}
<Suspense fallback={<SectionFallback />}>
<HomeTrendingForYou items={by_tags} preferences={preferences} />
<HomeTrendingForYou items={for_you} preferences={preferences} />
</Suspense>
{/* Rising Now */}
@@ -129,6 +135,17 @@ function AuthHomePage(props) {
<HomeFresh items={fresh} />
</Suspense>
<Suspense fallback={<SectionFallback />}>
<HomeCollections
featured={collections_featured}
recent={collections_recent}
trending={collections_trending}
editorial={collections_editorial}
community={collections_community}
isLoggedIn
/>
</Suspense>
{/* 4. Explore Categories */}
<Suspense fallback={<SectionFallback />}>
<HomeCategories />

View File

@@ -1,23 +1,26 @@
import React from 'react'
import ArtworkGalleryGrid from '../../components/artwork/ArtworkGalleryGrid'
/**
* Personalized trending: artworks matching user's top tags, sorted by trending score.
* Label and browse link adapt to the user's first top tag.
*/
export default function HomeTrendingForYou({ items, preferences }) {
if (!Array.isArray(items) || items.length === 0) return null
const topTag = preferences?.top_tags?.[0]
const heading = topTag ? `🎯 Trending in #${topTag}` : '🎯 Trending For You'
const link = topTag ? `/browse?tags=${encodeURIComponent(topTag)}&sort=trending` : '/discover/trending'
const heading = 'Picked For You'
const subheading = topTag
? `Fresh recommendations informed by your recent interest in #${topTag}.`
: 'A live preview of your personalized discovery feed.'
const link = '/discover/for-you'
return (
<section className="mt-14 px-4 sm:px-6 lg:px-8">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-xl font-bold text-white">{heading}</h2>
<a href={link} className="text-sm text-nova-300 hover:text-white transition">
See all
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-[0.7rem] font-semibold uppercase tracking-[0.28em] text-sky-200/70">Personalized feed</p>
<h2 className="mt-2 text-xl font-bold text-white">{heading}</h2>
<p className="mt-1 max-w-2xl text-sm text-slate-300">{subheading}</p>
</div>
<a href={link} className="text-sm text-nova-300 transition hover:text-white">
Open full feed
</a>
</div>
<ArtworkGalleryGrid items={items.slice(0, 8)} compact />

View File

@@ -411,6 +411,14 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
setConversations((prev) => mergeConversationSummary(prev, patch))
}, [])
const handleUnreadTotalPatched = useCallback((nextUnreadTotal) => {
if (!Number.isFinite(Number(nextUnreadTotal))) {
return
}
setUnreadTotal(Math.max(0, Number(nextUnreadTotal)))
}, [])
useEffect(() => {
let cancelled = false
@@ -586,6 +594,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
}}
onMarkRead={handleMarkRead}
onConversationPatched={handleConversationPatched}
onUnreadTotalPatched={handleUnreadTotalPatched}
/>
) : (
<div className="flex flex-1 items-center justify-center p-8">
@@ -632,7 +641,7 @@ function StatChip({ label, value, tone = 'sky' }) {
}
return (
<div className={`rounded-2xl border px-3 py-3 ${tones[tone] || tones.sky}`}>
<div data-testid={`messages-stat-${String(label).toLowerCase()}`} className={`rounded-2xl border px-3 py-3 ${tones[tone] || tones.sky}`}>
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] opacity-70">{label}</p>
<p className="mt-2 text-lg font-semibold">{Number(value || 0).toLocaleString()}</p>
</div>

View File

@@ -52,10 +52,14 @@ export default function ProfileShow() {
socialLinks,
followerCount,
recentFollowers,
followContext,
followAnalytics,
suggestedUsers,
viewerIsFollowing,
heroBgUrl,
profileComments,
creatorStories,
collections,
achievements,
leaderboardRank,
countryName,
@@ -64,6 +68,10 @@ export default function ProfileShow() {
initialTab,
profileUrl,
galleryUrl,
collectionCreateUrl,
collectionReorderUrl,
collectionsFeaturedUrl,
collectionFeatureLimit,
profileTabUrls,
} = props
@@ -137,13 +145,15 @@ export default function ProfileShow() {
isOwner={isOwner}
viewerIsFollowing={viewerIsFollowing}
followerCount={followerCount}
recentFollowers={recentFollowers}
followContext={followContext}
heroBgUrl={heroBgUrl}
countryName={countryName}
leaderboardRank={leaderboardRank}
extraActions={galleryUrl ? (
<a
href={galleryUrl}
className="inline-flex items-center gap-2 rounded-xl border border-white/15 px-4 py-2.5 text-sm font-medium text-slate-300 transition-all hover:bg-white/5 hover:text-white"
className="inline-flex shrink-0 items-center gap-2 whitespace-nowrap rounded-xl border border-white/15 px-3.5 py-2 text-sm font-medium text-slate-300 transition-all hover:bg-white/5 hover:text-white"
>
<i className="fa-solid fa-images fa-fw" />
View Gallery
@@ -178,6 +188,7 @@ export default function ProfileShow() {
stats={stats}
followerCount={followerCount}
recentFollowers={recentFollowers}
suggestedUsers={suggestedUsers}
socialLinks={socialLinksObj}
countryName={countryName}
profileUrl={profileUrl}
@@ -194,7 +205,14 @@ export default function ProfileShow() {
<TabAchievements achievements={achievements} />
)}
{activeTab === 'collections' && (
<TabCollections collections={[]} />
<TabCollections
collections={collections}
isOwner={isOwner}
createUrl={collectionCreateUrl}
reorderUrl={collectionReorderUrl}
featuredUrl={collectionsFeaturedUrl}
featureLimit={collectionFeatureLimit}
/>
)}
{activeTab === 'about' && (
<TabAbout
@@ -216,6 +234,7 @@ export default function ProfileShow() {
<TabStats
stats={stats}
followerCount={followerCount}
followAnalytics={followAnalytics}
/>
)}
{activeTab === 'favourites' && (
@@ -231,6 +250,9 @@ export default function ProfileShow() {
user={user}
isOwner={isOwner}
isLoggedIn={isLoggedIn}
stats={stats}
followerCount={followerCount}
creatorStories={creatorStories}
/>
)}
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,84 @@
import React from 'react'
import { Link, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
const kpiItems = [
{ key: 'views', label: 'Views', icon: 'fa-eye', color: 'text-emerald-400' },
{ key: 'likes', label: 'Likes', icon: 'fa-heart', color: 'text-pink-400' },
{ key: 'saves', label: 'Saves', icon: 'fa-bookmark', color: 'text-amber-400' },
{ key: 'remixes', label: 'Remixes', icon: 'fa-code-branch', color: 'text-cyan-400' },
{ key: 'comments', label: 'Comments', icon: 'fa-comment', color: 'text-blue-400' },
{ key: 'challenge_entries', label: 'Challenges', icon: 'fa-trophy', color: 'text-violet-400' },
]
const secondaryItems = [
{ key: 'favorites', label: 'Favorites', icon: 'fa-star' },
{ key: 'shares', label: 'Shares', icon: 'fa-share-nodes' },
{ key: 'downloads', label: 'Downloads', icon: 'fa-download' },
]
export default function StudioCardAnalytics() {
const { props } = usePage()
const { card, analytics } = props
return (
<StudioLayout title={`Analytics: ${card?.title || 'Nova Card'}`}>
<Link href="/studio/cards" className="mb-6 inline-flex items-center gap-2 text-sm text-slate-400 transition-colors hover:text-white">
<i className="fa-solid fa-arrow-left" />
Back to Cards
</Link>
<div className="mb-8 flex items-center gap-4 rounded-2xl border border-white/10 bg-nova-900/60 p-4">
{card?.preview_url ? <img src={card.preview_url} alt={card.title} className="h-20 w-20 rounded-xl object-cover bg-nova-800" /> : null}
<div>
<h2 className="text-lg font-bold text-white">{card?.title}</h2>
<p className="mt-1 text-xs text-slate-500">/{card?.slug}</p>
<p className="mt-2 text-xs uppercase tracking-[0.18em] text-slate-400">{card?.status} {card?.visibility}</p>
</div>
</div>
<div className="mb-8 grid grid-cols-2 gap-4 sm:grid-cols-3 xl:grid-cols-6">
{kpiItems.map((item) => (
<div key={item.key} className="rounded-2xl border border-white/10 bg-nova-900/60 p-5">
<div className="mb-2 flex items-center gap-2">
<i className={`fa-solid ${item.icon} ${item.color}`} />
<span className="text-xs font-medium uppercase tracking-wider text-slate-400">{item.label}</span>
</div>
<p className="text-2xl font-bold tabular-nums text-white">{(analytics?.[item.key] ?? 0).toLocaleString()}</p>
</div>
))}
</div>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
<div className="rounded-2xl border border-white/10 bg-nova-900/40 p-6">
<h3 className="mb-4 text-sm font-semibold uppercase tracking-[0.18em] text-slate-300">Ranking signals</h3>
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">Trending score</div>
<div className="mt-2 text-3xl font-bold tabular-nums text-white">{Number(analytics?.trending_score ?? 0).toFixed(2)}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">Last engaged</div>
<div className="mt-2 text-sm text-white">{analytics?.last_engaged_at || 'No activity yet'}</div>
</div>
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-nova-900/40 p-6">
<h3 className="mb-4 text-sm font-semibold uppercase tracking-[0.18em] text-slate-300">Secondary metrics</h3>
<div className="space-y-3">
{secondaryItems.map((item) => (
<div key={item.key} className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
<div className="flex items-center gap-2 text-sm text-slate-300">
<i className={`fa-solid ${item.icon} text-slate-500`} />
{item.label}
</div>
<div className="text-base font-semibold tabular-nums text-white">{(analytics?.[item.key] ?? 0).toLocaleString()}</div>
</div>
))}
</div>
</div>
</div>
</StudioLayout>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,134 @@
import React from 'react'
import { Head, Link, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import NovaCardCanvasPreview from '../../components/nova-cards/NovaCardCanvasPreview'
function requestJson(url, { method = 'POST' } = {}) {
return fetch(url, {
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
'X-Requested-With': 'XMLHttpRequest',
},
}).then(async (response) => {
const payload = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(payload?.message || 'Request failed')
return payload
})
}
function StatCard({ label, value, icon }) {
return (
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">{label}</div>
<div className="mt-3 flex items-center gap-3">
<span className="inline-flex h-12 w-12 items-center justify-center rounded-2xl border border-sky-300/20 bg-sky-400/10 text-sky-100">
<i className={`fa-solid ${icon}`} />
</span>
<span className="text-3xl font-semibold tracking-[-0.04em] text-white">{value}</span>
</div>
</div>
)
}
export default function StudioCardsIndex() {
const { props } = usePage()
const cards = props.cards?.data || []
const stats = props.stats || {}
const endpoints = props.endpoints || {}
async function duplicateCard(cardId) {
const url = (endpoints.duplicatePattern || '').replace('__CARD__', String(cardId))
if (!url) return
const payload = await requestJson(url)
if (payload?.data?.id) {
window.location.assign((endpoints.editPattern || '/studio/cards/__CARD__/edit').replace('__CARD__', String(payload.data.id)))
}
}
return (
<StudioLayout title="Nova Cards">
<Head title="Nova Cards Studio" />
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.15),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl">
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/75">Creation surface</p>
<h2 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Build quote cards, mood cards, and visual text art.</h2>
<p className="mt-3 text-sm leading-7 text-slate-300">Drafts autosave, templates stay structured, and every published card gets a public preview image ready for discovery and sharing.</p>
</div>
<div className="flex flex-wrap gap-3">
<Link href={endpoints.create || '/studio/cards/create'} className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">
<i className="fa-solid fa-plus" />
New card
</Link>
<a href="/cards" className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
<i className="fa-solid fa-compass" />
Browse public cards
</a>
</div>
</div>
</section>
<section className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<StatCard label="All cards" value={stats.all || 0} icon="fa-layer-group" />
<StatCard label="Drafts" value={stats.drafts || 0} icon="fa-file-lines" />
<StatCard label="Processing" value={stats.processing || 0} icon="fa-wand-magic-sparkles" />
<StatCard label="Published" value={stats.published || 0} icon="fa-earth-americas" />
</section>
<section className="mt-8">
<div className="mb-4 flex items-center justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Latest work</p>
<h3 className="mt-1 text-2xl font-semibold text-white">Your card library</h3>
</div>
</div>
{cards.length === 0 ? (
<div className="rounded-[28px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-16 text-center">
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-[24px] border border-white/12 bg-white/[0.05] text-slate-400">
<i className="fa-solid fa-rectangle-history-circle-user text-3xl" />
</div>
<h3 className="mt-5 text-2xl font-semibold text-white">No cards yet</h3>
<p className="mx-auto mt-3 max-w-xl text-sm leading-7 text-slate-300">Start with a square card or jump straight into a story-sized template. Your first draft will be created automatically in the editor.</p>
<Link href={endpoints.create || '/studio/cards/create'} className="mt-6 inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">
<i className="fa-solid fa-plus" />
Create your first card
</Link>
</div>
) : (
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{cards.map((card) => (
<div key={card.id} className="group rounded-[28px] border border-white/10 bg-white/[0.04] p-4 shadow-[0_22px_60px_rgba(2,6,23,0.22)] transition hover:-translate-y-1 hover:border-sky-300/30 hover:bg-white/[0.06]">
<a href={(endpoints.editPattern || '/studio/cards/__CARD__/edit').replace('__CARD__', String(card.id))}>
<NovaCardCanvasPreview card={card} className="w-full" />
<div className="mt-4 flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="truncate text-lg font-semibold tracking-[-0.03em] text-white">{card.title}</div>
<div className="mt-1 line-clamp-2 text-sm leading-6 text-slate-300">{card.quote_text}</div>
</div>
<span className={`rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] ${card.status === 'published' ? 'border-emerald-300/25 bg-emerald-400/10 text-emerald-100' : card.status === 'processing' ? 'border-amber-300/25 bg-amber-400/10 text-amber-100' : 'border-white/10 bg-white/[0.05] text-slate-200'}`}>
{card.status}
</span>
</div>
<div className="mt-4 flex items-center justify-between text-xs text-slate-400">
<span>{card.category?.name || 'Uncategorized'}</span>
<span>{card.format}</span>
</div>
</a>
<div className="mt-4 flex gap-3">
<a href={(endpoints.editPattern || '/studio/cards/__CARD__/edit').replace('__CARD__', String(card.id))} className="flex-1 rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-3 text-center text-sm font-semibold text-white transition hover:bg-white/[0.08]">Edit</a>
<button type="button" onClick={() => duplicateCard(card.id)} className="rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">Duplicate</button>
</div>
</div>
))}
</div>
)}
</section>
</StudioLayout>
)
}

View File

@@ -38,6 +38,7 @@ const initialState = {
category: '',
tags: '',
description: '',
isMature: false,
licenseAccepted: false,
},
}
@@ -300,6 +301,7 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) {
category: state.metadata.category,
tags: state.metadata.tags,
description: state.metadata.description,
is_mature: state.metadata.isMature,
license: state.metadata.licenseAccepted,
})
@@ -610,59 +612,8 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize }) {
<div className="relative isolate overflow-hidden">
<div className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[420px] bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.22),_transparent_32%),radial-gradient(circle_at_top_right,_rgba(251,146,60,0.16),_transparent_30%),linear-gradient(180deg,_rgba(8,17,28,0.98),_rgba(7,17,28,1))]" />
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
{/* ── Wizard ─────────────────────────────────────────────────────── */}
<div className="overflow-hidden rounded-[32px] border border-white/10 bg-[#08111c]/92 shadow-[0_30px_120px_rgba(2,8,23,0.38)]">
<div className="grid gap-8 border-b border-white/8 px-5 py-6 sm:px-8 lg:grid-cols-[1.45fr_0.85fr] lg:items-start lg:gap-10 lg:py-8">
<div>
<p className="text-[11px] uppercase tracking-[0.28em] text-sky-200/80">Skinbase Upload Studio</p>
<h1 className="mt-3 max-w-3xl text-3xl font-semibold tracking-tight text-white sm:text-4xl">
Upload artwork with less friction and better control.
</h1>
<p className="mt-4 max-w-2xl text-sm leading-7 text-slate-300 sm:text-base">
The upload flow now stays focused on three steps: add the file, finish the metadata, then publish with confidence. The interface is simpler, but the secure processing pipeline stays intact.
</p>
<div className="mt-6 grid gap-3 sm:grid-cols-3">
{[
{
title: 'Fast onboarding',
description: 'Clearer file requirements and a friendlier first step.',
},
{
title: 'Safer publishing',
description: 'Processing state, rights, and readiness stay visible the whole time.',
},
{
title: 'Cleaner review',
description: 'Metadata and publish options are easier to scan before going live.',
},
].map((item) => (
<div key={item.title} className="rounded-2xl border border-white/8 bg-white/[0.04] p-4">
<p className="text-sm font-semibold text-white">{item.title}</p>
<p className="mt-2 text-sm leading-6 text-slate-300">{item.description}</p>
</div>
))}
</div>
</div>
<aside className="rounded-[28px] border border-white/8 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-5 shadow-[0_18px_70px_rgba(0,0,0,0.16)]">
<p className="text-[11px] uppercase tracking-[0.24em] text-sky-200/70">Before you start</p>
<div className="mt-4 space-y-4">
{[
'Choose the final file you actually want published. Replacing after upload requires a reset.',
'ZIP, RAR, and 7Z packs still need at least one screenshot for preview generation.',
'You will confirm rights and visibility before the final publish step.',
].map((item, index) => (
<div key={item} className="flex items-start gap-3">
<span className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-sky-300/20 bg-sky-400/10 text-xs font-semibold text-sky-100">
{index + 1}
</span>
<p className="text-sm leading-6 text-slate-300">{item}</p>
</div>
))}
</div>
</aside>
</div>
<div className="px-4 py-5 sm:px-6 lg:px-8 lg:py-8">
<UploadWizard
initialDraftId={draftId ?? null}
@@ -672,6 +623,59 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize }) {
/>
</div>
</div>
{/* ── Help / info section ─────────────────────────────────────────── */}
<div className="mt-8 grid gap-8 lg:grid-cols-[1.45fr_0.85fr] lg:items-start lg:gap-10">
<div>
<p className="text-[11px] uppercase tracking-[0.28em] text-sky-200/50">Skinbase Upload Studio</p>
<h2 className="mt-3 max-w-3xl text-xl font-semibold tracking-tight text-white/70 sm:text-2xl">
Upload artwork with less friction and better control.
</h2>
<p className="mt-3 max-w-2xl text-sm leading-7 text-slate-400">
The upload flow now stays focused on three steps: add the file, finish the metadata, then publish with confidence. The interface is simpler, but the secure processing pipeline stays intact.
</p>
<div className="mt-5 grid gap-3 sm:grid-cols-3">
{[
{
title: 'Fast onboarding',
description: 'Clearer file requirements and a friendlier first step.',
},
{
title: 'Safer publishing',
description: 'Processing state, rights, and readiness stay visible the whole time.',
},
{
title: 'Cleaner review',
description: 'Metadata and publish options are easier to scan before going live.',
},
].map((item) => (
<div key={item.title} className="rounded-2xl border border-white/6 bg-white/[0.02] p-4">
<p className="text-sm font-semibold text-white/70">{item.title}</p>
<p className="mt-2 text-sm leading-6 text-slate-500">{item.description}</p>
</div>
))}
</div>
</div>
<aside className="rounded-[28px] border border-white/6 bg-white/[0.02] p-5">
<p className="text-[11px] uppercase tracking-[0.24em] text-sky-200/50">Before you start</p>
<div className="mt-4 space-y-4">
{[
'Choose the final file you actually want published. Replacing after upload requires a reset.',
'ZIP, RAR, and 7Z packs still need at least one screenshot for preview generation.',
'You will confirm rights and visibility before the final publish step.',
].map((item, index) => (
<div key={item} className="flex items-start gap-3">
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-white/10 bg-white/5 text-xs font-semibold text-slate-400">
{index + 1}
</span>
<p className="text-sm leading-6 text-slate-500">{item}</p>
</div>
))}
</div>
</aside>
</div>
</div>
</div>
</section>
@@ -1005,6 +1009,16 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize }) {
/>
</label>
<div className="mt-4">
<Checkbox
checked={state.metadata.isMature}
onChange={(e) => dispatch({ type: 'SET_METADATA', payload: { isMature: e.target.checked } })}
size={16}
variant="accent"
label="Mark this artwork as mature content."
/>
</div>
<div className="mt-4">
<Checkbox
checked={state.metadata.licenseAccepted}

View File

@@ -40,6 +40,8 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
const debouncedQuery = useDebounce(query, DEBOUNCE_MS)
const isExpanded = phase === 'opening' || phase === 'open'
const isMobileOverlayVisible = mobileOverlayPhase !== 'closed'
const hasSuggestions = artworks.length > 0 || users.length > 0 || tags.length > 0
const suggestionListId = open && hasSuggestions ? 'sb-suggestions' : undefined
// flat list of navigable items: artworks → users → tags
const allItems = [
@@ -216,6 +218,8 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
// ── widths / opacities ───────────────────────────────────────────────────
const pillOpacity = phase === 'idle' ? 1 : 0
const formOpacity = (phase === 'opening' || phase === 'open' || phase === 'closing') ? 1 : 0
const collapsedWidth = 'clamp(8.75rem, 8vw + 4rem, 10.5rem)'
const expandedWidth = 'min(100%, clamp(15rem, 42vw, 28rem))'
return (
<>
@@ -252,8 +256,9 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
style={{
position: 'relative',
height: '40px',
width: isExpanded ? '100%' : '168px',
maxWidth: isExpanded ? '560px' : '168px',
width: isExpanded ? expandedWidth : collapsedWidth,
maxWidth: '100%',
minWidth: '0',
transition: 'width 340ms cubic-bezier(0.16,1,0.3,1), max-width 340ms cubic-bezier(0.16,1,0.3,1)',
}}
>
@@ -261,7 +266,7 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
<button
type="button"
onClick={expand}
aria-label="Open search"
aria-label="Search"
style={{ position: 'absolute', inset: 0, opacity: pillOpacity, pointerEvents: phase === 'idle' ? 'auto' : 'none', transition: 'opacity 120ms ease' }}
className="w-full h-full flex items-center gap-2.5 px-3.5 rounded-lg
bg-white/[0.05] border border-white/[0.09]
@@ -271,7 +276,7 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/>
</svg>
<span className="text-sm flex-1 text-left truncate">Search</span>
<kbd className="shrink-0 inline-flex items-center gap-0.5 text-[10px] font-medium px-1.5 py-0.5 rounded-md bg-white/[0.06] border border-white/[0.10] text-white/30">
<kbd className="hidden lg:inline-flex shrink-0 items-center gap-0.5 text-[10px] font-medium px-1.5 py-0.5 rounded-md bg-white/[0.06] border border-white/[0.10] text-white/30">
{isMac ? '\u2318' : 'Ctrl'}K
</kbd>
</button>
@@ -292,16 +297,17 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
type="search"
value={query}
onChange={e => { setQuery(e.target.value); setActiveIdx(-1) }}
onFocus={() => (artworks.length > 0 || tags.length > 0) && setOpen(true)}
onFocus={() => hasSuggestions && setOpen(true)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
aria-label="Search"
aria-autocomplete="list"
aria-controls="sb-suggestions"
aria-expanded={open}
aria-controls={suggestionListId}
aria-activedescendant={activeIdx >= 0 ? `sb-item-${activeIdx}` : undefined}
autoComplete="off"
className="w-full h-full bg-white/[0.06] border border-white/[0.12] rounded-lg
py-0 pl-10 pr-16 text-sm text-white placeholder-soft outline-none
py-0 pl-10 pr-11 lg:pr-16 text-sm text-white placeholder-soft outline-none
focus:border-accent focus:ring-1 focus:ring-accent/30 transition-colors"
/>
<div className="absolute right-2.5 top-1/2 -translate-y-1/2 flex items-center gap-1.5">
@@ -311,7 +317,7 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/>
</svg>
)}
<kbd className="text-[10px] font-medium px-1.5 py-0.5 rounded-md bg-white/[0.06] border border-white/[0.10] text-white/30 pointer-events-none select-none">Esc</kbd>
<kbd className="hidden lg:inline-flex text-[10px] font-medium px-1.5 py-0.5 rounded-md bg-white/[0.06] border border-white/[0.10] text-white/30 pointer-events-none select-none">Esc</kbd>
<button type="button" onClick={collapse} aria-label="Close search"
className="w-5 h-5 flex items-center justify-center rounded-md text-white/30 hover:text-white hover:bg-white/10 transition-colors">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
@@ -323,7 +329,7 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
</form>
{/* ── SUGGESTIONS DROPDOWN ── */}
{open && (artworks.length > 0 || tags.length > 0) && (
{open && hasSuggestions && (
<ul
id="sb-suggestions"
role="listbox"

View File

@@ -14,6 +14,10 @@ window.Pusher = Pusher
let echoInstance = null
function isLoopbackHost(host) {
return ['127.0.0.1', '0.0.0.0', 'localhost', '::1'].includes(String(host || '').toLowerCase())
}
export function getEcho() {
if (echoInstance !== null) {
return echoInstance || null
@@ -27,13 +31,23 @@ export function getEcho() {
const scheme = import.meta.env.VITE_REVERB_SCHEME || window.location.protocol.replace(':', '') || 'https'
const forceTLS = scheme === 'https'
const configuredHost = import.meta.env.VITE_REVERB_HOST || window.location.hostname
const publicHostname = window.location.hostname
const useWindowHost = !isLoopbackHost(publicHostname) && isLoopbackHost(configuredHost)
const resolvedHost = useWindowHost ? publicHostname : configuredHost
const resolvedPort = useWindowHost
? Number(forceTLS ? 443 : 80)
: Number(import.meta.env.VITE_REVERB_PORT || (forceTLS ? 443 : 80))
const resolvedSecurePort = useWindowHost
? Number(forceTLS ? 443 : 80)
: Number(import.meta.env.VITE_REVERB_PORT || 443)
echoInstance = new Echo({
broadcaster: 'reverb',
key,
wsHost: import.meta.env.VITE_REVERB_HOST || window.location.hostname,
wsPort: Number(import.meta.env.VITE_REVERB_PORT || (forceTLS ? 443 : 80)),
wssPort: Number(import.meta.env.VITE_REVERB_PORT || 443),
wsHost: resolvedHost,
wsPort: resolvedPort,
wssPort: resolvedSecurePort,
forceTLS,
enabledTransports: ['ws', 'wss'],
authEndpoint: '/broadcasting/auth',

View File

@@ -0,0 +1,39 @@
import './bootstrap'
import React from 'react'
import { createRoot } from 'react-dom/client'
import { createInertiaApp } from '@inertiajs/react'
import CollectionShow from './Pages/Collection/CollectionShow'
import CollectionSeriesShow from './Pages/Collection/CollectionSeriesShow'
import CollectionManage from './Pages/Collection/CollectionManage'
import CollectionFeaturedIndex from './Pages/Collection/CollectionFeaturedIndex'
import SavedCollections from './Pages/Collection/SavedCollections'
import CollectionStaffSurfaces from './Pages/Collection/CollectionStaffSurfaces'
import CollectionStaffProgramming from './Pages/Collection/CollectionStaffProgramming'
import CollectionDashboard from './Pages/Collection/CollectionDashboard'
import CollectionAnalytics from './Pages/Collection/CollectionAnalytics'
import CollectionHistory from './Pages/Collection/CollectionHistory'
import NovaCardsAdminIndex from './Pages/Collection/NovaCardsAdminIndex'
import NovaCardsTemplateAdmin from './Pages/Collection/NovaCardsTemplateAdmin'
const pages = {
'Collection/CollectionShow': CollectionShow,
'Collection/CollectionSeriesShow': CollectionSeriesShow,
'Collection/CollectionManage': CollectionManage,
'Collection/CollectionFeaturedIndex': CollectionFeaturedIndex,
'Collection/SavedCollections': SavedCollections,
'Collection/CollectionStaffSurfaces': CollectionStaffSurfaces,
'Collection/CollectionStaffProgramming': CollectionStaffProgramming,
'Collection/CollectionDashboard': CollectionDashboard,
'Collection/CollectionAnalytics': CollectionAnalytics,
'Collection/CollectionHistory': CollectionHistory,
'Collection/NovaCardsAdminIndex': NovaCardsAdminIndex,
'Collection/NovaCardsTemplateAdmin': NovaCardsTemplateAdmin,
}
createInertiaApp({
resolve: (name) => pages[name],
setup({ el, App, props }) {
const root = createRoot(el)
root.render(<App {...props} />)
},
})

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react'
import axios from 'axios'
import SuggestedUsersWidget from '../social/SuggestedUsersWidget'
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
@@ -44,7 +45,7 @@ function SideCard({ title, icon, children, className = '' }) {
function StatsCard({ stats, followerCount, user, onTabChange }) {
const items = [
{
label: 'Artworks',
label: 'Uploads',
value: fmt(stats?.uploads_count ?? 0),
icon: 'fa-solid fa-image',
color: 'text-sky-400',
@@ -285,67 +286,6 @@ function TrendingHashtagsCard() {
// Suggested to follow card
// ─────────────────────────────────────────────────────────────────────────────
function SuggestionsCard({ excludeUsername, isLoggedIn }) {
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!isLoggedIn) { setLoading(false); return }
axios.get('/api/search/users', { params: { q: '', per_page: 5 } })
.then(({ data }) => {
const list = (data.data ?? []).filter((u) => u.username !== excludeUsername).slice(0, 4)
setUsers(list)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [excludeUsername, isLoggedIn])
if (!isLoggedIn) return null
if (!loading && users.length === 0) return null
return (
<SideCard title="Discover Creators" icon="fa-solid fa-compass">
<div className="px-4 py-3 space-y-2.5">
{loading ? (
[1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-center gap-2.5 animate-pulse">
<div className="w-8 h-8 rounded-full bg-white/10 shrink-0" />
<div className="flex-1 space-y-1">
<div className="h-2.5 bg-white/10 rounded w-24" />
<div className="h-2 bg-white/6 rounded w-16" />
</div>
</div>
))
) : (
users.map((u) => (
<a
key={u.id}
href={u.profile_url ?? `/@${u.username}`}
className="flex items-center gap-2.5 group"
>
<img
src={u.avatar_url ?? '/images/avatar_default.webp'}
alt={u.username}
className="w-8 h-8 rounded-full object-cover ring-1 ring-white/10 group-hover:ring-sky-500/40 transition-all shrink-0"
loading="lazy"
/>
<div className="flex-1 min-w-0">
<p className="text-[13px] font-medium text-slate-300 group-hover:text-white transition-colors truncate leading-tight">
{u.name || u.username}
</p>
<p className="text-[11px] text-slate-600 truncate">@{u.username}</p>
</div>
<span className="shrink-0 text-[11px] text-sky-500/80 group-hover:text-sky-400 transition-colors font-medium">
View
</span>
</a>
))
)}
</div>
</SideCard>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// Main export
// ─────────────────────────────────────────────────────────────────────────────
@@ -370,6 +310,7 @@ export default function FeedSidebar({
stats,
followerCount,
recentFollowers,
suggestedUsers,
socialLinks,
countryName,
isLoggedIn,
@@ -397,9 +338,12 @@ export default function FeedSidebar({
onTabChange={onTabChange}
/>
<SuggestionsCard
<SuggestedUsersWidget
title="Discover Creators"
excludeUsername={user?.username}
isLoggedIn={isLoggedIn}
initialUsers={suggestedUsers}
limit={4}
/>
<TrendingHashtagsCard />

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { usePage } from '@inertiajs/react'
import LevelBadge from '../xp/LevelBadge'
const IMAGE_FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
@@ -118,6 +119,87 @@ function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
function sendDiscoveryEvent(endpoint, payload) {
if (!endpoint) return
void fetch(endpoint, {
method: 'POST',
credentials: 'same-origin',
keepalive: true,
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(payload),
}).catch(() => {})
}
async function sendFeedbackSignal(endpoint, payload) {
if (!endpoint) {
throw new Error('missing_feedback_endpoint')
}
const response = await fetch(endpoint, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(payload),
})
if (!response.ok) {
throw new Error('feedback_request_failed')
}
return response.json().catch(() => null)
}
async function requestJson(endpoint, { method = 'GET', body } = {}) {
const response = await fetch(endpoint, {
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) {
const error = new Error(payload?.message || 'Request failed.')
error.payload = payload
throw error
}
return payload
}
function trackRecommendationFeedback(item, eventType, extraMeta = {}) {
const endpoint = item?.discovery_endpoint
const artworkId = Number(item?.id ?? 0)
if (!endpoint || artworkId <= 0) return
sendDiscoveryEvent(endpoint, {
event_type: eventType,
artwork_id: artworkId,
algo_version: item?.recommendation_algo_version || item?.algo_version || undefined,
meta: {
gallery_type: item?.recommendation_surface || item?.gallery_type || 'recommendation-surface',
source: item?.recommendation_source || null,
reason: item?.recommendation_reason || null,
score: item?.recommendation_score ?? null,
...extraMeta,
},
})
}
function HeartIcon(props) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" {...props}>
@@ -145,6 +227,34 @@ function ViewIcon(props) {
)
}
function HideIcon(props) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6l12 12" />
<path strokeLinecap="round" strokeLinejoin="round" d="M18 6 6 18" />
</svg>
)
}
function TagIcon(props) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 13.5 10.5 3.75H4.5v6l9.75 9.75a2.12 2.12 0 0 0 3 0l3-3a2.12 2.12 0 0 0 0-3Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M7.875 7.875h.008v.008h-.008z" />
</svg>
)
}
function CollectionIcon(props) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 6.75A2.25 2.25 0 0 1 6.75 4.5h10.5a2.25 2.25 0 0 1 2.25 2.25v10.5a2.25 2.25 0 0 1-2.25 2.25H6.75a2.25 2.25 0 0 1-2.25-2.25V6.75Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 12h7.5" />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8.25v7.5" />
</svg>
)
}
function ActionLink({ href, label, children, onClick }) {
return (
<a
@@ -185,6 +295,113 @@ function BadgePill({ className = '', iconClass = '', children }) {
)
}
function CollectionPickerModal({
open,
artworkTitle,
collections,
loading,
error,
notice,
createUrl,
attachingCollectionId,
onAttach,
onClose,
}) {
useEffect(() => {
if (!open) return undefined
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
onClose()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [open, onClose])
if (!open) {
return null
}
return (
<div className="fixed inset-0 z-[140] flex items-center justify-center p-4">
<button
type="button"
aria-label="Close add to collection dialog"
onClick={onClose}
className="absolute inset-0 bg-slate-950/80 backdrop-blur-sm"
/>
<div className="relative z-10 w-full max-w-2xl overflow-hidden rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(8,15,27,0.98),rgba(6,11,20,0.98))] shadow-[0_40px_120px_rgba(2,6,23,0.55)]">
<div className="border-b border-white/10 px-6 py-5">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-sky-200/80">Collections</p>
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Add to collection</h3>
<p className="mt-2 text-sm text-slate-300">Choose a showcase for <span className="font-semibold text-white">{artworkTitle}</span>.</p>
</div>
<button
type="button"
onClick={onClose}
className="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-slate-300 transition hover:bg-white/[0.08] hover:text-white"
>
<i className="fa-solid fa-xmark" />
</button>
</div>
</div>
<div className="space-y-4 px-6 py-6">
{notice ? <div className="rounded-2xl border border-emerald-400/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-100">{notice}</div> : null}
{error ? <div className="rounded-2xl border border-rose-400/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
{loading ? (
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] px-5 py-10 text-center text-sm text-slate-300">Loading collections...</div>
) : collections.length ? (
<div className="grid gap-3 md:grid-cols-2">
{collections.map((collection) => (
<div key={collection.id} className="rounded-[24px] border border-white/10 bg-white/[0.04] p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-white">{collection.title}</div>
<div className="mt-1 text-xs text-slate-400">{collection.artworks_count} artworks {collection.visibility}</div>
</div>
{collection.already_attached ? <span className="inline-flex items-center rounded-full border border-emerald-400/20 bg-emerald-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-emerald-100">Added</span> : null}
</div>
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
onClick={() => onAttach(collection)}
disabled={collection.already_attached || attachingCollectionId === collection.id}
className={`inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] ${collection.already_attached || attachingCollectionId === collection.id ? 'border-white/8 bg-white/[0.03] text-slate-500' : 'border-sky-300/20 bg-sky-400/10 text-sky-100 transition hover:bg-sky-400/15'}`}
>
<CollectionIcon className="h-3.5 w-3.5" />
{attachingCollectionId === collection.id ? 'Adding...' : collection.already_attached ? 'Already added' : 'Add'}
</button>
<a href={collection.manage_url} className="inline-flex items-center gap-2 rounded-xl border border-white/12 bg-white/[0.05] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.08]">Manage</a>
</div>
</div>
))}
</div>
) : (
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] px-5 py-10 text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-[20px] border border-white/10 bg-white/[0.05] text-slate-400">
<CollectionIcon className="h-7 w-7" />
</div>
<h4 className="mt-4 text-lg font-semibold text-white">Create your first collection</h4>
<p className="mx-auto mt-2 max-w-md text-sm leading-relaxed text-slate-300">Start a curated showcase, then add this artwork into the sequence.</p>
<a href={createUrl} className="mt-5 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-plus" />
Create Collection
</a>
</div>
)}
</div>
</div>
</div>
)
}
export default function ArtworkCard({
artwork,
variant = 'default',
@@ -206,9 +423,18 @@ export default function ArtworkCard({
decoding = 'async',
fetchPriority,
onLike,
onDismissed,
showActions = true,
metricBadge = null,
}) {
let inertiaProps = {}
try {
inertiaProps = usePage()?.props || {}
} catch {
inertiaProps = {}
}
const item = artwork || {}
const rawAuthor = item.author || item.creator
const title = decodeHtml(item.title || item.name || 'Untitled artwork')
@@ -255,10 +481,51 @@ export default function ArtworkCard({
const [likeCount, setLikeCount] = useState(Number(likes ?? 0) || 0)
const [likeBusy, setLikeBusy] = useState(false)
const [downloadBusy, setDownloadBusy] = useState(false)
const [hideBusy, setHideBusy] = useState(false)
const [dislikeBusy, setDislikeBusy] = useState(false)
const [dismissed, setDismissed] = useState(false)
const [collectionPickerOpen, setCollectionPickerOpen] = useState(false)
const [collectionOptionsLoading, setCollectionOptionsLoading] = useState(false)
const [collectionOptionsLoaded, setCollectionOptionsLoaded] = useState(false)
const [collectionOptions, setCollectionOptions] = useState([])
const [collectionCreateUrl, setCollectionCreateUrl] = useState('/settings/collections/create')
const [collectionPickerError, setCollectionPickerError] = useState('')
const [collectionPickerNotice, setCollectionPickerNotice] = useState('')
const [attachingCollectionId, setAttachingCollectionId] = useState(null)
const openTrackedRef = useRef(false)
const primaryTag = useMemo(() => {
if (item?.primary_tag && typeof item.primary_tag === 'object') {
return item.primary_tag
}
if (Array.isArray(item?.tags) && item.tags.length > 0) {
return item.tags[0]
}
return null
}, [item.primary_tag, item.tags])
const hideArtworkEndpoint = item.hide_artwork_endpoint || item?.negative_feedback?.hide_artwork_endpoint || null
const dislikeTagEndpoint = item.dislike_tag_endpoint || item?.negative_feedback?.dislike_tag_endpoint || null
const canHideRecommendation = Boolean(item?.id && hideArtworkEndpoint && item?.recommendation_algo_version)
const canDislikePrimaryTag = Boolean(dislikeTagEndpoint && item?.recommendation_algo_version && (primaryTag?.id || primaryTag?.slug))
const authUserId = Number(inertiaProps?.auth?.user?.id ?? 0)
const itemOwnerId = Number(item.author_id ?? rawAuthor?.id ?? item.user_id ?? item.creator?.id ?? 0)
const canAddToCollection = Boolean(authUserId > 0 && Number(item.id ?? 0) > 0 && itemOwnerId > 0 && itemOwnerId === authUserId)
const collectionOptionsEndpoint = canAddToCollection
? (item.collection_options_endpoint || `/settings/collections/artworks/${item.id}/options`)
: null
useEffect(() => {
setLiked(Boolean(item.viewer?.is_liked))
setLikeCount(Number(item.likes ?? item.favourites ?? 0) || 0)
setDismissed(false)
setCollectionPickerOpen(false)
setCollectionOptionsLoading(false)
setCollectionOptionsLoaded(false)
setCollectionOptions([])
setCollectionPickerError('')
setCollectionPickerNotice('')
setAttachingCollectionId(null)
}, [item.id, item.likes, item.favourites, item.viewer?.is_liked])
const articleData = useMemo(() => ({
@@ -268,6 +535,16 @@ export default function ArtworkCard({
'data-art-img': image,
}), [href, image, item.id, title])
const handleOpen = () => {
if (openTrackedRef.current) return
openTrackedRef.current = true
trackRecommendationFeedback(item, 'click', {
interaction_origin: 'artwork-card-open',
target_url: href,
})
}
const handleLike = async () => {
if (!item.id || likeBusy) {
onLike?.(item)
@@ -294,6 +571,12 @@ export default function ArtworkCard({
throw new Error('like_request_failed')
}
if (nextState) {
trackRecommendationFeedback(item, 'favorite', {
interaction_origin: 'artwork-card-like',
})
}
onLike?.(item)
} catch {
setLiked(!nextState)
@@ -309,6 +592,11 @@ export default function ArtworkCard({
setDownloadBusy(true)
try {
trackRecommendationFeedback(item, 'download', {
interaction_origin: 'artwork-card-download',
target_url: downloadHref,
})
const link = document.createElement('a')
link.href = downloadHref
link.rel = 'noopener noreferrer'
@@ -322,6 +610,125 @@ export default function ArtworkCard({
}
}
const dismissArtwork = (kind) => {
setDismissed(true)
onDismissed?.(item, kind)
}
const handleHideArtwork = async (event) => {
event.preventDefault()
event.stopPropagation()
if (!canHideRecommendation || hideBusy) return
setHideBusy(true)
try {
await sendFeedbackSignal(hideArtworkEndpoint, {
artwork_id: Number(item.id),
algo_version: item.recommendation_algo_version || item.algo_version || undefined,
source: item.recommendation_source || 'recommendation-card',
meta: {
gallery_type: item.recommendation_surface || item.gallery_type || 'recommendation-surface',
reason: item.recommendation_reason || null,
primary_tag_slug: primaryTag?.slug || null,
interaction_origin: 'artwork-card-hide',
},
})
dismissArtwork('hide-artwork')
} catch {
// Keep the card visible if the feedback request fails.
} finally {
setHideBusy(false)
}
}
const handleDislikePrimaryTag = async (event) => {
event.preventDefault()
event.stopPropagation()
if (!canDislikePrimaryTag || dislikeBusy) return
setDislikeBusy(true)
try {
await sendFeedbackSignal(dislikeTagEndpoint, {
tag_id: primaryTag?.id ? Number(primaryTag.id) : undefined,
tag_slug: primaryTag?.slug || undefined,
algo_version: item.recommendation_algo_version || item.algo_version || undefined,
source: item.recommendation_source || 'recommendation-card',
meta: {
artwork_id: Number(item.id),
gallery_type: item.recommendation_surface || item.gallery_type || 'recommendation-surface',
reason: item.recommendation_reason || null,
interaction_origin: 'artwork-card-dislike-tag',
},
})
dismissArtwork('dislike-tag')
} catch {
// Keep the card visible if the feedback request fails.
} finally {
setDislikeBusy(false)
}
}
const handleOpenCollectionPicker = async (event) => {
event.preventDefault()
event.stopPropagation()
if (!collectionOptionsEndpoint || collectionOptionsLoading) return
setCollectionPickerOpen(true)
setCollectionPickerError('')
setCollectionPickerNotice('')
if (collectionOptionsLoaded) {
return
}
setCollectionOptionsLoading(true)
try {
const payload = await requestJson(collectionOptionsEndpoint)
setCollectionOptions(Array.isArray(payload?.data) ? payload.data : [])
setCollectionCreateUrl(payload?.meta?.create_url || '/settings/collections/create')
setCollectionOptionsLoaded(true)
} catch (error) {
setCollectionPickerError(error.message || 'Unable to load collections.')
} finally {
setCollectionOptionsLoading(false)
}
}
const handleAttachToCollection = async (collection) => {
if (!collection?.attach_url || attachingCollectionId === collection.id) return
setAttachingCollectionId(collection.id)
setCollectionPickerError('')
setCollectionPickerNotice('')
try {
await requestJson(collection.attach_url, {
method: 'POST',
body: { artwork_ids: [Number(item.id)] },
})
setCollectionOptions((current) => current.map((entry) => (
entry.id === collection.id
? { ...entry, already_attached: true, artworks_count: Number(entry.artworks_count || 0) + 1 }
: entry
)))
setCollectionPickerNotice(`Added to ${collection.title}.`)
} catch (error) {
const firstError = error?.payload?.errors
? Object.values(error.payload.errors).flat().find(Boolean)
: null
setCollectionPickerError(firstError || error.message || 'Unable to add artwork to collection.')
} finally {
setAttachingCollectionId(null)
}
}
if (dismissed) {
return null
}
if (variant === 'embed') {
return (
<article
@@ -333,6 +740,7 @@ export default function ArtworkCard({
href={href}
className="flex gap-3 p-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
aria-label={`Open artwork: ${cardLabel}`}
onClick={handleOpen}
>
<div className="h-16 w-20 shrink-0 overflow-hidden rounded-lg bg-white/5">
<img
@@ -371,15 +779,17 @@ export default function ArtworkCard({
}
return (
<article
className={cx('group relative', articleClassName, className)}
style={articleStyle}
{...articleData}
>
<div className={cx('relative overflow-hidden rounded-[1.6rem] border border-white/8 bg-slate-950/80 shadow-[0_18px_60px_rgba(2,6,23,0.45)] transition duration-300 ease-out group-hover:-translate-y-1 group-hover:scale-[1.02] group-hover:border-white/14 group-hover:shadow-[0_24px_80px_rgba(8,47,73,0.5)] group-focus-within:-translate-y-1 group-focus-within:scale-[1.02] group-focus-within:border-sky-200/40', frameClassName)}>
<>
<article
className={cx('group relative', articleClassName, className)}
style={articleStyle}
{...articleData}
>
<div className={cx('relative overflow-hidden rounded-[1.6rem] border border-white/8 bg-slate-950/80 shadow-[0_18px_60px_rgba(2,6,23,0.45)] transition duration-300 ease-out group-hover:-translate-y-1 group-hover:scale-[1.02] group-hover:border-white/14 group-hover:shadow-[0_24px_80px_rgba(8,47,73,0.5)] group-focus-within:-translate-y-1 group-focus-within:scale-[1.02] group-focus-within:border-sky-200/40', frameClassName)}>
<a
href={href}
aria-label={`Open artwork: ${cardLabel}`}
onClick={handleOpen}
className="absolute inset-0 z-10 rounded-[inherit] cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
>
<span className="sr-only">{cardLabel}</span>
@@ -426,7 +836,7 @@ export default function ArtworkCard({
{showActions && (
<div className={cx(
'absolute right-3 z-20 flex translate-y-2 gap-2 opacity-100 transition duration-200 md:opacity-0 md:group-hover:translate-y-0 md:group-hover:opacity-100 md:group-focus-within:translate-y-0 md:group-focus-within:opacity-100',
'absolute right-3 z-20 flex max-w-[14rem] flex-wrap justify-end translate-y-2 gap-2 opacity-100 transition duration-200 md:opacity-0 md:group-hover:translate-y-0 md:group-hover:opacity-100 md:group-focus-within:translate-y-0 md:group-focus-within:opacity-100',
relativePublishedAt ? 'top-12' : 'top-3'
)}>
<ActionButton label={liked ? 'Unlike artwork' : 'Like artwork'} onClick={handleLike}>
@@ -440,6 +850,18 @@ export default function ArtworkCard({
<ActionLink href={href} label="View artwork">
<ViewIcon className="h-4 w-4" />
</ActionLink>
{canAddToCollection ? (
<ActionButton label="Add artwork to collection" onClick={handleOpenCollectionPicker}>
<CollectionIcon className="h-4 w-4" />
</ActionButton>
) : null}
{canHideRecommendation ? (
<ActionButton label={hideBusy ? 'Hiding artwork' : 'Hide artwork'} onClick={handleHideArtwork}>
<HideIcon className={cx('h-4 w-4', hideBusy ? 'animate-pulse text-amber-200' : 'text-white/90')} />
</ActionButton>
) : null}
</div>
)}
@@ -482,9 +904,36 @@ export default function ArtworkCard({
{metadataLine}
</div>
) : null}
{canDislikePrimaryTag ? (
<div className="pointer-events-auto mt-2">
<button
type="button"
onClick={handleDislikePrimaryTag}
className="inline-flex items-center gap-1.5 rounded-full border border-white/12 bg-black/35 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-white/80 transition hover:border-amber-200/40 hover:bg-black/55 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
>
<TagIcon className={cx('h-3.5 w-3.5', dislikeBusy ? 'animate-pulse text-amber-200' : '')} />
{dislikeBusy ? 'Updating' : `Less of #${primaryTag?.slug || primaryTag?.name}`}
</button>
</div>
) : null}
</div>
</div>
</div>
</article>
</div>
</article>
<CollectionPickerModal
open={collectionPickerOpen}
artworkTitle={title}
collections={collectionOptions}
loading={collectionOptionsLoading}
error={collectionPickerError}
notice={collectionPickerNotice}
createUrl={collectionCreateUrl}
attachingCollectionId={attachingCollectionId}
onAttach={handleAttachToCollection}
onClose={() => setCollectionPickerOpen(false)}
/>
</>
)
}

View File

@@ -1,6 +1,67 @@
import React from 'react'
import React, { useEffect, useMemo, useState } from 'react'
import ArtworkCard from './ArtworkCard'
function getCsrfToken() {
if (typeof document === 'undefined') return ''
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
async function revokeDismissSignal(entry) {
const item = entry?.item || null
const kind = entry?.kind || null
if (!item || !kind) {
throw new Error('missing_dismiss_entry')
}
const endpoint = kind === 'dislike-tag'
? item.dislike_tag_endpoint || item?.negative_feedback?.dislike_tag_endpoint
: item.hide_artwork_endpoint || item?.negative_feedback?.hide_artwork_endpoint
if (!endpoint) {
throw new Error('missing_revoke_endpoint')
}
const payload = kind === 'dislike-tag'
? {
tag_id: item?.primary_tag?.id ? Number(item.primary_tag.id) : undefined,
tag_slug: item?.primary_tag?.slug || item?.primary_tag?.name || undefined,
artwork_id: Number(item.id),
algo_version: item?.recommendation_algo_version || item?.algo_version || undefined,
meta: {
gallery_type: item?.recommendation_surface || item?.gallery_type || 'recommendation-surface',
reason: item?.recommendation_reason || null,
interaction_origin: 'artwork-gallery-undo-dislike-tag',
},
}
: {
artwork_id: Number(item.id),
algo_version: item?.recommendation_algo_version || item?.algo_version || undefined,
meta: {
gallery_type: item?.recommendation_surface || item?.gallery_type || 'recommendation-surface',
reason: item?.recommendation_reason || null,
interaction_origin: 'artwork-gallery-undo-hide',
},
}
const response = await fetch(endpoint, {
method: 'DELETE',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(payload),
})
if (!response.ok) {
throw new Error('revoke_request_failed')
}
return response.json().catch(() => null)
}
function cx(...parts) {
return parts.filter(Boolean).join(' ')
}
@@ -14,6 +75,36 @@ function getArtworkKey(item, index) {
return `artwork-${index}`
}
function DismissNotice({ notice, onUndo, onClose }) {
if (!notice) return null
return (
<div className="pointer-events-none fixed bottom-5 right-5 z-50 max-w-sm" aria-live="polite" aria-atomic="true">
<div className="pointer-events-auto rounded-2xl border border-amber-300/30 bg-slate-950/92 px-4 py-3 text-amber-50 shadow-2xl shadow-black/40 backdrop-blur">
<p className="text-[11px] uppercase tracking-[0.2em] text-amber-100/70">Discovery Feedback</p>
<p className="mt-1 text-sm font-medium">{notice.message}</p>
<div className="mt-3 flex items-center gap-2">
<button
type="button"
onClick={onUndo}
disabled={notice.busy}
className="inline-flex items-center rounded-full border border-white/15 px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-white/85 transition hover:border-white/30 hover:text-white"
>
{notice.busy ? 'Undoing...' : 'Undo'}
</button>
<button
type="button"
onClick={onClose}
className="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-white/55 transition hover:text-white/85"
>
Dismiss
</button>
</div>
</div>
</div>
)
}
export default function ArtworkGallery({
items,
layout = 'grid',
@@ -30,13 +121,81 @@ export default function ArtworkGallery({
if (!Array.isArray(items) || items.length === 0) return null
const visibleItems = typeof limit === 'number' ? items.slice(0, limit) : items
const [dismissedEntries, setDismissedEntries] = useState([])
const [dismissNotice, setDismissNotice] = useState(null)
const visibleArtworkItems = useMemo(
() => visibleItems.filter((item) => !dismissedEntries.some((entry) => entry.item?.id === item?.id)),
[dismissedEntries, visibleItems]
)
const baseClassName = layout === 'masonry'
? 'grid gap-6'
: 'grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'
: 'grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5'
useEffect(() => {
if (!dismissNotice) {
return undefined
}
const timeoutId = window.setTimeout(() => {
setDismissNotice(null)
}, 3200)
return () => {
window.clearTimeout(timeoutId)
}
}, [dismissNotice])
function handleDismissed(item, kind) {
if (!item?.id) return
setDismissedEntries((current) => {
const next = current.filter((entry) => entry.item?.id !== item.id)
next.push({ item, kind })
return next
})
setDismissNotice({
itemId: item.id,
busy: false,
message: kind === 'dislike-tag'
? `We will show less content like #${item?.primary_tag?.slug || item?.primary_tag?.name || 'this tag'}.`
: 'Artwork hidden from this recommendation view.',
})
}
async function handleUndoDismiss() {
if (!dismissNotice?.itemId) {
setDismissNotice(null)
return
}
const entry = dismissedEntries.find((current) => current.item?.id === dismissNotice.itemId)
if (!entry) {
setDismissNotice(null)
return
}
setDismissNotice((current) => current ? { ...current, busy: true } : current)
try {
await revokeDismissSignal(entry)
} catch {
setDismissNotice({
itemId: entry.item.id,
busy: false,
message: 'Undo failed. The feedback signal is still active.',
})
return
}
setDismissedEntries((current) => current.filter((entry) => entry.item?.id !== dismissNotice.itemId))
setDismissNotice(null)
}
return (
<div className={cx(baseClassName, className)} {...containerProps}>
{visibleItems.map((item, index) => {
<>
<div className={cx(baseClassName, className)} {...containerProps}>
{visibleArtworkItems.map((item, index) => {
const cardProps = resolveCardProps?.(item, index) || {}
const { className: resolvedClassName = '', ...restCardProps } = cardProps
@@ -48,11 +207,14 @@ export default function ArtworkGallery({
showStats={showStats}
showAuthor={showAuthor}
className={cx(cardClassName, resolvedClassName)}
onDismissed={handleDismissed}
{...restCardProps}
/>
)
})}
{children}
</div>
</div>
<DismissNotice notice={dismissNotice} onUndo={handleUndoDismiss} onClose={() => setDismissNotice(null)} />
</>
)
}

View File

@@ -350,7 +350,7 @@ export default function ArtworkRecommendationsRails({ artwork, related = [] }) {
}
try {
const response = await fetch(`/api/art/${artwork.id}/similar`, { credentials: 'same-origin' })
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()
const items = dedupeByUrl((payload?.data || []).map(normalizeSimilar).filter(Boolean))

View File

@@ -119,10 +119,32 @@ export default function ReactionBar({ entityType, entityId, initialTotals = {},
hoverTimeout.current = setTimeout(() => setPickerOpen(false), 400)
}
const isArtworkVariant = entityType === 'artwork'
const triggerClassName = isArtworkVariant
? [
'inline-flex items-center gap-2.5 rounded-full border px-4 py-2.5 text-sm font-semibold transition-all duration-200',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50',
myReaction
? 'border-accent/35 bg-accent/12 text-accent shadow-[0_12px_30px_rgba(245,158,11,0.14)] hover:bg-accent/18'
: 'border-white/[0.12] bg-white/[0.06] text-white/75 hover:border-accent/30 hover:bg-white/[0.1] hover:text-white',
].join(' ')
: [
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-all duration-200',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50',
myReaction
? 'text-accent'
: 'text-white/40 hover:text-white/70',
].join(' ')
const summaryClassName = isArtworkVariant
? 'inline-flex items-center gap-2 rounded-full border border-white/[0.1] bg-white/[0.05] px-3 py-1.5 transition-colors hover:border-white/[0.16] hover:bg-white/[0.08] group/summary'
: 'inline-flex items-center gap-1.5 rounded-full px-1.5 py-0.5 transition-colors hover:bg-white/[0.06] group/summary'
return (
<div
ref={containerRef}
className="flex items-center gap-2"
className={isArtworkVariant ? 'flex flex-wrap items-center gap-3' : 'flex items-center gap-2'}
onMouseLeave={onMouseLeave}
>
{/* ── Trigger button ──────────────────────────────────────────── */}
@@ -138,21 +160,15 @@ export default function ReactionBar({ entityType, entityId, initialTotals = {},
toggle('thumbs_up')
}
}}
className={[
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-all duration-200',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50',
myReaction
? 'text-accent'
: 'text-white/40 hover:text-white/70',
].join(' ')}
className={triggerClassName}
aria-label={myReaction ? `You reacted with ${myReactionData?.label}. Click to remove.` : 'React to this comment'}
>
{myReaction ? (
<span className="text-base leading-none">{myReactionData?.emoji}</span>
<span className={isArtworkVariant ? 'text-xl leading-none' : 'text-base leading-none'}>{myReactionData?.emoji}</span>
) : (
<HeartOutlineIcon className="h-4 w-4" />
<HeartOutlineIcon className={isArtworkVariant ? 'h-5 w-5' : 'h-4 w-4'} />
)}
<span>{myReaction ? myReactionData?.label : 'React'}</span>
<span>{myReaction ? myReactionData?.label : (isArtworkVariant ? 'React to this artwork' : 'React')}</span>
</button>
{/* ── Floating picker ─────────────────────────────────────── */}
@@ -202,7 +218,7 @@ export default function ReactionBar({ entityType, entityId, initialTotals = {},
<button
type="button"
onClick={() => setPickerOpen(v => !v)}
className="inline-flex items-center gap-1.5 rounded-full px-1.5 py-0.5 transition-colors hover:bg-white/[0.06] group/summary"
className={summaryClassName}
aria-label={`${totalCount} reaction${totalCount !== 1 ? 's' : ''}`}
>
{/* Stacked emoji circles (Facebook-style, max 3) */}

View File

@@ -1,4 +1,5 @@
import React from 'react'
import LevelBadge from '../xp/LevelBadge'
const ROLE_STYLES = {
admin: 'bg-red-500/15 text-red-300',
@@ -18,6 +19,7 @@ export default function AuthorBadge({ user, size = 'md' }) {
const role = (user?.role ?? 'member').toLowerCase()
const cls = ROLE_STYLES[role] ?? ROLE_STYLES.member
const label = ROLE_LABELS[role] ?? 'Member'
const level = Number(user?.level ?? 0)
const rank = user?.rank ?? null
const imgSize = size === 'sm' ? 'h-8 w-8' : 'h-10 w-10'
@@ -37,11 +39,7 @@ export default function AuthorBadge({ user, size = 'md' }) {
<span className={`inline-flex rounded-full px-2 py-0.5 text-[11px] font-medium ${cls}`}>
{label}
</span>
{rank && (
<span className="inline-flex rounded-full bg-emerald-500/12 px-2 py-0.5 text-[11px] font-medium text-emerald-300">
{rank}
</span>
)}
{rank && level > 0 ? <LevelBadge level={level} rank={rank} compact /> : null}
</div>
</div>
</div>

View File

@@ -4,6 +4,30 @@ import React, {
import ArtworkGallery from '../artwork/ArtworkGallery';
import './MasonryGallery.css';
function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
}
async function sendDiscoveryEvent(endpoint, payload) {
if (!endpoint) return;
try {
await fetch(endpoint, {
method: 'POST',
credentials: 'same-origin',
keepalive: true,
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(payload),
});
} catch {
// Discovery telemetry should never block the gallery UX.
}
}
// ── Masonry helpers ────────────────────────────────────────────────────────
const ROW_SIZE = 8;
const ROW_GAP = 16;
@@ -89,6 +113,7 @@ async function fetchPageData(url) {
nextCursor,
nextPageUrl,
hasMore,
meta: json.meta ?? null,
};
}
@@ -106,6 +131,7 @@ async function fetchPageData(url) {
nextCursor: el.dataset.nextCursor || null,
nextPageUrl: el.dataset.nextPageUrl || null,
hasMore: null,
meta: null,
};
}
@@ -193,6 +219,12 @@ function getMasonryCardProps(art, idx) {
decoding: idx < 8 ? 'sync' : 'async',
fetchPriority: idx === 0 ? 'high' : undefined,
imageClassName: 'nova-card-main-image absolute inset-0 h-full w-full object-cover group-hover:scale-[1.03]',
metricBadge: art.recommendation_reason
? {
label: art.recommendation_reason,
className: 'bg-sky-500/14 text-sky-100 ring-sky-300/30 max-w-[15rem] truncate',
}
: null,
};
}
@@ -222,15 +254,20 @@ function MasonryGallery({
rankApiEndpoint = null,
rankType = null,
gridClassName = null,
discoveryEndpoint = null,
algoVersion: initialAlgoVersion = null,
}) {
const [artworks, setArtworks] = useState(initialArtworks);
const [nextCursor, setNextCursor] = useState(initialNextCursor);
const [nextPageUrl, setNextPageUrl] = useState(initialNextPageUrl);
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(!initialNextCursor && !initialNextPageUrl);
const [algoVersion, setAlgoVersion] = useState(initialAlgoVersion);
const gridRef = useRef(null);
const triggerRef = useRef(null);
const viewedArtworkIdsRef = useRef(new Set());
const clickedArtworkIdsRef = useRef(new Set());
// ── Ranking API fallback ───────────────────────────────────────────────
// When the server-side render provides no initial artworks (e.g. cache miss
@@ -298,9 +335,13 @@ function MasonryGallery({
setLoading(true);
try {
const { artworks: newItems, nextCursor: nc, nextPageUrl: np, hasMore } =
const { artworks: newItems, nextCursor: nc, nextPageUrl: np, hasMore, meta } =
await fetchPageData(fetchUrl);
if (meta?.algo_version) {
setAlgoVersion(meta.algo_version);
}
if (!newItems.length) {
setDone(true);
} else {
@@ -334,6 +375,95 @@ function MasonryGallery({
return () => io.disconnect();
}, [done, fetchNext]);
useEffect(() => {
if (galleryType !== 'for-you' || !discoveryEndpoint) return undefined;
const grid = gridRef.current;
if (!grid || !(window.IntersectionObserver)) return undefined;
const artworkIndex = new Map(artworks.map((art, index) => [String(art.id), { art, index }]));
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting || entry.intersectionRatio < 0.65) {
return;
}
const card = entry.target.closest('[data-art-id]');
const artworkId = card?.getAttribute('data-art-id');
if (!artworkId || viewedArtworkIdsRef.current.has(artworkId)) {
return;
}
const candidate = artworkIndex.get(artworkId);
if (!candidate?.art?.id) {
return;
}
viewedArtworkIdsRef.current.add(artworkId);
observer.unobserve(entry.target);
sendDiscoveryEvent(discoveryEndpoint, {
event_type: 'view',
artwork_id: Number(candidate.art.id),
algo_version: candidate.art.recommendation_algo_version || algoVersion || undefined,
meta: {
gallery_type: galleryType,
position: candidate.index + 1,
source: candidate.art.recommendation_source || null,
reason: candidate.art.recommendation_reason || null,
score: candidate.art.recommendation_score ?? null,
},
});
});
},
{ threshold: [0.65] },
);
const cards = grid.querySelectorAll('[data-art-id]');
cards.forEach((card) => observer.observe(card));
return () => observer.disconnect();
}, [algoVersion, artworks, discoveryEndpoint, galleryType]);
useEffect(() => {
if (galleryType !== 'for-you' || !discoveryEndpoint) return undefined;
const grid = gridRef.current;
if (!grid) return undefined;
const handleClick = (event) => {
const card = event.target.closest('[data-art-id]');
if (!card) return;
const artworkId = card.getAttribute('data-art-id');
if (!artworkId || clickedArtworkIdsRef.current.has(artworkId)) {
return;
}
const artwork = artworks.find((item) => String(item.id) === artworkId);
if (!artwork?.id) {
return;
}
clickedArtworkIdsRef.current.add(artworkId);
sendDiscoveryEvent(discoveryEndpoint, {
event_type: 'click',
artwork_id: Number(artwork.id),
algo_version: artwork.recommendation_algo_version || algoVersion || undefined,
meta: {
gallery_type: galleryType,
source: artwork.recommendation_source || null,
reason: artwork.recommendation_reason || null,
score: artwork.recommendation_score ?? null,
target_url: artwork.url || null,
},
});
};
grid.addEventListener('click', handleClick, true);
return () => grid.removeEventListener('click', handleClick, true);
}, [algoVersion, artworks, discoveryEndpoint, galleryType]);
// Gallery V2 spec §7: 5 col desktop / 3 tablet / 2 mobile for all gallery pages.
// Discover feeds (home/discover page) retain the same 5-col layout.
const gridClass = gridClassName || 'grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6';

View File

@@ -104,7 +104,7 @@ function ConversationRow({ conv, isActive, currentUserId, onlineUserIds, typingU
<div className="mt-3 rounded-2xl border border-white/[0.04] bg-black/10 px-3 py-2.5">
{typingUsers.length === 0 && senderLabel ? <p className="text-[11px] text-white/35">{senderLabel}</p> : null}
<p className={`mt-1 flex items-center gap-2 truncate text-sm ${typingUsers.length > 0 ? 'text-emerald-200' : 'text-white/62'}`}>
<p data-testid={`conversation-preview-${conv.id}`} className={`mt-1 flex items-center gap-2 truncate text-sm ${typingUsers.length > 0 ? 'text-emerald-200' : 'text-white/62'}`}>
{typingUsers.length > 0 ? <SidebarTypingIcon /> : null}
<span className="truncate">{preview}</span>
</p>

View File

@@ -14,6 +14,7 @@ export default function ConversationThread({
onBack,
onMarkRead,
onConversationPatched,
onUnreadTotalPatched,
}) {
const [messages, setMessages] = useState([])
const [loading, setLoading] = useState(true)
@@ -47,6 +48,35 @@ export default function ConversationThread({
const animatedMessageIdsRef = useRef(new Set())
const [animatedMessageIds, setAnimatedMessageIds] = useState({})
const prefersReducedMotion = usePrefersReducedMotion()
const draftStorageKey = useMemo(() => (conversationId ? `nova_draft_${conversationId}` : null), [conversationId])
const readDraftFromStorage = useCallback(() => {
if (!draftStorageKey || typeof window === 'undefined') {
return ''
}
try {
return window.localStorage.getItem(draftStorageKey) ?? ''
} catch {
return ''
}
}, [draftStorageKey])
const persistDraftToStorage = useCallback((value) => {
if (!draftStorageKey || typeof window === 'undefined') {
return
}
try {
if (value.trim() === '') {
window.localStorage.removeItem(draftStorageKey)
} else {
window.localStorage.setItem(draftStorageKey, value)
}
} catch {
// no-op
}
}, [draftStorageKey])
const myParticipant = useMemo(() => (
conversation?.my_participant
@@ -221,13 +251,13 @@ export default function ConversationThread({
setPresenceUsers([])
setTypingUsers([])
setNextCursor(null)
setBody('')
setBody(readDraftFromStorage())
setFiles([])
loadMessages()
if (!realtimeEnabled) {
loadTyping()
}
}, [conversationId, loadMessages, loadTyping, realtimeEnabled])
}, [conversationId, loadMessages, loadTyping, readDraftFromStorage, realtimeEnabled])
useEffect(() => {
setParticipantState(conversation?.all_participants ?? [])
@@ -319,8 +349,28 @@ export default function ConversationThread({
try {
const data = await apiFetch(`/api/messages/${conversationId}/delta?after_message_id=${encodeURIComponent(lastServerMessage.id)}`)
const incoming = normalizeMessages(data.data ?? [], currentUserId)
if (data?.conversation?.id) {
patchConversation(data.conversation)
}
if (Number.isFinite(Number(data?.summary?.unread_total))) {
onUnreadTotalPatched?.(data.summary.unread_total)
}
if (incoming.length > 0) {
setMessages((prev) => mergeMessageLists(prev, incoming))
const latestIncoming = incoming[incoming.length - 1] ?? null
const latestRemoteIncoming = [...incoming].reverse().find((message) => message.sender_id !== currentUserId) ?? null
if (latestIncoming) {
patchLastMessage(latestIncoming)
}
if (latestRemoteIncoming && document.visibilityState === 'visible') {
queueReadReceipt(latestRemoteIncoming.id)
}
}
} catch {
// no-op
@@ -423,7 +473,7 @@ export default function ConversationThread({
typingExpiryTimersRef.current.clear()
echo.leave(`conversation.${conversationId}`)
}
}, [apiFetch, conversationId, currentUserId, patchConversation, patchLastMessage, queueReadReceipt, realtimeEnabled])
}, [apiFetch, conversationId, currentUserId, onUnreadTotalPatched, patchConversation, patchLastMessage, queueReadReceipt, realtimeEnabled])
useEffect(() => {
const known = knownMessageIdsRef.current
@@ -464,6 +514,7 @@ export default function ConversationThread({
const handleBodyChange = useCallback((value) => {
setBody(value)
persistDraftToStorage(value)
if (value.trim() === '') {
if (stopTypingRef.current) window.clearTimeout(stopTypingRef.current)
@@ -481,7 +532,7 @@ export default function ConversationThread({
stopTypingRef.current = window.setTimeout(() => {
apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
}, 1200)
}, [apiFetch, conversationId])
}, [apiFetch, conversationId, persistDraftToStorage])
const handleFiles = useCallback((selectedFiles) => {
const nextFiles = Array.from(selectedFiles || []).slice(0, 5)
@@ -517,6 +568,7 @@ export default function ConversationThread({
shouldStickToBottomRef.current = true
setMessages((prev) => mergeMessageLists(prev, [optimisticMessage]))
setBody('')
persistDraftToStorage('')
setFiles([])
if (fileInputRef.current) fileInputRef.current.value = ''
setSending(true)
@@ -533,19 +585,28 @@ export default function ConversationThread({
body: formData,
})
if (draftStorageKey && typeof window !== 'undefined') {
try {
window.localStorage.removeItem(draftStorageKey)
} catch {
// no-op
}
}
const normalized = normalizeMessage(created, currentUserId)
setMessages((prev) => mergeMessageLists(prev, [normalized]))
patchLastMessage(normalized, { unread_count: 0 })
} catch (err) {
setMessages((prev) => prev.filter((message) => !messagesMatch(message, { id: optimisticId, client_temp_id: clientTempId })))
setBody(trimmed)
persistDraftToStorage(trimmed)
setFiles(files)
setError(err.message)
} finally {
setSending(false)
apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
}
}, [apiFetch, body, conversationId, currentUserId, currentUsername, files, patchLastMessage, sending])
}, [apiFetch, body, conversationId, currentUserId, currentUsername, files, patchLastMessage, persistDraftToStorage, sending])
const updateReactions = useCallback((messageId, summary) => {
setMessages((prev) => prev.map((message) => {
@@ -1104,9 +1165,20 @@ function participantHasReadMessage(participant, message) {
}
function formatSeenTime(iso) {
return new Date(iso).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
const then = new Date(iso).getTime()
if (!Number.isFinite(then)) {
return 'moments ago'
}
const diffSeconds = Math.max(0, Math.floor((Date.now() - then) / 1000))
if (diffSeconds < 60) return 'moments ago'
if (diffSeconds < 3600) return `${Math.floor(diffSeconds / 60)}m ago`
if (diffSeconds < 86400) return `${Math.floor(diffSeconds / 3600)}h ago`
if (diffSeconds < 604800) return `${Math.floor(diffSeconds / 86400)}d ago`
return new Date(iso).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
})
}

View File

@@ -64,6 +64,7 @@ export default function MessageBubble({ message, isMine, showAvatar, endsSequenc
return (
<div
id={message?.id ? `message-${message.id}` : undefined}
className={`group flex items-end gap-2.5 sm:gap-3 ${isMine ? 'flex-row-reverse' : 'flex-row'} ${showAvatar ? 'mt-4' : 'mt-1'}`}
style={prefersReducedMotion ? undefined : {
opacity: isArrivalVisible ? 1 : 0.55,

View File

@@ -0,0 +1,17 @@
import React from 'react'
const styles = {
idle: 'border-white/10 bg-white/[0.04] text-slate-300',
saving: 'border-sky-300/20 bg-sky-400/10 text-sky-100',
saved: 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100',
error: 'border-rose-300/20 bg-rose-400/10 text-rose-100',
}
export default function NovaCardAutosaveIndicator({ status = 'idle', message = '' }) {
return (
<span className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.16em] ${styles[status] || styles.idle}`}>
<i className={`fa-solid ${status === 'saving' ? 'fa-rotate fa-spin' : status === 'saved' ? 'fa-check' : status === 'error' ? 'fa-triangle-exclamation' : 'fa-cloud'}`} />
{message || (status === 'saving' ? 'Saving' : status === 'saved' ? 'Saved' : status === 'error' ? 'Failed' : 'Draft')}
</span>
)
}

View File

@@ -0,0 +1,187 @@
import React from 'react'
const aspectRatios = {
square: '1 / 1',
portrait: '4 / 5',
story: '9 / 16',
landscape: '16 / 9',
}
const placementStyles = {
'top-left': { top: '12%', left: '12%' },
'top-right': { top: '12%', right: '12%' },
'bottom-left': { bottom: '12%', left: '12%' },
'bottom-right': { bottom: '12%', right: '12%' },
}
function overlayStyle(style) {
if (style === 'dark-strong') return 'linear-gradient(180deg, rgba(2,6,23,0.38), rgba(2,6,23,0.68))'
if (style === 'light-soft') return 'linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0.22))'
if (style === 'dark-soft') return 'linear-gradient(180deg, rgba(2,6,23,0.18), rgba(2,6,23,0.48))'
return 'none'
}
function positionStyle(position) {
if (position === 'top') return { alignItems: 'flex-start', paddingTop: '10%' }
if (position === 'upper-middle') return { alignItems: 'flex-start', paddingTop: '22%' }
if (position === 'lower-middle') return { alignItems: 'flex-end', paddingBottom: '18%' }
if (position === 'bottom') return { alignItems: 'flex-end', paddingBottom: '10%' }
return { alignItems: 'center' }
}
function alignmentClass(alignment) {
if (alignment === 'left') return 'items-start text-left'
if (alignment === 'right') return 'items-end text-right'
return 'items-center text-center'
}
function focalPositionStyle(position) {
if (position === 'top') return 'center top'
if (position === 'bottom') return 'center bottom'
if (position === 'left') return 'left center'
if (position === 'right') return 'right center'
if (position === 'top-left') return 'left top'
if (position === 'top-right') return 'right top'
if (position === 'bottom-left') return 'left bottom'
if (position === 'bottom-right') return 'right bottom'
return 'center center'
}
function shadowValue(preset) {
if (preset === 'none') return 'none'
if (preset === 'strong') return '0 12px 36px rgba(2, 6, 23, 0.72)'
return '0 8px 24px rgba(2, 6, 23, 0.5)'
}
function resolveTextBlocks(card, project) {
const blocks = Array.isArray(project.text_blocks) ? project.text_blocks.filter((block) => block?.enabled !== false && String(block?.text || '').trim() !== '') : []
if (blocks.length) return blocks
const content = project.content || {}
return [
{ key: 'title', type: 'title', text: card?.title || content.title || 'Untitled card', enabled: true },
{ key: 'quote', type: 'quote', text: card?.quote_text || content.quote_text || 'Your next quote starts here.', enabled: true },
{ key: 'author', type: 'author', text: card?.quote_author || content.quote_author || '', enabled: Boolean(card?.quote_author || content.quote_author) },
{ key: 'source', type: 'source', text: card?.quote_source || content.quote_source || '', enabled: Boolean(card?.quote_source || content.quote_source) },
].filter((block) => String(block.text || '').trim() !== '')
}
function blockClass(type) {
if (type === 'title') return 'text-[11px] font-semibold uppercase tracking-[0.28em] text-white/55'
if (type === 'author') return 'font-medium uppercase tracking-[0.18em] text-white/80 sm:text-sm lg:text-base'
if (type === 'source') return 'text-[11px] uppercase tracking-[0.18em] text-white/50 sm:text-xs'
if (type === 'caption') return 'text-[10px] uppercase tracking-[0.2em] text-white/45'
if (type === 'body') return 'text-sm leading-6 text-white/90 sm:text-base'
return 'font-semibold tracking-[-0.03em] sm:text-[1.65rem] lg:text-[2.1rem]'
}
function blockStyle(type, typography, textColor, accentColor) {
const quoteSize = Math.max(26, Math.min(typography.quote_size || 72, 120))
const authorSize = Math.max(14, Math.min(typography.author_size || 28, 42))
const letterSpacing = Math.max(-1, Math.min(typography.letter_spacing || 0, 10))
const lineHeight = Math.max(0.9, Math.min(typography.line_height || 1.2, 1.8))
const shadowPreset = typography.shadow_preset || 'soft'
if (type === 'title') {
return { color: accentColor, letterSpacing: `${Math.max(letterSpacing, 0) / 10}em`, textShadow: shadowValue(shadowPreset) }
}
if (type === 'author' || type === 'source') {
return { color: accentColor, fontSize: `${authorSize / 4}px`, textShadow: shadowValue(shadowPreset) }
}
if (type === 'body' || type === 'caption') {
return { color: textColor, lineHeight, textShadow: shadowValue(shadowPreset) }
}
return { color: textColor, fontSize: `${quoteSize / 4}px`, lineHeight, letterSpacing: `${letterSpacing / 12}px`, textShadow: shadowValue(shadowPreset) }
}
export default function NovaCardCanvasPreview({ card, className = '' }) {
const project = card?.project_json || {}
const layout = project.layout || {}
const typography = project.typography || {}
const background = project.background || {}
const backgroundImage = card?.background_image?.processed_url
const colors = Array.isArray(background.gradient_colors) && background.gradient_colors.length >= 2
? background.gradient_colors
: ['#0f172a', '#1d4ed8']
const backgroundStyle = background.type === 'upload' && backgroundImage
? `url(${backgroundImage}) ${focalPositionStyle(background.focal_position)}/cover no-repeat`
: background.type === 'solid'
? background.solid_color || '#111827'
: `linear-gradient(180deg, ${colors[0]}, ${colors[1]})`
const textBlocks = resolveTextBlocks(card, project)
const decorations = Array.isArray(project.decorations) ? project.decorations : []
const assetItems = Array.isArray(project.assets?.items) ? project.assets.items : []
const textColor = typography.text_color || '#ffffff'
const accentColor = typography.accent_color || textColor
const maxWidth = layout.max_width === 'compact' ? '62%' : layout.max_width === 'wide' ? '88%' : '76%'
const padding = layout.padding === 'tight' ? '8%' : layout.padding === 'airy' ? '14%' : '11%'
return (
<div className={`relative overflow-hidden rounded-[28px] border border-white/10 bg-slate-950 shadow-[0_30px_80px_rgba(2,6,23,0.35)] ${className}`} style={{ aspectRatio: aspectRatios[card?.format || 'square'] || aspectRatios.square }}>
<div className="absolute inset-0" style={{ background: backgroundStyle, filter: background.type === 'upload' && Number(background.blur_level || 0) > 0 ? `blur(${Math.max(Number(background.blur_level) / 8, 0)}px)` : undefined }} />
<div className="absolute inset-0" style={{ background: overlayStyle(background.overlay_style), opacity: Math.max(0, Math.min(Number(background.opacity || 50), 100)) / 100 }} />
<div className="absolute left-4 top-4 rounded-full border border-white/10 bg-black/25 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white/80 backdrop-blur">
{(card?.format || 'square').replace('-', ' ')}
</div>
{decorations.slice(0, 6).map((decoration, index) => {
const placement = placementStyles[decoration.placement] || placementStyles['top-right']
return (
<div
key={`${decoration.key || decoration.glyph || 'dec'}-${index}`}
className="absolute text-white/85 drop-shadow-[0_6px_14px_rgba(2,6,23,0.3)]"
style={{
...placement,
color: accentColor,
fontSize: `${Math.max(18, Math.min(decoration.size || 28, 64))}px`,
}}
>
{decoration.glyph || '✦'}
</div>
)
})}
{assetItems.slice(0, 6).map((item, index) => {
if (item?.type === 'frame') {
const top = index % 2 === 0 ? '10%' : '88%'
return <div key={`${item.asset_key || item.label || 'frame'}-${index}`} className="absolute left-[12%] right-[12%] h-px bg-white/35" style={{ top }} />
}
return (
<div
key={`${item.asset_key || item.label || 'asset'}-${index}`}
className="absolute text-white/85 drop-shadow-[0_6px_14px_rgba(2,6,23,0.3)]"
style={{
top: `${12 + ((index % 3) * 18)}%`,
left: `${10 + (Math.floor(index / 3) * 72)}%`,
color: accentColor,
fontSize: `${Math.max(18, Math.min(item.size || 26, 56))}px`,
}}
>
{item.glyph || item.label || '✦'}
</div>
)
})}
<div className={`relative flex h-full w-full ${alignmentClass(layout.alignment)}`} style={{ padding, ...positionStyle(layout.position) }}>
<div className="flex w-full flex-col gap-4" style={{ maxWidth }}>
{textBlocks.map((block, index) => {
const type = block?.type || 'body'
const text = type === 'author' ? `${block.text}` : block.text
return (
<div key={`${block.key || type}-${index}`} style={blockStyle(type, typography, textColor, accentColor)} className={blockClass(type)}>
{text}
</div>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,22 @@
import React from 'react'
export default function NovaCardFontPicker({ fonts = [], selectedKey = null, onSelect }) {
return (
<div className="grid gap-3 sm:grid-cols-2">
{fonts.map((font) => {
const active = selectedKey === font.key
return (
<button
key={font.key}
type="button"
onClick={() => onSelect?.(font)}
className={`rounded-[22px] border p-4 text-left transition ${active ? 'border-sky-300/35 bg-sky-400/10 text-white' : 'border-white/10 bg-white/[0.03] text-slate-200 hover:border-white/20 hover:bg-white/[0.05]'}`}
>
<div className="text-lg font-semibold tracking-[-0.03em]" style={{ fontFamily: font.family }}>{font.label}</div>
<div className="mt-2 text-sm text-slate-400">{font.recommended_use}</div>
</button>
)
})}
</div>
)
}

View File

@@ -0,0 +1,25 @@
import React from 'react'
export default function NovaCardGradientPicker({ gradients = [], selectedKey = null, onSelect }) {
return (
<div className="grid gap-3 sm:grid-cols-2">
{gradients.map((gradient) => {
const active = selectedKey === gradient.key
return (
<button
key={gradient.key}
type="button"
onClick={() => onSelect?.(gradient)}
className={`overflow-hidden rounded-[22px] border text-left transition ${active ? 'border-sky-300/35 bg-sky-400/10' : 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]'}`}
>
<div className="h-20 w-full" style={{ background: `linear-gradient(135deg, ${gradient.colors?.[0] || '#0f172a'}, ${gradient.colors?.[1] || '#1d4ed8'})` }} />
<div className="p-3">
<div className="text-sm font-semibold text-white">{gradient.label}</div>
<div className="mt-1 text-[11px] uppercase tracking-[0.18em] text-slate-400">{gradient.key}</div>
</div>
</button>
)
})}
</div>
)
}

View File

@@ -0,0 +1,255 @@
import React from 'react'
const TYPE_LABELS = {
style: 'Style',
layout: 'Layout',
background: 'Background',
typography: 'Typography',
starter: 'Starter',
}
const TYPE_ICONS = {
style: 'fa-palette',
layout: 'fa-table-columns',
background: 'fa-image',
typography: 'fa-font',
starter: 'fa-star',
}
function PresetCard({ preset, onApply, onDelete, applying }) {
return (
<div className="group relative flex items-center gap-3 rounded-[18px] border border-white/10 bg-white/[0.03] px-3.5 py-3 transition hover:border-white/20 hover:bg-white/[0.05]">
<button
type="button"
disabled={applying}
onClick={() => onApply(preset)}
className="flex flex-1 items-center gap-3 text-left"
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-white/10 bg-white/[0.06] text-sky-300 text-xs">
<i className={`fa-solid ${TYPE_ICONS[preset.preset_type] || 'fa-sparkles'}`} />
</span>
<span className="flex-1 min-w-0">
<span className="block truncate text-sm font-semibold text-white">{preset.name}</span>
<span className="block text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">
{TYPE_LABELS[preset.preset_type] || preset.preset_type}
{preset.is_default ? ' · Default' : ''}
</span>
</span>
{applying ? (
<i className="fa-solid fa-rotate fa-spin text-sky-300 text-xs" />
) : (
<i className="fa-solid fa-chevron-right text-slate-500 text-xs opacity-0 transition group-hover:opacity-100" />
)}
</button>
<button
type="button"
onClick={() => onDelete(preset)}
className="ml-1 rounded-full p-1.5 text-slate-500 opacity-0 transition hover:text-rose-400 group-hover:opacity-100"
title="Delete preset"
>
<i className="fa-solid fa-trash-can text-xs" />
</button>
</div>
)
}
export default function NovaCardPresetPicker({
presets = {},
endpoints = {},
cardId = null,
onApplyPatch,
onPresetsChange,
activeType = null,
}) {
const [selectedType, setSelectedType] = React.useState(activeType || 'style')
const [applyingId, setApplyingId] = React.useState(null)
const [capturing, setCapturing] = React.useState(false)
const [captureName, setCaptureName] = React.useState('')
const [captureType, setCaptureType] = React.useState('style')
const [showCaptureForm, setShowCaptureForm] = React.useState(false)
const [error, setError] = React.useState(null)
const typeKeys = Object.keys(TYPE_LABELS)
const listedPresets = Array.isArray(presets[selectedType]) ? presets[selectedType] : []
async function handleApply(preset) {
if (!cardId || !endpoints.presetApplyPattern) return
const url = endpoints.presetApplyPattern
.replace('__PRESET__', preset.id)
.replace('__CARD__', cardId)
setApplyingId(preset.id)
setError(null)
try {
const res = await fetch(url, {
method: 'GET',
headers: { 'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json' },
})
const data = await res.json()
if (!res.ok) throw new Error(data?.message || 'Failed to apply preset')
if (data?.project_patch && onApplyPatch) {
onApplyPatch(data.project_patch)
}
} catch (err) {
setError(err.message || 'Failed to apply preset')
} finally {
setApplyingId(null)
}
}
async function handleDelete(preset) {
if (!endpoints.presetDestroyPattern) return
const url = endpoints.presetDestroyPattern.replace('__PRESET__', preset.id)
if (!window.confirm(`Delete preset "${preset.name}"?`)) return
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
const res = await fetch(url, {
method: 'DELETE',
headers: {
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
'X-CSRF-TOKEN': csrfToken,
},
})
if (!res.ok) throw new Error('Failed to delete preset')
onPresetsChange?.()
} catch (err) {
setError(err.message || 'Failed to delete preset')
}
}
async function handleCapture(e) {
e.preventDefault()
if (!cardId || !endpoints.capturePresetPattern || !captureName.trim()) return
const url = endpoints.capturePresetPattern.replace('__CARD__', cardId)
setCapturing(true)
setError(null)
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
'X-CSRF-TOKEN': csrfToken,
},
body: JSON.stringify({ name: captureName.trim(), preset_type: captureType }),
})
const data = await res.json()
if (!res.ok) throw new Error(data?.message || 'Failed to capture preset')
setCaptureName('')
setShowCaptureForm(false)
onPresetsChange?.()
} catch (err) {
setError(err.message || 'Failed to capture preset')
} finally {
setCapturing(false)
}
}
return (
<div className="flex flex-col gap-4">
{/* Type tabs */}
<div className="flex flex-wrap gap-2">
{typeKeys.map((type) => (
<button
key={type}
type="button"
onClick={() => setSelectedType(type)}
className={`rounded-full border px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.16em] transition ${
selectedType === type
? 'border-sky-300/30 bg-sky-400/15 text-sky-100'
: 'border-white/10 bg-white/[0.03] text-slate-300 hover:bg-white/[0.05]'
}`}
>
<i className={`fa-solid ${TYPE_ICONS[type]} mr-1.5`} />
{TYPE_LABELS[type]}
{Array.isArray(presets[type]) && presets[type].length > 0 && (
<span className="ml-1.5 rounded-full bg-white/10 px-1.5 text-[10px]">{presets[type].length}</span>
)}
</button>
))}
</div>
{/* Preset list */}
{listedPresets.length > 0 ? (
<div className="flex flex-col gap-2">
{listedPresets.map((preset) => (
<PresetCard
key={preset.id}
preset={preset}
applying={applyingId === preset.id}
onApply={handleApply}
onDelete={handleDelete}
/>
))}
</div>
) : (
<p className="text-sm text-slate-500">No {TYPE_LABELS[selectedType]?.toLowerCase()} presets saved yet.</p>
)}
{/* Capture from current card */}
{cardId && endpoints.capturePresetPattern && (
<div className="mt-1 border-t border-white/[0.06] pt-3">
{showCaptureForm ? (
<form onSubmit={handleCapture} className="flex flex-col gap-2">
<input
type="text"
value={captureName}
onChange={(e) => setCaptureName(e.target.value)}
placeholder="Preset name…"
maxLength={64}
required
className="w-full rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white placeholder-slate-500 outline-none focus:border-sky-400/40"
/>
<select
value={captureType}
onChange={(e) => setCaptureType(e.target.value)}
className="w-full rounded-xl border border-white/10 bg-slate-900 px-3 py-2 text-sm text-white outline-none focus:border-sky-400/40"
>
{typeKeys.map((type) => (
<option key={type} value={type}>{TYPE_LABELS[type]}</option>
))}
</select>
<div className="flex gap-2">
<button
type="submit"
disabled={capturing || !captureName.trim()}
className="flex-1 rounded-xl bg-sky-500/20 py-2 text-sm font-semibold text-sky-200 transition hover:bg-sky-500/30 disabled:opacity-50"
>
{capturing ? 'Saving…' : 'Save preset'}
</button>
<button
type="button"
onClick={() => setShowCaptureForm(false)}
className="rounded-xl border border-white/10 px-4 py-2 text-sm text-slate-400 transition hover:bg-white/[0.05]"
>
Cancel
</button>
</div>
</form>
) : (
<button
type="button"
onClick={() => setShowCaptureForm(true)}
className="flex w-full items-center gap-2 rounded-xl border border-dashed border-white/15 px-3 py-2.5 text-sm text-slate-400 transition hover:border-white/25 hover:text-slate-200"
>
<i className="fa-solid fa-plus text-xs" />
Capture current as preset
</button>
)}
</div>
)}
{error && (
<p className="rounded-xl border border-rose-400/20 bg-rose-400/10 px-3 py-2 text-xs text-rose-300">{error}</p>
)}
</div>
)
}

View File

@@ -0,0 +1,31 @@
import React from 'react'
export default function NovaCardTemplatePicker({ templates = [], selectedId = null, onSelect }) {
return (
<div className="grid gap-3 sm:grid-cols-2">
{templates.map((template) => {
const active = Number(selectedId) === Number(template.id)
return (
<button
key={template.id}
type="button"
onClick={() => onSelect?.(template)}
className={`rounded-[24px] border p-4 text-left transition ${active ? 'border-sky-300/35 bg-sky-400/10 text-white' : 'border-white/10 bg-white/[0.03] text-slate-200 hover:border-white/20 hover:bg-white/[0.05]'}`}
>
<div className="text-xs font-semibold uppercase tracking-[0.22em] text-slate-400">Template</div>
<div className="mt-2 text-base font-semibold tracking-[-0.03em]">{template.name}</div>
{template.description ? <div className="mt-2 text-sm text-slate-400">{template.description}</div> : null}
<div className="mt-3 flex flex-wrap gap-2 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">
{(template.supported_formats || []).map((format) => (
<span key={`${template.id}-${format}`} className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">
{format}
</span>
))}
</div>
</button>
)
})}
</div>
)
}

View File

@@ -3,13 +3,15 @@ import ProfileCoverEditor from './ProfileCoverEditor'
import LevelBadge from '../xp/LevelBadge'
import XPProgressBar from '../xp/XPProgressBar'
import FollowButton from '../social/FollowButton'
import FollowersPreview from '../social/FollowersPreview'
import MutualFollowersBadge from '../social/MutualFollowersBadge'
function formatCompactNumber(value) {
const numeric = Number(value ?? 0)
return numeric.toLocaleString()
}
export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing, followerCount, heroBgUrl, countryName, leaderboardRank, extraActions = null }) {
export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing, followerCount, recentFollowers = [], followContext = null, heroBgUrl, countryName, leaderboardRank, extraActions = null }) {
const [following, setFollowing] = useState(viewerIsFollowing)
const [count, setCount] = useState(followerCount)
const [editorOpen, setEditorOpen] = useState(false)
@@ -22,11 +24,10 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
? new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
: null
const bio = profile?.bio || profile?.about || ''
const heroFacts = [
const progressPercent = Math.round(Number(user?.progress_percent ?? 0))
const heroStats = [
{ label: 'Followers', value: formatCompactNumber(count) },
{ label: 'Level', value: `Lv ${formatCompactNumber(user?.level ?? 1)}` },
{ label: 'Progress', value: `${Math.round(Number(user?.progress_percent ?? 0))}%` },
{ label: 'Member since', value: joinDate ?? 'Recently joined' },
]
return (
@@ -99,7 +100,7 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
</div>
<div className="min-w-0 flex-1 text-center md:text-left">
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_280px] xl:items-start">
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_430px] xl:items-start">
<div className="min-w-0">
<div className="flex flex-wrap items-center justify-center gap-2 md:justify-start">
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300">
@@ -115,6 +116,7 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
<div className="mt-4 flex flex-wrap items-center justify-center gap-2 md:justify-start">
<LevelBadge level={user?.level} rank={user?.rank} />
{!isOwner ? <MutualFollowersBadge context={followContext} /> : null}
{countryName ? (
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-slate-300">
{profile?.country_code ? (
@@ -171,7 +173,15 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
</div>
<div className="space-y-3 xl:pt-1">
<div className="flex flex-wrap items-center justify-center gap-2 xl:justify-end">
{!isOwner && recentFollowers?.length > 0 ? (
<FollowersPreview
users={followContext?.follower_overlap?.users?.length ? followContext.follower_overlap.users : recentFollowers}
label={followContext?.follower_overlap?.label || `${formatCompactNumber(followerCount)} followers`}
href={`/@${uname}/activity`}
/>
) : null}
<div className="flex flex-wrap items-center justify-center gap-2 xl:flex-nowrap xl:justify-end">
{extraActions}
{isOwner ? (
<>
@@ -198,8 +208,10 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
username={uname}
initialFollowing={following}
initialCount={count}
className="shrink-0 whitespace-nowrap"
followingClassName="border border-emerald-400/40 bg-emerald-500/12 text-emerald-300 hover:bg-emerald-500/18"
idleClassName="border border-sky-400/40 bg-sky-500/12 text-sky-200 hover:bg-sky-500/20"
sizeClassName="px-3.5 py-2 text-sm"
onChange={({ following: nextFollowing, followersCount }) => {
setFollowing(nextFollowing)
setCount(followersCount)
@@ -216,7 +228,7 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
}
}}
aria-label="Share profile"
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-medium text-slate-300 transition-all hover:bg-white/[0.08] hover:text-white"
className="inline-flex shrink-0 items-center gap-2 whitespace-nowrap rounded-2xl border border-white/10 bg-white/[0.04] px-3.5 py-2 text-sm font-medium text-slate-300 transition-all hover:bg-white/[0.08] hover:text-white"
>
<i className="fa-solid fa-share-nodes fa-fw" />
Share
@@ -225,16 +237,32 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
)}
</div>
<div className="grid grid-cols-2 gap-2 text-left">
{heroFacts.map((fact) => (
<div
key={fact.label}
className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]"
>
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">{fact.label}</div>
<div className="mt-1.5 text-sm font-semibold tracking-tight text-white md:text-base">{fact.value}</div>
</div>
))}
<div className="rounded-[24px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.72),rgba(9,17,31,0.92))] p-3 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]">
<div className="grid grid-cols-2 gap-2">
{heroStats.map((fact) => (
<div
key={fact.label}
className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2.5"
>
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">{fact.label}</div>
<div className="mt-1 text-sm font-semibold tracking-tight text-white md:text-[15px]">{fact.value}</div>
</div>
))}
</div>
<div className="mt-2.5 flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-[11px] font-medium text-slate-300">
<span className="h-1.5 w-1.5 rounded-full bg-cyan-300" />
Progress {progressPercent}%
</span>
{joinDate ? (
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-[11px] font-medium text-slate-300">
<i className="fa-solid fa-calendar-days text-[10px] text-slate-500" />
Since {joinDate}
</span>
) : null}
</div>
</div>
</div>
</div>
@@ -246,10 +274,8 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
</div>
<ProfileCoverEditor
open={editorOpen}
isOpen={editorOpen}
onClose={() => setEditorOpen(false)}
currentCoverUrl={coverUrl}
currentPosition={coverPosition}
coverUrl={coverUrl}
coverPosition={coverPosition}
onCoverUpdated={(nextUrl, nextPosition) => {

View File

@@ -0,0 +1,197 @@
import React from 'react'
function typeMeta(type) {
switch (type) {
case 'upload':
return { icon: 'fa-solid fa-image', label: 'Upload', tone: 'text-sky-200 bg-sky-400/12 border-sky-300/20' }
case 'comment':
return { icon: 'fa-solid fa-comment-dots', label: 'Comment', tone: 'text-amber-100 bg-amber-400/12 border-amber-300/20' }
case 'reply':
return { icon: 'fa-solid fa-reply', label: 'Reply', tone: 'text-orange-100 bg-orange-400/12 border-orange-300/20' }
case 'like':
return { icon: 'fa-solid fa-heart', label: 'Like', tone: 'text-rose-100 bg-rose-400/12 border-rose-300/20' }
case 'favourite':
return { icon: 'fa-solid fa-bookmark', label: 'Favourite', tone: 'text-pink-100 bg-pink-400/12 border-pink-300/20' }
case 'follow':
return { icon: 'fa-solid fa-user-plus', label: 'Follow', tone: 'text-emerald-100 bg-emerald-400/12 border-emerald-300/20' }
case 'achievement':
return { icon: 'fa-solid fa-trophy', label: 'Achievement', tone: 'text-yellow-100 bg-yellow-400/12 border-yellow-300/20' }
case 'forum_post':
return { icon: 'fa-solid fa-signs-post', label: 'Forum thread', tone: 'text-violet-100 bg-violet-400/12 border-violet-300/20' }
case 'forum_reply':
return { icon: 'fa-solid fa-comments', label: 'Forum reply', tone: 'text-indigo-100 bg-indigo-400/12 border-indigo-300/20' }
default:
return { icon: 'fa-solid fa-bolt', label: 'Activity', tone: 'text-slate-100 bg-white/6 border-white/10' }
}
}
function profileName(actor) {
if (!actor) return 'Creator'
return actor.username ? `@${actor.username}` : actor.name || 'Creator'
}
function headline(activity) {
switch (activity?.type) {
case 'upload':
return activity?.artwork?.title ? `Uploaded ${activity.artwork.title}` : 'Uploaded new artwork'
case 'comment':
return activity?.artwork?.title ? `Commented on ${activity.artwork.title}` : 'Posted a new comment'
case 'reply':
return activity?.artwork?.title ? `Replied on ${activity.artwork.title}` : 'Posted a reply'
case 'like':
return activity?.artwork?.title ? `Liked ${activity.artwork.title}` : 'Liked an artwork'
case 'favourite':
return activity?.artwork?.title ? `Favourited ${activity.artwork.title}` : 'Saved an artwork'
case 'follow':
return activity?.target_user ? `Started following @${activity.target_user.username || activity.target_user.name}` : 'Started following a creator'
case 'achievement':
return activity?.achievement?.name ? `Unlocked ${activity.achievement.name}` : 'Unlocked a new achievement'
case 'forum_post':
return activity?.forum?.thread?.title ? `Started forum thread ${activity.forum.thread.title}` : 'Started a new forum thread'
case 'forum_reply':
return activity?.forum?.thread?.title ? `Replied in ${activity.forum.thread.title}` : 'Posted a forum reply'
default:
return 'Shared new activity'
}
}
function body(activity) {
if (activity?.comment?.body) return activity.comment.body
if (activity?.forum?.post?.excerpt) return activity.forum.post.excerpt
if (activity?.achievement?.description) return activity.achievement.description
return ''
}
function cta(activity) {
if (activity?.comment?.url) return { href: activity.comment.url, label: 'Open comment' }
if (activity?.artwork?.url) return { href: activity.artwork.url, label: 'View artwork' }
if (activity?.forum?.post?.url) return { href: activity.forum.post.url, label: 'Open reply' }
if (activity?.forum?.thread?.url) return { href: activity.forum.thread.url, label: 'Open thread' }
if (activity?.target_user?.profile_url) return { href: activity.target_user.profile_url, label: 'View profile' }
return null
}
function AchievementIcon({ achievement }) {
const raw = String(achievement?.icon || '').trim()
const className = raw.startsWith('fa-') ? raw : `fa-solid ${raw || 'fa-trophy'}`
return (
<div className="inline-flex h-12 w-12 items-center justify-center rounded-2xl border border-yellow-300/20 bg-yellow-400/12 text-yellow-100">
<i className={className} />
</div>
)
}
export default function ActivityCard({ activity }) {
const meta = typeMeta(activity?.type)
const nextAction = cta(activity)
const copy = body(activity)
return (
<article className="rounded-[28px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.96),rgba(7,11,19,0.92))] p-5 shadow-[0_18px_45px_rgba(0,0,0,0.28)] backdrop-blur-xl">
<div className="flex flex-col gap-4 md:flex-row md:items-start">
<div className="flex items-start gap-4 md:w-[17rem] md:shrink-0">
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-[20px] border border-white/10 bg-slate-950/70">
{activity?.actor?.avatar_url ? (
<img src={activity.actor.avatar_url} alt={profileName(activity.actor)} className="h-full w-full object-cover" loading="lazy" />
) : (
<div className="flex h-full w-full items-center justify-center text-slate-500">
<i className="fa-solid fa-user" />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-white">{profileName(activity.actor)}</div>
{activity?.actor?.badge?.label ? (
<div className="mt-1 inline-flex items-center rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300">
{activity.actor.badge.label}
</div>
) : null}
<div className="mt-2 text-xs uppercase tracking-[0.18em] text-slate-500">{activity?.time_ago || ''}</div>
</div>
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className={`inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] ${meta.tone}`}>
<i className={meta.icon} />
{meta.label}
</span>
</div>
<h3 className="mt-3 text-lg font-semibold tracking-[-0.02em] text-white">{headline(activity)}</h3>
{copy ? <p className="mt-2 max-w-3xl text-sm leading-7 text-slate-400">{copy}</p> : null}
</div>
<div className="text-xs text-slate-500 md:text-right">{activity?.created_at ? new Date(activity.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : ''}</div>
</div>
{activity?.artwork ? (
<div className="mt-4 flex items-center gap-3 rounded-2xl border border-white/[0.06] bg-white/[0.03] p-3">
{activity.artwork.thumb ? (
<img src={activity.artwork.thumb} alt={activity.artwork.title} className="h-16 w-16 rounded-2xl object-cover ring-1 ring-white/10" loading="lazy" />
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-slate-500">
<i className="fa-solid fa-image" />
</div>
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-white">{activity.artwork.title}</div>
<div className="mt-2 flex flex-wrap gap-3 text-xs text-slate-400">
<span>{Number(activity.artwork.stats?.likes || 0).toLocaleString()} likes</span>
<span>{Number(activity.artwork.stats?.views || 0).toLocaleString()} views</span>
<span>{Number(activity.artwork.stats?.comments || 0).toLocaleString()} comments</span>
</div>
</div>
</div>
) : null}
{activity?.target_user ? (
<div className="mt-4 flex items-center gap-3 rounded-2xl border border-white/[0.06] bg-white/[0.03] p-3">
<div className="h-12 w-12 overflow-hidden rounded-2xl border border-white/10 bg-slate-950/70">
{activity.target_user.avatar_url ? (
<img src={activity.target_user.avatar_url} alt={activity.target_user.username || activity.target_user.name} className="h-full w-full object-cover" loading="lazy" />
) : null}
</div>
<div className="min-w-0 flex-1">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Target creator</div>
<div className="mt-1 text-sm font-medium text-white">@{activity.target_user.username || activity.target_user.name}</div>
</div>
</div>
) : null}
{activity?.achievement ? (
<div className="mt-4 flex items-center gap-3 rounded-2xl border border-white/[0.06] bg-white/[0.03] p-3">
<AchievementIcon achievement={activity.achievement} />
<div className="min-w-0 flex-1">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Achievement unlocked</div>
<div className="mt-1 text-sm font-medium text-white">{activity.achievement.name}</div>
{activity.achievement.description ? <div className="mt-1 text-sm text-slate-400">{activity.achievement.description}</div> : null}
</div>
</div>
) : null}
{activity?.forum?.thread ? (
<div className="mt-4 rounded-2xl border border-white/[0.06] bg-white/[0.03] p-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Forum activity</div>
<div className="mt-1 text-sm font-medium text-white">{activity.forum.thread.title}</div>
<div className="mt-2 text-xs text-slate-400">{activity.forum.thread.category_name}</div>
</div>
) : null}
{nextAction ? (
<a
href={nextAction.href}
className="mt-4 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-3.5 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-200 transition hover:border-white/20 hover:bg-white/[0.1] hover:text-white"
>
{nextAction.label}
<i className="fa-solid fa-arrow-right text-[10px]" />
</a>
) : null}
</div>
</div>
</article>
)
}

View File

@@ -0,0 +1,63 @@
import React from 'react'
import ActivityCard from './ActivityCard'
export default function ActivityFeed({ activities, loading, loadingMore, error, sentinelRef }) {
if (loading) {
return (
<div className="rounded-[28px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(10,16,26,0.92),rgba(6,10,18,0.88))] p-6 text-sm text-slate-400 shadow-[0_20px_60px_rgba(0,0,0,0.25)]">
Loading activity...
</div>
)
}
if (error) {
return (
<div className="rounded-[28px] border border-rose-300/20 bg-rose-500/10 p-6 text-sm text-rose-100 shadow-[0_20px_60px_rgba(0,0,0,0.25)]">
{error}
</div>
)
}
if (!activities.length) {
return (
<div className="rounded-[28px] border border-dashed border-white/10 bg-[linear-gradient(180deg,rgba(10,16,26,0.92),rgba(6,10,18,0.88))] px-6 py-12 text-center shadow-[0_20px_60px_rgba(0,0,0,0.25)]">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-3xl border border-white/10 bg-white/[0.04] text-sky-300">
<i className="fa-solid fa-wave-square text-xl" />
</div>
<h3 className="mt-5 text-lg font-semibold text-white">No activity yet</h3>
<p className="mx-auto mt-2 max-w-lg text-sm leading-7 text-slate-400">
Upload artwork, join a conversation, follow creators, or post in the forum to start building this profile timeline.
</p>
<div className="mt-6 flex flex-wrap justify-center gap-3">
<a href="/upload" className="inline-flex items-center gap-2 rounded-full border border-sky-300/30 bg-sky-400/12 px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100 transition hover:border-sky-200/50 hover:bg-sky-400/18">
<i className="fa-solid fa-upload" />
Upload artwork
</a>
<a href="/uploads/latest" className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-200 transition hover:border-white/20 hover:bg-white/[0.1] hover:text-white">
<i className="fa-solid fa-comment-dots" />
Comment on artwork
</a>
<a href="/discover/trending" className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-200 transition hover:border-white/20 hover:bg-white/[0.1] hover:text-white">
<i className="fa-solid fa-user-plus" />
Follow creators
</a>
</div>
</div>
)
}
return (
<div className="space-y-4">
{activities.map((activity) => (
<ActivityCard key={activity.id} activity={activity} />
))}
<div ref={sentinelRef} className="h-12" aria-hidden="true" />
{loadingMore ? (
<div className="text-center text-sm text-slate-400">Loading more activity...</div>
) : null}
</div>
)
}

View File

@@ -0,0 +1,36 @@
import React from 'react'
const FILTERS = [
{ key: 'all', label: 'All' },
{ key: 'uploads', label: 'Uploads' },
{ key: 'comments', label: 'Comments' },
{ key: 'likes', label: 'Likes' },
{ key: 'forum', label: 'Forum' },
{ key: 'following', label: 'Following' },
]
export default function ActivityFilters({ activeFilter, onChange }) {
return (
<div className="flex flex-wrap gap-2">
{FILTERS.map((filter) => {
const active = activeFilter === filter.key
return (
<button
key={filter.key}
type="button"
onClick={() => onChange(filter.key)}
className={[
'inline-flex items-center rounded-full border px-3.5 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] transition-all',
active
? 'border-sky-300/35 bg-sky-400/14 text-sky-100 shadow-[0_0_0_1px_rgba(125,211,252,0.1)]'
: 'border-white/10 bg-white/[0.04] text-slate-300 hover:border-white/20 hover:bg-white/[0.08] hover:text-white',
].join(' ')}
>
{filter.label}
</button>
)
})}
</div>
)
}

View File

@@ -0,0 +1,138 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import ActivityFeed from './ActivityFeed'
import ActivityFilters from './ActivityFilters'
function endpointForUser(user) {
return `/api/profile/${encodeURIComponent(user.username || user.name || '')}/activity`
}
export default function ActivityTab({ user }) {
const [activeFilter, setActiveFilter] = useState('all')
const [activities, setActivities] = useState([])
const [meta, setMeta] = useState({ current_page: 1, has_more: false, total: 0 })
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState('')
const requestIdRef = useRef(0)
const sentinelRef = useRef(null)
const fetchFeed = useCallback(async ({ filter, page, append }) => {
const requestId = requestIdRef.current + 1
requestIdRef.current = requestId
if (append) {
setLoadingMore(true)
} else {
setLoading(true)
}
try {
setError('')
const params = new URLSearchParams({
filter,
page: String(page),
per_page: '20',
})
const response = await fetch(`${endpointForUser(user)}?${params.toString()}`, {
headers: {
Accept: 'application/json',
},
credentials: 'same-origin',
})
if (!response.ok) {
throw new Error('Failed to load profile activity.')
}
const payload = await response.json()
if (requestId !== requestIdRef.current) return
setActivities((current) => append ? [...current, ...(payload.data || [])] : (payload.data || []))
setMeta(payload.meta || { current_page: page, has_more: false, total: 0 })
} catch {
if (requestId === requestIdRef.current) {
setError('Could not load this activity timeline right now.')
}
} finally {
if (requestId === requestIdRef.current) {
setLoading(false)
setLoadingMore(false)
}
}
}, [user])
useEffect(() => {
fetchFeed({ filter: activeFilter, page: 1, append: false })
}, [activeFilter, fetchFeed])
const hasMore = Boolean(meta?.has_more)
const nextPage = Number(meta?.current_page || 1) + 1
useEffect(() => {
const sentinel = sentinelRef.current
if (!sentinel || loading || loadingMore || !hasMore || !('IntersectionObserver' in window)) {
return undefined
}
const observer = new IntersectionObserver((entries) => {
const [entry] = entries
if (entry?.isIntersecting) {
fetchFeed({ filter: activeFilter, page: nextPage, append: true })
}
}, { rootMargin: '240px 0px' })
observer.observe(sentinel)
return () => observer.disconnect()
}, [activeFilter, fetchFeed, hasMore, loading, loadingMore, nextPage])
const summary = useMemo(() => {
const total = Number(meta?.total || activities.length || 0)
return total ? `${total.toLocaleString()} recent actions` : 'No recent actions'
}, [activities.length, meta?.total])
return (
<div
id="tabpanel-activity"
role="tabpanel"
aria-labelledby="tab-activity"
className="mx-auto max-w-7xl px-4 pt-4 pb-10 md:px-6"
>
<div className="rounded-[32px] border border-white/[0.06] bg-[linear-gradient(135deg,rgba(56,189,248,0.14),rgba(10,16,26,0.94),rgba(249,115,22,0.08))] p-5 shadow-[0_22px_70px_rgba(0,0,0,0.26)] md:p-6">
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/75">Activity</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white md:text-3xl">Recent actions and contributions</h2>
<p className="mt-3 text-sm leading-7 text-slate-300 md:text-[15px]">
A living timeline of uploads, discussions, follows, achievements, and forum participation from {user.username || user.name}.
</p>
</div>
<div className="self-start rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-300">
{summary}
</div>
</div>
<div className="mt-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<ActivityFilters activeFilter={activeFilter} onChange={setActiveFilter} />
<div className="flex flex-wrap gap-2 text-xs text-slate-400">
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2">
<i className="fa-solid fa-bolt text-sky-300" />
Timeline updates automatically as new actions are logged
</span>
</div>
</div>
</div>
<div className="mt-6">
<ActivityFeed
activities={activities}
loading={loading}
loadingMore={loadingMore}
error={error}
sentinelRef={sentinelRef}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,277 @@
import React from 'react'
import CollectionVisibilityBadge from './CollectionVisibilityBadge'
async function requestJson(url, { method = 'GET', body } = {}) {
const response = await fetch(url, {
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
'X-Requested-With': 'XMLHttpRequest',
},
body: body ? JSON.stringify(body) : undefined,
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
const message = payload?.message || 'Request failed.'
throw new Error(message)
}
return payload
}
function formatUpdated(value) {
if (!value) return 'Updated recently'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return 'Updated recently'
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
}).format(date)
}
function StatPill({ icon, label, value }) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.05] px-2.5 py-1 text-[11px] font-medium text-slate-300">
<i className={`fa-solid ${icon} text-[10px] text-slate-400`} />
<span className="text-white">{value}</span>
<span>{label}</span>
</span>
)
}
export default function CollectionCard({ collection, isOwner, onDelete, onToggleFeature, onMoveUp, onMoveDown, canMoveUp, canMoveDown, busy = false, saveContext = null, saveContextMeta = null }) {
const coverImage = collection?.cover_image
const [saved, setSaved] = React.useState(Boolean(collection?.saved))
const [saveBusy, setSaveBusy] = React.useState(false)
React.useEffect(() => {
setSaved(Boolean(collection?.saved))
setSaveBusy(false)
}, [collection?.id, collection?.saved])
function handleDelete(event) {
event.preventDefault()
event.stopPropagation()
onDelete?.(collection)
}
function stop(event) {
event.stopPropagation()
}
function handleToggleFeature(event) {
event.preventDefault()
event.stopPropagation()
onToggleFeature?.(collection)
}
async function handleSaveToggle(event) {
event.preventDefault()
event.stopPropagation()
if (saveBusy) return
const targetUrl = saved ? collection?.unsave_url : collection?.save_url
if (!targetUrl) {
if (collection?.login_url) {
window.location.assign(collection.login_url)
}
return
}
setSaveBusy(true)
try {
const payload = await requestJson(targetUrl, {
method: saved ? 'DELETE' : 'POST',
body: saved ? undefined : {
context: saveContext,
context_meta: saveContextMeta || undefined,
},
})
setSaved(Boolean(payload?.saved))
} catch (error) {
window.console?.error?.(error)
} finally {
setSaveBusy(false)
}
}
return (
<a
href={collection?.url || '#'}
className={`group relative overflow-hidden rounded-[28px] border border-white/10 bg-white/[0.04] shadow-[0_22px_60px_rgba(2,6,23,0.22)] transition-all duration-200 hover:-translate-y-1 hover:border-sky-300/30 hover:bg-white/[0.06] ${busy ? 'opacity-70' : ''} lg:max-w-[360px] lg:mx-auto`}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.12),transparent_34%),radial-gradient(circle_at_bottom_right,rgba(249,115,22,0.12),transparent_28%)] opacity-0 transition duration-300 group-hover:opacity-100" />
<div className="relative">
{coverImage ? (
<div className="aspect-[16/10] overflow-hidden bg-slate-950">
<img
src={coverImage}
alt={collection?.title || 'Collection cover'}
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.04]"
loading="lazy"
/>
</div>
) : (
<div className="flex aspect-[16/10] items-center justify-center bg-[linear-gradient(135deg,rgba(8,17,31,1),rgba(15,23,42,1),rgba(8,17,31,1))] text-slate-500">
<i className="fa-solid fa-layer-group text-4xl" />
</div>
)}
<div className="p-5">
<div className="mb-3 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-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100">
Featured
</span>
) : null}
{collection?.mode === 'smart' ? (
<span className="inline-flex items-center rounded-full border border-sky-300/25 bg-sky-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100">
Smart
</span>
) : null}
{!isOwner && collection?.program_key ? (
<span className="inline-flex items-center rounded-full border border-lime-300/25 bg-lime-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-lime-100">
Program · {collection.program_key}
</span>
) : null}
{!isOwner && collection?.partner_label ? (
<span className="inline-flex items-center rounded-full border border-white/10 bg-white/[0.05] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-200">
Partner · {collection.partner_label}
</span>
) : null}
{!isOwner && collection?.sponsorship_label ? (
<span className="inline-flex items-center rounded-full border border-amber-300/20 bg-amber-300/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100">
Sponsor · {collection.sponsorship_label}
</span>
) : null}
{isOwner ? <CollectionVisibilityBadge visibility={collection?.visibility} /> : null}
</div>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="truncate text-lg font-semibold tracking-[-0.03em] text-white">{collection?.title}</h3>
{collection?.subtitle ? <p className="mt-1 truncate text-sm text-slate-300">{collection.subtitle}</p> : null}
{collection?.owner?.name ? <p className="mt-1 truncate text-sm text-slate-400">Curated by {collection.owner.name}{collection?.owner?.username ? ` • @${collection.owner.username}` : ''}</p> : null}
<p className="mt-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
{(collection?.artworks_count ?? 0).toLocaleString()} artworks
</p>
</div>
</div>
{collection?.description_excerpt ? (
<p className="mt-3 line-clamp-2 text-sm leading-relaxed text-slate-300">{collection.description_excerpt}</p>
) : collection?.smart_summary ? (
<p className="mt-3 line-clamp-2 text-sm leading-relaxed text-slate-300">{collection.smart_summary}</p>
) : null}
<div className="mt-4 flex flex-wrap gap-2">
<StatPill icon="fa-heart" label="likes" value={(collection?.likes_count ?? 0).toLocaleString()} />
<StatPill icon="fa-bell" label="followers" value={(collection?.followers_count ?? 0).toLocaleString()} />
{collection?.collaborators_count > 1 ? <StatPill icon="fa-user-group" label="curators" value={(collection?.collaborators_count ?? 0).toLocaleString()} /> : null}
</div>
<div className="mt-4 flex items-center justify-between gap-3 text-xs text-slate-400">
<span>{collection?.is_featured ? 'Featured' : 'Updated'} {formatUpdated(collection?.featured_at || collection?.updated_at)}</span>
<div className="flex items-center gap-2" onClick={stop}>
{!isOwner && (collection?.save_url || collection?.unsave_url || collection?.login_url) ? (
<button
type="button"
onClick={handleSaveToggle}
disabled={saveBusy}
className={`inline-flex items-center gap-2 rounded-full border px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.14em] transition ${saved ? 'border-violet-300/20 bg-violet-400/10 text-violet-100 hover:bg-violet-400/15' : 'border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.08]'} disabled:opacity-60`}
>
<i className={`fa-solid ${saveBusy ? 'fa-circle-notch fa-spin' : (saved ? 'fa-bookmark' : 'fa-bookmark')} text-[11px]`} />
{saved ? 'Saved' : 'Save'}
</button>
) : null}
<span className="inline-flex items-center gap-1 text-slate-200">
<i className="fa-solid fa-arrow-right text-[11px]" />
Open
</span>
</div>
</div>
{isOwner ? (
<div className="mt-4 flex flex-wrap gap-2" onClick={stop}>
{collection?.visibility === 'public' ? (
<button
type="button"
onClick={handleToggleFeature}
className={`inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] transition ${collection?.is_featured ? 'border-amber-300/25 bg-amber-300/10 text-amber-100 hover:bg-amber-300/15' : 'border-sky-300/20 bg-sky-400/10 text-sky-100 hover:bg-sky-400/15'}`}
>
<i className={`fa-solid ${collection?.is_featured ? 'fa-star' : 'fa-sparkles'} fa-fw`} />
{collection?.is_featured ? 'Featured' : 'Feature'}
</button>
) : null}
<a
href={collection?.edit_url || collection?.manage_url || '#'}
className="inline-flex items-center gap-2 rounded-xl border border-white/12 bg-white/[0.05] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-slate-100 transition hover:bg-white/[0.09]"
>
<i className="fa-solid fa-pen-to-square fa-fw" />
Edit
</a>
<a
href={collection?.manage_url || collection?.edit_url || '#'}
className="inline-flex items-center gap-2 rounded-xl border border-sky-300/20 bg-sky-400/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-sky-100 transition hover:bg-sky-400/15"
>
<i className="fa-solid fa-grip fa-fw" />
Manage Artworks
</a>
{onMoveUp ? (
<button
type="button"
disabled={!canMoveUp}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onMoveUp(collection)
}}
className={`inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] ${canMoveUp ? 'border-white/12 bg-white/[0.05] text-white hover:bg-white/[0.09]' : 'border-white/8 bg-white/[0.03] text-slate-500'}`}
>
<i className="fa-solid fa-arrow-up fa-fw" />
Up
</button>
) : null}
{onMoveDown ? (
<button
type="button"
disabled={!canMoveDown}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onMoveDown(collection)
}}
className={`inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] ${canMoveDown ? 'border-white/12 bg-white/[0.05] text-white hover:bg-white/[0.09]' : 'border-white/8 bg-white/[0.03] text-slate-500'}`}
>
<i className="fa-solid fa-arrow-down fa-fw" />
Down
</button>
) : null}
{collection?.delete_url ? (
<button
type="button"
onClick={handleDelete}
className="inline-flex items-center gap-2 rounded-xl border border-rose-400/20 bg-rose-400/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-rose-100 transition hover:bg-rose-400/15"
>
<i className="fa-solid fa-trash-can fa-fw" />
Delete
</button>
) : null}
</div>
) : null}
</div>
</div>
</a>
)
}

View File

@@ -0,0 +1,43 @@
import React from 'react'
export default function CollectionEmptyState({ isOwner, createUrl }) {
const smartUrl = createUrl ? `${createUrl}?mode=smart` : null
return (
<div className="relative overflow-hidden rounded-[28px] border border-white/10 bg-[linear-gradient(135deg,rgba(56,189,248,0.08),rgba(255,255,255,0.04),rgba(249,115,22,0.1))] px-6 py-14 text-center shadow-[0_26px_80px_rgba(2,6,23,0.28)]">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.12),transparent_32%),radial-gradient(circle_at_bottom_right,rgba(249,115,22,0.12),transparent_30%)]" />
<div className="relative mx-auto max-w-xl">
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-[24px] border border-white/12 bg-white/[0.06] text-sky-200 shadow-[0_18px_40px_rgba(2,6,23,0.28)]">
<i className="fa-solid fa-layer-group text-3xl" />
</div>
<h3 className="text-2xl font-semibold tracking-[-0.03em] text-white">
{isOwner ? 'Create your first collection' : 'No public collections yet'}
</h3>
<p className="mx-auto mt-3 max-w-md text-sm leading-relaxed text-slate-300">
{isOwner
? 'Collections turn your gallery into intentional showcases. Build them manually or let smart rules keep them fresh from your own artwork library.'
: 'This creator has not published any collections.'}
</p>
{isOwner && createUrl ? (
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
<a
href={createUrl}
className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/25 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15"
>
<i className="fa-solid fa-plus fa-fw" />
Create Manual Collection
</a>
<a
href={smartUrl || createUrl}
className="inline-flex items-center gap-2 rounded-2xl border border-white/12 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]"
>
<i className="fa-solid fa-wand-magic-sparkles fa-fw" />
Create Smart Collection
</a>
</div>
) : null}
{isOwner ? <p className="mx-auto mt-6 max-w-lg text-xs uppercase tracking-[0.18em] text-slate-400">Examples: Featured wallpapers, best of 2026, cyberpunk studies, blue neon universe</p> : null}
</div>
</div>
)
}

View File

@@ -0,0 +1,25 @@
import React from 'react'
const STYLES = {
public: 'border-emerald-400/25 bg-emerald-400/10 text-emerald-100',
unlisted: 'border-amber-300/25 bg-amber-300/10 text-amber-100',
private: 'border-white/15 bg-white/6 text-slate-200',
}
const LABELS = {
public: 'Public',
unlisted: 'Unlisted',
private: 'Private',
}
export default function CollectionVisibilityBadge({ visibility, className = '' }) {
const value = String(visibility || 'public').toLowerCase()
const label = LABELS[value] || 'Public'
const style = STYLES[value] || STYLES.public
return (
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] ${style} ${className}`.trim()}>
{label}
</span>
)
}

View File

@@ -1,155 +1,6 @@
import React, { useRef, useState } from 'react'
import LevelBadge from '../../xp/LevelBadge'
import React from 'react'
import ActivityTab from '../activity/ActivityTab'
const DEFAULT_AVATAR = 'https://files.skinbase.org/default/avatar_default.webp'
function CommentItem({ comment }) {
return (
<div className="flex gap-3 py-4 border-b border-white/5 last:border-0">
<a href={comment.author_profile_url} className="shrink-0 mt-0.5">
<img
src={comment.author_avatar || DEFAULT_AVATAR}
alt={comment.author_name}
className="w-9 h-9 rounded-xl object-cover ring-1 ring-white/10"
onError={(e) => { e.target.src = DEFAULT_AVATAR }}
loading="lazy"
/>
</a>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<a
href={comment.author_profile_url}
className="text-sm font-semibold text-slate-200 hover:text-white transition-colors"
>
{comment.author_name}
</a>
<LevelBadge level={comment.author_level} rank={comment.author_rank} compact />
<span className="text-slate-600 text-xs ml-auto whitespace-nowrap">
{(() => {
try {
const d = new Date(comment.created_at)
const diff = Date.now() - d.getTime()
const mins = Math.floor(diff / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins}m ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ago`
const days = Math.floor(hrs / 24)
if (days < 30) return `${days}d ago`
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
} catch { return '' }
})()}
</span>
</div>
<p className="text-sm text-slate-400 leading-relaxed break-words whitespace-pre-line">
{comment.body}
</p>
{comment.author_signature && (
<p className="text-xs text-slate-600 mt-2 italic border-t border-white/5 pt-1 truncate">
{comment.author_signature}
</p>
)}
</div>
</div>
)
}
/**
* TabActivity
* Profile comments list + comment form for authenticated visitors.
* Also acts as "Activity" tab.
*/
export default function TabActivity({ profileComments, user, isOwner, isLoggedIn }) {
const uname = user.username || user.name
const formRef = useRef(null)
const [submitted, setSubmitted] = useState(false)
return (
<div
id="tabpanel-activity"
role="tabpanel"
aria-labelledby="tab-activity"
className="pt-6 max-w-2xl"
>
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-4 flex items-center gap-2">
<i className="fa-solid fa-comments text-orange-400 fa-fw" />
Comments
{profileComments?.length > 0 && (
<span className="ml-1 px-1.5 py-0.5 rounded bg-white/5 text-slate-400 font-normal text-[11px]">
{profileComments.length}
</span>
)}
</h2>
{/* Comments list */}
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 shadow-xl shadow-black/20 mb-5">
{!profileComments?.length ? (
<p className="text-slate-500 text-sm text-center py-8">
No comments yet. Be the first to leave one!
</p>
) : (
<div>
{profileComments.map((comment) => (
<CommentItem key={comment.id} comment={comment} />
))}
</div>
)}
</div>
{/* Comment form */}
{!isOwner && (
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 shadow-xl shadow-black/20">
<h3 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-4 flex items-center gap-2">
<i className="fa-solid fa-pen text-sky-400 fa-fw" />
Write a Comment
</h3>
{isLoggedIn ? (
submitted ? (
<div className="flex items-center gap-2 text-green-400 text-sm p-3 rounded-xl bg-green-500/10 ring-1 ring-green-500/20">
<i className="fa-solid fa-check fa-fw" />
Your comment has been posted!
</div>
) : (
<form
ref={formRef}
method="POST"
action={`/@${uname.toLowerCase()}/comment`}
onSubmit={() => setSubmitted(false)}
>
<input type="hidden" name="_token" value={
(() => document.querySelector('meta[name="csrf-token"]')?.content ?? '')()
} />
<textarea
name="body"
rows={4}
required
minLength={2}
maxLength={2000}
placeholder={`Write a comment for ${uname}`}
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2.5 text-sm text-slate-200 placeholder:text-slate-600 resize-none focus:outline-none focus:ring-2 focus:ring-sky-400/40 focus:border-sky-400/30 transition-all"
/>
<div className="mt-3 flex justify-end">
<button
type="submit"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-sky-600 hover:bg-sky-500 text-white text-sm font-semibold transition-all shadow-lg shadow-sky-900/30"
>
<i className="fa-solid fa-paper-plane fa-fw" />
Post Comment
</button>
</div>
</form>
)
) : (
<p className="text-sm text-slate-400 text-center py-4">
<a href="/login" className="text-sky-400 hover:text-sky-300 hover:underline transition-colors">
Log in
</a>
{' '}to leave a comment.
</p>
)}
</div>
)}
</div>
)
export default function TabActivity({ user }) {
return <ActivityTab user={user} />
}

View File

@@ -1,49 +1,138 @@
import React from 'react'
import React, { useEffect, useState } from 'react'
import CollectionCard from '../collections/CollectionCard'
import CollectionEmptyState from '../collections/CollectionEmptyState'
/**
* TabCollections
* Collections feature placeholder.
*/
export default function TabCollections({ collections }) {
if (collections?.length > 0) {
return (
<div
id="tabpanel-collections"
role="tabpanel"
aria-labelledby="tab-collections"
className="pt-6"
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{collections.map((col) => (
<div
key={col.id}
className="bg-white/4 ring-1 ring-white/10 rounded-2xl overflow-hidden group hover:ring-sky-400/30 transition-all cursor-pointer shadow-xl shadow-black/20"
>
{col.cover_image ? (
<div className="aspect-video overflow-hidden bg-black/30">
<img
src={col.cover_image}
alt={col.title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
/>
</div>
) : (
<div className="aspect-video bg-white/5 flex items-center justify-center text-slate-600">
<i className="fa-solid fa-layer-group text-3xl" />
</div>
)}
<div className="p-4">
<h3 className="font-semibold text-white truncate">{col.title}</h3>
<p className="text-sm text-slate-500 mt-0.5">{col.items_count ?? 0} artworks</p>
</div>
</div>
))}
</div>
</div>
)
function getCsrfToken() {
if (typeof document === 'undefined') return ''
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
async function deleteCollection(url) {
const response = await fetch(url, {
method: 'DELETE',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || 'Unable to delete collection.')
}
return payload
}
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 || 'Unable to update collection presentation.')
}
return payload
}
const FILTERS = ['all', 'featured', 'smart', 'manual']
export default function TabCollections({ collections, isOwner, createUrl, reorderUrl, featuredUrl, featureLimit = 3 }) {
const [items, setItems] = useState(Array.isArray(collections) ? collections : [])
const [busyId, setBusyId] = useState(null)
const [filter, setFilter] = useState('all')
useEffect(() => {
setItems(Array.isArray(collections) ? collections : [])
}, [collections])
async function handleDelete(collection) {
if (!collection?.delete_url) return
if (!window.confirm(`Delete "${collection.title}"? Artworks will remain untouched.`)) return
setBusyId(collection.id)
try {
await deleteCollection(collection.delete_url)
setItems((current) => current.filter((item) => item.id !== collection.id))
} catch (error) {
window.alert(error.message)
} finally {
setBusyId(null)
}
}
async function handleToggleFeature(collection) {
const url = collection?.is_featured ? collection?.unfeature_url : collection?.feature_url
const method = collection?.is_featured ? 'DELETE' : 'POST'
if (!url) return
setBusyId(collection.id)
try {
const payload = await requestJson(url, { method })
setItems((current) => current.map((item) => (
item.id === collection.id
? {
...item,
is_featured: payload?.collection?.is_featured ?? !item.is_featured,
featured_at: payload?.collection?.featured_at ?? item.featured_at,
updated_at: payload?.collection?.updated_at ?? item.updated_at,
}
: item
)))
} catch (error) {
window.alert(error.message)
} finally {
setBusyId(null)
}
}
async function handleMove(collection, direction) {
const index = items.findIndex((item) => item.id === collection.id)
const nextIndex = index + direction
if (index < 0 || nextIndex < 0 || nextIndex >= items.length || !reorderUrl) return
const next = [...items]
const temp = next[index]
next[index] = next[nextIndex]
next[nextIndex] = temp
setItems(next)
try {
const payload = await requestJson(reorderUrl, {
method: 'POST',
body: { collection_ids: next.map((item) => item.id) },
})
if (Array.isArray(payload?.collections)) {
setItems(payload.collections)
}
} catch (error) {
window.alert(error.message)
setItems(Array.isArray(collections) ? collections : [])
}
}
const featuredItems = items.filter((collection) => collection.is_featured)
const smartItems = items.filter((collection) => collection.mode === 'smart')
const filteredItems = items.filter((collection) => {
if (filter === 'featured') return collection.is_featured
if (filter === 'smart') return collection.mode === 'smart'
if (filter === 'manual') return collection.mode !== 'smart'
return true
})
return (
<div
id="tabpanel-collections"
@@ -51,15 +140,84 @@ export default function TabCollections({ collections }) {
aria-labelledby="tab-collections"
className="pt-6"
>
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl px-8 py-16 text-center shadow-xl shadow-black/20 backdrop-blur">
<div className="w-20 h-20 rounded-2xl bg-white/5 flex items-center justify-center mx-auto mb-5 text-slate-500">
<i className="fa-solid fa-layer-group text-3xl" />
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Collections</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Curated showcases from the gallery</h2>
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-slate-300">
Collections now support featured presentation, smart rule-based curation, and richer profile storytelling.
</p>
</div>
<div className="flex flex-wrap gap-3">
{featuredUrl ? <a href={featuredUrl} className="inline-flex items-center gap-2 self-start rounded-2xl border border-white/12 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]"><i className="fa-solid fa-compass fa-fw" />Browse Featured</a> : null}
{isOwner && createUrl ? <a href={createUrl} className="inline-flex items-center gap-2 self-start rounded-2xl border border-sky-300/25 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15"><i className="fa-solid fa-plus fa-fw" />Create Collection</a> : null}
</div>
<h3 className="text-lg font-bold text-white mb-2">Collections Coming Soon</h3>
<p className="text-slate-500 text-sm max-w-sm mx-auto">
Group your artworks into curated collections.
</p>
</div>
<div className="mb-5 flex flex-wrap items-center gap-2">
{FILTERS.map((value) => (
<button
key={value}
type="button"
onClick={() => setFilter(value)}
className={`inline-flex items-center gap-2 rounded-full border px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] transition ${filter === value ? 'border-sky-300/25 bg-sky-400/10 text-sky-100' : 'border-white/10 bg-white/[0.04] text-slate-300 hover:bg-white/[0.07]'}`}
>
{value}
</button>
))}
</div>
{items.length > 0 && featuredItems.length > 0 && filter === 'all' ? (
<section className="mb-6 overflow-hidden rounded-[28px] border border-amber-300/15 bg-[linear-gradient(135deg,rgba(251,191,36,0.08),rgba(255,255,255,0.04),rgba(56,189,248,0.08))] p-5 shadow-[0_26px_70px_rgba(2,6,23,0.22)]">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100/80">Featured Collections</p>
<h3 className="mt-2 text-xl font-semibold text-white">Premium profile showcases</h3>
</div>
{isOwner ? <p className="text-xs uppercase tracking-[0.18em] text-slate-300">{featuredItems.length}/{featureLimit} featured</p> : null}
</div>
<div className="mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
{featuredItems.map((collection, index) => (
<CollectionCard
key={`featured-${collection.id}`}
collection={collection}
isOwner={isOwner}
onDelete={handleDelete}
onToggleFeature={handleToggleFeature}
onMoveUp={isOwner ? () => handleMove(collection, -1) : null}
onMoveDown={isOwner ? () => handleMove(collection, 1) : null}
canMoveUp={index > 0}
canMoveDown={index < featuredItems.length - 1}
busy={busyId === collection.id}
/>
))}
</div>
</section>
) : null}
{isOwner && items.length > 0 && featuredItems.length === 0 ? <div className="mb-5 rounded-[24px] border border-white/10 bg-white/[0.04] px-5 py-4 text-sm text-slate-300">Feature your best collections to pin them at the top of your profile.</div> : null}
{isOwner && items.length > 0 && smartItems.length === 0 ? <div className="mb-5 rounded-[24px] border border-white/10 bg-white/[0.04] px-5 py-4 text-sm text-slate-300">Create a smart collection from your tags or categories to keep a showcase updated automatically.</div> : null}
{items.length === 0 ? (
<CollectionEmptyState isOwner={isOwner} createUrl={createUrl} />
) : (
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
{filteredItems.map((collection, index) => (
<CollectionCard
key={collection.id}
collection={collection}
isOwner={isOwner}
onDelete={handleDelete}
onToggleFeature={handleToggleFeature}
onMoveUp={isOwner ? () => handleMove(collection, -1) : null}
onMoveDown={isOwner ? () => handleMove(collection, 1) : null}
canMoveUp={index > 0}
canMoveDown={index < filteredItems.length - 1}
busy={busyId === collection.id}
/>
))}
</div>
)}
</div>
)
}

View File

@@ -75,6 +75,7 @@ export default function TabPosts({
stats,
followerCount,
recentFollowers,
suggestedUsers,
socialLinks,
countryName,
profileUrl,
@@ -117,7 +118,7 @@ export default function TabPosts({
const summaryCards = [
{ label: 'Followers', value: formatCompactNumber(followerCount), icon: 'fa-user-group' },
{ label: 'Artworks', value: formatCompactNumber(stats?.uploads_count ?? 0), icon: 'fa-image' },
{ label: 'Uploads', value: formatCompactNumber(stats?.uploads_count ?? 0), icon: 'fa-image' },
{ label: 'Awards', value: formatCompactNumber(stats?.awards_received_count ?? 0), icon: 'fa-trophy' },
{ label: 'Location', value: countryName || 'Unknown', icon: 'fa-location-dot' },
]
@@ -239,6 +240,7 @@ export default function TabPosts({
stats={stats}
followerCount={followerCount}
recentFollowers={recentFollowers}
suggestedUsers={suggestedUsers}
socialLinks={socialLinks}
countryName={countryName}
isLoggedIn={!!authUser}

View File

@@ -18,7 +18,7 @@ function KpiCard({ icon, label, value, color = 'text-sky-400' }) {
* TabStats
* KPI overview cards. Charts can be added here once chart infrastructure exists.
*/
export default function TabStats({ stats, followerCount }) {
export default function TabStats({ stats, followerCount, followAnalytics }) {
const kpis = [
{ icon: 'fa-eye', label: 'Profile Views', value: stats?.profile_views_count, color: 'text-sky-400' },
{ icon: 'fa-images', label: 'Uploads', value: stats?.uploads_count, color: 'text-violet-400' },
@@ -29,6 +29,12 @@ export default function TabStats({ stats, followerCount }) {
{ icon: 'fa-trophy', label: 'Awards Received', value: stats?.awards_received_count, color: 'text-yellow-400' },
{ icon: 'fa-comment', label: 'Comments Received', value: stats?.comments_received_count, color: 'text-orange-400' },
]
const trendCards = [
{ icon: 'fa-arrow-trend-up', label: 'Followers Today', value: followAnalytics?.daily?.gained ?? 0, color: 'text-emerald-400' },
{ icon: 'fa-user-minus', label: 'Unfollows Today', value: followAnalytics?.daily?.lost ?? 0, color: 'text-rose-400' },
{ icon: 'fa-chart-line', label: 'Weekly Net', value: followAnalytics?.weekly?.net ?? 0, color: 'text-sky-400' },
{ icon: 'fa-percent', label: 'Weekly Growth %', value: followAnalytics?.weekly?.growth_rate ?? 0, color: 'text-amber-400' },
]
const hasStats = stats !== null && stats !== undefined
@@ -56,6 +62,15 @@ export default function TabStats({ stats, followerCount }) {
<KpiCard key={kpi.label} {...kpi} />
))}
</div>
<h3 className="mt-8 mb-4 text-xs font-semibold uppercase tracking-widest text-slate-500 flex items-center gap-2">
<i className="fa-solid fa-user-group text-emerald-400 fa-fw" />
Follow Growth
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
{trendCards.map((card) => (
<KpiCard key={card.label} {...card} />
))}
</div>
<p className="text-xs text-slate-600 mt-6 text-center">
More detailed analytics (charts, trends) coming soon.
</p>

View File

@@ -1,9 +1,195 @@
import React, { useState } from 'react'
import React, { useCallback, useRef, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import EmojiPickerButton from '../comments/EmojiPickerButton'
function BoldIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} className="h-4 w-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 4h8a4 4 0 0 1 0 8H6zM6 12h9a4 4 0 0 1 0 8H6z" />
</svg>
)
}
function ItalicIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} className="h-4 w-4">
<line x1="19" y1="4" x2="10" y2="4" />
<line x1="14" y1="20" x2="5" y2="20" />
<line x1="15" y1="4" x2="9" y2="20" />
</svg>
)
}
function CodeIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="h-4 w-4">
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
)
}
function LinkIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="h-4 w-4">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
)
}
function ListIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="h-4 w-4">
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<circle cx="3" cy="6" r="1" fill="currentColor" stroke="none" />
<circle cx="3" cy="12" r="1" fill="currentColor" stroke="none" />
<circle cx="3" cy="18" r="1" fill="currentColor" stroke="none" />
</svg>
)
}
function QuoteIcon() {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className="h-4 w-4">
<path d="M4.583 17.321C3.553 16.227 3 15 3 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311C9.591 11.68 11 13.176 11 15c0 1.933-1.567 3.5-3.5 3.5-1.073 0-2.099-.456-2.917-1.179zM15.583 17.321C14.553 16.227 14 15 14 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311C20.591 11.68 22 13.176 22 15c0 1.933-1.567 3.5-3.5 3.5-1.073 0-2.099-.456-2.917-1.179z" />
</svg>
)
}
function ToolbarBtn({ title, onClick, children }) {
return (
<button
type="button"
title={title}
onMouseDown={(event) => {
event.preventDefault()
onClick()
}}
className="flex h-7 w-7 items-center justify-center rounded-md text-white/40 transition-colors hover:bg-white/[0.08] hover:text-white/70"
>
{children}
</button>
)
}
export default function CommentForm({ placeholder = 'Write a comment…', submitLabel = 'Post', onSubmit, onCancel, compact = false }) {
const [content, setContent] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [tab, setTab] = useState('write')
const textareaRef = useRef(null)
const wrapSelection = useCallback((before, after) => {
const element = textareaRef.current
if (!element) return
const start = element.selectionStart
const end = element.selectionEnd
const selected = content.slice(start, end)
const replacement = before + (selected || 'text') + after
const next = content.slice(0, start) + replacement + content.slice(end)
setContent(next)
requestAnimationFrame(() => {
const cursorPos = selected ? start + replacement.length : start + before.length
const cursorEnd = selected ? start + replacement.length : start + before.length + 4
element.selectionStart = cursorPos
element.selectionEnd = cursorEnd
element.focus()
})
}, [content])
const prefixLines = useCallback((prefix) => {
const element = textareaRef.current
if (!element) return
const start = element.selectionStart
const end = element.selectionEnd
const selected = content.slice(start, end)
const lines = selected ? selected.split('\n') : ['']
const prefixed = lines.map((line) => prefix + line).join('\n')
const next = content.slice(0, start) + prefixed + content.slice(end)
setContent(next)
requestAnimationFrame(() => {
element.selectionStart = start
element.selectionEnd = start + prefixed.length
element.focus()
})
}, [content])
const insertLink = useCallback(() => {
const element = textareaRef.current
if (!element) return
const start = element.selectionStart
const end = element.selectionEnd
const selected = content.slice(start, end)
const isUrl = /^https?:\/\//.test(selected)
const replacement = isUrl ? `[link](${selected})` : `[${selected || 'link'}](https://)`
const next = content.slice(0, start) + replacement + content.slice(end)
setContent(next)
requestAnimationFrame(() => {
if (isUrl) {
element.selectionStart = start + 1
element.selectionEnd = start + 5
} else {
const urlStart = start + replacement.length - 1
element.selectionStart = urlStart - 8
element.selectionEnd = urlStart - 1
}
element.focus()
})
}, [content])
const insertAtCursor = useCallback((text) => {
const element = textareaRef.current
if (!element) {
setContent((current) => current + text)
return
}
const start = element.selectionStart ?? content.length
const end = element.selectionEnd ?? content.length
const next = content.slice(0, start) + text + content.slice(end)
setContent(next)
requestAnimationFrame(() => {
element.selectionStart = start + text.length
element.selectionEnd = start + text.length
element.focus()
})
}, [content])
const handleKeyDown = useCallback((event) => {
const mod = event.ctrlKey || event.metaKey
if (!mod) return
switch (event.key.toLowerCase()) {
case 'b':
event.preventDefault()
wrapSelection('**', '**')
break
case 'i':
event.preventDefault()
wrapSelection('*', '*')
break
case 'k':
event.preventDefault()
insertLink()
break
case 'e':
event.preventDefault()
wrapSelection('`', '`')
break
default:
break
}
}, [insertLink, wrapSelection])
const handleSubmit = async (event) => {
event.preventDefault()
@@ -15,6 +201,7 @@ export default function CommentForm({ placeholder = 'Write a comment…', submit
try {
await onSubmit?.(trimmed)
setContent('')
setTab('write')
} catch (submitError) {
setError(submitError?.message || 'Unable to post comment.')
} finally {
@@ -24,19 +211,119 @@ export default function CommentForm({ placeholder = 'Write a comment…', submit
return (
<form className="space-y-3" onSubmit={handleSubmit}>
<textarea
value={content}
onChange={(event) => setContent(event.target.value)}
rows={compact ? 3 : 4}
maxLength={10000}
placeholder={placeholder}
className="w-full rounded-2xl border border-white/[0.08] bg-white/[0.04] px-4 py-3 text-sm text-white placeholder-white/35 outline-none transition focus:border-sky-400/40 focus:bg-white/[0.06]"
/>
<div className={`rounded-2xl border border-white/[0.08] bg-white/[0.04] transition-all duration-200 focus-within:border-white/[0.12] focus-within:shadow-lg focus-within:shadow-black/20 ${compact ? 'rounded-xl' : ''}`}>
<div className="flex items-center justify-between border-b border-white/[0.06] px-3 py-1.5">
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setTab('write')}
className={[
'rounded-md px-3 py-1 text-xs font-medium transition-colors',
tab === 'write' ? 'bg-white/[0.08] text-white' : 'text-white/40 hover:text-white/60',
].join(' ')}
>
Write
</button>
<button
type="button"
onClick={() => setTab('preview')}
className={[
'rounded-md px-3 py-1 text-xs font-medium transition-colors',
tab === 'preview' ? 'bg-white/[0.08] text-white' : 'text-white/40 hover:text-white/60',
].join(' ')}
>
Preview
</button>
</div>
<div className="flex items-center gap-1.5">
<span
className={[
'text-[11px] tabular-nums font-medium transition-colors',
content.length > 9000 ? 'text-amber-400/80' : 'text-white/20',
].join(' ')}
>
{content.length > 0 && `${content.length.toLocaleString()}/10,000`}
</span>
<EmojiPickerButton onEmojiSelect={insertAtCursor} disabled={busy} />
</div>
</div>
{tab === 'write' && (
<div className="flex items-center gap-0.5 border-b border-white/[0.04] px-3 py-1">
<ToolbarBtn title="Bold (Ctrl+B)" onClick={() => wrapSelection('**', '**')}>
<BoldIcon />
</ToolbarBtn>
<ToolbarBtn title="Italic (Ctrl+I)" onClick={() => wrapSelection('*', '*')}>
<ItalicIcon />
</ToolbarBtn>
<ToolbarBtn title="Inline code (Ctrl+E)" onClick={() => wrapSelection('`', '`')}>
<CodeIcon />
</ToolbarBtn>
<ToolbarBtn title="Link (Ctrl+K)" onClick={insertLink}>
<LinkIcon />
</ToolbarBtn>
<div className="mx-1 h-4 w-px bg-white/[0.08]" />
<ToolbarBtn title="Bulleted list" onClick={() => prefixLines('- ')}>
<ListIcon />
</ToolbarBtn>
<ToolbarBtn title="Quote" onClick={() => prefixLines('> ')}>
<QuoteIcon />
</ToolbarBtn>
</div>
)}
{tab === 'write' && (
<textarea
ref={textareaRef}
value={content}
onChange={(event) => setContent(event.target.value)}
onKeyDown={handleKeyDown}
rows={compact ? 3 : 4}
maxLength={10000}
placeholder={placeholder}
disabled={busy}
className="w-full resize-none bg-transparent px-4 py-3 text-sm leading-relaxed text-white/90 placeholder-white/35 outline-none transition disabled:opacity-50"
/>
)}
{tab === 'preview' && (
<div className="min-h-[7rem] px-4 py-3">
{content.trim() ? (
<div className="prose prose-invert prose-sm max-w-none text-[13px] leading-relaxed text-white/80 [&_a]:text-accent [&_a]:no-underline hover:[&_a]:underline [&_code]:rounded [&_code]:bg-white/[0.08] [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:text-[12px] [&_code]:text-amber-300/80 [&_blockquote]:border-l-2 [&_blockquote]:border-accent/40 [&_blockquote]:pl-3 [&_blockquote]:text-white/50 [&_ul]:list-disc [&_ul]:pl-4 [&_ol]:list-decimal [&_ol]:pl-4 [&_li]:text-white/70 [&_strong]:text-white [&_em]:text-white/70 [&_p]:mb-2 [&_p:last-child]:mb-0">
<ReactMarkdown
allowedElements={['p', 'strong', 'em', 'a', 'code', 'pre', 'ul', 'ol', 'li', 'blockquote', 'br']}
unwrapDisallowed
components={{
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer nofollow">{children}</a>
),
}}
>
{content}
</ReactMarkdown>
</div>
) : (
<p className="text-sm italic text-white/25">Nothing to preview</p>
)}
</div>
)}
{tab === 'write' && (
<div className="px-4 pb-2">
<p className="text-[11px] text-white/15">
Markdown supported · <kbd className="rounded bg-white/[0.06] px-1 py-0.5 text-[10px] text-white/25">Ctrl+B</kbd> bold · <kbd className="rounded bg-white/[0.06] px-1 py-0.5 text-[10px] text-white/25">Ctrl+I</kbd> italic · <kbd className="rounded bg-white/[0.06] px-1 py-0.5 text-[10px] text-white/25">Ctrl+K</kbd> link
</p>
</div>
)}
</div>
{error ? <p className="text-sm text-rose-300">{error}</p> : null}
<div className="flex items-center justify-between gap-3">
<span className="text-xs text-white/35">{content.trim().length}/10000</span>
<span className="text-xs text-white/35">Use emoji and markdown to match the rest of the site.</span>
<div className="flex items-center gap-2">
{onCancel ? (
<button

View File

@@ -2,7 +2,7 @@ import React, { useState } from 'react'
import LevelBadge from '../xp/LevelBadge'
import CommentForm from './CommentForm'
function CommentItem({ comment, canReply, onReply, onDelete }) {
function CommentItem({ comment, canReply, onReply, onDelete, onReport }) {
const [replying, setReplying] = useState(false)
return (
@@ -42,6 +42,11 @@ function CommentItem({ comment, canReply, onReply, onDelete }) {
Delete
</button>
) : null}
{comment.can_report ? (
<button type="button" onClick={() => onReport?.(comment)} className="transition hover:text-amber-300">
Report
</button>
) : null}
</div>
{replying ? (
@@ -62,7 +67,7 @@ function CommentItem({ comment, canReply, onReply, onDelete }) {
{Array.isArray(comment.replies) && comment.replies.length > 0 ? (
<div className="mt-4 space-y-3 border-l border-white/[0.08] pl-4">
{comment.replies.map((reply) => (
<CommentItem key={reply.id} comment={reply} canReply={canReply} onReply={onReply} onDelete={onDelete} />
<CommentItem key={reply.id} comment={reply} canReply={canReply} onReply={onReply} onDelete={onDelete} onReport={onReport} />
))}
</div>
) : null}
@@ -72,7 +77,7 @@ function CommentItem({ comment, canReply, onReply, onDelete }) {
)
}
export default function CommentList({ comments = [], canReply = false, onReply, onDelete, emptyMessage = 'No comments yet.' }) {
export default function CommentList({ comments = [], canReply = false, onReply, onDelete, onReport, emptyMessage = 'No comments yet.' }) {
if (!comments.length) {
return <p className="text-sm text-white/45">{emptyMessage}</p>
}
@@ -80,8 +85,8 @@ export default function CommentList({ comments = [], canReply = false, onReply,
return (
<div className="space-y-4">
{comments.map((comment) => (
<CommentItem key={comment.id} comment={comment} canReply={canReply} onReply={onReply} onDelete={onDelete} />
<CommentItem key={comment.id} comment={comment} canReply={canReply} onReply={onReply} onDelete={onDelete} onReport={onReport} />
))}
</div>
)
}
}

View File

@@ -17,6 +17,7 @@ export default function FollowButton({
const [count, setCount] = useState(Number(initialCount || 0))
const [loading, setLoading] = useState(false)
const [confirmOpen, setConfirmOpen] = useState(false)
const [hovered, setHovered] = useState(false)
const csrfToken = typeof document !== 'undefined'
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
@@ -65,6 +66,8 @@ export default function FollowButton({
<button
type="button"
onClick={onToggle}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
disabled={loading || !username}
aria-label={following ? 'Unfollow creator' : 'Follow creator'}
className={[
@@ -74,8 +77,8 @@ export default function FollowButton({
className,
].join(' ')}
>
<i className={`fa-solid fa-fw ${loading ? 'fa-circle-notch fa-spin' : following ? 'fa-user-check' : 'fa-user-plus'}`} />
<span>{following ? 'Following' : 'Follow'}</span>
<i className={`fa-solid fa-fw ${loading ? 'fa-circle-notch fa-spin' : following ? (hovered ? 'fa-user-minus' : 'fa-user-check') : 'fa-user-plus'}`} />
<span>{following ? (hovered ? 'Unfollow' : 'Following') : 'Follow'}</span>
{showCount ? <span className="text-xs opacity-70">{count.toLocaleString()}</span> : null}
</button>
@@ -94,4 +97,4 @@ export default function FollowButton({
/>
</>
)
}
}

View File

@@ -0,0 +1,40 @@
import React from 'react'
export default function FollowersPreview({ users = [], label = '', href = null }) {
if (!Array.isArray(users) || users.length === 0) return null
const preview = users.slice(0, 4)
return (
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
<div className="flex items-center gap-3">
<div className="flex -space-x-2">
{preview.map((user) => (
<a
key={user.id}
href={user.profile_url || `/@${user.username}`}
className="inline-flex h-9 w-9 overflow-hidden rounded-full border border-[#09111f] bg-[#09111f] ring-1 ring-white/10"
title={user.name || user.username}
>
<img
src={user.avatar_url || '/images/avatar_default.webp'}
alt={user.username || user.name}
className="h-full w-full object-cover"
loading="lazy"
/>
</a>
))}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-white/90">{label}</p>
{href ? (
<a href={href} className="text-xs text-sky-300/80 transition-colors hover:text-sky-200">
View network
</a>
) : null}
</div>
</div>
</div>
)
}

View File

@@ -72,14 +72,14 @@ export default function MessageInboxBadge({ initialUnreadCount = 0, userId = nul
return (
<a
href={href}
className="relative w-10 h-10 inline-flex items-center justify-center rounded-lg hover:bg-white/5"
className="relative inline-flex h-9 w-9 lg:h-10 lg:w-10 items-center justify-center rounded-lg hover:bg-white/5"
title="Messages"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg className="h-[18px] w-[18px] lg:h-5 lg:w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
{unreadCount > 0 ? (
<span className="absolute -bottom-1 right-0 text-[11px] tabular-nums px-1.5 py-0.5 rounded bg-red-700/70 text-white border border-sb-line">
<span className="absolute -bottom-1 right-0 rounded border border-sb-line bg-red-700/70 px-1 py-0 text-[10px] tabular-nums text-white lg:px-1.5 lg:py-0.5 lg:text-[11px]">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
) : null}

View File

@@ -0,0 +1,14 @@
import React from 'react'
export default function MutualFollowersBadge({ context }) {
const label = context?.follower_overlap?.label || context?.shared_following?.label || null
if (!label) return null
return (
<span className="inline-flex items-center gap-2 rounded-full border border-emerald-400/20 bg-emerald-400/10 px-3 py-1.5 text-xs font-medium text-emerald-200">
<i className="fa-solid fa-user-group fa-fw text-[11px]" />
{label}
</span>
)
}

View File

@@ -96,15 +96,15 @@ export default function NotificationDropdown({ initialUnreadCount = 0, notificat
<button
type="button"
onClick={() => setOpen((value) => !value)}
className="relative inline-flex h-10 w-10 items-center justify-center rounded-lg text-white/75 transition hover:bg-white/5 hover:text-white"
className="relative inline-flex h-9 w-9 lg:h-10 lg:w-10 items-center justify-center rounded-lg text-white/75 transition hover:bg-white/5 hover:text-white"
aria-label="Notifications"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg className="h-[18px] w-[18px] lg:h-5 lg:w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 8a6 6 0 10-12 0c0 7-3 7-3 7h18s-3 0-3-7" />
<path d="M13.7 21a2 2 0 01-3.4 0" />
</svg>
{unreadCount > 0 ? (
<span className="absolute -bottom-1 right-0 rounded bg-red-700/80 px-1.5 py-0.5 text-[11px] font-semibold text-white border border-sb-line">
<span className="absolute -bottom-1 right-0 rounded border border-sb-line bg-red-700/80 px-1 py-0 text-[10px] font-semibold text-white lg:px-1.5 lg:py-0.5 lg:text-[11px]">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
) : null}

View File

@@ -0,0 +1,101 @@
import React, { useEffect, useState } from 'react'
import axios from 'axios'
import FollowButton from './FollowButton'
export default function SuggestedUsersWidget({
title = 'Suggested Users',
limit = 4,
isLoggedIn = false,
excludeUsername = null,
initialUsers = null,
}) {
const [users, setUsers] = useState(Array.isArray(initialUsers) ? initialUsers : [])
const [loading, setLoading] = useState(!Array.isArray(initialUsers) && isLoggedIn)
useEffect(() => {
if (!isLoggedIn || Array.isArray(initialUsers)) {
setLoading(false)
return
}
let cancelled = false
axios.get('/api/users/suggestions', { params: { limit } })
.then(({ data }) => {
if (cancelled) return
const nextUsers = Array.isArray(data?.data) ? data.data : []
setUsers(nextUsers)
})
.catch(() => {
if (!cancelled) setUsers([])
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [initialUsers, isLoggedIn, limit])
const visibleUsers = users
.filter((user) => user?.username && user.username !== excludeUsername)
.slice(0, limit)
if (!isLoggedIn) return null
if (!loading && visibleUsers.length === 0) return null
return (
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.03] overflow-hidden">
<div className="flex items-center gap-2 px-4 py-3 border-b border-white/[0.05]">
<i className="fa-solid fa-compass text-slate-500 fa-fw text-[13px]" />
<span className="text-[11px] font-semibold uppercase tracking-widest text-slate-500">{title}</span>
</div>
<div className="px-4 py-3 space-y-3">
{loading ? [1, 2, 3].map((key) => (
<div key={key} className="animate-pulse flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-white/10" />
<div className="min-w-0 flex-1 space-y-2">
<div className="h-2.5 w-24 rounded bg-white/10" />
<div className="h-2 w-32 rounded bg-white/6" />
</div>
</div>
)) : visibleUsers.map((user) => (
<div key={user.id} className="rounded-2xl border border-white/[0.05] bg-white/[0.03] p-3">
<div className="flex items-start gap-3">
<a href={user.profile_url || `/@${user.username}`} className="shrink-0">
<img
src={user.avatar_url || '/images/avatar_default.webp'}
alt={user.username}
className="h-10 w-10 rounded-full object-cover ring-1 ring-white/10"
loading="lazy"
/>
</a>
<div className="min-w-0 flex-1">
<a href={user.profile_url || `/@${user.username}`} className="block truncate text-sm font-semibold text-white/90 hover:text-white">
{user.name || user.username}
</a>
<p className="truncate text-xs text-slate-500">@{user.username}</p>
<p className="mt-1 text-xs text-slate-400">{user.context?.follower_overlap?.label || user.context?.shared_following?.label || user.reason}</p>
</div>
</div>
<div className="mt-3 flex items-center justify-between gap-3">
<span className="text-[11px] text-slate-500">{Number(user.followers_count || 0).toLocaleString()} followers</span>
<FollowButton
username={user.username}
initialFollowing={false}
initialCount={Number(user.followers_count || 0)}
showCount={false}
sizeClassName="px-3 py-1.5 text-xs"
className="min-w-[110px]"
/>
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -9,9 +9,29 @@ import { createPortal } from 'react-dom'
* visible whether the toast is currently shown
* onHide callback when the toast finishes (auto-hidden after ~2 s)
* duration ms before auto-dismiss (default 2000)
* variant success or error tone (default success)
*/
export default function ShareToast({ message = 'Link copied!', visible = false, onHide, duration = 2000 }) {
export default function ShareToast({ message = 'Link copied!', visible = false, onHide, duration = 2000, variant = 'success' }) {
const [show, setShow] = useState(false)
const config = variant === 'error'
? {
border: 'border-rose-300/25',
background: 'bg-rose-950/90',
text: 'text-rose-50',
icon: 'text-rose-300',
role: 'alert',
live: 'assertive',
iconPath: 'M12 9v3.75m0 3.75h.007v.008H12v-.008ZM10.29 3.86 1.82 18a1.875 1.875 0 0 0 1.606 2.813h16.148A1.875 1.875 0 0 0 21.18 18L12.71 3.86a1.875 1.875 0 0 0-3.42 0Z',
}
: {
border: 'border-white/[0.10]',
background: 'bg-nova-800/90',
text: 'text-white',
icon: 'text-emerald-400',
role: 'status',
live: 'polite',
iconPath: 'M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z',
}
useEffect(() => {
if (visible) {
@@ -35,17 +55,19 @@ export default function ShareToast({ message = 'Link copied!', visible = false,
return createPortal(
<div
role="status"
aria-live="polite"
role={config.role}
aria-live={config.live}
className={[
'fixed bottom-24 left-1/2 z-[10001] -translate-x-1/2 rounded-full border border-white/[0.10] bg-nova-800/90 px-5 py-2.5 text-sm font-medium text-white shadow-xl backdrop-blur-md transition-all duration-200',
'fixed bottom-24 left-1/2 z-[10001] -translate-x-1/2 rounded-full border px-5 py-2.5 text-sm font-medium shadow-xl backdrop-blur-md transition-all duration-200',
config.border,
config.background,
config.text,
show ? 'translate-y-0 opacity-100' : 'translate-y-3 opacity-0',
].join(' ')}
>
<span className="flex items-center gap-2">
{/* Check icon */}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4 text-emerald-400">
<path fillRule="evenodd" d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z" clipRule="evenodd" />
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className={`h-4 w-4 ${config.icon}`}>
<path fillRule="evenodd" d={config.iconPath} clipRule="evenodd" />
</svg>
{message}
</span>

View File

@@ -1,4 +1,4 @@
import React, { forwardRef } from 'react'
import React, { forwardRef, useId } from 'react'
/**
* Nova TextInput
@@ -26,7 +26,11 @@ const TextInput = forwardRef(function TextInput(
},
ref,
) {
const inputId = id ?? (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined)
const generatedId = useId()
const labelSlug = typeof label === 'string'
? label.toLowerCase().replace(/\s+/g, '-')
: null
const inputId = id ?? labelSlug ?? `text-input-${generatedId.replace(/[:]/g, '')}`
const sizeClass = {
sm: 'py-1.5 text-xs',

View File

@@ -1,4 +1,4 @@
import React, { forwardRef } from 'react'
import React, { forwardRef, useId } from 'react'
/**
* Nova Textarea
@@ -14,7 +14,11 @@ const Textarea = forwardRef(function Textarea(
{ label, error, hint, required, rows = 4, resize = false, id, className = '', ...rest },
ref,
) {
const inputId = id ?? (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined)
const generatedId = useId()
const labelSlug = typeof label === 'string'
? label.toLowerCase().replace(/\s+/g, '-')
: null
const inputId = id ?? labelSlug ?? `textarea-${generatedId.replace(/[:]/g, '')}`
const inputClass = [
'block w-full rounded-xl border bg-white/[0.06] text-white text-sm',

View File

@@ -1,7 +1,7 @@
import React from 'react'
import TagPicker from '../tags/TagPicker'
import Checkbox from '../../Components/ui/Checkbox'
import MarkdownEditor from '../ui/MarkdownEditor'
import RichTextEditor from '../forum/RichTextEditor'
export default function UploadSidebar({
title = 'Artwork details',
@@ -13,6 +13,7 @@ export default function UploadSidebar({
onChangeTitle,
onChangeTags,
onChangeDescription,
onToggleMature,
onToggleRights,
}) {
return (
@@ -46,14 +47,16 @@ export default function UploadSidebar({
<label className="block">
<span className="text-sm font-medium text-white/90">Description <span className="text-red-300">*</span></span>
<MarkdownEditor
id="upload-sidebar-description"
value={metadata.description}
<div className="mt-2">
<RichTextEditor
content={metadata.description}
onChange={onChangeDescription}
placeholder="Describe your artwork (Markdown supported)."
error={errors.description}
placeholder="Describe your artwork, tools, inspiration…"
error={Array.isArray(errors.description) ? errors.description[0] : errors.description}
minHeight={12}
autofocus={false}
/>
{errors.description && <p className="mt-1 text-xs text-red-200">{errors.description}</p>}
</div>
</label>
</div>
</section>
@@ -74,6 +77,18 @@ export default function UploadSidebar({
/>
</section>
<section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<Checkbox
id="upload-sidebar-mature"
checked={Boolean(metadata.isMature)}
onChange={(event) => onToggleMature?.(event.target.checked)}
variant="accent"
size={20}
label="Mark this artwork as mature content."
hint="Use this for NSFW, explicit, or otherwise age-restricted artwork."
/>
</section>
<section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<Checkbox
id="upload-sidebar-rights"

View File

@@ -50,6 +50,7 @@ const initialMetadata = {
subCategoryId: '',
tags: [],
description: '',
isMature: false,
rightsAccepted: false,
contentType: '',
}
@@ -426,6 +427,7 @@ export default function UploadWizard({
onChangeTitle={(value) => setMeta({ title: value })}
onChangeTags={(value) => setMeta({ tags: value })}
onChangeDescription={(value) => setMeta({ description: value })}
onToggleMature={(value) => setMeta({ isMature: Boolean(value) })}
onToggleRights={(value) => setMeta({ rightsAccepted: Boolean(value) })}
/>
)
@@ -581,8 +583,8 @@ export default function UploadWizard({
)}
</div>
{/* Right column: PublishPanel (sticky sidebar on lg+) */}
{(primaryFile || resolvedArtworkId) && machine.state !== machineStates.complete && (
{/* Right column: PublishPanel (sticky sidebar on lg+, Step 2+ only) */}
{(primaryFile || resolvedArtworkId) && machine.state !== machineStates.complete && activeStep > 1 && (
<div className="hidden shrink-0 lg:block lg:w-80 xl:w-[22rem] lg:sticky lg:top-20 lg:self-start">
<PublishPanel
primaryPreviewUrl={primaryPreviewUrl}
@@ -612,7 +614,7 @@ export default function UploadWizard({
</div>
{/* ── Mobile: floating "Publish" button that opens bottom sheet ────── */}
{(primaryFile || resolvedArtworkId) && machine.state !== machineStates.complete && (
{(primaryFile || resolvedArtworkId) && machine.state !== machineStates.complete && activeStep > 1 && (
<div className="fixed bottom-4 right-4 z-30 lg:hidden">
<button
type="button"

View File

@@ -277,6 +277,42 @@ describe('UploadWizard step flow', () => {
expect(window.axios.post).not.toHaveBeenCalledWith('/api/uploads/session-1/publish', expect.anything(), expect.anything())
})
it('includes the mature flag in the final publish payload when selected', async () => {
installAxiosStubs({ statusValue: 'ready' })
await renderWizard({ initialDraftId: 311, contentTypes: [{ id: 1, name: 'Art', categories: [{ id: 10, name: 'Root', children: [{ id: 11, name: 'Sub' }] }] }] })
await completeStep1ToReady()
await screen.findByText(/artwork details/i)
const titleInput = screen.getByPlaceholderText(/give your artwork a clear title/i)
await act(async () => {
await userEvent.type(titleInput, 'Mature Piece')
await userEvent.selectOptions(screen.getByRole('combobox', { name: /root category/i }), '10')
await userEvent.selectOptions(screen.getByRole('combobox', { name: /subcategory/i }), '11')
await userEvent.click(screen.getByLabelText(/mark this artwork as mature content/i))
await userEvent.click(screen.getByLabelText(/i confirm i own the rights to this content/i))
await userEvent.click(screen.getByRole('button', { name: /continue to publish/i }))
})
await waitFor(() => {
const publish = screen.getByRole('button', { name: /^publish$/i })
expect(publish.disabled).toBe(false)
})
await act(async () => {
await userEvent.click(screen.getByRole('button', { name: /^publish$/i }))
})
await waitFor(() => {
expect(window.axios.post).toHaveBeenCalledWith(
'/api/uploads/311/publish',
expect.objectContaining({ is_mature: true }),
expect.anything(),
)
})
})
it('keeps mobile sticky action bar visible class', async () => {
installAxiosStubs()
await renderWizard({ initialDraftId: 306 })

View File

@@ -28,67 +28,50 @@ export default function Step1FileUpload({
// Machine state (passed for potential future use)
machine,
}) {
const fileSelected = Boolean(primaryFile)
return (
<div className="space-y-5 rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-6 shadow-[0_24px_90px_rgba(2,8,23,0.28)] backdrop-blur sm:p-7">
{/* Step header */}
<div className="rounded-2xl border border-white/8 bg-white/[0.04] px-5 py-4">
<div className="space-y-6 rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-6 shadow-[0_24px_90px_rgba(2,8,23,0.28)] backdrop-blur sm:p-8">
{/* ── Hero heading ─────────────────────────────────────────────────── */}
<div className="text-center">
<span className="inline-flex items-center gap-1.5 rounded-full border border-sky-400/30 bg-sky-400/10 px-3 py-1 text-[11px] uppercase tracking-widest text-sky-300">
Step 1 of 3
</span>
<h2
ref={headingRef}
tabIndex={-1}
className="text-lg font-semibold text-white focus:outline-none"
className="mt-4 text-2xl font-bold text-white focus:outline-none"
>
Upload your artwork
</h2>
<p className="mt-1 text-sm text-white/60">
Drop or browse a file. Validation runs immediately. Upload starts when you click&nbsp;
<span className="text-white/80">Start upload</span>.
<p className="mx-auto mt-2 max-w-md text-sm text-white/55">
Drop an image or an archive pack. We validate the file instantly so you can start uploading straight away.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-3">
{[
{
title: '1. Add the file',
body: 'Drop an image or archive pack into the upload area.',
},
{
title: '2. Check validation',
body: 'We flag unsupported formats, missing screenshots, and basic file issues immediately.',
},
{
title: '3. Start upload',
body: 'Once the file is clean, the secure processing pipeline takes over.',
},
].map((item) => (
<div key={item.title} className="rounded-2xl border border-white/8 bg-white/[0.03] p-4">
<p className="text-sm font-semibold text-white">{item.title}</p>
<p className="mt-2 text-sm leading-6 text-slate-300">{item.body}</p>
</div>
))}
</div>
{/* Locked notice */}
{/* ── Locked notice ────────────────────────────────────────────────── */}
{fileSelectionLocked && (
<div className="flex items-center gap-2 rounded-2xl bg-amber-500/10 px-4 py-3 text-xs text-amber-100 ring-1 ring-amber-300/30">
<svg className="h-3.5 w-3.5 shrink-0" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<div className="flex items-center gap-2.5 rounded-2xl bg-amber-500/10 px-4 py-3 text-sm text-amber-100 ring-1 ring-amber-300/30">
<svg className="h-4 w-4 shrink-0" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 0010 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clipRule="evenodd" />
</svg>
File is locked after upload. Reset to change.
File is locked after upload starts. Reset to change the file.
</div>
)}
{/* Primary dropzone */}
{/* ── Primary dropzone ─────────────────────────────────────────────── */}
<UploadDropzone
title="Upload your artwork file"
description="Drag & drop or click to browse. Accepted: JPG, PNG, WEBP, ZIP, RAR, 7Z."
title="Drop your file here"
description="JPG, PNG, WEBP · ZIP, RAR, 7Z · Images up to 50 MB · Archives up to 200 MB"
fileName={primaryFile?.name || ''}
previewUrl={primaryPreviewUrl}
fileMeta={fileMetadata}
fileHint="No file selected"
invalid={primaryErrors.length > 0}
errors={primaryErrors}
showLooksGood={Boolean(primaryFile) && primaryErrors.length === 0}
looksGoodText="Looks good"
showLooksGood={fileSelected && primaryErrors.length === 0}
looksGoodText="File looks good — ready to upload"
locked={fileSelectionLocked}
onPrimaryFileChange={(file) => {
if (fileSelectionLocked) return
@@ -96,10 +79,10 @@ export default function Step1FileUpload({
}}
/>
{/* Screenshots (archives only) */}
{/* ── Screenshots (archives only) ──────────────────────────────────── */}
<ScreenshotUploader
title="Archive screenshots"
description="We need at least 1 screenshot to generate thumbnails and analyze content."
description="Add at least 1 screenshot so we can generate a thumbnail and analyze your content."
visible={isArchive}
files={screenshots}
min={1}
@@ -108,9 +91,54 @@ export default function Step1FileUpload({
errors={screenshotErrors}
invalid={isArchive && screenshotErrors.length > 0}
showLooksGood={isArchive && screenshots.length > 0 && screenshotErrors.length === 0}
looksGoodText="Looks good"
looksGoodText="Screenshots look good"
onFilesChange={onScreenshotsChange}
/>
{/* ── Subtle what-happens-next hints (shown only before a file is picked) */}
{!fileSelected && (
<div className="grid gap-3 sm:grid-cols-3">
{[
{
icon: (
<svg className="h-5 w-5 text-sky-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 15v4a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-4" /><path d="M7 10l5-5 5 5" /><path d="M12 5v10" />
</svg>
),
label: 'Add your file',
hint: 'Image or archive — drop it in or click to browse.',
},
{
icon: (
<svg className="h-5 w-5 text-violet-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M9 12l2 2 4-4" /><path d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10z" />
</svg>
),
label: 'Instant validation',
hint: 'Format, size, and screenshot checks run immediately.',
},
{
icon: (
<svg className="h-5 w-5 text-emerald-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M5 13l4 4L19 7" />
</svg>
),
label: 'Start upload',
hint: 'One click sends your file through the secure pipeline.',
},
].map((item) => (
<div key={item.label} className="flex items-start gap-3 rounded-2xl border border-white/8 bg-white/[0.03] px-4 py-4">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border border-white/10 bg-white/[0.05]">
{item.icon}
</div>
<div>
<p className="text-sm font-semibold text-white">{item.label}</p>
<p className="mt-1 text-xs leading-5 text-slate-400">{item.hint}</p>
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -1,7 +1,6 @@
import React from 'react'
import ContentTypeSelector from '../ContentTypeSelector'
import CategorySelector from '../CategorySelector'
import React, { useEffect, useMemo, useState } from 'react'
import UploadSidebar from '../UploadSidebar'
import { getContentTypeValue, getContentTypeVisualKey } from '../../../lib/uploadUtils'
/**
* Step2Details
@@ -33,8 +32,78 @@ export default function Step2Details({
onChangeTitle,
onChangeTags,
onChangeDescription,
onToggleMature,
onToggleRights,
}) {
const [isContentTypeChooserOpen, setIsContentTypeChooserOpen] = useState(() => !metadata.contentType)
const [isCategoryChooserOpen, setIsCategoryChooserOpen] = useState(() => !metadata.rootCategoryId)
const [isSubCategoryChooserOpen, setIsSubCategoryChooserOpen] = useState(() => !metadata.subCategoryId)
const [categorySearch, setCategorySearch] = useState('')
const [subCategorySearch, setSubCategorySearch] = useState('')
const contentTypeOptions = useMemo(
() => (Array.isArray(contentTypes) ? contentTypes : []).map((item) => {
const normalizedName = String(item?.name || '').trim().toLowerCase()
const normalizedSlug = String(item?.slug || '').trim().toLowerCase()
if (normalizedName === 'other' || normalizedSlug === 'other') {
return {
...item,
name: 'Others',
}
}
return item
}),
[contentTypes]
)
const selectedContentType = useMemo(
() => contentTypeOptions.find((item) => String(getContentTypeValue(item)) === String(metadata.contentType || '')) ?? null,
[contentTypeOptions, metadata.contentType]
)
const selectedRoot = useMemo(
() => filteredCategoryTree.find((item) => String(item.id) === String(metadata.rootCategoryId || '')) ?? null,
[filteredCategoryTree, metadata.rootCategoryId]
)
const subCategories = selectedRoot?.children || []
const selectedSubCategory = useMemo(
() => subCategories.find((item) => String(item.id) === String(metadata.subCategoryId || '')) ?? null,
[subCategories, metadata.subCategoryId]
)
const sortedFilteredCategories = useMemo(() => {
const sorted = [...filteredCategoryTree].sort((a, b) => a.name.localeCompare(b.name))
const q = categorySearch.trim().toLowerCase()
return q ? sorted.filter((c) => c.name.toLowerCase().includes(q)) : sorted
}, [filteredCategoryTree, categorySearch])
const sortedFilteredSubCategories = useMemo(() => {
const sorted = [...subCategories].sort((a, b) => a.name.localeCompare(b.name))
const q = subCategorySearch.trim().toLowerCase()
return q ? sorted.filter((s) => s.name.toLowerCase().includes(q)) : sorted
}, [subCategories, subCategorySearch])
useEffect(() => {
if (!metadata.contentType) {
setIsContentTypeChooserOpen(true)
}
}, [metadata.contentType])
useEffect(() => {
if (!metadata.rootCategoryId) {
setIsCategoryChooserOpen(true)
}
}, [metadata.rootCategoryId])
useEffect(() => {
if (!metadata.subCategoryId) {
setIsSubCategoryChooserOpen(true)
}
}, [metadata.subCategoryId])
return (
<div className="space-y-5 rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-6 shadow-[0_24px_90px_rgba(2,8,23,0.28)] backdrop-blur sm:p-7">
{/* Step header */}
@@ -95,56 +164,306 @@ export default function Step2Details({
</div>
</div>
{/* Content type selector */}
<section className="rounded-2xl ring-1 ring-white/10 bg-gradient-to-br from-white/[0.04] to-white/[0.01] p-5 sm:p-6">
<section className="rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(14,165,233,0.08),_rgba(15,23,36,0.92)_52%)] p-5 sm:p-6">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-sm font-semibold text-white">Content type</h3>
<p className="mt-0.5 text-xs text-white/55">Choose what kind of artwork this is.</p>
<p className="mt-1 text-xs text-white/55">Choose the main content family first.</p>
</div>
<span className="rounded-full border border-sky-400/35 bg-sky-400/10 px-2.5 py-0.5 text-[11px] uppercase tracking-widest text-sky-300">
Step 2a
</span>
</div>
<ContentTypeSelector
contentTypes={contentTypes}
selected={metadata.contentType}
error={metadataErrors.contentType}
onChange={onContentTypeChange}
/>
{contentTypeOptions.length === 0 && (
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-4 text-sm text-slate-400">
No content types are available right now.
</div>
)}
{selectedContentType && !isContentTypeChooserOpen && (
<div className="rounded-2xl border border-emerald-400/25 bg-emerald-400/[0.08] p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-emerald-200/80">Selected content type</div>
<div className="mt-1 text-lg font-semibold text-white">{selectedContentType.name}</div>
<div className="mt-1 text-sm text-slate-400">
{filteredCategoryTree.length > 0
? `Continue by choosing one of the ${filteredCategoryTree.length} matching categories below.`
: 'This content type does not have categories yet.'}
</div>
</div>
<button
type="button"
onClick={() => setIsContentTypeChooserOpen(true)}
className="inline-flex items-center justify-center rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm font-medium text-slate-200 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
>
Change
</button>
</div>
</div>
)}
{(!selectedContentType || isContentTypeChooserOpen) && (
<div className="grid gap-3 lg:grid-cols-2">
{contentTypeOptions.map((ct) => {
const typeValue = String(getContentTypeValue(ct))
const isActive = typeValue === String(metadata.contentType || '')
const visualKey = getContentTypeVisualKey(ct)
const categoryCount = Array.isArray(ct.categories) ? ct.categories.length : 0
return (
<button
key={typeValue || ct.name}
type="button"
onClick={() => {
setIsContentTypeChooserOpen(false)
setIsCategoryChooserOpen(true)
onContentTypeChange(typeValue)
}}
className={[
'group flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left transition-all',
isActive
? 'border-emerald-400/40 bg-emerald-400/10 shadow-[0_0_0_1px_rgba(52,211,153,0.18)]'
: 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.06]',
].join(' ')}
aria-pressed={isActive}
>
<div className={`flex h-12 w-12 items-center justify-center rounded-2xl border ${isActive ? 'border-emerald-400/30 bg-emerald-400/10' : 'border-white/10 bg-white/[0.04]'}`}>
<img
src={`/gfx/mascot_${visualKey}.webp`}
alt=""
className="h-8 w-8 object-contain"
onError={(e) => { e.currentTarget.style.display = 'none' }}
/>
</div>
<div className="min-w-0 flex-1">
<div className={`text-sm font-semibold ${isActive ? 'text-emerald-200' : 'text-white'}`}>{ct.name}</div>
<div className="mt-1 text-[11px] text-slate-500">{categoryCount} {categoryCount === 1 ? 'category' : 'categories'}</div>
</div>
<div className={`text-xs ${isActive ? 'text-emerald-300' : 'text-slate-500 group-hover:text-slate-300'}`}>
{isActive ? 'Selected' : 'Open'}
</div>
</button>
)
})}
</div>
)}
{metadataErrors.contentType && <p className="mt-3 text-xs text-red-300">{metadataErrors.contentType}</p>}
</section>
{/* Category selector */}
<section className="rounded-2xl ring-1 ring-white/10 bg-gradient-to-br from-white/[0.04] to-white/[0.01] p-5 sm:p-6">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<section className="rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(168,85,247,0.08),_rgba(15,23,36,0.88)_55%)] p-5 sm:p-6">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<h3 className="text-sm font-semibold text-white">Category</h3>
<p className="mt-0.5 text-xs text-white/55">
{requiresSubCategory ? 'Select a category, then a subcategory.' : 'Select a category.'}
</p>
<h4 className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Category path</h4>
<p className="mt-1 text-sm text-slate-400">Choose the main branch first, then refine with a subcategory when needed.</p>
</div>
<span className="rounded-full border border-violet-400/35 bg-violet-400/10 px-2.5 py-0.5 text-[11px] uppercase tracking-widest text-violet-300">
Step 2b
</span>
</div>
<CategorySelector
categories={filteredCategoryTree}
rootCategoryId={metadata.rootCategoryId}
subCategoryId={metadata.subCategoryId}
hasContentType={Boolean(metadata.contentType)}
error={metadataErrors.category}
onRootChange={onRootCategoryChange}
onSubChange={onSubCategoryChange}
allRoots={allRootCategoryOptions}
onRootChangeAll={(rootId, contentTypeValue) => {
if (contentTypeValue) {
onContentTypeChange(contentTypeValue)
}
onRootCategoryChange(rootId)
}}
/>
{!selectedContentType && (
<div className="mt-5 rounded-2xl border border-dashed border-white/12 bg-white/[0.02] px-5 py-10 text-center">
<div className="text-sm font-medium text-white">Select a content type first</div>
<p className="mt-2 text-sm text-slate-500">Once you choose the content type, the matching category tree will appear here.</p>
</div>
)}
{selectedContentType && (
<div className="mt-5 space-y-5">
<div className="flex items-center gap-2 text-sm text-slate-400">
<span className="rounded-full border border-emerald-400/20 bg-emerald-400/10 px-2.5 py-1 text-emerald-200">{selectedContentType.name}</span>
<span>contains {filteredCategoryTree.length} top-level {filteredCategoryTree.length === 1 ? 'category' : 'categories'}</span>
</div>
{selectedRoot && !isCategoryChooserOpen && (
<div className="rounded-2xl border border-purple-400/25 bg-purple-400/[0.08] p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-purple-200/80">Selected category</div>
<div className="mt-1 text-lg font-semibold text-white">{selectedRoot.name}</div>
<div className="mt-1 text-sm text-slate-400">
{subCategories.length > 0
? `Next step: choose one of the ${subCategories.length} subcategories below.`
: 'This category is complete. No subcategory is required.'}
</div>
</div>
<button
type="button"
onClick={() => { setCategorySearch(''); setIsCategoryChooserOpen(true) }}
className="inline-flex items-center justify-center rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm font-medium text-slate-200 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
>
Change
</button>
</div>
</div>
)}
{(!selectedRoot || isCategoryChooserOpen) && (
<div className="space-y-3">
<div className="relative">
<svg className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" /></svg>
<input
type="search"
value={categorySearch}
onChange={(e) => setCategorySearch(e.target.value)}
placeholder="Search categories…"
className="w-full rounded-xl border border-white/10 bg-white/[0.04] py-2.5 pl-9 pr-4 text-sm text-white placeholder:text-slate-500 focus:border-purple-400/40 focus:outline-none focus:ring-1 focus:ring-purple-400/30"
/>
</div>
{sortedFilteredCategories.length === 0 && (
<p className="py-4 text-center text-sm text-slate-500">No categories match &ldquo;{categorySearch}&rdquo;</p>
)}
<div className="grid gap-3 lg:grid-cols-2">
{sortedFilteredCategories.map((cat) => {
const isActive = String(metadata.rootCategoryId || '') === String(cat.id)
const childCount = cat.children?.length || 0
return (
<button
key={cat.id}
type="button"
onClick={() => {
setIsCategoryChooserOpen(false)
onRootCategoryChange(String(cat.id))
}}
className={[
'rounded-2xl border px-4 py-4 text-left transition-all',
isActive
? 'border-purple-400/40 bg-purple-400/12 shadow-[0_0_0_1px_rgba(192,132,252,0.15)]'
: 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]',
].join(' ')}
>
<div className="flex items-start justify-between gap-3">
<div>
<div className={`text-sm font-semibold ${isActive ? 'text-purple-200' : 'text-white'}`}>{cat.name}</div>
<div className="mt-1 text-[11px] text-slate-500">{childCount > 0 ? `${childCount} subcategories available` : 'Standalone category'}</div>
</div>
<span className={`rounded-full px-2 py-1 text-[11px] ${isActive ? 'bg-purple-300/15 text-purple-200' : 'bg-white/[0.05] text-slate-500'}`}>
{isActive ? 'Selected' : 'Choose'}
</span>
</div>
</button>
)
})}
</div>
</div>
)}
{selectedRoot && subCategories.length > 0 && (
<div className="rounded-2xl border border-cyan-400/15 bg-cyan-400/[0.05] p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h5 className="text-[11px] font-semibold uppercase tracking-[0.18em] text-cyan-200/80">Subcategories</h5>
<p className="mt-1 text-sm text-slate-400">Refine <span className="text-white">{selectedRoot.name}</span> with one more level.</p>
</div>
<span className="rounded-full border border-cyan-400/20 bg-cyan-400/10 px-2 py-1 text-[11px] text-cyan-200">{subCategories.length}</span>
</div>
{!metadata.subCategoryId && requiresSubCategory && (
<div className="mt-4 rounded-xl border border-amber-400/20 bg-amber-400/10 px-3 py-2 text-sm text-amber-100">
Subcategory still needs to be selected.
</div>
)}
{selectedSubCategory && !isSubCategoryChooserOpen && (
<div className="mt-4 rounded-2xl border border-cyan-400/25 bg-cyan-400/[0.09] p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-cyan-200/80">Selected subcategory</div>
<div className="mt-1 text-lg font-semibold text-white">{selectedSubCategory.name}</div>
<div className="mt-1 text-sm text-slate-300">
Final category path: <span className="text-white">{selectedRoot.name}</span> / <span className="text-cyan-100">{selectedSubCategory.name}</span>
</div>
</div>
<button
type="button"
onClick={() => { setSubCategorySearch(''); setIsSubCategoryChooserOpen(true) }}
className="inline-flex items-center justify-center rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm font-medium text-slate-200 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
>
Change
</button>
</div>
</div>
)}
{(!selectedSubCategory || isSubCategoryChooserOpen) && (
<div className="mt-4 space-y-3">
<div className="relative">
<svg className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" /></svg>
<input
type="search"
value={subCategorySearch}
onChange={(e) => setSubCategorySearch(e.target.value)}
placeholder="Search subcategories…"
className="w-full rounded-xl border border-white/10 bg-white/[0.04] py-2.5 pl-9 pr-4 text-sm text-white placeholder:text-slate-500 focus:border-cyan-400/40 focus:outline-none focus:ring-1 focus:ring-cyan-400/30"
/>
</div>
{sortedFilteredSubCategories.length === 0 && (
<p className="py-4 text-center text-sm text-slate-500">No subcategories match &ldquo;{subCategorySearch}&rdquo;</p>
)}
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{sortedFilteredSubCategories.map((sub) => {
const isActive = String(metadata.subCategoryId || '') === String(sub.id)
return (
<button
key={sub.id}
type="button"
onClick={() => {
setIsSubCategoryChooserOpen(false)
onSubCategoryChange(String(sub.id))
}}
className={[
'group rounded-2xl border px-4 py-3 text-left transition-all',
isActive
? 'border-cyan-400/40 bg-cyan-400/[0.13] shadow-[0_0_0_1px_rgba(34,211,238,0.14)]'
: 'border-white/10 bg-white/[0.04] hover:border-white/20 hover:bg-white/[0.06]',
].join(' ')}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className={[
'text-sm font-semibold transition-colors',
isActive ? 'text-cyan-100' : 'text-slate-100 group-hover:text-white',
].join(' ')}>
{sub.name}
</div>
<div className={[
'mt-1 text-xs',
isActive ? 'text-cyan-200/80' : 'text-slate-500 group-hover:text-slate-300',
].join(' ')}>
Subcategory option
</div>
</div>
<span className={[
'shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium',
isActive
? 'bg-cyan-300/15 text-cyan-100'
: 'bg-white/[0.05] text-slate-500 group-hover:text-slate-300',
].join(' ')}>
{isActive ? 'Selected' : 'Choose'}
</span>
</div>
</button>
)
})}
</div>
</div>
)}
</div>
)}
{selectedRoot && subCategories.length === 0 && (
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-400">
<span className="font-medium text-white">{selectedRoot.name}</span> does not have subcategories. Selecting it is enough.
</div>
)}
</div>
)}
{metadataErrors.category && <p className="mt-4 text-xs text-red-300">{metadataErrors.category}</p>}
</section>
{/* Title, tags, description, rights */}
@@ -156,6 +475,7 @@ export default function Step2Details({
onChangeTitle={onChangeTitle}
onChangeTags={onChangeTags}
onChangeDescription={onChangeDescription}
onToggleMature={onToggleMature}
onToggleRights={onToggleRights}
/>
</div>

View File

@@ -1,6 +1,14 @@
import React from 'react'
import { motion, useReducedMotion } from 'framer-motion'
function stripHtml(value) {
return String(value || '')
.replace(/<[^>]*>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
/**
* PublishCheckBadge a single status item for the review section
*/
@@ -66,6 +74,7 @@ export default function Step3Publish({
(c) => String(c.id) === String(metadata.subCategoryId)
) ?? null
const subLabel = subCategory?.name ?? null
const descriptionPreview = stripHtml(metadata.description)
const checks = [
{ label: 'File uploaded', ok: uploadReady },
@@ -137,6 +146,7 @@ export default function Step3Publish({
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-white/55">
<span>Tags: <span className="text-white/75">{(metadata.tags || []).length}</span></span>
<span>Audience: <span className="text-white/75">{metadata.isMature ? 'Mature' : 'General'}</span></span>
{!isArchive && fileMetadata?.resolution && fileMetadata.resolution !== '—' && (
<span>Resolution: <span className="text-white/75">{fileMetadata.resolution}</span></span>
)}
@@ -145,8 +155,8 @@ export default function Step3Publish({
)}
</div>
{metadata.description && (
<p className="line-clamp-2 text-xs text-white/50">{metadata.description}</p>
{descriptionPreview && (
<p className="line-clamp-2 text-xs text-white/50">{descriptionPreview}</p>
)}
</div>
</div>

View File

@@ -42,6 +42,8 @@ function mountAll() {
limit: parseInt(container.dataset.limit || '40', 10),
rankApiEndpoint: container.dataset.rankApiEndpoint || null,
rankType: container.dataset.rankType || null,
discoveryEndpoint: container.dataset.discoveryEndpoint || null,
algoVersion: container.dataset.algoVersion || null,
};
createRoot(container).render(<MasonryGallery {...props} />);

View File

@@ -232,6 +232,7 @@ export default function useUploadMachine({
category: metadata.subCategoryId || metadata.rootCategoryId || null,
tags: Array.isArray(metadata.tags) ? metadata.tags.join(', ') : '',
license: Boolean(metadata.rightsAccepted),
is_mature: Boolean(metadata.isMature),
})
const draftIdCandidate = Number(draftResponse?.data?.artwork_id ?? draftResponse?.data?.id)
@@ -395,6 +396,7 @@ export default function useUploadMachine({
description: String(metadata.description || '').trim() || null,
category: resolvedCategoryId ? String(resolvedCategoryId) : null,
tags: Array.isArray(metadata.tags) ? metadata.tags : [],
is_mature: Boolean(metadata.isMature),
mode,
...(mode === 'schedule' && publishAt ? { publish_at: publishAt } : {}),
...(timezone ? { timezone } : {}),

View File

@@ -5,8 +5,6 @@
// Alpine.js — powers x-data/x-show/@click in Blade layouts (e.g. cookie banner, toasts).
// Guard: don't start a second instance if app.js already loaded Alpine on this page.
import Alpine from 'alpinejs';
import React from 'react';
import { createRoot } from 'react-dom/client';
if (!window.Alpine) {
window.Alpine = Alpine;
Alpine.start();
@@ -24,6 +22,24 @@ function safeParseJson(value, fallback) {
}
}
var reactRuntimePromise = null;
function getReactRuntime() {
if (!reactRuntimePromise) {
reactRuntimePromise = Promise.all([
import('react'),
import('react-dom/client'),
]).then(function (modules) {
return {
React: modules[0].default,
createRoot: modules[1].createRoot,
};
});
}
return reactRuntimePromise;
}
function mountStoryEditor() {
var storyEditorRoot = document.getElementById('story-editor-react-root');
if (!storyEditorRoot) return;
@@ -49,11 +65,16 @@ function mountStoryEditor() {
storyEditorRoot.dataset.reactMounted = 'true';
void import('./components/editor/StoryEditor')
.then(function (module) {
void Promise.all([
import('./components/editor/StoryEditor'),
getReactRuntime(),
])
.then(function (resolved) {
var module = resolved[0];
var reactRuntime = resolved[1];
var StoryEditor = module.default;
createRoot(storyEditorRoot).render(
React.createElement(StoryEditor, {
reactRuntime.createRoot(storyEditorRoot).render(
reactRuntime.React.createElement(StoryEditor, {
mode: mode,
initialStory: initialStory,
storyTypes: storyTypes,
@@ -77,10 +98,15 @@ function mountToolbarNotifications() {
var props = safeParseJson(rootEl.getAttribute('data-props'), {});
rootEl.dataset.reactMounted = 'true';
void import('./components/social/NotificationDropdown.jsx')
.then(function (module) {
void Promise.all([
import('./components/social/NotificationDropdown.jsx'),
getReactRuntime(),
])
.then(function (resolved) {
var module = resolved[0];
var reactRuntime = resolved[1];
var Component = module.default;
createRoot(rootEl).render(React.createElement(Component, props));
reactRuntime.createRoot(rootEl).render(reactRuntime.React.createElement(Component, props));
})
.catch(function () {
rootEl.dataset.reactMounted = 'false';
@@ -94,10 +120,15 @@ function mountToolbarMessages() {
var props = safeParseJson(rootEl.getAttribute('data-props'), {});
rootEl.dataset.reactMounted = 'true';
void import('./components/social/MessageInboxBadge.jsx')
.then(function (module) {
void Promise.all([
import('./components/social/MessageInboxBadge.jsx'),
getReactRuntime(),
])
.then(function (resolved) {
var module = resolved[0];
var reactRuntime = resolved[1];
var Component = module.default;
createRoot(rootEl).render(React.createElement(Component, props));
reactRuntime.createRoot(rootEl).render(reactRuntime.React.createElement(Component, props));
})
.catch(function () {
rootEl.dataset.reactMounted = 'false';
@@ -110,10 +141,15 @@ function mountStorySocial() {
var props = safeParseJson(socialRoot.getAttribute('data-props'), {});
socialRoot.dataset.reactMounted = 'true';
void import('./components/social/StorySocialPanel.jsx')
.then(function (module) {
void Promise.all([
import('./components/social/StorySocialPanel.jsx'),
getReactRuntime(),
])
.then(function (resolved) {
var module = resolved[0];
var reactRuntime = resolved[1];
var Component = module.default;
createRoot(socialRoot).render(React.createElement(Component, {
reactRuntime.createRoot(socialRoot).render(reactRuntime.React.createElement(Component, {
story: props.story,
creator: props.creator,
initialState: props.state,
@@ -132,10 +168,15 @@ function mountStorySocial() {
var followProps = safeParseJson(followRoot.getAttribute('data-props'), {});
followRoot.dataset.reactMounted = 'true';
void import('./components/social/FollowButton.jsx')
.then(function (module) {
void Promise.all([
import('./components/social/FollowButton.jsx'),
getReactRuntime(),
])
.then(function (resolved) {
var module = resolved[0];
var reactRuntime = resolved[1];
var Component = module.default;
createRoot(followRoot).render(React.createElement(Component, {
reactRuntime.createRoot(followRoot).render(reactRuntime.React.createElement(Component, {
username: followProps.username,
initialFollowing: Boolean(followProps.following),
initialCount: Number(followProps.followers_count || 0),

View File

@@ -4,10 +4,20 @@ import { createRoot } from 'react-dom/client'
import { createInertiaApp } from '@inertiajs/react'
import ProfileShow from './Pages/Profile/ProfileShow'
import ProfileGallery from './Pages/Profile/ProfileGallery'
import CollectionShow from './Pages/Collection/CollectionShow'
import CollectionSeriesShow from './Pages/Collection/CollectionSeriesShow'
import CollectionManage from './Pages/Collection/CollectionManage'
import CollectionFeaturedIndex from './Pages/Collection/CollectionFeaturedIndex'
import SavedCollections from './Pages/Collection/SavedCollections'
const pages = {
'Profile/ProfileShow': ProfileShow,
'Profile/ProfileGallery': ProfileGallery,
'Collection/CollectionShow': CollectionShow,
'Collection/CollectionSeriesShow': CollectionSeriesShow,
'Collection/CollectionManage': CollectionManage,
'Collection/CollectionFeaturedIndex': CollectionFeaturedIndex,
'Collection/SavedCollections': SavedCollections,
}
createInertiaApp({

View File

@@ -11,6 +11,8 @@ import StudioArchived from './Pages/Studio/StudioArchived'
import StudioArtworkAnalytics from './Pages/Studio/StudioArtworkAnalytics'
import StudioArtworkEdit from './Pages/Studio/StudioArtworkEdit'
import StudioAnalytics from './Pages/Studio/StudioAnalytics'
import StudioCardsIndex from './Pages/Studio/StudioCardsIndex'
import StudioCardEditor from './Pages/Studio/StudioCardEditor'
const pages = {
'Studio/StudioDashboard': StudioDashboard,
@@ -20,6 +22,8 @@ const pages = {
'Studio/StudioArtworkAnalytics': StudioArtworkAnalytics,
'Studio/StudioArtworkEdit': StudioArtworkEdit,
'Studio/StudioAnalytics': StudioAnalytics,
'Studio/StudioCardsIndex': StudioCardsIndex,
'Studio/StudioCardEditor': StudioCardEditor,
}
createInertiaApp({

View File

@@ -1,5 +1,26 @@
@extends('layouts.nova')
@php
$presentMd = $presentMd ?? \App\Services\ThumbnailPresenter::present($artwork, 'md');
$presentLg = $presentLg ?? \App\Services\ThumbnailPresenter::present($artwork, 'lg');
$presentXl = $presentXl ?? \App\Services\ThumbnailPresenter::present($artwork, 'xl');
$presentSq = $presentSq ?? \App\Services\ThumbnailPresenter::present($artwork, 'sq');
$canonicalUrl = route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]);
$meta = $meta ?? [
'title' => trim((string) ($artwork->title ?? 'Artwork') . ' by ' . (string) ($artwork->user?->name ?? $artwork->user?->username ?? 'Unknown Author') . ' | Skinbase'),
'description' => (string) ($artwork->description ?? ''),
'canonical' => $canonicalUrl,
'og_image' => $presentXl['url'] ?? $presentLg['url'] ?? $presentMd['url'] ?? null,
'og_width' => $presentXl['width'] ?? $presentLg['width'] ?? null,
'og_height' => $presentXl['height'] ?? $presentLg['height'] ?? null,
];
$artworkData = $artworkData ?? [];
$relatedItems = $relatedItems ?? [];
$comments = $comments ?? [];
@endphp
@push('head')
<title>{{ $meta['title'] }}</title>
<meta name="description" content="{{ $meta['description'] }}">
@@ -30,7 +51,7 @@
@php
$authorName = $artwork->user?->name ?: $artwork->user?->username ?: null;
$keywords = $artwork->tags->pluck('name')->merge($artwork->categories->pluck('name'))->filter()->unique()->implode(', ');
$keywords = $artwork->tags->pluck('name')->filter()->unique()->values()->all();
$license = $artwork->license_url ?? null;
$imageObject = [
@@ -47,7 +68,7 @@
'author' => $authorName ? ['@type' => 'Person', 'name' => $authorName] : null,
'datePublished' => optional($artwork->published_at)->toAtomString(),
'license' => $license,
'keywords' => $keywords !== '' ? $keywords : null,
'keywords' => !empty($keywords) ? $keywords : null,
];
$creativeWork = [
@@ -59,7 +80,7 @@
'author' => $authorName ? ['@type' => 'Person', 'name' => $authorName] : null,
'datePublished' => optional($artwork->published_at)->toAtomString(),
'license' => $license,
'keywords' => $keywords !== '' ? $keywords : null,
'keywords' => !empty($keywords) ? $keywords : null,
'image' => $meta['og_image'] ?? null,
];

View File

@@ -0,0 +1,132 @@
@extends('layouts.nova')
@section('meta-description', $meta['description'] ?? '')
@push('head')
<title>{{ $meta['title'] ?? 'Card Challenges - Skinbase Nova' }}</title>
<link rel="canonical" href="{{ $meta['canonical'] ?? route('cards.challenges') }}" />
@endpush
@section('content')
<section class="px-6 pt-8 md:px-10">
<div class="rounded-[34px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)] md:p-8">
<p class="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/75">Nova Cards</p>
<h1 class="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white md:text-5xl">{{ $heading }}</h1>
<p class="mt-4 max-w-3xl text-sm leading-7 text-slate-300 md:text-base">{{ $subheading }}</p>
</div>
</section>
@if(!empty($challengeEntryItems ?? []))
<section class="px-6 pt-8 md:px-10">
<div class="rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 class="text-2xl font-semibold tracking-[-0.03em] text-white">Entries</h2>
@auth
@if(!empty($challenge ?? null))
<button type="button" data-report-target-type="nova_card_challenge" data-report-target-id="{{ $challenge->id }}" class="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
<i class="fa-solid fa-flag"></i>
Report challenge
</button>
@endif
@endauth
</div>
<div class="mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
@foreach($challengeEntryItems as $entry)
<div class="space-y-3">
@include('cards.partials.tile', ['card' => $entry['card']])
@auth
<button type="button" data-report-target-type="nova_card_challenge_entry" data-report-target-id="{{ $entry['id'] }}" class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.08]">
<i class="fa-solid fa-flag"></i>
Report entry
</button>
@endauth
</div>
@endforeach
</div>
</div>
</section>
@endif
<section class="px-6 py-8 md:px-10">
<div class="grid gap-4 xl:grid-cols-2">
@foreach(($challenges ?? collect()) as $challengeItem)
<article class="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 transition hover:border-sky-300/30 hover:bg-white/[0.06]">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">{{ strtoupper($challengeItem->status) }}</div>
<h2 class="mt-2 text-2xl font-semibold text-white">{{ $challengeItem->title }}</h2>
</div>
@if($challengeItem->featured)
<span class="rounded-full border border-amber-300/25 bg-amber-300/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100">Featured</span>
@endif
</div>
@if($challengeItem->prompt)
<p class="mt-4 text-sm leading-7 text-slate-300">{{ $challengeItem->prompt }}</p>
@endif
@if($challengeItem->description)
<p class="mt-3 text-sm leading-7 text-slate-400">{{ $challengeItem->description }}</p>
@endif
<div class="mt-4 flex items-center justify-between text-xs text-slate-400">
<span>{{ number_format((int) $challengeItem->entries_count) }} entries</span>
<span>{{ optional($challengeItem->starts_at)->format('M j, Y') ?: 'Open date TBD' }}</span>
</div>
<div class="mt-4 flex flex-wrap gap-3">
<a href="{{ route('cards.challenges.show', ['slug' => $challengeItem->slug]) }}" class="inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">
<i class="fa-solid fa-arrow-right"></i>
View challenge
</a>
@auth
<button type="button" data-report-target-type="nova_card_challenge" data-report-target-id="{{ $challengeItem->id }}" class="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
<i class="fa-solid fa-flag"></i>
Report challenge
</button>
@endauth
</div>
</article>
@endforeach
</div>
</section>
@endsection
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', () => {
const reportButtons = document.querySelectorAll('[data-report-target-type][data-report-target-id]')
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || ''
reportButtons.forEach((button) => {
button.addEventListener('click', () => {
const reason = window.prompt('Why are you reporting this Nova Cards item?')
if (!reason || !reason.trim()) {
return
}
const details = window.prompt('Add extra details for moderators (optional)')
fetch(@json(route('api.reports.store')), {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': csrf,
'Accept': 'application/json',
'Content-Type': 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify({
target_type: button.dataset.reportTargetType,
target_id: Number(button.dataset.reportTargetId),
reason: reason.trim(),
details: details && details.trim() ? details.trim() : null,
}),
}).then(async (response) => {
if (!response.ok) {
return
}
window.alert('Report submitted. Thank you.')
}).catch(() => {})
})
})
})
</script>
@endpush

View File

@@ -0,0 +1,67 @@
@extends('layouts.nova')
@section('meta-description', $meta['description'] ?? '')
@push('head')
<title>{{ $meta['title'] ?? ($collection['name'] . ' - Nova Cards Collection - Skinbase Nova') }}</title>
<link rel="canonical" href="{{ $meta['canonical'] ?? $collection['public_url'] }}" />
@if(!empty($meta['robots']))
<meta name="robots" content="{{ $meta['robots'] }}" />
@endif
<script type="application/ld+json">
{!! json_encode([
'@context' => 'https://schema.org',
'@type' => 'CollectionPage',
'name' => $collection['name'],
'description' => $meta['description'] ?? $collection['description'],
'url' => $meta['canonical'] ?? $collection['public_url'],
'creator' => [
'@type' => 'Person',
'name' => data_get($collection, 'owner.username'),
],
'mainEntity' => collect($collection['items'] ?? [])->map(fn ($item) => [
'@type' => 'CreativeWork',
'name' => data_get($item, 'card.title'),
'url' => data_get($item, 'card.public_url'),
])->values()->all(),
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) !!}
</script>
@endpush
@section('content')
<section class="px-6 pt-8 md:px-10">
<div class="rounded-[34px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)] md:p-8">
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center rounded-full border border-white/10 bg-white/[0.05] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-200">{{ $collection['cards_count'] }} cards</span>
@if(!empty($collection['official']))
<span class="inline-flex items-center rounded-full border border-amber-300/25 bg-amber-300/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100">Official</span>
@endif
</div>
<h1 class="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white md:text-5xl">{{ $collection['name'] }}</h1>
<p class="mt-4 max-w-3xl text-sm leading-7 text-slate-300 md:text-base">{{ $collection['description'] ?: 'A curated Nova Cards collection.' }}</p>
<div class="mt-5 text-sm text-slate-400">
Curated by <a href="{{ route('cards.creator', ['username' => strtolower($collection['owner']['username'])]) }}" class="font-semibold text-sky-100 transition hover:text-white">@{{ $collection['owner']['username'] }}</a>
</div>
</div>
</section>
<section class="px-6 pt-8 md:px-10">
@if(empty($collection['items']))
<div class="rounded-[28px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-14 text-center">
<h2 class="text-2xl font-semibold text-white">No public cards in this collection yet</h2>
<p class="mx-auto mt-3 max-w-xl text-sm leading-7 text-slate-300">Cards added here will appear once they are public and approved.</p>
</div>
@else
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
@foreach($collection['items'] as $item)
<div class="space-y-3">
@include('cards.partials.tile', ['card' => $item['card']])
@if(!empty($item['note']))
<div class="rounded-[20px] border border-white/10 bg-white/[0.03] px-4 py-3 text-sm leading-6 text-slate-300">{{ $item['note'] }}</div>
@endif
</div>
@endforeach
</div>
@endif
</section>
@endsection

View File

@@ -0,0 +1,801 @@
@extends('layouts.nova')
@section('meta-description', $meta['description'] ?? '')
@push('head')
<title>{{ $meta['title'] ?? 'Nova Cards - Skinbase Nova' }}</title>
<link rel="canonical" href="{{ $meta['canonical'] ?? route('cards.index') }}" />
@if(!empty($meta['robots']))
<meta name="robots" content="{{ $meta['robots'] }}" />
@endif
<script type="application/ld+json">
{!! json_encode([
'@context' => 'https://schema.org',
'@type' => 'CollectionPage',
'name' => $meta['title'] ?? 'Nova Cards - Skinbase Nova',
'description' => $meta['description'] ?? '',
'url' => $meta['canonical'] ?? route('cards.index'),
'isPartOf' => [
'@type' => 'WebSite',
'name' => config('app.name'),
'url' => url('/'),
],
'mainEntity' => collect($cards ?? [])->take(12)->map(function ($card) {
return [
'@type' => 'CreativeWork',
'name' => $card['title'] ?? null,
'url' => $card['public_url'] ?? null,
'creator' => [
'@type' => 'Person',
'name' => data_get($card, 'creator.username'),
],
];
})->values()->all(),
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) !!}
</script>
@endpush
@section('content')
<section class="px-6 pt-8 md:px-10">
<div class="rounded-[34px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)] md:p-8">
<p class="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/75">Nova Cards</p>
<h1 class="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white md:text-5xl">{{ $heading }}</h1>
<p class="mt-4 max-w-3xl text-sm leading-7 text-slate-300 md:text-base">{{ $subheading }}</p>
<div class="mt-6 flex flex-wrap gap-3">
<a href="{{ route('studio.cards.create') }}" class="inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">
<i class="fa-solid fa-plus"></i>
Create a card
</a>
<a href="{{ route('cards.popular') }}" class="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
<i class="fa-solid fa-fire"></i>
Popular
</a>
<a href="{{ route('cards.remixed') }}" class="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
<i class="fa-solid fa-code-branch"></i>
Remixed
</a>
<a href="{{ route('cards.remix-highlights') }}" class="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
<i class="fa-solid fa-sparkles"></i>
Best remixes
</a>
<a href="{{ route('cards.editorial') }}" class="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
<i class="fa-solid fa-feather-pointed"></i>
Editorial
</a>
<a href="{{ route('cards.seasonal') }}" class="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
<i class="fa-solid fa-sun"></i>
Seasonal
</a>
<a href="{{ route('cards.challenges') }}" class="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
<i class="fa-solid fa-trophy"></i>
Challenges
</a>
@if(($context ?? null) !== 'index')
<a href="{{ route('cards.index') }}" class="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
<i class="fa-solid fa-compass"></i>
Browse all cards
</a>
@endif
</div>
</div>
</section>
@if(($context ?? null) === 'index' && (!empty($featuredCards) || !empty($trendingCards)))
<section class="px-6 pt-8 md:px-10">
<div class="grid gap-6 xl:grid-cols-2">
<div class="rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
<div class="mb-4 flex items-center justify-between gap-4">
<div>
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Featured</p>
<h2 class="mt-1 text-2xl font-semibold text-white">Editors picks</h2>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2">
@foreach($featuredCards as $card)
@include('cards.partials.tile', ['card' => $card])
@endforeach
</div>
</div>
<div class="rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
<div class="mb-4 flex items-center justify-between gap-4">
<div>
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Trending</p>
<h2 class="mt-1 text-2xl font-semibold text-white">Most viewed right now</h2>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2">
@foreach($trendingCards as $card)
@include('cards.partials.tile', ['card' => $card])
@endforeach
</div>
</div>
</div>
</section>
@endif
@if(in_array(($context ?? null), ['creator', 'creator-portfolio'], true) && !empty($creatorSummary))
<section class="px-6 pt-8 md:px-10">
<div class="grid gap-6 xl:grid-cols-[minmax(0,1.25fr)_minmax(0,1fr)]">
<div class="rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Creator profile</p>
<h2 class="mt-1 text-2xl font-semibold text-white">{{ $creatorSummary['creator']['display_name'] }}</h2>
<p class="mt-2 text-sm leading-7 text-slate-300">{{ ($context ?? null) === 'creator-portfolio' ? 'A dedicated Nova Cards portfolio view with public works, signature themes, remix activity, and publishing history.' : 'A public snapshot of this creator\'s Nova Cards footprint, top styles, and strongest publishing signals.' }}</p>
</div>
<span class="rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100">{{ '@' . $creatorSummary['creator']['username'] }}</span>
</div>
<div class="mt-5 flex flex-wrap gap-2">
<a href="{{ route('cards.creator', ['username' => strtolower((string) $creatorSummary['creator']['username'])]) }}" class="inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold transition {{ ($context ?? null) === 'creator' ? 'border-sky-300/20 bg-sky-400/10 text-sky-100' : 'border-white/10 bg-white/[0.05] text-white hover:bg-white/[0.08]' }}">
<i class="fa-solid fa-user"></i>
Profile
</a>
<a href="{{ route('cards.creator.portfolio', ['username' => strtolower((string) $creatorSummary['creator']['username'])]) }}" class="inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold transition {{ ($context ?? null) === 'creator-portfolio' ? 'border-sky-300/20 bg-sky-400/10 text-sky-100' : 'border-white/10 bg-white/[0.05] text-white hover:bg-white/[0.08]' }}">
<i class="fa-solid fa-layer-group"></i>
Portfolio
</a>
</div>
<div class="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-6">
<div class="rounded-[22px] border border-white/10 bg-white/[0.03] p-4">
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Public cards</div>
<div class="mt-2 text-2xl font-semibold text-white">{{ number_format($creatorSummary['stats']['total_cards'] ?? 0) }}</div>
</div>
<div class="rounded-[22px] border border-white/10 bg-white/[0.03] p-4">
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Featured works</div>
<div class="mt-2 text-2xl font-semibold text-white">{{ number_format($creatorSummary['stats']['total_featured_cards'] ?? 0) }}</div>
</div>
<div class="rounded-[22px] border border-white/10 bg-white/[0.03] p-4">
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Views</div>
<div class="mt-2 text-2xl font-semibold text-white">{{ number_format($creatorSummary['stats']['total_views'] ?? 0) }}</div>
</div>
<div class="rounded-[22px] border border-white/10 bg-white/[0.03] p-4">
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Saves</div>
<div class="mt-2 text-2xl font-semibold text-white">{{ number_format($creatorSummary['stats']['total_saves'] ?? 0) }}</div>
</div>
<div class="rounded-[22px] border border-white/10 bg-white/[0.03] p-4">
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Remixes</div>
<div class="mt-2 text-2xl font-semibold text-white">{{ number_format($creatorSummary['stats']['total_remixes'] ?? 0) }}</div>
</div>
<div class="rounded-[22px] border border-white/10 bg-white/[0.03] p-4">
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Challenge entries</div>
<div class="mt-2 text-2xl font-semibold text-white">{{ number_format($creatorSummary['stats']['total_challenge_entries'] ?? 0) }}</div>
</div>
</div>
<div class="mt-5 grid gap-5 lg:grid-cols-3">
<div>
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Top styles</div>
<div class="mt-3 flex flex-wrap gap-2">
@forelse(($creatorSummary['top_styles'] ?? []) as $style)
<a href="{{ route('cards.style', ['styleSlug' => $style['key']]) }}" class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-slate-200 transition hover:bg-white/[0.08]">{{ $style['label'] }} <span class="text-xs text-slate-500">{{ $style['cards_count'] }}</span></a>
@empty
<span class="text-sm text-slate-500">No dominant style family yet.</span>
@endforelse
</div>
</div>
<div>
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Top categories</div>
<div class="mt-3 flex flex-wrap gap-2">
@forelse(($creatorSummary['top_categories'] ?? []) as $category)
<a href="{{ route('cards.category', ['categorySlug' => $category['slug']]) }}" class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-slate-200 transition hover:bg-white/[0.08]">{{ $category['name'] }} <span class="text-xs text-slate-500">{{ $category['cards_count'] }}</span></a>
@empty
<span class="text-sm text-slate-500">No category signal yet.</span>
@endforelse
</div>
</div>
<div>
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Top tags</div>
<div class="mt-3 flex flex-wrap gap-2">
@forelse(($creatorSummary['top_tags'] ?? []) as $tag)
<a href="{{ route('cards.tag', ['tagSlug' => $tag['slug']]) }}" class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-slate-200 transition hover:bg-white/[0.08]">#{{ $tag['name'] }} <span class="text-xs text-slate-500">{{ $tag['cards_count'] }}</span></a>
@empty
<span class="text-sm text-slate-500">No recurring tags yet.</span>
@endforelse
</div>
</div>
</div>
<div class="mt-5 rounded-[24px] border border-white/10 bg-white/[0.03] p-4">
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Signature themes</div>
<div class="mt-4 grid gap-5 lg:grid-cols-2">
<div>
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Top palettes</div>
<div class="mt-3 flex flex-wrap gap-2">
@forelse(($creatorSummary['top_palettes'] ?? []) as $palette)
<a href="{{ route('cards.palette', ['paletteSlug' => $palette['key']]) }}" class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-slate-200 transition hover:bg-white/[0.08]">{{ $palette['label'] }} <span class="text-xs text-slate-500">{{ $palette['cards_count'] }}</span></a>
@empty
<span class="text-sm text-slate-500">No signature palette family yet.</span>
@endforelse
</div>
</div>
<div>
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Signature moods</div>
<div class="mt-3 flex flex-wrap gap-2">
@forelse(($creatorSummary['top_moods'] ?? []) as $mood)
<a href="{{ route('cards.mood', ['moodSlug' => $mood['key']]) }}" class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-slate-200 transition hover:bg-white/[0.08]">{{ $mood['label'] }} <span class="text-xs text-slate-500">{{ $mood['cards_count'] }}</span></a>
@empty
<span class="text-sm text-slate-500">No recurring mood signal yet.</span>
@endforelse
</div>
</div>
</div>
</div>
<div class="mt-5 grid gap-5 lg:grid-cols-2">
<div class="rounded-[24px] border border-white/10 bg-white/[0.03] p-4">
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Portfolio depth</div>
<h3 class="mt-2 text-xl font-semibold text-white">Most remixed works</h3>
@if(!empty($creatorMostRemixedWorks))
<div class="mt-4 space-y-3">
@foreach($creatorMostRemixedWorks as $card)
<a href="{{ $card['public_url'] }}" class="flex items-start justify-between gap-3 rounded-[20px] border border-white/10 bg-[#08111f]/70 px-4 py-3 transition hover:border-white/20 hover:bg-[#0d1726]">
<div>
<div class="font-semibold text-white">{{ $card['title'] }}</div>
<div class="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">{{ $card['creator']['username'] ? '@' . $card['creator']['username'] : 'Creator' }}</div>
</div>
<span class="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-200">{{ number_format($card['remixes_count'] ?? 0) }} remixes</span>
</a>
@endforeach
</div>
@else
<div class="mt-4 rounded-[20px] border border-dashed border-white/12 bg-white/[0.03] px-4 py-6 text-sm text-slate-400">Remix traction will appear here as this creator's cards are remixed by the community.</div>
@endif
</div>
<div class="rounded-[24px] border border-white/10 bg-white/[0.03] p-4">
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Participation</div>
<h3 class="mt-2 text-xl font-semibold text-white">Challenge track record</h3>
@if(!empty($creatorChallengeHistory))
<div class="mt-4 space-y-3">
@foreach($creatorChallengeHistory as $entry)
<div class="rounded-[20px] border border-white/10 bg-[#08111f]/70 px-4 py-3">
<div class="flex items-start justify-between gap-3">
<div>
@if(!empty($entry['challenge_url']))
<a href="{{ $entry['challenge_url'] }}" class="font-semibold text-white transition hover:text-sky-100">{{ $entry['challenge_title'] }}</a>
@else
<div class="font-semibold text-white">{{ $entry['challenge_title'] }}</div>
@endif
<div class="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">{{ $entry['official'] ? 'Official challenge' : ucfirst($entry['challenge_status'] ?: 'challenge') }}</div>
</div>
<span class="rounded-full border border-sky-300/20 bg-sky-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-100">{{ $entry['status_label'] }}</span>
</div>
@if(!empty($entry['card_url']))
<div class="mt-3 text-sm text-slate-300">With <a href="{{ $entry['card_url'] }}" class="font-semibold text-sky-100 transition hover:text-white">{{ $entry['card_title'] }}</a></div>
@endif
</div>
@endforeach
</div>
@else
<div class="mt-4 rounded-[20px] border border-dashed border-white/12 bg-white/[0.03] px-4 py-6 text-sm text-slate-400">Challenge entries and featured placements will appear here as this creator participates in Nova Cards challenges.</div>
@endif
</div>
</div>
<div class="mt-5 rounded-[24px] border border-white/10 bg-white/[0.03] p-4">
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Audience traction</div>
<h3 class="mt-2 text-xl font-semibold text-white">Most liked works</h3>
@if(!empty($creatorMostLikedWorks))
<div class="mt-4 grid gap-3 lg:grid-cols-2">
@foreach($creatorMostLikedWorks as $card)
<a href="{{ $card['public_url'] }}" class="flex items-start justify-between gap-3 rounded-[20px] border border-white/10 bg-[#08111f]/70 px-4 py-3 transition hover:border-white/20 hover:bg-[#0d1726]">
<div>
<div class="font-semibold text-white">{{ $card['title'] }}</div>
<div class="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">{{ $card['creator']['username'] ? '@' . $card['creator']['username'] : 'Creator' }}</div>
</div>
<div class="flex flex-col items-end gap-1 text-right">
<span class="rounded-full border border-rose-300/20 bg-rose-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-rose-100">{{ number_format($card['likes_count'] ?? 0) }} likes</span>
<span class="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-200">{{ number_format($card['saves_count'] ?? 0) }} saves</span>
</div>
</a>
@endforeach
</div>
@else
<div class="mt-4 rounded-[20px] border border-dashed border-white/12 bg-white/[0.03] px-4 py-6 text-sm text-slate-400">Audience favorites will appear here once this creator's cards start collecting likes and saves.</div>
@endif
</div>
<div class="mt-5 rounded-[24px] border border-white/10 bg-white/[0.03] p-4">
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Remix activity</div>
<h3 class="mt-2 text-xl font-semibold text-white">Remix branches</h3>
<div class="mt-4 grid gap-3 sm:grid-cols-2">
<div class="rounded-[20px] border border-white/10 bg-white/[0.03] p-4">
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Community branches</div>
<div class="mt-2 text-2xl font-semibold text-white">{{ number_format($creatorRemixActivity['total_cards_remixed_by_community'] ?? 0) }}</div>
</div>
<div class="rounded-[20px] border border-white/10 bg-white/[0.03] p-4">
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Published remixes</div>
<div class="mt-2 text-2xl font-semibold text-white">{{ number_format($creatorRemixActivity['total_published_remixes'] ?? 0) }}</div>
</div>
</div>
@if(!empty($creatorRemixActivity['branches']))
<div class="mt-4 space-y-3">
@foreach($creatorRemixActivity['branches'] as $branch)
<div class="rounded-[20px] border border-white/10 bg-[#08111f]/70 px-4 py-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<a href="{{ $branch['card']['public_url'] }}" class="font-semibold text-white transition hover:text-sky-100">{{ $branch['card']['title'] }}</a>
<div class="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">{{ $branch['branch_type'] }}</div>
</div>
<a href="{{ $branch['lineage_url'] }}" class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-3 py-1.5 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-200 transition hover:bg-white/[0.08]">
<i class="fa-solid fa-code-branch"></i>
View lineage
</a>
</div>
<div class="mt-3 text-sm text-slate-300">Source: <span class="font-semibold text-white">{{ $branch['source_label'] }}</span></div>
<div class="mt-3 flex flex-wrap gap-3 text-xs text-slate-400">
<span>{{ number_format($branch['card']['remixes_count'] ?? 0) }} remixes</span>
<span>{{ number_format($branch['card']['likes_count'] ?? 0) }} likes</span>
<span>{{ number_format($branch['card']['saves_count'] ?? 0) }} saves</span>
</div>
</div>
@endforeach
</div>
@else
<div class="mt-4 rounded-[20px] border border-dashed border-white/12 bg-white/[0.03] px-4 py-6 text-sm text-slate-400">Remix branch activity will appear here once this creator publishes remixes or their cards start branching.</div>
@endif
</div>
<div class="mt-5 rounded-[24px] border border-white/10 bg-white/[0.03] p-4">
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Remix visualization</div>
<h3 class="mt-2 text-xl font-semibold text-white">Remix graph</h3>
@if(!empty($creatorRemixGraph))
<div class="mt-4 space-y-4">
@foreach($creatorRemixGraph as $branch)
<div>
<div class="flex flex-wrap items-center justify-between gap-3 text-sm">
<span class="font-semibold text-white">{{ $branch['root_title'] }}</span>
<span class="text-slate-400">{{ number_format($branch['cards_count']) }} cards · {{ number_format($branch['total_remixes']) }} remixes</span>
</div>
<div class="mt-2 h-3 overflow-hidden rounded-full bg-white/[0.06]">
<div class="h-full rounded-full bg-gradient-to-r from-sky-400 via-cyan-300 to-emerald-300" style="width: {{ $branch['width_percent'] }}%"></div>
</div>
<div class="mt-2 text-xs uppercase tracking-[0.16em] text-slate-500">Peak branch card: {{ $branch['peak_title'] }}</div>
</div>
@endforeach
</div>
@else
<div class="mt-4 rounded-[20px] border border-dashed border-white/12 bg-white/[0.03] px-4 py-6 text-sm text-slate-400">Branch volume will chart here once this creator has remix families with visible activity.</div>
@endif
</div>
<div class="mt-5 rounded-[24px] border border-white/10 bg-white/[0.03] p-4">
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Creator identity</div>
<h3 class="mt-2 text-xl font-semibold text-white">Preference signals</h3>
<div class="mt-4 grid gap-5 lg:grid-cols-2">
<div>
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Top formats</div>
<div class="mt-3 flex flex-wrap gap-2">
@forelse(($creatorPreferenceSignals['top_formats'] ?? []) as $format)
<span class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-slate-200">{{ $format['label'] }} <span class="text-xs text-slate-500">{{ $format['cards_count'] }}</span></span>
@empty
<span class="text-sm text-slate-500">No dominant format yet.</span>
@endforelse
</div>
</div>
<div>
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Favorite templates</div>
<div class="mt-3 flex flex-wrap gap-2">
@forelse(($creatorPreferenceSignals['top_templates'] ?? []) as $template)
<span class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-slate-200">{{ $template['name'] }} <span class="text-xs text-slate-500">{{ $template['cards_count'] }}</span></span>
@empty
<span class="text-sm text-slate-500">No preferred template signal yet.</span>
@endforelse
</div>
</div>
</div>
<div class="mt-5 grid gap-3 sm:grid-cols-2">
<div class="rounded-[20px] border border-white/10 bg-white/[0.03] p-4">
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Preferred editor mode</div>
<div class="mt-2 text-lg font-semibold text-white">{{ $creatorPreferenceSignals['preferred_editor_mode']['label'] ?? 'No preference yet' }}</div>
@if(!empty($creatorPreferenceSignals['preferred_editor_mode']))
<div class="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">{{ number_format($creatorPreferenceSignals['preferred_editor_mode']['cards_count']) }} cards</div>
@endif
</div>
<div class="rounded-[20px] border border-white/10 bg-white/[0.03] p-4">
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Saved presets</div>
<div class="mt-3 flex flex-wrap gap-2">
@forelse(($creatorPreferenceSignals['preset_counts'] ?? []) as $preset)
<span class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-slate-200">{{ $preset['label'] }} <span class="text-xs text-slate-500">{{ $preset['presets_count'] }}</span></span>
@empty
<span class="text-sm text-slate-500">No saved presets yet.</span>
@endforelse
</div>
</div>
</div>
</div>
<div class="mt-5 rounded-[24px] border border-white/10 bg-white/[0.03] p-4">
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Publishing history</div>
<h3 class="mt-2 text-xl font-semibold text-white">Recent timeline</h3>
@if(!empty($creatorTimeline))
<div class="mt-4 space-y-4">
@foreach($creatorTimeline as $event)
<div class="flex gap-4 rounded-[20px] border border-white/10 bg-[#08111f]/70 px-4 py-4">
<div class="mt-1 h-2.5 w-2.5 shrink-0 rounded-full bg-sky-300"></div>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center justify-between gap-3">
<a href="{{ $event['card']['public_url'] }}" class="font-semibold text-white transition hover:text-sky-100">{{ $event['card']['title'] }}</a>
<span class="text-xs uppercase tracking-[0.16em] text-slate-500">{{ $event['card']['published_at'] ? \Illuminate\Support\Carbon::parse($event['card']['published_at'])->format('M j, Y') : 'Published' }}</span>
</div>
<div class="mt-2 flex flex-wrap gap-2">
@forelse($event['signals'] as $signal)
<span class="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-200">{{ $signal }}</span>
@empty
<span class="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-200">Published</span>
@endforelse
</div>
<div class="mt-3 flex flex-wrap gap-3 text-xs text-slate-400">
<span>{{ number_format($event['card']['likes_count'] ?? 0) }} likes</span>
<span>{{ number_format($event['card']['saves_count'] ?? 0) }} saves</span>
<span>{{ number_format($event['card']['remixes_count'] ?? 0) }} remixes</span>
</div>
</div>
</div>
@endforeach
</div>
@else
<div class="mt-4 rounded-[20px] border border-dashed border-white/12 bg-white/[0.03] px-4 py-6 text-sm text-slate-400">Recent publishing milestones will appear here once this creator has public card activity.</div>
@endif
</div>
</div>
<div class="space-y-6">
<div class="rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
<div class="mb-4 flex items-center justify-between gap-4">
<div>
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Featured works</p>
<h2 class="mt-1 text-2xl font-semibold text-white">Staff-curated creator picks</h2>
</div>
</div>
@if(!empty($creatorFeaturedWorks))
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
@foreach($creatorFeaturedWorks as $card)
@include('cards.partials.tile', ['card' => $card])
@endforeach
</div>
@else
<div class="rounded-[22px] border border-dashed border-white/12 bg-white/[0.03] px-4 py-8 text-sm text-slate-400">No explicit featured works yet. Staff-featured cards will appear here.</div>
@endif
</div>
<div class="rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
<div class="mb-4 flex items-center justify-between gap-4">
<div>
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Featured collections</p>
<h2 class="mt-1 text-2xl font-semibold text-white">Curated sets by this creator</h2>
</div>
</div>
@if(!empty($creatorFeaturedCollections))
<div class="space-y-3">
@foreach($creatorFeaturedCollections as $collection)
<a href="{{ $collection['public_url'] }}" class="block rounded-[22px] border border-white/10 bg-white/[0.03] p-4 transition hover:border-white/20 hover:bg-white/[0.05]">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-base font-semibold text-white">{{ $collection['name'] }}</div>
<div class="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">{{ $collection['official'] ? 'Official collection' : '@' . ($collection['owner']['username'] ?? 'creator') }}</div>
</div>
<span class="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-200">{{ $collection['cards_count'] }} cards</span>
</div>
@if(!empty($collection['description']))
<div class="mt-2 text-sm text-slate-400">{{ $collection['description'] }}</div>
@endif
</a>
@endforeach
</div>
@else
<div class="rounded-[22px] border border-dashed border-white/12 bg-white/[0.03] px-4 py-8 text-sm text-slate-400">No featured public collections yet.</div>
@endif
</div>
<div class="rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
<div class="mb-4 flex items-center justify-between gap-4">
<div>
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Creator highlights</p>
<h2 class="mt-1 text-2xl font-semibold text-white">Strongest public works</h2>
</div>
</div>
@if(!empty($creatorHighlights))
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
@foreach($creatorHighlights as $card)
@include('cards.partials.tile', ['card' => $card])
@endforeach
</div>
@else
<div class="rounded-[22px] border border-dashed border-white/12 bg-white/[0.03] px-4 py-8 text-sm text-slate-400">Highlights will appear as this creator publishes more public cards.</div>
@endif
</div>
</div>
</div>
</section>
@endif
@if(($context ?? null) === 'editorial' && (!empty($featuredCreators) || !empty($landingCollections) || (($landingChallenges ?? collect())->count() > 0)))
<section class="px-6 pt-8 md:px-10">
@if(!empty($featuredCreators))
<div class="rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
<div class="mb-4">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Creators</p>
<h2 class="mt-1 text-2xl font-semibold text-white">Featured creators</h2>
</div>
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
@foreach($featuredCreators as $creator)
<a href="{{ $creator['public_url'] }}" class="block rounded-[22px] border border-white/10 bg-white/[0.03] p-4 transition hover:border-white/20 hover:bg-white/[0.05]">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-base font-semibold text-white">{{ $creator['display_name'] }}</div>
<div class="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">@{{ $creator['username'] }}</div>
</div>
<span class="rounded-full border border-sky-300/20 bg-sky-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-100">Staff pick</span>
</div>
<div class="mt-3 grid grid-cols-3 gap-2 text-center text-xs text-slate-300">
<div class="rounded-2xl border border-white/10 bg-white/[0.04] px-2 py-3">
<div class="text-[10px] uppercase tracking-[0.14em] text-slate-500">Cards</div>
<div class="mt-1 text-sm font-semibold text-white">{{ number_format($creator['public_cards_count']) }}</div>
</div>
<div class="rounded-2xl border border-white/10 bg-white/[0.04] px-2 py-3">
<div class="text-[10px] uppercase tracking-[0.14em] text-slate-500">Featured</div>
<div class="mt-1 text-sm font-semibold text-white">{{ number_format($creator['featured_cards_count']) }}</div>
</div>
<div class="rounded-2xl border border-white/10 bg-white/[0.04] px-2 py-3">
<div class="text-[10px] uppercase tracking-[0.14em] text-slate-500">Views</div>
<div class="mt-1 text-sm font-semibold text-white">{{ number_format($creator['total_views_count']) }}</div>
</div>
</div>
</a>
@endforeach
</div>
</div>
@endif
<div class="mt-6 grid gap-6 xl:grid-cols-2">
@if(!empty($landingCollections))
<div class="rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
<div class="mb-4">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Collections</p>
<h2 class="mt-1 text-2xl font-semibold text-white">Featured collections</h2>
</div>
<div class="space-y-3">
@foreach($landingCollections as $collection)
<a href="{{ $collection['public_url'] }}" class="block rounded-[22px] border border-white/10 bg-white/[0.03] p-4 transition hover:border-white/20 hover:bg-white/[0.05]">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-base font-semibold text-white">{{ $collection['name'] }}</div>
<div class="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">{{ $collection['official'] ? 'Official collection' : '@' . ($collection['owner']['username'] ?? 'creator') }}</div>
</div>
<span class="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-200">{{ $collection['cards_count'] }} cards</span>
</div>
@if(!empty($collection['description']))
<div class="mt-2 text-sm text-slate-400">{{ $collection['description'] }}</div>
@endif
</a>
@endforeach
</div>
</div>
@endif
@if(($landingChallenges ?? collect())->count() > 0)
<div class="rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
<div class="mb-4">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Challenges</p>
<h2 class="mt-1 text-2xl font-semibold text-white">Editorial challenge picks</h2>
</div>
<div class="space-y-3">
@foreach($landingChallenges as $challenge)
<a href="{{ route('cards.challenges.show', ['slug' => $challenge->slug]) }}" class="block rounded-[22px] border border-white/10 bg-white/[0.03] p-4 transition hover:border-white/20 hover:bg-white/[0.05]">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-base font-semibold text-white">{{ $challenge->title }}</div>
<div class="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">{{ ucfirst((string) $challenge->status) }}{{ $challenge->official ? ' · Official' : '' }}</div>
</div>
<span class="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-200">{{ (int) $challenge->entries_count }} entries</span>
</div>
@if(!empty($challenge->description))
<div class="mt-2 text-sm text-slate-400">{{ $challenge->description }}</div>
@endif
</a>
@endforeach
</div>
</div>
@endif
</div>
</section>
@endif
@if(($context ?? null) === 'seasonal' && count($seasonalHubs ?? []) > 0)
<section class="px-6 pt-8 md:px-10">
<div class="rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
<div class="mb-4">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Seasonal hubs</p>
<h2 class="mt-1 text-2xl font-semibold text-white">Recurring themes</h2>
</div>
<div class="flex flex-wrap gap-2">
@foreach($seasonalHubs as $hub)
<span class="inline-flex items-center rounded-full border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-slate-200">{{ $hub['label'] }}</span>
@endforeach
</div>
</div>
</section>
@endif
<section class="px-6 pt-8 md:px-10">
<div class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]">
<div class="rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
<div class="mb-4 flex items-center justify-between gap-4">
<div>
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Latest</p>
<h2 class="mt-1 text-2xl font-semibold text-white">{{ in_array(($context ?? null), ['creator', 'creator-portfolio'], true) ? (($context ?? null) === 'creator-portfolio' ? 'Portfolio works' : 'All published works') : 'Published cards' }}</h2>
</div>
</div>
@if(empty($cards))
<div class="rounded-[24px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-14 text-center">
<div class="mx-auto flex h-20 w-20 items-center justify-center rounded-[24px] border border-white/12 bg-white/[0.05] text-slate-400">
<i class="fa-solid fa-rectangle-history-circle-user text-3xl"></i>
</div>
<h3 class="mt-5 text-2xl font-semibold text-white">No public cards yet</h3>
<p class="mx-auto mt-3 max-w-xl text-sm leading-7 text-slate-300">As creators publish their Nova Cards, they will appear here with crawlable quote text and preview imagery.</p>
</div>
@else
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
@foreach($cards as $card)
@include('cards.partials.tile', ['card' => $card])
@endforeach
</div>
@endif
@if(isset($pagination) && method_exists($pagination, 'links'))
<div class="mt-6">
{{ $pagination->links() }}
</div>
@endif
</div>
<aside class="space-y-6">
@if(($context ?? null) === 'index' && count($categories ?? []) > 0)
<div class="rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Categories</p>
<div class="mt-4 flex flex-wrap gap-2">
@foreach($categories as $category)
<a href="{{ route('cards.category', ['categorySlug' => $category->slug]) }}" class="inline-flex items-center rounded-full border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-slate-200 transition hover:bg-white/[0.08]">{{ $category->name }}</a>
@endforeach
</div>
</div>
@endif
@if(($context ?? null) === 'index' && count($tags ?? []) > 0)
<div class="rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Popular tags</p>
<div class="mt-4 flex flex-wrap gap-2">
@foreach($tags as $tag)
<a href="{{ route('cards.tag', ['tagSlug' => $tag->slug]) }}" class="inline-flex items-center rounded-full border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-slate-200 transition hover:bg-white/[0.08]">#{{ $tag->name }}</a>
@endforeach
</div>
</div>
@endif
@if(in_array(($context ?? null), ['index', 'mood'], true) && count($moodFamilies ?? []) > 0)
<div class="rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Mood families</p>
<div class="mt-4 flex flex-wrap gap-2">
@foreach($moodFamilies as $mood)
<a href="{{ route('cards.mood', ['moodSlug' => $mood['key']]) }}" class="inline-flex items-center rounded-full border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-slate-200 transition hover:bg-white/[0.08]">{{ $mood['label'] }}</a>
@endforeach
</div>
</div>
@endif
@if(in_array(($context ?? null), ['index', 'style'], true) && count($styleFamilies ?? []) > 0)
<div class="rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Style families</p>
<div class="mt-4 flex flex-wrap gap-2">
@foreach($styleFamilies as $style)
<a href="{{ route('cards.style', ['styleSlug' => $style['key']]) }}" class="inline-flex items-center rounded-full border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-slate-200 transition hover:bg-white/[0.08]">{{ $style['label'] }}</a>
@endforeach
</div>
</div>
@endif
@if(in_array(($context ?? null), ['index', 'style', 'palette'], true) && count($paletteFamilies ?? []) > 0)
<div class="rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Palette families</p>
<div class="mt-4 flex flex-wrap gap-2">
@foreach($paletteFamilies as $palette)
<a href="{{ route('cards.palette', ['paletteSlug' => $palette['key']]) }}" class="inline-flex items-center rounded-full border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-slate-200 transition hover:bg-white/[0.08]">{{ $palette['label'] }}</a>
@endforeach
</div>
</div>
@endif
@if(in_array(($context ?? null), ['index', 'seasonal'], true) && count($seasonalHubs ?? []) > 0)
<div class="rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Seasonal hubs</p>
<div class="mt-4 flex flex-wrap gap-2">
@foreach($seasonalHubs as $hub)
<span class="inline-flex items-center rounded-full border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-slate-200">{{ $hub['label'] }}</span>
@endforeach
</div>
</div>
@endif
@if(($context ?? null) === 'index' && count($collections ?? []) > 0)
<div class="rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Collections</p>
<div class="mt-4 space-y-3">
@foreach($collections as $collection)
<a href="{{ $collection['public_url'] }}" class="block rounded-[22px] border border-white/10 bg-white/[0.03] p-4 transition hover:border-white/20 hover:bg-white/[0.05]">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-base font-semibold text-white">{{ $collection['name'] }}</div>
<div class="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">{{ $collection['official'] ? 'Official collection' : '@' . ($collection['owner']['username'] ?? 'creator') }}</div>
</div>
<span class="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-200">{{ $collection['cards_count'] }} cards</span>
</div>
@if(!empty($collection['description']))
<div class="mt-2 text-sm text-slate-400">{{ $collection['description'] }}</div>
@endif
</a>
@endforeach
</div>
</div>
@endif
@if(in_array(($context ?? null), ['creator', 'creator-portfolio'], true) && !empty($creatorSummary))
<div class="rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Creator pages</p>
<div class="mt-4 space-y-2 text-sm">
<a href="{{ route('cards.creator', ['username' => strtolower((string) $creatorSummary['creator']['username'])]) }}" class="block rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-3 text-slate-200 transition hover:bg-white/[0.08]">Profile overview</a>
<a href="{{ route('cards.creator.portfolio', ['username' => strtolower((string) $creatorSummary['creator']['username'])]) }}" class="block rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-3 text-slate-200 transition hover:bg-white/[0.08]">Portfolio page</a>
</div>
</div>
<div class="rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Creator signals</p>
<div class="mt-4 space-y-3 text-sm text-slate-300">
<div class="flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
<span>Total likes</span>
<span class="font-semibold text-white">{{ number_format($creatorSummary['stats']['total_likes'] ?? 0) }}</span>
</div>
<div class="flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
<span>Total saves</span>
<span class="font-semibold text-white">{{ number_format($creatorSummary['stats']['total_saves'] ?? 0) }}</span>
</div>
<div class="flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
<span>Total remixes</span>
<span class="font-semibold text-white">{{ number_format($creatorSummary['stats']['total_remixes'] ?? 0) }}</span>
</div>
</div>
</div>
@endif
@if(in_array(($context ?? null), ['remixed', 'remix-highlights'], true))
<div class="rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Remix discovery</p>
<div class="mt-4 space-y-2 text-sm">
<a href="{{ route('cards.remixed') }}" class="block rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-3 text-slate-200 transition hover:bg-white/[0.08]">Latest remixes</a>
<a href="{{ route('cards.remix-highlights') }}" class="block rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-3 text-slate-200 transition hover:bg-white/[0.08]">Best remixes</a>
</div>
</div>
@endif
@if(in_array(($context ?? null), ['editorial', 'seasonal'], true))
<div class="rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Discovery landings</p>
<div class="mt-4 space-y-2 text-sm">
<a href="{{ route('cards.editorial') }}" class="block rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-3 text-slate-200 transition hover:bg-white/[0.08]">Editorial picks</a>
<a href="{{ route('cards.seasonal') }}" class="block rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-3 text-slate-200 transition hover:bg-white/[0.08]">Seasonal cards</a>
</div>
</div>
@endif
<div class="rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">V2 resources</p>
<div class="mt-4 space-y-2 text-sm">
<a href="{{ route('cards.templates') }}" class="block rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-3 text-slate-200 transition hover:bg-white/[0.08]">Template packs</a>
<a href="{{ route('cards.assets') }}" class="block rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-3 text-slate-200 transition hover:bg-white/[0.08]">Asset packs</a>
</div>
</div>
</aside>
</div>
</section>
@endsection

View File

@@ -0,0 +1,60 @@
@extends('layouts.nova')
@section('meta-description', $meta['description'] ?? '')
@push('head')
<title>{{ $meta['title'] ?? 'Nova Card Lineage - Skinbase Nova' }}</title>
<link rel="canonical" href="{{ $meta['canonical'] ?? route('cards.lineage', ['slug' => $card['slug'], 'id' => $card['id']]) }}" />
@if(!empty($meta['robots']))
<meta name="robots" content="{{ $meta['robots'] }}" />
@endif
@endpush
@section('content')
<section class="px-6 pt-8 md:px-10">
<div class="rounded-[34px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)] md:p-8">
<p class="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/75">Remix lineage</p>
<h1 class="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white md:text-5xl">{{ $card['title'] }}</h1>
<p class="mt-4 max-w-3xl text-sm leading-7 text-slate-300 md:text-base">Trace this card back to its root, then browse the rest of the family that grew from the same original.</p>
</div>
</section>
<section class="px-6 pt-8 md:px-10">
<div class="rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Trail</p>
<div class="mt-4 flex flex-wrap items-center gap-3">
@foreach($trail as $index => $item)
<a href="{{ $item['public_url'] }}" class="rounded-2xl border {{ $item['id'] === $card['id'] ? 'border-sky-300/35 bg-sky-400/10 text-sky-100' : 'border-white/10 bg-white/[0.03] text-white' }} px-4 py-3 text-sm font-semibold transition hover:bg-white/[0.05]">{{ $item['title'] }}</a>
@if($index < count($trail) - 1)
<span class="text-slate-500"><i class="fa-solid fa-arrow-right"></i></span>
@endif
@endforeach
</div>
</div>
</section>
<section class="px-6 pt-8 md:px-10">
<div class="grid gap-6 xl:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
<div class="rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Root card</p>
<div class="mt-4">
@include('cards.partials.tile', ['card' => $rootCard])
</div>
</div>
<div class="rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Family variants</p>
<h2 class="mt-1 text-2xl font-semibold text-white">Cards in this remix branch</h2>
</div>
<a href="{{ $card['public_url'] }}" class="rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Back to card</a>
</div>
<div class="mt-4 grid gap-4 sm:grid-cols-2">
@foreach($familyCards as $familyCard)
@include('cards.partials.tile', ['card' => $familyCard])
@endforeach
</div>
</div>
</div>
</section>
@endsection

View File

@@ -0,0 +1,22 @@
<a href="{{ $card['public_url'] }}" class="group overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.03] transition hover:-translate-y-1 hover:border-sky-300/25 hover:bg-white/[0.05]">
@if(!empty($card['preview_url']))
<img src="{{ $card['preview_url'] }}" alt="{{ $card['title'] }}" class="aspect-[4/5] w-full object-cover transition duration-500 group-hover:scale-[1.02]" loading="lazy" />
@endif
<div class="p-4">
<div class="flex items-center justify-between gap-3">
<h3 class="truncate text-lg font-semibold tracking-[-0.03em] text-white">{{ $card['title'] }}</h3>
<span class="rounded-full border border-white/10 bg-white/[0.05] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-200">{{ $card['format'] }}</span>
</div>
@if(!empty($card['lineage']['original_card']))
<div class="mt-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/70">Remix</div>
@endif
<p class="mt-2 line-clamp-3 text-sm leading-6 text-slate-300">{{ $card['quote_text'] }}</p>
<div class="mt-4 flex items-center justify-between gap-3 text-xs text-slate-400">
<span>{{ $card['creator']['name'] ?: ('@' . $card['creator']['username']) }}</span>
<span>{{ number_format($card['likes_count'] ?? 0) }} likes</span>
</div>
@if(!empty($card['lineage']['original_card']) || (int) ($card['remixes_count'] ?? 0) > 0)
<div class="mt-3 text-xs text-sky-100/85">View lineage</div>
@endif
</div>
</a>

View File

@@ -0,0 +1,58 @@
@extends('layouts.nova')
@section('meta-description', $meta['description'] ?? '')
@push('head')
<title>{{ $meta['title'] ?? 'Nova Cards Resources - Skinbase Nova' }}</title>
<link rel="canonical" href="{{ $meta['canonical'] ?? route('cards.index') }}" />
@endpush
@section('content')
<section class="px-6 pt-8 md:px-10">
<div class="rounded-[34px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)] md:p-8">
<p class="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/75">Nova Cards</p>
<h1 class="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white md:text-5xl">{{ $heading }}</h1>
<p class="mt-4 max-w-3xl text-sm leading-7 text-slate-300 md:text-base">{{ $subheading }}</p>
</div>
</section>
<section class="px-6 pt-8 md:px-10">
<div class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,360px)]">
<div class="rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
<h2 class="text-2xl font-semibold tracking-[-0.03em] text-white">Official packs</h2>
<div class="mt-4 grid gap-4 md:grid-cols-2">
@foreach($packs as $pack)
<div class="rounded-[24px] border border-white/10 bg-white/[0.03] p-5">
<div class="flex items-center justify-between gap-3">
<h3 class="text-lg font-semibold text-white">{{ $pack['name'] ?? $pack['slug'] }}</h3>
@if(!empty($pack['official']))
<span class="rounded-full border border-sky-300/20 bg-sky-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100">Official</span>
@endif
</div>
@if(!empty($pack['description']))
<p class="mt-3 text-sm leading-7 text-slate-300">{{ $pack['description'] }}</p>
@endif
<div class="mt-4 text-xs text-slate-400">{{ strtoupper($pack['type'] ?? $resourceType) }} pack</div>
</div>
@endforeach
</div>
</div>
<aside class="rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
<h2 class="text-2xl font-semibold tracking-[-0.03em] text-white">{{ $resourceType === 'template' ? 'Included templates' : 'Pack notes' }}</h2>
@if($resourceType === 'template')
<div class="mt-4 space-y-3">
@foreach($templates as $template)
<div class="rounded-[18px] border border-white/10 bg-white/[0.03] px-4 py-3">
<div class="text-sm font-semibold text-white">{{ $template['name'] }}</div>
<div class="mt-1 text-xs text-slate-400">{{ $template['description'] }}</div>
</div>
@endforeach
</div>
@else
<p class="mt-4 text-sm leading-7 text-slate-300">Asset packs surface inside the v2 studio editor as official decorative sources, and can be layered with template packs for challenge-ready or remix-ready compositions.</p>
@endif
</aside>
</div>
</section>
@endsection

View File

@@ -0,0 +1,393 @@
@extends('layouts.nova')
@section('meta-description', $meta['description'] ?? '')
@push('head')
<title>{{ $meta['title'] ?? ($card['title'] . ' - Nova Cards - Skinbase Nova') }}</title>
<link rel="canonical" href="{{ $meta['canonical'] ?? $card['public_url'] }}" />
@if(!empty($meta['robots']))
<meta name="robots" content="{{ $meta['robots'] }}" />
@endif
@if(!empty($card['og_preview_url']))
<meta property="og:image" content="{{ $card['og_preview_url'] }}" />
@endif
<script type="application/ld+json">
{!! json_encode([
'@context' => 'https://schema.org',
'@type' => 'CreativeWork',
'name' => $card['title'],
'headline' => $card['title'],
'description' => $meta['description'] ?? $card['quote_text'],
'url' => $meta['canonical'] ?? $card['public_url'],
'image' => array_values(array_filter([$card['og_preview_url'] ?? null, $card['preview_url'] ?? null])),
'genre' => $card['format'] ?? null,
'keywords' => collect($card['tags'] ?? [])->pluck('name')->values()->all(),
'datePublished' => $card['published_at'] ?? null,
'dateModified' => $card['updated_at'] ?? null,
'creator' => [
'@type' => 'Person',
'name' => data_get($card, 'creator.username'),
'url' => !empty(data_get($card, 'creator.username')) ? route('cards.creator', ['username' => strtolower(data_get($card, 'creator.username'))]) : null,
],
'publisher' => [
'@type' => 'Organization',
'name' => config('app.name'),
'url' => url('/'),
],
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) !!}
</script>
@endpush
@section('content')
<section class="px-6 pt-8 md:px-10">
<div class="grid gap-6 xl:grid-cols-[minmax(0,1.1fr)_minmax(320px,420px)]">
<div class="rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-5 shadow-[0_24px_70px_rgba(2,6,23,0.32)] md:p-6">
@if(!empty($card['preview_url']))
<img src="{{ $card['preview_url'] }}" alt="{{ $card['title'] }}" class="w-full rounded-[24px] border border-white/10 object-cover" />
@endif
</div>
<div class="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.18)]">
<div class="flex flex-wrap items-center gap-2">
@if(!empty($card['featured']))
<span class="inline-flex items-center rounded-full border border-amber-300/25 bg-amber-300/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100">Featured</span>
@endif
<span class="inline-flex items-center rounded-full border border-white/10 bg-white/[0.05] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-200">{{ $card['format'] }}</span>
@if(!empty($card['category']))
<a href="{{ route('cards.category', ['categorySlug' => $card['category']['slug']]) }}" class="inline-flex items-center rounded-full border border-sky-300/20 bg-sky-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100">{{ $card['category']['name'] }}</a>
@endif
</div>
<h1 class="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white md:text-4xl">{{ $card['title'] }}</h1>
<blockquote class="mt-5 text-lg leading-8 text-slate-100 md:text-xl">{{ $card['quote_text'] }}</blockquote>
@if(!empty($card['quote_author']))
<p class="mt-4 text-sm font-semibold uppercase tracking-[0.22em] text-sky-100"> {{ $card['quote_author'] }}</p>
@endif
@if(!empty($card['quote_source']))
<p class="mt-2 text-sm text-slate-400">Source: {{ $card['quote_source'] }}</p>
@endif
@if(!empty($card['description']))
<p class="mt-5 text-sm leading-7 text-slate-300">{{ $card['description'] }}</p>
@endif
@if(!empty($card['lineage']['original_card']))
<div class="mt-5 rounded-[20px] border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-300">
Remixed from
<a href="{{ route('cards.show', ['slug' => $card['lineage']['original_card']['slug'], 'id' => $card['lineage']['original_card']['id']]) }}" class="font-semibold text-sky-100 transition hover:text-white">{{ $card['lineage']['original_card']['title'] }}</a>
</div>
@endif
@if(!empty($card['lineage']['original_card']) || (int) ($card['remixes_count'] ?? 0) > 0)
<div class="mt-4">
<a href="{{ route('cards.lineage', ['slug' => $card['slug'], 'id' => $card['id']]) }}" class="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
<i class="fa-solid fa-diagram-project"></i>
View remix lineage
</a>
</div>
@endif
<div class="mt-6 rounded-[24px] border border-white/10 bg-white/[0.03] p-4">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Creator</p>
<div class="mt-3 flex items-center justify-between gap-3">
<div>
<div class="text-base font-semibold text-white">{{ $card['creator']['name'] ?: ('@' . $card['creator']['username']) }}</div>
<a href="{{ route('cards.creator', ['username' => strtolower($card['creator']['username'])]) }}" class="text-sm text-slate-400 transition hover:text-slate-200">@{{ $card['creator']['username'] }}</a>
</div>
<div class="text-right text-xs text-slate-400">
<div>{{ number_format($card['views_count']) }} views</div>
<div>{{ number_format($card['shares_count']) }} shares</div>
<div>{{ number_format($card['likes_count']) }} likes</div>
</div>
</div>
</div>
<div class="mt-5 grid gap-3 sm:grid-cols-3">
<div class="rounded-[20px] border border-white/10 bg-white/[0.03] px-4 py-3 text-center">
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Likes</div>
<div class="mt-2 text-2xl font-semibold text-white">{{ number_format($card['likes_count']) }}</div>
</div>
<div class="rounded-[20px] border border-white/10 bg-white/[0.03] px-4 py-3 text-center">
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Saved</div>
<div class="mt-2 text-2xl font-semibold text-white">{{ number_format($card['saves_count']) }}</div>
</div>
<div class="rounded-[20px] border border-white/10 bg-white/[0.03] px-4 py-3 text-center">
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Remixes</div>
<div class="mt-2 text-2xl font-semibold text-white">{{ number_format($card['remixes_count']) }}</div>
</div>
</div>
@if(!empty($card['tags']))
<div class="mt-5 flex flex-wrap gap-2">
@foreach($card['tags'] as $tag)
<a href="{{ route('cards.tag', ['tagSlug' => $tag['slug']]) }}" class="inline-flex items-center rounded-full border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-slate-200 transition hover:bg-white/[0.08]">#{{ $tag['name'] }}</a>
@endforeach
</div>
@endif
<div class="mt-6 flex flex-wrap gap-3">
<button type="button" data-copy-card-link="{{ $card['public_url'] }}" class="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
<i class="fa-solid fa-link"></i>
Copy link
</button>
@auth
<button type="button" data-card-like class="inline-flex items-center gap-2 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100 transition hover:bg-rose-400/15">
<i class="fa-solid fa-heart"></i>
Like
</button>
<button type="button" data-card-favorite class="inline-flex items-center gap-2 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm font-semibold text-amber-100 transition hover:bg-amber-400/15">
<i class="fa-solid fa-star"></i>
Favorite
</button>
<button type="button" data-card-save class="inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">
<i class="fa-solid fa-bookmark"></i>
Save
</button>
@if(!empty($card['allow_remix']))
<button type="button" data-card-remix class="inline-flex items-center gap-2 rounded-2xl border border-violet-300/20 bg-violet-400/10 px-4 py-3 text-sm font-semibold text-violet-100 transition hover:bg-violet-400/15">
<i class="fa-solid fa-code-branch"></i>
Remix
</button>
@endif
<button type="button" data-card-report class="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
<i class="fa-solid fa-flag"></i>
Report
</button>
@endauth
@if(!empty($card['allow_download']) && !empty($card['preview_url']))
<a href="{{ $card['preview_url'] }}" download data-card-download-link class="inline-flex items-center gap-2 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm font-semibold text-emerald-100 transition hover:bg-emerald-400/15">
<i class="fa-solid fa-download"></i>
Download preview
</a>
@endif
</div>
</div>
</div>
</section>
<section id="comments" class="px-6 pt-8 md:px-10">
<div class="rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Discussion</p>
<h2 class="mt-1 text-2xl font-semibold text-white">Comments</h2>
</div>
<span class="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-slate-200">{{ count($comments ?? []) }}</span>
</div>
@if(session('status'))
<div class="mt-4 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-100">{{ session('status') }}</div>
@endif
@auth
<form action="{{ route('cards.comments.store', ['card' => $card['id']]) }}" method="post" class="mt-5 space-y-3">
@csrf
<textarea name="body" rows="4" required minlength="2" maxlength="4000" class="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" placeholder="Write a comment about this card..."></textarea>
<button type="submit" class="inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">
<i class="fa-solid fa-comment"></i>
Post comment
</button>
</form>
@else
<div class="mt-5 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-300">Sign in to comment on this Nova Card.</div>
@endauth
<div class="mt-6 space-y-4">
@forelse($comments as $comment)
<article id="comment-{{ $comment['id'] }}" class="rounded-[24px] border border-white/10 bg-white/[0.03] p-4">
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-3">
<img src="{{ $comment['user']['avatar_url'] }}" alt="{{ $comment['user']['display'] }}" class="h-12 w-12 rounded-2xl border border-white/10 object-cover" />
<div>
<a href="{{ $comment['user']['profile_url'] }}" class="text-base font-semibold text-white transition hover:text-sky-100">{{ $comment['user']['display'] }}</a>
<div class="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">{{ $comment['time_ago'] }}</div>
</div>
</div>
<div class="flex items-center gap-2">
@if($comment['can_report'])
<button type="button" data-card-comment-report="{{ $comment['id'] }}" class="rounded-2xl border border-white/10 bg-white/[0.05] px-3 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-white transition hover:bg-white/[0.08]">Report</button>
@endif
@if($comment['can_delete'])
<form action="{{ route('cards.comments.destroy', ['card' => $card['id'], 'comment' => $comment['id']]) }}" method="post">
@csrf
@method('DELETE')
<button type="submit" class="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-rose-100 transition hover:bg-rose-400/15">Delete</button>
</form>
@endif
</div>
</div>
<div class="mt-4 text-sm leading-7 text-slate-300">{!! $comment['rendered_content'] !!}</div>
</article>
@empty
<div class="rounded-2xl border border-dashed border-white/12 bg-white/[0.03] px-4 py-8 text-center text-sm text-slate-400">No comments yet.</div>
@endforelse
</div>
</div>
</section>
@foreach([
'Related in category' => $relatedByCategory,
'Related by tags' => $relatedByTags,
'More from creator' => $moreFromCreator,
] as $sectionTitle => $items)
@if(!empty($items))
<section class="px-6 pt-8 md:px-10">
<div class="rounded-[28px] border border-white/10 bg-white/[0.04] p-5">
<h2 class="text-2xl font-semibold tracking-[-0.03em] text-white">{{ $sectionTitle }}</h2>
<div class="mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
@foreach($items as $item)
@include('cards.partials.tile', ['card' => $item])
@endforeach
</div>
</div>
</section>
@endif
@endforeach
@endsection
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', () => {
const copyButton = document.querySelector('[data-copy-card-link]')
const downloadButton = document.querySelector('[data-card-download-link]')
const likeButton = document.querySelector('[data-card-like]')
const favoriteButton = document.querySelector('[data-card-favorite]')
const saveButton = document.querySelector('[data-card-save]')
const remixButton = document.querySelector('[data-card-remix]')
const reportButton = document.querySelector('[data-card-report]')
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || ''
const postEvent = (url, method = 'POST') => {
fetch(url, {
method,
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': csrf,
'Accept': 'application/json',
},
credentials: 'same-origin',
keepalive: true,
}).catch(() => {})
}
if (copyButton) {
copyButton.addEventListener('click', async () => {
try {
await navigator.clipboard?.writeText(copyButton.dataset.copyCardLink || '')
postEvent(@json(route('api.cards.share', ['id' => $card['id']])))
} catch (error) {
window.prompt('Copy this link', copyButton.dataset.copyCardLink || '')
}
})
}
if (downloadButton) {
downloadButton.addEventListener('click', () => {
postEvent(@json(route('api.cards.download', ['id' => $card['id']])))
})
}
if (likeButton) {
likeButton.addEventListener('click', () => postEvent(@json(route('api.cards.like', ['id' => $card['id']]))))
}
if (favoriteButton) {
favoriteButton.addEventListener('click', () => postEvent(@json(route('api.cards.favorite', ['id' => $card['id']]))))
}
if (saveButton) {
saveButton.addEventListener('click', () => postEvent(@json(route('api.cards.save', ['id' => $card['id']]))))
}
if (remixButton) {
remixButton.addEventListener('click', () => {
fetch(@json(route('api.cards.remix', ['id' => $card['id']])), {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': csrf,
'Accept': 'application/json',
},
credentials: 'same-origin',
}).then(async (response) => {
if (!response.ok) {
return
}
const payload = await response.json()
if (payload?.data?.id) {
window.location.assign(`/studio/cards/${payload.data.id}/edit`)
}
}).catch(() => {})
})
}
if (reportButton) {
reportButton.addEventListener('click', () => {
const reason = window.prompt('Why are you reporting this card?')
if (!reason || !reason.trim()) {
return
}
const details = window.prompt('Add extra details for moderators (optional)')
fetch(@json(route('api.reports.store')), {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': csrf,
'Accept': 'application/json',
'Content-Type': 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify({
target_type: 'nova_card',
target_id: @json($card['id']),
reason: reason.trim(),
details: details && details.trim() ? details.trim() : null,
}),
}).then(async (response) => {
if (!response.ok) {
return
}
window.alert('Report submitted. Thank you.')
}).catch(() => {})
})
}
document.querySelectorAll('[data-card-comment-report]').forEach((button) => {
button.addEventListener('click', () => {
const reason = window.prompt('Why are you reporting this comment?')
if (!reason || !reason.trim()) {
return
}
const details = window.prompt('Add extra details for moderators (optional)')
fetch(@json(route('api.reports.store')), {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': csrf,
'Accept': 'application/json',
'Content-Type': 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify({
target_type: 'nova_card_comment',
target_id: Number(button.dataset.cardCommentReport),
reason: reason.trim(),
details: details && details.trim() ? details.trim() : null,
}),
}).then(async (response) => {
if (!response.ok) {
return
}
window.alert('Report submitted. Thank you.')
}).catch(() => {})
})
})
})
</script>
@endpush

View File

@@ -0,0 +1,18 @@
@extends('layouts.nova')
@push('head')
<meta name="csrf-token" content="{{ csrf_token() }}" />
@vite(['resources/js/collections.jsx'])
<style>
body.page-collections main { padding-top: 4rem; }
</style>
<script>
document.addEventListener('DOMContentLoaded', function () {
document.body.classList.add('page-collections')
})
</script>
@endpush
@section('content')
@inertia
@endsection

View File

@@ -4,6 +4,7 @@
use App\Support\ForumPostContent;
use App\Support\AvatarUrl;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
$filesBaseUrl = rtrim((string) config('cdn.files_url', ''), '/');
@@ -26,6 +27,8 @@
'name' => $user->name,
'avatar_url' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash ?? null),
'role' => $user->role ?? 'member',
'level' => (int) ($user->level ?? 1),
'rank' => (string) ($user->rank ?? 'Newbie'),
] : null,
'attachments' => collect($post->attachments ?? [])->map(fn ($a) => [
'id' => $a->id,
@@ -40,6 +43,15 @@
$serializedOp = isset($opPost) && $opPost ? $serializePost($opPost) : null;
$serializedPosts = collect($posts->items())->map($serializePost)->values()->all();
$threadDescription = null;
$threadDescriptionSource = (string) ($serializedOp['rendered_content'] ?? $serializedOp['content'] ?? '');
if ($threadDescriptionSource !== '') {
$threadDescriptionSource = html_entity_decode($threadDescriptionSource, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$threadDescriptionSource = html_entity_decode($threadDescriptionSource, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$threadDescription = trim((string) preg_replace('/\s+/u', ' ', strip_tags($threadDescriptionSource)));
$threadDescription = Str::limit($threadDescription, 220);
}
$paginationData = [
'current_page' => $posts->currentPage(),
@@ -57,6 +69,7 @@
'id' => $thread->id,
'title' => $thread->title,
'slug' => $thread->slug,
'description'=> $threadDescription,
'views' => (int) ($thread->views ?? 0),
'is_pinned' => (bool) $thread->is_pinned,
'is_locked' => (bool) $thread->is_locked,

View File

@@ -47,6 +47,34 @@
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !!}
</script>
@endif
@if(($gallery_type ?? null) === 'tag' && !empty($tag_context))
<script type="application/ld+json">
{!! json_encode([
'@context' => 'https://schema.org',
'@type' => 'CollectionPage',
'name' => $page_title ?? ($hero_title ?? ('#' . ($tag_context['name'] ?? 'Tag'))),
'description' => $page_meta_description ?? ($hero_description ?? null),
'url' => $page_canonical ?? $seoUrl($seoPage),
'mainEntity' => [
'@type' => 'ItemList',
'numberOfItems' => method_exists($artworks, 'total') ? $artworks->total() : count($artworks ?? []),
'itemListElement' => collect(method_exists($artworks, 'getCollection') ? $artworks->getCollection() : ($artworks ?? []))
->take(12)
->values()
->map(fn ($artwork, $index) => [
'@type' => 'ListItem',
'position' => $index + 1,
'url' => !empty($artwork->slug) ? url('/' . $artwork->slug) : null,
'name' => $artwork->name ?? null,
])
->filter(fn (array $item) => filled($item['url']) || filled($item['name']))
->values()
->all(),
],
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !!}
</script>
@endif
@endpush
@php

View File

@@ -1,5 +1,18 @@
@php
$gridVersion = request()->query('grid') === 'v2' ? 'v2' : 'v1';
$deferToolbarSearch = request()->routeIs('index');
$metaDescription = trim($__env->yieldContent('meta-description', $page_meta_description ?? ''));
$metaKeywords = trim($__env->yieldContent('meta-keywords', $page_meta_keywords ?? ''));
$novaViteEntries = [
'resources/css/app.css',
'resources/css/nova-grid.css',
'resources/scss/nova.scss',
'resources/js/nova.js',
];
if (!$deferToolbarSearch) {
$novaViteEntries[] = 'resources/js/entry-search.jsx';
}
@endphp
<!DOCTYPE html>
<html lang="{{ app()->getLocale() }}" data-grid-version="{{ $gridVersion }}">
@@ -9,8 +22,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="csrf-token" content="{{ csrf_token() }}">
<meta name="description" content="{{ $page_meta_description ?? '' }}">
<meta name="keywords" content="{{ $page_meta_keywords ?? '' }}">
<meta name="description" content="{{ $metaDescription }}">
<meta name="keywords" content="{{ $metaKeywords }}">
@isset($page_robots)
<meta name="robots" content="{{ $page_robots }}" />
@endisset
@@ -35,9 +48,7 @@
<link rel="shortcut icon" href="/favicon/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/favicon/site.webmanifest" />
@vite(['resources/css/app.css','resources/css/nova-grid.css','resources/scss/nova.scss','resources/js/nova.js','resources/js/entry-search.jsx'])
@vite($novaViteEntries)
<style>
/* Card enter animation */
.nova-card-enter { opacity: 0; transform: translateY(10px) scale(0.995); }
@@ -66,6 +77,61 @@
[x-cloak] { display: none !important; }
</style>
@stack('head')
@if($deferToolbarSearch)
<script type="module">
(() => {
const searchEntryUrl = @js(Vite::asset('resources/js/entry-search.jsx'));
const triggerEvents = ['pointerdown', 'touchstart', 'focusin'];
let searchLoaded = false;
const loadSearch = () => {
if (searchLoaded) {
return;
}
searchLoaded = true;
cleanup();
import(searchEntryUrl);
};
const onReady = () => {
const searchRoot = document.getElementById('topbar-search-root');
if (!searchRoot) {
return;
}
triggerEvents.forEach((eventName) => {
searchRoot.addEventListener(eventName, loadSearch, { once: true, passive: true });
});
if ('requestIdleCallback' in window) {
window.requestIdleCallback(loadSearch, { timeout: 2000 });
} else {
window.setTimeout(loadSearch, 1500);
}
};
const cleanup = () => {
const searchRoot = document.getElementById('topbar-search-root');
if (!searchRoot) {
return;
}
triggerEvents.forEach((eventName) => {
searchRoot.removeEventListener(eventName, loadSearch);
});
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', onReady, { once: true });
} else {
onReady();
}
})();
</script>
@endif
@if(isset($page) && is_array($page))
@inertiaHead
@endif

View File

@@ -2,7 +2,7 @@
<footer class="border-t border-neutral-800 bg-nova">
<div class="px-6 md:px-10 py-8 flex flex-col md:flex-row md:items-center md:justify-between gap-2">
<div class="text-xl font-semibold tracking-wide flex items-center gap-1">
<img src="/gfx/skinbase_logo.png" alt="Skinbase" class="h-16 w-auto object-contain">
<img src="/gfx/skinbase_logo.png" alt="Skinbase" width="320" height="64" class="h-16 w-auto object-contain">
<span class="sr-only">Skinbase</span>
</div>

View File

@@ -1,11 +1,11 @@
<header class="fixed inset-x-0 top-0 z-50 h-16 bg-black/40 backdrop-blur border-b border-white/10">
<div class="mx-auto w-full h-full px-4 flex items-center gap-3">
<div class="mx-auto w-full h-full px-3 sm:px-4 flex items-center gap-2 sm:gap-3">
<!-- Mobile hamburger -->
<button id="btnSidebar"
type="button"
data-mobile-toggle
class="md:hidden inline-flex items-center justify-center w-10 h-10 rounded-lg hover:bg-white/5"
class="lg:hidden inline-flex items-center justify-center w-10 h-10 rounded-lg hover:bg-white/5"
aria-label="Open menu"
aria-controls="mobileMenu"
aria-expanded="false">
@@ -19,7 +19,7 @@
<!-- Logo -->
<a href="/" class="flex items-center gap-2 pr-2 shrink-0">
<img src="/gfx/sb_logo.png" alt="Skinbase.org" class="h-8 w-auto rounded-sm shadow-sm object-contain">
<img src="/gfx/sb_logo.webp" alt="Skinbase.org" width="289" height="100" class="h-9 w-auto rounded-sm shadow-sm object-contain">
<span class="sr-only">Skinbase.org</span>
</a>
@@ -169,36 +169,21 @@
</nav>
<!-- Search: collapsed pill expands on click -->
<div class="flex-1 flex items-center justify-center px-2 min-w-0">
<div class="flex-1 flex items-center justify-center px-1 sm:px-2 min-w-0">
<div id="topbar-search-root" class="w-full flex justify-center"></div>
</div>
@auth
<!-- Upload CTA -->
<a href="{{ route('upload') }}"
class="hidden md:inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium transition-colors shrink-0">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M5 12h14" />
</svg>
Upload
</a>
<!-- Notification icons -->
<div class="hidden md:flex items-center gap-1 text-soft">
<a href="{{ route('discover.for-you') }}"
class="relative w-10 h-10 inline-flex items-center justify-center rounded-lg transition-colors {{ request()->routeIs('discover.for-you') ? 'bg-yellow-500/15 text-yellow-300' : 'hover:bg-white/5' }}"
title="For You">
<i class="fa-solid fa-wand-magic-sparkles w-5 h-5 text-[1.1rem] {{ request()->routeIs('discover.for-you') ? 'text-yellow-300' : 'text-sb-muted' }}"></i>
</a>
<div class="hidden md:flex items-center gap-0.5 lg:gap-1 text-soft shrink-0">
<a href="{{ route('dashboard.favorites') }}"
class="relative w-10 h-10 inline-flex items-center justify-center rounded-lg hover:bg-white/5"
class="relative inline-flex w-9 h-9 lg:w-10 lg:h-10 items-center justify-center rounded-lg hover:bg-white/5"
title="Favourites">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg class="w-[18px] h-[18px] lg:w-5 lg:h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 21s-7-4.4-9-9a5.5 5.5 0 0 1 9-6 5.5 5.5 0 0 1 9 6c-2 4.6-9 9-9 9z" />
</svg>
@if(($favCount ?? 0) > 0)
<span class="absolute -bottom-1 right-0 text-[11px] tabular-nums px-1.5 py-0.5 rounded bg-red-700/70 text-white border border-sb-line">{{ $favCount }}</span>
<span class="absolute -bottom-1 right-0 text-[10px] lg:text-[11px] tabular-nums px-1 py-0 lg:px-1.5 lg:py-0.5 rounded bg-red-700/70 text-white border border-sb-line">{{ $favCount }}</span>
@endif
</a>
@@ -216,7 +201,7 @@
<!-- Profile dropdown -->
<div class="relative">
<button class="flex items-center gap-2 pl-2 pr-3 h-10 rounded-lg hover:bg-white/5 transition-colors" data-dd="user">
<button class="flex items-center gap-2 pl-1.5 sm:pl-2 pr-2 sm:pr-3 h-10 rounded-lg hover:bg-white/5 transition-colors shrink-0" data-dd="user">
@php
$toolbarUserId = (int) ($userId ?? Auth::id() ?? 0);
$toolbarAvatarHash = $avatarHash ?? optional(Auth::user())->profile->avatar_hash ?? null;
@@ -224,7 +209,7 @@
<img class="w-7 h-7 rounded-full object-cover ring-1 ring-white/10"
src="{{ \App\Support\AvatarUrl::forUser($toolbarUserId, $toolbarAvatarHash, 64) }}"
alt="{{ $displayName ?? 'User' }}" />
<span class="hidden xl:inline text-sm text-white/90">{{ $displayName ?? 'User' }}</span>
<span class="hidden min-[900px]:inline-block max-w-[8rem] truncate text-sm text-white/90">{{ $displayName ?? 'User' }}</span>
<svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6" />
</svg>
@@ -291,7 +276,7 @@
Settings
</a>
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true))
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true) && \Illuminate\Support\Facades\Route::has('admin.usernames.moderation'))
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('admin.usernames.moderation') }}">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-user-shield text-xs text-sb-muted"></i></span>
Moderation
@@ -310,7 +295,7 @@
</div>
@else
<!-- Guest auth toolbar: desktop CTA + secondary sign-in. -->
<div class="hidden md:flex items-center gap-4">
<div class="hidden lg:flex items-center gap-4 shrink-0">
<a href="/register"
aria-label="Join Skinbase"
class="inline-flex items-center px-4 py-2 rounded-lg bg-gradient-to-r from-indigo-500 to-cyan-500 text-white text-sm font-semibold shadow-sm transition duration-200 ease-out hover:-translate-y-[1px] hover:shadow-[0_0_15px_rgba(99,102,241,0.7)] focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-black/40">
@@ -324,7 +309,7 @@
</div>
<!-- Guest auth on mobile: icon trigger with lightweight dropdown menu. -->
<details class="relative md:hidden">
<details class="relative lg:hidden shrink-0">
<summary
aria-label="Open authentication menu"
class="list-none inline-flex items-center justify-center w-10 h-10 rounded-lg text-gray-300 hover:text-white hover:bg-white/5 cursor-pointer focus:outline-none focus:ring-2 focus:ring-indigo-500">
@@ -371,9 +356,6 @@
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/most-downloaded"><i class="fa-solid fa-download w-4 text-center text-sb-muted"></i>Most Downloaded</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('downloads.today') }}"><i class="fa-solid fa-arrow-down-short-wide w-4 text-center text-sb-muted"></i>Today Downloads</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/on-this-day"><i class="fa-solid fa-calendar-day w-4 text-center text-sb-muted"></i>On This Day</a>
@auth
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('discover.for-you') }}"><i class="fa-solid fa-wand-magic-sparkles w-4 text-center text-yellow-400/70"></i>For You</a>
@endauth
</div>
</div>

View File

@@ -2,6 +2,7 @@
@push('head')
@vite(['resources/js/profile.jsx'])
<meta name="csrf-token" content="{{ csrf_token() }}" />
{{-- OG image (not in nova base layout) --}}
@if(!empty($og_image))
<meta property="og:image" content="{{ $og_image }}">

View File

@@ -4,7 +4,7 @@
<meta name="csrf-token" content="{{ csrf_token() }}" />
@vite(['resources/js/studio.jsx'])
<style>
body.page-studio main { padding-top: 4rem; }
body.page-studio main { padding-top: 2.3rem; }
</style>
<script>
document.addEventListener('DOMContentLoaded', function () {

View File

@@ -24,6 +24,49 @@
</x-slot>
</x-nova-page-header>
@php
$cacheTone = match ($feed_meta['cache_status'] ?? null) {
'hit' => 'text-emerald-200 ring-emerald-400/30 bg-emerald-500/12',
'stale' => 'text-amber-200 ring-amber-400/30 bg-amber-500/12',
default => 'text-sky-100 ring-sky-300/30 bg-sky-500/12',
};
$generatedAt = !empty($feed_meta['generated_at']) ? \Illuminate\Support\Carbon::parse($feed_meta['generated_at'])->diffForHumans() : null;
@endphp
<section class="px-6 md:px-10">
<div class="grid gap-3 rounded-[1.6rem] border border-white/8 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_42%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-5 shadow-[0_18px_60px_rgba(2,6,23,0.38)] md:grid-cols-[minmax(0,1fr)_auto] md:items-center">
<div class="space-y-2">
<p class="text-[0.7rem] font-semibold uppercase tracking-[0.28em] text-sky-200/70">Personalized discovery</p>
<p class="max-w-3xl text-sm leading-6 text-slate-300">
This feed now runs on the same recommendation engine as the API, so your views and clicks on this page can refine what shows up next.
</p>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs text-slate-200/85">
<span class="inline-flex items-center gap-2 rounded-full ring-1 ring-white/12 bg-white/6 px-3 py-1.5">
<span class="text-slate-400">Model</span>
<span>{{ $feed_meta['algo_version'] ?? 'n/a' }}</span>
</span>
<span class="inline-flex items-center gap-2 rounded-full ring-1 px-3 py-1.5 {{ $cacheTone }}">
<span class="text-slate-300/70">Cache</span>
<span>{{ str_replace(['-', '_'], ' ', $feed_meta['cache_status'] ?? 'unknown') }}</span>
</span>
@if (!empty($feed_meta['total_candidates']))
<span class="inline-flex items-center gap-2 rounded-full ring-1 ring-white/12 bg-white/6 px-3 py-1.5">
<span class="text-slate-400">Candidates</span>
<span>{{ number_format((int) $feed_meta['total_candidates']) }}</span>
</span>
@endif
@if ($generatedAt)
<span class="inline-flex items-center gap-2 rounded-full ring-1 ring-white/12 bg-white/6 px-3 py-1.5">
<span class="text-slate-400">Refreshed</span>
<span>{{ $generatedAt }}</span>
</span>
@endif
</div>
</div>
</section>
{{-- ── Artwork grid (React MasonryGallery) ── --}}
@php
$galleryArtworks = $artworks->map(fn ($art) => [
@@ -32,13 +75,20 @@
'thumb' => $art->thumb_url ?? null,
'thumb_srcset' => $art->thumb_srcset ?? null,
'uname' => $art->uname ?? '',
'username' => $art->uname ?? '',
'username' => $art->username ?? '',
'avatar_url' => $art->avatar_url ?? null,
'published_at' => $art->published_at ?? null,
'content_type_name' => $art->content_type_name ?? '',
'category_name' => $art->category_name ?? '',
'category_slug' => $art->category_slug ?? '',
'slug' => $art->slug ?? '',
'url' => $art->url ?? null,
'width' => $art->width ?? null,
'height' => $art->height ?? null,
'recommendation_source' => $art->recommendation_source ?? 'mixed',
'recommendation_reason' => $art->recommendation_reason ?? 'Picked for you',
'recommendation_score' => $art->recommendation_score,
'recommendation_algo_version' => $art->recommendation_algo_version ?? ($feed_meta['algo_version'] ?? null),
])->values();
@endphp
<section class="px-6 pt-8 md:px-10">
@@ -47,6 +97,8 @@
data-artworks="{{ json_encode($galleryArtworks) }}"
data-gallery-type="for-you"
data-cursor-endpoint="{{ route('discover.for-you') }}"
data-discovery-endpoint="{{ route('api.discovery.events.store') }}"
data-algo-version="{{ $feed_meta['algo_version'] ?? '' }}"
@if (!empty($next_cursor)) data-next-cursor="{{ $next_cursor }}" @endif
data-limit="40"
class="min-h-32"

View File

@@ -7,6 +7,13 @@
]);
@endphp
@php
$followingActivity = collect($following_activity ?? []);
$networkTrending = collect($network_trending ?? []);
$suggestedUsers = collect($suggested_users ?? $fallback_creators ?? []);
$fallbackTrending = collect($fallback_trending ?? []);
@endphp
@section('content')
<x-nova-page-header
@@ -23,9 +30,128 @@
</x-slot>
</x-nova-page-header>
@if (($section ?? null) === 'following')
<section class="px-6 pt-2 md:px-10">
@if (!empty($empty))
<div class="rounded-[32px] border border-white/10 bg-[linear-gradient(135deg,rgba(56,189,248,0.08),rgba(255,255,255,0.03),rgba(249,115,22,0.06))] p-6 shadow-[0_20px_60px_rgba(2,6,23,0.24)]">
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p class="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Personalized following feed</p>
<h2 class="mt-2 text-3xl font-semibold tracking-[-0.03em] text-white">Your network starts here</h2>
<p class="mt-3 max-w-2xl text-sm leading-relaxed text-slate-300">Follow a few creators to unlock a feed made of their newest art, social activity, and rising work from around your network.</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="/discover/trending" class="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-medium text-slate-200 transition-colors hover:bg-white/[0.08]">
<i class="fa-solid fa-fire fa-fw"></i>
Explore trending
</a>
<a href="/feed/following" class="inline-flex items-center gap-2 rounded-2xl border border-sky-400/20 bg-sky-500/10 px-4 py-2.5 text-sm font-medium text-sky-200 transition-colors hover:bg-sky-500/15">
<i class="fa-solid fa-newspaper fa-fw"></i>
Open post feed
</a>
</div>
</div>
</div>
@endif
<div class="mt-6 grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
<div class="rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
<div class="mb-4 flex items-center justify-between gap-4">
<div>
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Activity from people you follow</p>
<h3 class="mt-1 text-xl font-semibold text-white">Network activity</h3>
</div>
<a href="/community/activity?filter=following" class="text-sm text-sky-300/80 transition-colors hover:text-sky-200">View all</a>
</div>
<div class="space-y-3">
@forelse ($followingActivity as $activity)
<div class="rounded-2xl border border-white/[0.06] bg-white/[0.02] px-4 py-3">
<div class="flex items-start gap-3">
<img src="{{ data_get($activity, 'user.avatar_url') ?: '/images/avatar_default.webp' }}" alt="{{ data_get($activity, 'user.username') }}" class="h-10 w-10 rounded-full object-cover ring-1 ring-white/10" loading="lazy" />
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-white">{{ data_get($activity, 'user.username') ? '@' . data_get($activity, 'user.username') : data_get($activity, 'user.name', 'Creator') }}</p>
<p class="mt-1 text-sm text-slate-300">
@if (data_get($activity, 'type') === 'follow')
started following {{ data_get($activity, 'target_user.username') ? '@' . data_get($activity, 'target_user.username') : 'another creator' }}
@elseif (data_get($activity, 'type') === 'upload')
published <a href="{{ data_get($activity, 'artwork.url') }}" class="text-sky-300 hover:text-sky-200">{{ data_get($activity, 'artwork.title', 'a new artwork') }}</a>
@elseif (in_array(data_get($activity, 'type'), ['comment', 'reply'], true))
{{ data_get($activity, 'type') === 'reply' ? 'replied on' : 'commented on' }} <a href="{{ data_get($activity, 'artwork.url') }}" class="text-sky-300 hover:text-sky-200">{{ data_get($activity, 'artwork.title', 'an artwork') }}</a>
@else
{{ ucfirst(str_replace('_', ' ', (string) data_get($activity, 'type', 'activity'))) }}
@endif
</p>
<p class="mt-1 text-xs text-slate-500">{{ data_get($activity, 'time_ago') ?: data_get($activity, 'created_at') }}</p>
</div>
</div>
</div>
@empty
<div class="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] px-4 py-8 text-center text-sm text-slate-400">Follow activity will appear here as your network starts moving.</div>
@endforelse
</div>
</div>
<div class="space-y-6">
<div class="rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
<div class="mb-4">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Trending in your network</p>
<h3 class="mt-1 text-xl font-semibold text-white">Network highlights</h3>
</div>
<div class="space-y-3">
@foreach (($empty ?? false) ? $fallbackTrending->take(4) : $networkTrending->take(4) as $item)
@php
$itemId = (int) data_get($item, 'id', 0);
$itemSlug = (string) data_get($item, 'slug', '');
$itemUrl = $itemSlug !== '' && $itemId > 0
? route('art.show', ['id' => $itemId, 'slug' => $itemSlug])
: data_get($item, 'url', '#');
$itemThumb = data_get($item, 'thumb_url') ?: data_get($item, 'thumb') ?: data_get($item, 'thumbnail_url') ?: '/images/placeholder.jpg';
$itemTitle = data_get($item, 'title') ?: data_get($item, 'name', 'Artwork');
@endphp
<a href="{{ $itemUrl }}" class="flex items-center gap-3 rounded-2xl border border-white/[0.06] bg-white/[0.02] p-3 transition-colors hover:bg-white/[0.04]">
<img src="{{ $itemThumb }}" alt="{{ $itemTitle }}" class="h-16 w-16 rounded-xl object-cover" loading="lazy" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-white">{{ $itemTitle ?: 'Untitled artwork' }}</p>
<p class="truncate text-xs text-slate-400">{{ data_get($item, 'author.username') ? '@' . data_get($item, 'author.username') : data_get($item, 'username', data_get($item, 'uname')) }}</p>
@if (is_array($item) && isset($item['stats']))
<p class="mt-1 text-[11px] text-slate-500">{{ number_format((int) data_get($item, 'stats.favorites', 0)) }} favourites · {{ number_format((int) data_get($item, 'stats.views', 0)) }} views</p>
@endif
</div>
</a>
@endforeach
</div>
</div>
<div class="rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
<div class="mb-4">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Suggested creators</p>
<h3 class="mt-1 text-xl font-semibold text-white">Who to follow next</h3>
</div>
<div class="space-y-3">
@foreach ($suggestedUsers->take(4) as $userCard)
<a href="{{ $userCard['profile_url'] ?? ('/@' . strtolower((string) ($userCard['username'] ?? ''))) }}" class="flex items-start gap-3 rounded-2xl border border-white/[0.06] bg-white/[0.02] p-3 transition-colors hover:bg-white/[0.04]">
<img src="{{ $userCard['avatar_url'] ?? '/images/avatar_default.webp' }}" alt="{{ $userCard['username'] ?? 'creator' }}" class="h-10 w-10 rounded-full object-cover ring-1 ring-white/10" loading="lazy" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-white">{{ $userCard['name'] ?? $userCard['username'] ?? 'Creator' }}</p>
<p class="truncate text-xs text-slate-500">@{{ $userCard['username'] ?? 'creator' }}</p>
<p class="mt-1 text-xs text-slate-400">{{ data_get($userCard, 'context.follower_overlap.label') ?: data_get($userCard, 'context.shared_following.label') ?: ($userCard['reason'] ?? 'Recommended for you') }}</p>
</div>
</a>
@endforeach
</div>
</div>
</div>
</div>
</section>
@endif
{{-- ── Artwork grid (React MasonryGallery) ── --}}
@php
$galleryArtworks = collect($artworks->items())->map(fn ($art) => [
$galleryItems = method_exists($artworks, 'items') ? $artworks->items() : (is_iterable($artworks) ? $artworks : []);
$galleryArtworks = collect($galleryItems)->map(fn ($art) => [
'id' => $art->id,
'name' => $art->name ?? null,
'thumb' => $art->thumb_url ?? $art->thumb ?? null,

View File

@@ -1,9 +1,10 @@
@extends('layouts.nova')
@section('meta-description', $meta['description'])
@section('meta-keywords', $meta['keywords'])
@push('head')
<title>{{ $meta['title'] }}</title>
<meta name="description" content="{{ $meta['description'] }}">
<meta name="keywords" content="{{ $meta['keywords'] }}">
<link rel="canonical" href="{{ $meta['canonical'] }}">
{{-- Open Graph --}}
@@ -53,15 +54,23 @@
@section('main-class', '')
@section('content')
@include('web.home.hero', ['artwork' => $props['hero'] ?? null])
{{-- Inline props for the React component (avoids data-attribute length limits) --}}
<script id="homepage-props" type="application/json">
{!! json_encode($props, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) !!}
</script>
<div id="homepage-root" class="min-h-screen">
<div id="homepage-root" class="min-h-[40vh]">
{{-- Loading skeleton (replaced by React on hydration) --}}
<div class="flex min-h-[60vh] items-center justify-center">
<div class="h-8 w-8 animate-spin rounded-full border-4 border-nova-500 border-t-transparent"></div>
<div class="space-y-10 px-4 pt-10 sm:px-6 lg:px-8">
<div class="h-14 rounded-2xl bg-nova-800/70"></div>
<div class="grid gap-4 lg:grid-cols-4">
<div class="aspect-video rounded-2xl bg-nova-800/70"></div>
<div class="aspect-video rounded-2xl bg-nova-800/70"></div>
<div class="aspect-video rounded-2xl bg-nova-800/70"></div>
<div class="aspect-video rounded-2xl bg-nova-800/70"></div>
</div>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More