Wire admin studio SSR and search infrastructure

This commit is contained in:
2026-05-01 11:46:06 +02:00
parent 257b0dbef6
commit 18cea8b0f0
329 changed files with 197465 additions and 2741 deletions

View File

@@ -0,0 +1,159 @@
import React, { useState } from 'react'
import { Link, usePage } from '@inertiajs/react'
const adminNavGroups = [
{
label: 'Overview',
items: [
{ label: 'Dashboard', href: '/moderation', icon: 'fa-solid fa-gauge-high', exact: true },
],
},
{
label: 'People',
items: [
{ label: 'All Users', href: '/moderation/users', icon: 'fa-solid fa-users' },
{ label: 'Staff', href: '/moderation/users?role=admin', icon: 'fa-solid fa-shield-halved' },
{ label: 'Moderators', href: '/moderation/users?role=moderator', icon: 'fa-solid fa-user-shield' },
],
},
{
label: 'Content',
items: [
{ label: 'Stories', href: '/moderation/stories', icon: 'fa-solid fa-feather-pointed' },
{ label: 'Artworks', href: '/moderation/artworks', icon: 'fa-solid fa-images' },
{ label: 'Featured Artworks', href: '/moderation/artworks/featured', icon: 'fa-solid fa-star' },
{ label: 'Homepage Announcements', href: '/moderation/homepage/announcements', icon: 'fa-solid fa-bullhorn' },
{ label: 'Upload Queue', href: '/moderation/uploads', icon: 'fa-solid fa-cloud-arrow-up' },
{ label: 'Username Queue', href: '/moderation/usernames/moderation', icon: 'fa-solid fa-id-badge' },
{ label: 'AI Biography', href: '/moderation/ai-biography', icon: 'fa-solid fa-wand-magic-sparkles' },
],
},
{
label: 'System',
items: [
{ label: 'Settings', href: '/moderation/settings', icon: 'fa-solid fa-gear' },
],
},
]
function NavLink({ item, active }) {
const cls = `flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ${
active
? 'bg-rose-500/20 text-rose-300 shadow-sm shadow-rose-500/10'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`
return (
<Link href={item.href} className={cls}>
<i className={`${item.icon} w-5 text-center text-base`} />
<span>{item.label}</span>
</Link>
)
}
function Sidebar({ pathname }) {
const isActive = (item) => {
if (item.exact) return pathname === item.href
return pathname.startsWith(item.href.split('?')[0])
}
return (
<aside className="flex h-full w-64 flex-col overflow-y-auto border-r border-white/[0.07] bg-[rgba(10,14,22,0.98)] px-3 py-6">
{/* Brand */}
<div className="mb-8 px-3">
<Link href="/moderation" className="flex items-center gap-2.5">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-rose-500/20">
<i className="fa-solid fa-shield-halved text-sm text-rose-400" />
</span>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-rose-400/80">Skinbase</p>
<p className="text-sm font-bold text-white">Admin Panel</p>
</div>
</Link>
</div>
{/* Nav groups */}
<nav className="flex-1 space-y-6">
{adminNavGroups.map((group) => (
<div key={group.label}>
<p className="mb-1.5 px-4 text-[10px] font-bold uppercase tracking-[0.2em] text-slate-600">
{group.label}
</p>
<div className="space-y-0.5">
{group.items.map((item) => (
<NavLink key={item.href} item={item} active={isActive(item)} />
))}
</div>
</div>
))}
</nav>
{/* Footer links */}
<div className="mt-6 space-y-0.5 border-t border-white/[0.06] pt-4">
<Link
href="/studio"
className="flex items-center gap-3 rounded-xl px-4 py-2.5 text-sm text-slate-500 transition hover:bg-white/5 hover:text-slate-300"
>
<i className="fa-solid fa-arrow-left w-5 text-center text-base" />
<span>Back to Studio</span>
</Link>
</div>
</aside>
)
}
export default function AdminLayout({ children, title, subtitle }) {
const { url } = usePage()
const [mobileOpen, setMobileOpen] = useState(false)
const pathname = url.split('?')[0]
return (
<div className="flex min-h-screen bg-[radial-gradient(ellipse_at_top,_rgba(239,68,68,0.08),_transparent_40%),linear-gradient(180deg,#060a12_0%,#020409_100%)]">
{/* Desktop sidebar */}
<div className="hidden lg:flex lg:w-64 lg:flex-shrink-0">
<div className="fixed inset-y-0 left-0 w-64">
<Sidebar pathname={pathname} />
</div>
</div>
{/* Mobile header */}
<div className="fixed inset-x-0 top-0 z-40 flex items-center justify-between border-b border-white/10 bg-[rgba(10,14,22,0.97)] px-4 py-3 backdrop-blur-xl lg:hidden">
<Link href="/moderation" className="flex items-center gap-2">
<i className="fa-solid fa-shield-halved text-rose-400" />
<span className="text-sm font-bold text-white">Admin Panel</span>
</Link>
<button
onClick={() => setMobileOpen(!mobileOpen)}
className="rounded-full border border-white/10 p-2 text-slate-400 hover:text-white"
aria-label="Toggle navigation"
>
<i className={mobileOpen ? 'fa-solid fa-xmark' : 'fa-solid fa-bars'} />
</button>
</div>
{/* Mobile drawer */}
{mobileOpen && (
<div className="fixed inset-0 z-30 lg:hidden">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setMobileOpen(false)} />
<div className="absolute left-0 top-0 h-full w-72 pt-14">
<Sidebar pathname={pathname} />
</div>
</div>
)}
{/* Main content */}
<div className="flex flex-1 flex-col lg:pl-64">
<main className="flex-1 px-6 py-8 pt-20 lg:pt-8">
{(title || subtitle) && (
<div className="mb-8">
{title && <h1 className="text-2xl font-bold text-white">{title}</h1>}
{subtitle && <p className="mt-1 text-sm text-slate-400">{subtitle}</p>}
</div>
)}
{children}
</main>
</div>
</div>
)
}

View File

@@ -174,6 +174,95 @@ function NavLink({ item, active }) {
)
}
function studioContextSummary(option) {
if (!option) return ''
if (option.kind === 'personal') return option.description || 'Your creator workspace'
return [option.roleLabel, option.description].filter(Boolean).join(' · ')
}
function studioContextInitials(label) {
const initials = String(label || '')
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((segment) => segment.charAt(0).toUpperCase())
.join('')
return initials || 'SB'
}
function StudioContextAvatar({ option, size = 'md' }) {
const sizeClass = size === 'lg' ? 'h-11 w-11 text-sm' : 'h-9 w-9 text-xs'
if (option?.avatarUrl) {
return (
<img
src={option.avatarUrl}
alt={option.label}
className={`${sizeClass} rounded-2xl object-cover ring-1 ring-white/10 shadow-[0_10px_24px_rgba(15,23,42,0.25)]`}
/>
)
}
const toneClass = option?.kind === 'personal'
? 'border-sky-300/25 bg-sky-300/12 text-sky-100'
: 'border-amber-300/20 bg-amber-300/10 text-amber-100'
return (
<span className={`${sizeClass} inline-flex items-center justify-center rounded-2xl border font-semibold uppercase tracking-[0.16em] ${toneClass}`}>
{option?.kind === 'personal' ? <i className="fa-solid fa-user text-[12px]" /> : studioContextInitials(option?.label)}
</span>
)
}
function StudioContextOptionContent({ option, expanded = false }) {
return (
<div className="flex min-w-0 items-center gap-3">
<StudioContextAvatar option={option} size={expanded ? 'lg' : 'md'} />
<div className="min-w-0 flex-1">
<div className={`truncate font-semibold ${expanded ? 'text-[15px]' : 'text-sm'} text-white`}>
{option.label}
</div>
<div className="truncate text-xs text-slate-400">
{studioContextSummary(option)}
</div>
</div>
{expanded && option.kind === 'group' && option.status ? (
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-300">
{String(option.status).replace(/_/g, ' ')}
</span>
) : null}
</div>
)
}
function buildStudioContextOptions(currentGroup, studioGroups, userLabel, userAvatarUrl) {
return [
{
value: '',
label: 'Personal studio',
description: userLabel || 'Your creator workspace',
avatarUrl: userAvatarUrl || null,
group: 'Workspace',
kind: 'personal',
},
...studioGroups.map((group) => ({
value: group.slug,
label: group.name,
description: Number(group.artworks_count || 0) > 0
? `${Number(group.artworks_count || 0).toLocaleString()} artwork${Number(group.artworks_count || 0) === 1 ? '' : 's'}`
: 'Group workspace',
roleLabel: group.role_label || 'Member',
status: group.status,
avatarUrl: group.avatar_url || null,
group: 'Groups',
kind: 'group',
isCurrent: currentGroup?.slug === group.slug,
})),
]
}
export default function StudioLayout({ children, title, subtitle, actions }) {
const { url, props } = usePage()
const [mobileOpen, setMobileOpen] = useState(false)
@@ -181,9 +270,12 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
const pathname = url.split('?')[0]
const studioGroups = Array.isArray(props.studio_groups) ? props.studio_groups : []
const currentGroup = props.studioGroup || null
const userLabel = props.auth?.user?.name || props.auth?.user?.username || 'Your creator workspace'
const userAvatarUrl = props.auth?.user?.avatar_url || null
const canManageNews = Boolean(props.auth?.user?.is_admin || props.auth?.user?.is_moderator)
const canManageWorlds = canManageNews
const isStaff = Boolean(props.auth?.user?.is_staff)
const studioContextOptions = buildStudioContextOptions(currentGroup, studioGroups, userLabel, userAvatarUrl)
const navGroups = baseNavGroups.map((group) => {
if ((!canManageNews && !canManageWorlds) || group.label !== 'Content') {
@@ -324,6 +416,7 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
<StudioSidebarContent
currentGroup={currentGroup}
studioGroups={studioGroups}
studioContextOptions={studioContextOptions}
navGroups={navGroups}
quickCreateItems={quickCreateItems}
isActive={isActive}
@@ -341,6 +434,7 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
<StudioSidebarContent
currentGroup={currentGroup}
studioGroups={studioGroups}
studioContextOptions={studioContextOptions}
navGroups={navGroups}
quickCreateItems={quickCreateItems}
isActive={isActive}
@@ -361,7 +455,7 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
</div>
<div className="flex flex-wrap items-center gap-3 lg:justify-end">
{studioGroups.length > 0 ? <ContextSwitcher currentGroup={currentGroup} studioGroups={studioGroups} onContextChange={handleContextChange} /> : null}
{studioGroups.length > 0 ? <ContextSwitcher currentGroup={currentGroup} studioContextOptions={studioContextOptions} onContextChange={handleContextChange} /> : null}
{actions}
<div className="relative">
<button
@@ -399,42 +493,45 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
)
}
function ContextSwitcher({ currentGroup, studioGroups, onContextChange }) {
function ContextSwitcher({ currentGroup, studioContextOptions, onContextChange }) {
return (
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/20 px-3 py-2 text-sm text-slate-200">
<i className="fa-solid fa-people-group text-sky-200" />
<div className="inline-flex items-center gap-3 rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.88),rgba(7,14,28,0.94))] p-2 pr-3 text-sm text-slate-200 shadow-[0_18px_40px_rgba(2,6,23,0.24)]">
<span className="inline-flex h-11 w-11 items-center justify-center rounded-[20px] border border-sky-300/14 bg-sky-300/10 text-sky-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]">
<i className="fa-solid fa-people-group text-sm" />
</span>
<NovaSelect
value={currentGroup?.slug || ''}
onChange={(value) => onContextChange?.(value)}
options={[
{ value: '', label: 'Personal studio' },
...studioGroups.map((group) => ({ value: group.slug, label: group.name })),
]}
searchable={false}
options={studioContextOptions}
searchable={true}
searchPlaceholder="Search studios or groups…"
renderOption={(option) => <StudioContextOptionContent option={option} expanded />}
renderValue={(option) => <StudioContextOptionContent option={option} />}
className="min-w-[280px] border-white/10 bg-white/[0.03] shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] hover:border-white/20"
/>
</div>
)
}
function StudioSidebarContent({ currentGroup, studioGroups, navGroups, quickCreateItems, isActive, isStaff, onNavigate, onQuickCreate, onContextChange }) {
function StudioSidebarContent({ currentGroup, studioContextOptions, navGroups, quickCreateItems, isActive, isStaff, onNavigate, onQuickCreate, onContextChange }) {
return (
<>
<div className="mb-6 rounded-[26px] border border-white/10 bg-white/[0.04] p-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/70">Skinbase Nova</p>
<h2 className="mt-2 text-xl font-semibold text-white">Creator Studio</h2>
<p className="mt-2 text-sm leading-6 text-slate-400">Create, manage, and grow from one modular workspace built for every creator surface.</p>
{studioGroups.length > 0 ? (
{studioContextOptions.length > 1 ? (
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Context</p>
<NovaSelect
value={currentGroup?.slug || ''}
onChange={(value) => onContextChange?.(value)}
className="mt-2"
options={[
{ value: '', label: 'Personal studio' },
...studioGroups.map((group) => ({ value: group.slug, label: group.name })),
]}
searchable={false}
options={studioContextOptions}
searchable={true}
searchPlaceholder="Search studios or groups…"
renderOption={(option) => <StudioContextOptionContent option={option} expanded />}
renderValue={(option) => <StudioContextOptionContent option={option} />}
/>
</div>
) : null}

View File

@@ -0,0 +1,11 @@
import React from 'react'
import AdminLayout from '../../Layouts/AdminLayout'
import AiBiographyAdmin from '../Moderation/AiBiographyAdmin'
export default function AdminAiBiography() {
return (
<AdminLayout>
<AiBiographyAdmin />
</AdminLayout>
)
}

View File

@@ -0,0 +1,79 @@
import React from 'react'
import { Head, router } from '@inertiajs/react'
import AdminLayout from '../../Layouts/AdminLayout'
export default function AdminArtworks({ artworks }) {
const items = artworks?.data ?? []
return (
<AdminLayout title="Artworks" subtitle="Browse and manage all artworks on the platform">
<Head title="Admin · Artworks" />
<div className="overflow-hidden rounded-2xl border border-white/[0.07] bg-white/[0.02]">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.07] text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
<th className="px-5 py-3.5">Artwork</th>
<th className="px-5 py-3.5">Author</th>
<th className="px-5 py-3.5">Status</th>
<th className="px-5 py-3.5">Uploaded</th>
<th className="px-5 py-3.5 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.04]">
{items.length === 0 && (
<tr><td colSpan={5} className="px-5 py-12 text-center text-slate-500">No artworks found.</td></tr>
)}
{items.map((artwork) => (
<tr key={artwork.id} className="transition hover:bg-white/[0.025]">
<td className="px-5 py-4">
<div className="flex items-center gap-3">
{artwork.thumb && (
<img src={artwork.thumb} alt={artwork.title} className="h-10 w-10 rounded-lg object-cover" />
)}
<span className="font-medium text-white">{artwork.title || <span className="italic text-slate-500">Untitled</span>}</span>
</div>
</td>
<td className="px-5 py-4 text-slate-400">{artwork.user?.name ?? '—'}</td>
<td className="px-5 py-4">
<span className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize ${
artwork.status === 'published' ? 'bg-teal-500/20 text-teal-300'
: artwork.status === 'pending' ? 'bg-amber-500/20 text-amber-300'
: 'bg-slate-500/20 text-slate-400'
}`}>{artwork.status ?? 'unknown'}</span>
</td>
<td className="px-5 py-4 text-slate-500">
{artwork.created_at ? new Date(artwork.created_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) : '—'}
</td>
<td className="px-5 py-4 text-right">
<a href={`/studio/artworks/${artwork.id}/edit`}
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-white/70 transition hover:bg-white/[0.09]">
Edit
</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
{artworks?.last_page > 1 && (
<div className="flex items-center justify-between border-t border-white/[0.06] px-5 py-4">
<p className="text-xs text-slate-500">Showing {artworks.from}{artworks.to} of {artworks.total} artworks</p>
<div className="flex gap-1">
{artworks.links.map((link, i) => (
link.url ? (
<button key={i} type="button" onClick={() => router.get(link.url, {}, { preserveScroll: true })}
className={`rounded-lg px-3 py-1.5 text-xs transition ${link.active ? 'bg-rose-500/20 font-semibold text-rose-300' : 'text-slate-500 hover:bg-white/[0.06] hover:text-white'}`}
dangerouslySetInnerHTML={{ __html: link.label }} />
) : (
<span key={i} className="rounded-lg px-3 py-1.5 text-xs text-slate-700" dangerouslySetInnerHTML={{ __html: link.label }} />
)
))}
</div>
</div>
)}
</div>
</AdminLayout>
)
}

View File

@@ -0,0 +1,73 @@
import React from 'react'
import AdminLayout from '../../Layouts/AdminLayout'
import { Head } from '@inertiajs/react'
function StatCard({ icon, label, value, color = 'sky' }) {
const colors = {
sky: 'from-sky-500/20 to-sky-500/5 border-sky-500/20 text-sky-400',
rose: 'from-rose-500/20 to-rose-500/5 border-rose-500/20 text-rose-400',
amber: 'from-amber-500/20 to-amber-500/5 border-amber-500/20 text-amber-400',
violet: 'from-violet-500/20 to-violet-500/5 border-violet-500/20 text-violet-400',
}
return (
<div className={`rounded-2xl border bg-gradient-to-br p-6 ${colors[color]}`}>
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-400">{label}</p>
<p className="mt-2 text-3xl font-bold text-white">{value.toLocaleString()}</p>
</div>
<div className={`flex h-12 w-12 items-center justify-center rounded-xl bg-white/5`}>
<i className={`${icon} text-xl ${colors[color].split(' ').at(-1)}`} />
</div>
</div>
</div>
)
}
export default function Dashboard({ stats }) {
return (
<AdminLayout title="Dashboard" subtitle="Overview of your Skinbase platform">
<Head title="Admin Dashboard" />
{/* Stats grid */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard icon="fa-solid fa-users" label="Total Users" value={stats.total_users} color="sky" />
<StatCard icon="fa-solid fa-user-plus" label="New Today" value={stats.new_users_today} color="violet" />
<StatCard icon="fa-solid fa-shield-halved" label="Staff Members" value={stats.staff_count} color="rose" />
<StatCard icon="fa-solid fa-user-shield" label="Moderators" value={stats.moderator_count} color="amber" />
</div>
{/* Quick links */}
<div className="mt-10">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-slate-500">Quick Actions</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{[
{ label: 'Manage Users', href: '/moderation/users', icon: 'fa-solid fa-users', desc: 'Search, promote or demote users' },
{ label: 'Staff Roles', href: '/moderation/users?role=admin', icon: 'fa-solid fa-shield-halved', desc: 'View all admins, managers and editorial staff' },
{ label: 'Username Queue', href: '/moderation/usernames/moderation', icon: 'fa-solid fa-id-badge', desc: 'Review pending username requests' },
{ label: 'Upload Queue', href: '/moderation/uploads', icon: 'fa-solid fa-cloud-arrow-up', desc: 'Moderate pending artwork submissions' },
{ label: 'Stories', href: '/moderation/stories', icon: 'fa-solid fa-feather-pointed', desc: 'Browse all creator stories' },
{ label: 'Artworks', href: '/moderation/artworks', icon: 'fa-solid fa-images', desc: 'Browse all uploaded artworks' },
{ label: 'Featured Artworks', href: '/moderation/artworks/featured', icon: 'fa-solid fa-star', desc: 'Curate the homepage featured artwork lineup' },
{ label: 'AI Biography', href: '/moderation/ai-biography', icon: 'fa-solid fa-wand-magic-sparkles', desc: 'Review generated creator biographies and moderation flags' },
].map((item) => (
<a
key={item.href}
href={item.href}
className="group flex items-start gap-4 rounded-2xl border border-white/[0.07] bg-white/[0.03] p-5 transition hover:border-white/15 hover:bg-white/[0.06]"
>
<div className="mt-0.5 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-rose-500/10">
<i className={`${item.icon} text-rose-400`} />
</div>
<div>
<p className="font-semibold text-white group-hover:text-rose-300 transition">{item.label}</p>
<p className="mt-0.5 text-xs text-slate-500">{item.desc}</p>
</div>
</a>
))}
</div>
</div>
</AdminLayout>
)
}

View File

@@ -0,0 +1,11 @@
import React from 'react'
import AdminLayout from '../../Layouts/AdminLayout'
import FeaturedArtworksAdmin from '../Collection/FeaturedArtworksAdmin'
export default function AdminFeaturedArtworks() {
return (
<AdminLayout>
<FeaturedArtworksAdmin />
</AdminLayout>
)
}

View File

@@ -0,0 +1,67 @@
import React from 'react'
import { Head } from '@inertiajs/react'
import AdminLayout from '../../Layouts/AdminLayout'
const SETTING_GROUPS = [
{
label: 'Platform',
items: [
{ key: 'site_name', label: 'Site Name', type: 'text', description: 'The public name of the platform' },
{ key: 'site_description', label: 'Site Description', type: 'textarea', description: 'Short tagline shown in meta tags' },
{ key: 'maintenance_mode', label: 'Maintenance Mode', type: 'toggle', description: 'Put the site into maintenance mode' },
],
},
{
label: 'Registration',
items: [
{ key: 'registration_open', label: 'Open Registration', type: 'toggle', description: 'Allow new users to register' },
{ key: 'require_invite', label: 'Require Invite', type: 'toggle', description: 'New users must have an invite code' },
],
},
]
export default function AdminSettings({ settings = {} }) {
return (
<AdminLayout title="Settings" subtitle="Platform-wide configuration">
<Head title="Admin · Settings" />
<div className="space-y-8 max-w-2xl">
{SETTING_GROUPS.map((group) => (
<div key={group.label} className="rounded-2xl border border-white/[0.07] bg-white/[0.02] p-6">
<h2 className="mb-5 text-sm font-bold uppercase tracking-wider text-slate-500">{group.label}</h2>
<div className="space-y-5">
{group.items.map((item) => (
<div key={item.key} className="flex items-start justify-between gap-6">
<div>
<p className="text-sm font-medium text-white">{item.label}</p>
<p className="mt-0.5 text-xs text-slate-500">{item.description}</p>
</div>
{item.type === 'toggle' ? (
<div className="flex h-6 w-11 flex-shrink-0 cursor-not-allowed items-center rounded-full border border-white/10 bg-white/[0.06] px-1 opacity-60">
<span className="h-4 w-4 rounded-full bg-slate-600" />
</div>
) : item.type === 'textarea' ? (
<textarea
defaultValue={settings[item.key] ?? ''}
rows={2}
readOnly
className="w-64 cursor-not-allowed resize-none rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white/60"
/>
) : (
<input
type="text"
defaultValue={settings[item.key] ?? ''}
readOnly
className="w-64 cursor-not-allowed rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white/60"
/>
)}
</div>
))}
</div>
</div>
))}
<p className="text-xs text-slate-600">Full settings management via config files and environment variables.</p>
</div>
</AdminLayout>
)
}

View File

@@ -0,0 +1,74 @@
import React from 'react'
import { Head, router } from '@inertiajs/react'
import AdminLayout from '../../Layouts/AdminLayout'
export default function AdminStories({ stories }) {
const items = stories?.data ?? []
return (
<AdminLayout title="Stories" subtitle="Review all stories submitted by creators">
<Head title="Admin · Stories" />
<div className="overflow-hidden rounded-2xl border border-white/[0.07] bg-white/[0.02]">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.07] text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
<th className="px-5 py-3.5">Title</th>
<th className="px-5 py-3.5">Author</th>
<th className="px-5 py-3.5">Status</th>
<th className="px-5 py-3.5">Published</th>
<th className="px-5 py-3.5 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.04]">
{items.length === 0 && (
<tr><td colSpan={5} className="px-5 py-12 text-center text-slate-500">No stories found.</td></tr>
)}
{items.map((story) => (
<tr key={story.id} className="transition hover:bg-white/[0.025]">
<td className="px-5 py-4 font-medium text-white">{story.title || <span className="italic text-slate-500">Untitled</span>}</td>
<td className="px-5 py-4 text-slate-400">{story.creator?.name ?? '—'}</td>
<td className="px-5 py-4">
<span className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize ${
story.status === 'published' ? 'bg-teal-500/20 text-teal-300'
: story.status === 'pending_review' ? 'bg-amber-500/20 text-amber-300'
: 'bg-slate-500/20 text-slate-400'
}`}>{story.status?.replace('_', ' ') ?? 'draft'}</span>
</td>
<td className="px-5 py-4 text-slate-500">
{story.published_at ? new Date(story.published_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) : '—'}
</td>
<td className="px-5 py-4 text-right">
<a
href={`/studio/stories/${story.id}/edit`}
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-white/70 transition hover:bg-white/[0.09]"
>
Edit
</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
{stories?.last_page > 1 && (
<div className="flex items-center justify-between border-t border-white/[0.06] px-5 py-4">
<p className="text-xs text-slate-500">Showing {stories.from}{stories.to} of {stories.total} stories</p>
<div className="flex gap-1">
{stories.links.map((link, i) => (
link.url ? (
<button key={i} type="button" onClick={() => router.get(link.url, {}, { preserveScroll: true })}
className={`rounded-lg px-3 py-1.5 text-xs transition ${link.active ? 'bg-rose-500/20 font-semibold text-rose-300' : 'text-slate-500 hover:bg-white/[0.06] hover:text-white'}`}
dangerouslySetInnerHTML={{ __html: link.label }} />
) : (
<span key={i} className="rounded-lg px-3 py-1.5 text-xs text-slate-700" dangerouslySetInnerHTML={{ __html: link.label }} />
)
))}
</div>
</div>
)}
</div>
</AdminLayout>
)
}

View File

@@ -1,6 +1,13 @@
import React from 'react'
import { Head } from '@inertiajs/react'
import AdminLayout from '../../Layouts/AdminLayout'
import AdminUploadQueue from '../../components/admin/AdminUploadQueue'
export default function UploadQueuePage() {
return <AdminUploadQueue />
return (
<AdminLayout title="Upload Queue" subtitle="Review and moderate pending artwork submissions">
<Head title="Admin · Upload Queue" />
<AdminUploadQueue />
</AdminLayout>
)
}

View File

@@ -1,6 +1,13 @@
import React from 'react'
import { Head } from '@inertiajs/react'
import AdminLayout from '../../Layouts/AdminLayout'
import AdminUsernameQueue from '../../components/admin/AdminUsernameQueue'
export default function UsernameQueuePage() {
return <AdminUsernameQueue />
return (
<AdminLayout title="Username Queue" subtitle="Review and approve pending username change requests">
<Head title="Admin · Username Queue" />
<AdminUsernameQueue />
</AdminLayout>
)
}

View File

@@ -0,0 +1,219 @@
import React, { useState } from 'react'
import { Head, router, usePage } from '@inertiajs/react'
import AdminLayout from '../../../Layouts/AdminLayout'
const ROLE_BADGE = {
user: 'bg-slate-500/20 text-slate-300',
creator: 'bg-sky-500/20 text-sky-300',
moderator: 'bg-violet-500/20 text-violet-300',
editorial: 'bg-teal-500/20 text-teal-300',
manager: 'bg-amber-500/20 text-amber-300',
admin: 'bg-rose-500/20 text-rose-300',
}
function RoleBadge({ role }) {
return (
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize ${ROLE_BADGE[role] ?? 'bg-slate-500/20 text-slate-300'}`}>
{role}
</span>
)
}
function RoleDropdown({ user, roles, currentUserIsAdmin }) {
const [open, setOpen] = useState(false)
const [pending, setPending] = useState(false)
const handleSelect = (newRole) => {
if (newRole === user.role) { setOpen(false); return }
setPending(true)
router.patch(`/admin/users/${user.id}/role`, { role: newRole }, {
preserveScroll: true,
onFinish: () => { setPending(false); setOpen(false) },
})
}
const availableRoles = currentUserIsAdmin
? roles
: roles.filter((r) => r.value !== 'admin')
return (
<div className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
disabled={pending}
className="flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-white/70 transition hover:bg-white/[0.09] disabled:opacity-50"
>
{pending ? <i className="fa-solid fa-spinner animate-spin" /> : <i className="fa-solid fa-pen-to-square" />}
Change role
</button>
{open && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute right-0 z-20 mt-1 w-44 rounded-xl border border-white/10 bg-[rgba(12,16,26,0.98)] shadow-2xl backdrop-blur-xl">
{availableRoles.map((r) => (
<button
key={r.value}
type="button"
onClick={() => handleSelect(r.value)}
className={`flex w-full items-center gap-2.5 px-3 py-2.5 text-left text-sm transition hover:bg-white/[0.06] first:rounded-t-xl last:rounded-b-xl ${r.value === user.role ? 'text-white/90' : 'text-white/60'}`}
>
<span className={`h-2 w-2 rounded-full ${r.value === user.role ? 'bg-rose-400' : 'bg-white/20'}`} />
{r.label}
{r.value === user.role && <i className="fa-solid fa-check ml-auto text-xs text-rose-400" />}
</button>
))}
</div>
</>
)}
</div>
)
}
export default function UsersIndex({ users, filters, roles }) {
const { props } = usePage()
const currentUserIsAdmin = Boolean(props.auth?.user?.is_admin)
const flash = props.flash ?? {}
const handleSearch = (e) => {
e.preventDefault()
const search = e.target.elements.search.value
router.get('/moderation/users', { search, role: filters.role }, { preserveState: true })
}
const handleRoleFilter = (role) => {
router.get('/moderation/users', { search: filters.search, role }, { preserveState: true })
}
return (
<AdminLayout title="Users" subtitle="Search, view and manage user roles">
<Head title="Admin · Users" />
{/* Flash messages */}
{flash.success && (
<div className="mb-6 rounded-xl border border-teal-500/20 bg-teal-500/10 px-4 py-3 text-sm text-teal-300">
<i className="fa-solid fa-circle-check mr-2" />{flash.success}
</div>
)}
{flash.error && (
<div className="mb-6 rounded-xl border border-rose-500/20 bg-rose-500/10 px-4 py-3 text-sm text-rose-300">
<i className="fa-solid fa-circle-exclamation mr-2" />{flash.error}
</div>
)}
{/* Search + filter bar */}
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<form onSubmit={handleSearch} className="flex flex-1 items-center gap-2">
<div className="relative flex-1 max-w-sm">
<i className="fa-solid fa-magnifying-glass absolute left-3.5 top-1/2 -translate-y-1/2 text-xs text-slate-500" />
<input
name="search"
defaultValue={filters.search}
placeholder="Search name, username or email…"
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-600 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10"
/>
</div>
<button type="submit" className="rounded-xl bg-rose-500/80 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-rose-500">
Search
</button>
</form>
{/* Role filter chips */}
<div className="flex flex-wrap gap-2">
{[{ value: 'all', label: 'All' }, ...roles].map((r) => (
<button
key={r.value}
type="button"
onClick={() => handleRoleFilter(r.value === 'all' ? '' : r.value)}
className={`rounded-full px-3 py-1 text-xs font-semibold transition ${
(filters.role === r.value || (r.value === 'all' && !filters.role))
? 'bg-rose-500/20 text-rose-300'
: 'bg-white/[0.05] text-slate-400 hover:bg-white/[0.09]'
}`}
>
{r.label}
</button>
))}
</div>
</div>
{/* Users table */}
<div className="overflow-hidden rounded-2xl border border-white/[0.07] bg-white/[0.02]">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.07] text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
<th className="px-5 py-3.5">User</th>
<th className="px-5 py-3.5">Email</th>
<th className="px-5 py-3.5">Role</th>
<th className="px-5 py-3.5">Joined</th>
<th className="px-5 py-3.5 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.04]">
{users.data.length === 0 && (
<tr>
<td colSpan={5} className="px-5 py-12 text-center text-slate-500">No users found.</td>
</tr>
)}
{users.data.map((user) => (
<tr key={user.id} className="group transition hover:bg-white/[0.025]">
<td className="px-5 py-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-slate-700 text-xs font-bold text-white uppercase">
{user.name?.[0] ?? '?'}
</div>
<div>
<p className="font-medium text-white">{user.name}</p>
{user.username && <p className="text-xs text-slate-500">@{user.username}</p>}
</div>
</div>
</td>
<td className="px-5 py-4 text-slate-400">{user.email}</td>
<td className="px-5 py-4">
<RoleBadge role={user.role ?? 'user'} />
</td>
<td className="px-5 py-4 text-slate-500">
{new Date(user.created_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}
</td>
<td className="px-5 py-4 text-right">
<RoleDropdown user={user} roles={roles} currentUserIsAdmin={currentUserIsAdmin} />
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{users.last_page > 1 && (
<div className="flex items-center justify-between border-t border-white/[0.06] px-5 py-4">
<p className="text-xs text-slate-500">
Showing {users.from}{users.to} of {users.total} users
</p>
<div className="flex gap-1">
{users.links.map((link, i) => (
link.url ? (
<button
key={i}
type="button"
onClick={() => router.get(link.url, {}, { preserveScroll: true })}
className={`rounded-lg px-3 py-1.5 text-xs transition ${
link.active
? 'bg-rose-500/20 font-semibold text-rose-300'
: 'text-slate-500 hover:bg-white/[0.06] hover:text-white'
}`}
dangerouslySetInnerHTML={{ __html: link.label }}
/>
) : (
<span key={i} className="rounded-lg px-3 py-1.5 text-xs text-slate-700" dangerouslySetInnerHTML={{ __html: link.label }} />
)
))}
</div>
</div>
)}
</div>
</AdminLayout>
)
}

View File

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

View File

@@ -3,6 +3,7 @@ import { Head, usePage } from '@inertiajs/react'
import CollectionCard from '../../components/profile/collections/CollectionCard'
import CollectionVisibilityBadge from '../../components/profile/collections/CollectionVisibilityBadge'
import Checkbox from '../../components/ui/Checkbox'
import DateTimePicker from '../../components/ui/DateTimePicker'
import NovaSelect from '../../components/ui/NovaSelect'
function getCsrfToken() {
@@ -671,17 +672,21 @@ function SmartRuleRow({
{rule.field === 'created_at' ? (
<Field label="Date Range">
<div className="grid gap-3 sm:grid-cols-2">
<input
type="date"
<DateTimePicker
value={rule.value?.from || ''}
onChange={(event) => onValueChange({ ...(rule.value || {}), from: 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 focus:bg-white/[0.06]"
onChange={(nextValue) => onValueChange({ ...(rule.value || {}), from: nextValue })}
mode="date"
placeholder="From date"
clearable
className="bg-white/[0.04]"
/>
<input
type="date"
<DateTimePicker
value={rule.value?.to || ''}
onChange={(event) => onValueChange({ ...(rule.value || {}), to: 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 focus:bg-white/[0.06]"
onChange={(nextValue) => onValueChange({ ...(rule.value || {}), to: nextValue })}
mode="date"
placeholder="To date"
clearable
className="bg-white/[0.04]"
/>
</div>
</Field>
@@ -2138,19 +2143,19 @@ export default function CollectionManage() {
<div className="grid gap-5 md:grid-cols-2">
<Field label="Publish At" help="Leave empty to publish immediately. A future time keeps it off public surfaces until it goes live.">
<input type="datetime-local" value={form.published_at} onChange={(event) => updateForm('published_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 transition focus:border-sky-300/35 focus:bg-white/[0.06]" />
<DateTimePicker value={form.published_at} onChange={(nextValue) => updateForm('published_at', nextValue)} placeholder="Publish time" clearable className="bg-white/[0.04]" />
</Field>
<Field label="Unpublish At" help="Optional automatic sunset time for seasonal or editorial collections.">
<input type="datetime-local" value={form.unpublished_at} onChange={(event) => updateForm('unpublished_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 transition focus:border-sky-300/35 focus:bg-white/[0.06]" />
<DateTimePicker value={form.unpublished_at} onChange={(nextValue) => updateForm('unpublished_at', nextValue)} placeholder="Unpublish time" clearable className="bg-white/[0.04]" />
</Field>
</div>
<div className="grid gap-5 md:grid-cols-2">
<Field label="Archive At" help="Optional timestamp for moving the collection to long-term archive workflows.">
<input type="datetime-local" value={form.archived_at} onChange={(event) => updateForm('archived_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 transition focus:border-sky-300/35 focus:bg-white/[0.06]" />
<DateTimePicker value={form.archived_at} onChange={(nextValue) => updateForm('archived_at', nextValue)} placeholder="Archive time" clearable className="bg-white/[0.04]" />
</Field>
<Field label="Expire At" help="Optional hard expiry for promotional or seasonal collections.">
<input type="datetime-local" value={form.expired_at} onChange={(event) => updateForm('expired_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 transition focus:border-sky-300/35 focus:bg-white/[0.06]" />
<DateTimePicker value={form.expired_at} onChange={(nextValue) => updateForm('expired_at', nextValue)} placeholder="Expiry time" clearable className="bg-white/[0.04]" />
</Field>
</div>
</AdvancedSection>
@@ -2734,7 +2739,7 @@ export default function CollectionManage() {
<div className="flex min-w-0 flex-1 flex-col gap-2 md:min-w-[240px]">
<NovaSelect value={inviteExpiryMode} onChange={(val) => setInviteExpiryMode(val)} searchable={false} options={[{ value: 'default', label: `Default (${inviteExpiryDays} days)` }, ...inviteExpiryOptions.map((days) => ({ value: String(days), label: `${days} day${days === 1 ? '' : 's'}` })), { value: 'custom', label: 'Custom date' }]} />
{inviteExpiryMode === 'custom' ? (
<input type="datetime-local" value={inviteCustomExpiry} onChange={(event) => setInviteCustomExpiry(event.target.value)} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" />
<DateTimePicker value={inviteCustomExpiry} onChange={setInviteCustomExpiry} placeholder="Custom expiry" clearable className="bg-white/[0.04]" />
) : (
<p className="px-1 text-xs text-slate-400">Leave this on default to use the global expiry window for collaborator invites.</p>
)}

View File

@@ -3,6 +3,7 @@ import { Head, usePage } from '@inertiajs/react'
import CollectionCard from '../../components/profile/collections/CollectionCard'
import ShareToast from '../../components/ui/ShareToast'
import Checkbox from '../../components/ui/Checkbox'
import DateTimePicker from '../../components/ui/DateTimePicker'
import NovaSelect from '../../components/ui/NovaSelect'
function getCsrfToken() {
@@ -634,8 +635,8 @@ export default function CollectionStaffProgramming() {
<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>
<Field label="Starts At"><DateTimePicker value={assignmentForm.starts_at} onChange={(nextValue) => setAssignmentForm((current) => ({ ...current, starts_at: nextValue }))} placeholder="Start time" clearable className="bg-white/[0.04]" /></Field>
<Field label="Ends At"><DateTimePicker value={assignmentForm.ends_at} onChange={(nextValue) => setAssignmentForm((current) => ({ ...current, ends_at: nextValue }))} placeholder="End time" clearable className="bg-white/[0.04]" /></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">

View File

@@ -2,6 +2,7 @@ import React from 'react'
import { Head, usePage } from '@inertiajs/react'
import CollectionCard from '../../components/profile/collections/CollectionCard'
import Checkbox from '../../components/ui/Checkbox'
import DateTimePicker from '../../components/ui/DateTimePicker'
import NovaSelect from '../../components/ui/NovaSelect'
function getCsrfToken() {
@@ -406,8 +407,8 @@ export default function CollectionStaffSurfaces() {
<Field label="Mode"><NovaSelect value={definitionForm.mode} onChange={(val) => setDefinitionForm((current) => ({ ...current, mode: val }))} searchable={false} options={[{ value: 'manual', label: 'Manual' }, { value: 'automatic', label: 'Automatic' }, { value: 'hybrid', label: 'Hybrid' }]} /></Field>
<Field label="Ranking"><NovaSelect value={definitionForm.ranking_mode} onChange={(val) => setDefinitionForm((current) => ({ ...current, ranking_mode: val }))} searchable={false} options={[{ value: 'ranking_score', label: 'Ranking score' }, { value: 'recent_activity', label: 'Recent activity' }, { value: 'quality_score', label: 'Quality score' }]} /></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="Starts At" help="Optional activation window for the full surface definition."><DateTimePicker value={definitionForm.starts_at} onChange={(nextValue) => setDefinitionForm((current) => ({ ...current, starts_at: nextValue }))} placeholder="Start time" clearable className="bg-white/[0.04]" /></Field>
<Field label="Ends At" help="Leave blank when the surface should stay live until staff changes it."><DateTimePicker value={definitionForm.ends_at} onChange={(nextValue) => setDefinitionForm((current) => ({ ...current, ends_at: nextValue }))} placeholder="End time" clearable className="bg-white/[0.04]" /></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>
<div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white"><Checkbox checked={definitionForm.is_active} onChange={(event) => setDefinitionForm((current) => ({ ...current, is_active: event.target.checked }))} label="Active" /></div>
</div>
@@ -429,8 +430,8 @@ export default function CollectionStaffSurfaces() {
<Field label="Collection"><NovaSelect value={String(placementForm.collection_id || '')} onChange={(val) => setPlacementForm((current) => ({ ...current, collection_id: val }))} options={collectionOptions.map((o) => ({ value: String(o.id), label: o.title }))} /></Field>
<Field label="Placement Type"><NovaSelect value={placementForm.placement_type} onChange={(val) => setPlacementForm((current) => ({ ...current, placement_type: val }))} searchable={false} options={[{ value: 'manual', label: 'Manual' }, { value: 'campaign', label: 'Campaign' }, { value: 'scheduled_override', label: 'Scheduled override' }]} /></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="Starts At"><DateTimePicker value={placementForm.starts_at} onChange={(nextValue) => setPlacementForm((current) => ({ ...current, starts_at: nextValue }))} placeholder="Start time" clearable className="bg-white/[0.04]" /></Field>
<Field label="Ends At"><DateTimePicker value={placementForm.ends_at} onChange={(nextValue) => setPlacementForm((current) => ({ ...current, ends_at: nextValue }))} placeholder="End time" clearable className="bg-white/[0.04]" /></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>
<div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white"><Checkbox checked={placementForm.is_active} onChange={(event) => setPlacementForm((current) => ({ ...current, is_active: event.target.checked }))} label="Active placement" /></div>
</div>
@@ -493,8 +494,8 @@ export default function CollectionStaffSurfaces() {
<Field label="Placement Type"><NovaSelect value={batchForm.placement_type} onChange={(val) => setBatchForm((current) => ({ ...current, placement_type: val }))} searchable={false} options={[{ value: 'campaign', label: 'Campaign' }, { value: 'manual', label: 'Manual' }, { value: 'scheduled_override', label: 'Scheduled override' }]} /></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>
<div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white"><Checkbox checked={batchForm.is_active} onChange={(event) => setBatchForm((current) => ({ ...current, is_active: event.target.checked }))} label="Active placement" /></div>
<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>
<Field label="Starts At"><DateTimePicker value={batchForm.starts_at} onChange={(nextValue) => setBatchForm((current) => ({ ...current, starts_at: nextValue }))} placeholder="Start time" clearable className="bg-white/[0.04]" /></Field>
<Field label="Ends At"><DateTimePicker value={batchForm.ends_at} onChange={(nextValue) => setBatchForm((current) => ({ ...current, ends_at: nextValue }))} placeholder="End time" clearable className="bg-white/[0.04]" /></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">

View File

@@ -1,6 +1,7 @@
import React from 'react'
import { Head, usePage } from '@inertiajs/react'
import Checkbox from '../../components/ui/Checkbox'
import DateTimePicker from '../../components/ui/DateTimePicker'
import NovaSelect from '../../components/ui/NovaSelect'
function getCsrfToken() {
@@ -576,21 +577,11 @@ export default function FeaturedArtworksAdmin() {
</Field>
<Field label="Featured Since">
<input
type="datetime-local"
value={form.featured_at}
onChange={(event) => setForm((current) => ({ ...current, featured_at: event.target.value }))}
className="w-full rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"
/>
<DateTimePicker value={form.featured_at} onChange={(nextValue) => setForm((current) => ({ ...current, featured_at: nextValue }))} placeholder="Featured since" clearable className="bg-[#08111d]" />
</Field>
<Field label="Expires">
<input
type="datetime-local"
value={form.expires_at}
onChange={(event) => setForm((current) => ({ ...current, expires_at: event.target.value }))}
className="w-full rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"
/>
<DateTimePicker value={form.expires_at} onChange={(nextValue) => setForm((current) => ({ ...current, expires_at: nextValue }))} placeholder="Expiry date" clearable className="bg-[#08111d]" />
</Field>
<div className="sm:col-span-2 flex flex-wrap gap-3">

View File

@@ -1,6 +1,7 @@
import React from 'react'
import { Head, Link, usePage } from '@inertiajs/react'
import Checkbox from '../../components/ui/Checkbox'
import DateTimePicker from '../../components/ui/DateTimePicker'
import NovaSelect from '../../components/ui/NovaSelect'
function requestJson(url, { method = 'GET', body } = {}) {
@@ -115,14 +116,14 @@ export default function NovaCardsChallengeAdmin() {
<span className="mb-2 block">Winner card</span>
<NovaSelect value={String(form.winner_card_id || '')} onChange={(val) => setForm((current) => ({ ...current, winner_card_id: val }))} placeholder="No winner" options={cards.map((c) => ({ value: String(c.id), label: c.title }))} />
</div>
<label className="text-sm text-slate-300">
<div 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">
<DateTimePicker value={form.starts_at} onChange={(nextValue) => setForm((current) => ({ ...current, starts_at: nextValue }))} placeholder="Starts at" clearable className="bg-[#0d1726]" />
</div>
<div 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>
<DateTimePicker value={form.ends_at} onChange={(nextValue) => setForm((current) => ({ ...current, ends_at: nextValue }))} placeholder="Ends at" clearable className="bg-[#0d1726]" />
</div>
<textarea value={JSON.stringify(form.rules_json || {}, null, 2)} onChange={(event) => {
try {
setForm((current) => ({ ...current, rules_json: JSON.parse(event.target.value || '{}') }))

View File

@@ -203,6 +203,7 @@ function CommunityActivityPage({
)
}
if (typeof document !== 'undefined') {
const mountEl = document.getElementById('community-activity-root')
if (mountEl) {
@@ -216,5 +217,6 @@ if (mountEl) {
createRoot(mountEl).render(<CommunityActivityPage {...props} />)
}
}
export default CommunityActivityPage

View File

@@ -112,6 +112,7 @@ function LatestCommentsPage({ initialComments = [], initialMeta = {}, isAuthenti
}
// Auto-mount when the Blade view provides #latest-comments-root
if (typeof document !== 'undefined') {
const mountEl = document.getElementById('latest-comments-root')
if (mountEl) {
let props = {}
@@ -123,5 +124,6 @@ if (mountEl) {
}
createRoot(mountEl).render(<LatestCommentsPage {...props} />)
}
}
export default LatestCommentsPage

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react'
import { usePage } from '@inertiajs/react'
import axios from 'axios'
import SeoHead from '../../components/seo/SeoHead'
import PostCard from '../../Components/Feed/PostCard'
import PostCardSkeleton from '../../Components/Feed/PostCardSkeleton'
@@ -28,7 +29,7 @@ function EmptyFollowingState() {
export default function FollowingFeed() {
const { props } = usePage()
const { auth } = props
const { auth, seo } = props
const authUser = auth?.user ?? null
const [posts, setPosts] = useState([])
@@ -72,7 +73,9 @@ export default function FollowingFeed() {
}, [])
return (
<div className="min-h-screen bg-[#080f1e]">
<>
<SeoHead seo={seo} />
<div className="min-h-screen bg-[#080f1e]">
{/* ── Page header ────────────────────────────────────────────────────── */}
<div className="max-w-2xl mx-auto px-4 pt-8 pb-4">
<div className="flex items-center justify-between mb-6">
@@ -150,5 +153,6 @@ export default function FollowingFeed() {
)}
</div>
</div>
</>
)
}

View File

@@ -1,12 +1,13 @@
import React, { useState, useEffect, useCallback } from 'react'
import { usePage } from '@inertiajs/react'
import axios from 'axios'
import SeoHead from '../../components/seo/SeoHead'
import PostCard from '../../Components/Feed/PostCard'
import PostCardSkeleton from '../../Components/Feed/PostCardSkeleton'
export default function HashtagFeed() {
const { props } = usePage()
const { auth, tag } = props
const { auth, tag, seo } = props
const authUser = auth?.user ?? null
const [posts, setPosts] = useState([])
@@ -39,7 +40,9 @@ export default function HashtagFeed() {
const handleDeleted = useCallback((id) => setPosts((prev) => prev.filter((p) => p.id !== id)), [])
return (
<div className="min-h-screen bg-[#080f1e]">
<>
<SeoHead seo={seo} />
<div className="min-h-screen bg-[#080f1e]">
<div className="max-w-2xl mx-auto px-4 pt-8 pb-16">
{/* Header */}
<div className="mb-6">
@@ -110,5 +113,6 @@ export default function HashtagFeed() {
</div>
</div>
</div>
</>
)
}

View File

@@ -1,12 +1,13 @@
import React, { useState, useEffect, useCallback } from 'react'
import { usePage } from '@inertiajs/react'
import axios from 'axios'
import SeoHead from '../../components/seo/SeoHead'
import PostCard from '../../Components/Feed/PostCard'
import PostCardSkeleton from '../../Components/Feed/PostCardSkeleton'
export default function SavedFeed() {
const { props } = usePage()
const { auth } = props
const { auth, seo } = props
const authUser = auth?.user ?? null
const [posts, setPosts] = useState([])
@@ -38,7 +39,9 @@ export default function SavedFeed() {
const handleUnsaved = useCallback((id) => setPosts((prev) => prev.filter((p) => p.id !== id)), [])
return (
<div className="min-h-screen bg-[#080f1e]">
<>
<SeoHead seo={seo} />
<div className="min-h-screen bg-[#080f1e]">
<div className="max-w-2xl mx-auto px-4 pt-8 pb-16">
{/* Header */}
<div className="mb-6">
@@ -101,5 +104,6 @@ export default function SavedFeed() {
</div>
</div>
</div>
</>
)
}

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { usePage } from '@inertiajs/react'
import axios from 'axios'
import SeoHead from '../../components/seo/SeoHead'
import PostCard from '../../Components/Feed/PostCard'
import PostCardSkeleton from '../../Components/Feed/PostCardSkeleton'
@@ -36,7 +37,7 @@ function TrendingHashtagsSidebar({ hashtags }) {
/* ── Main page ─────────────────────────────────────────────────────────────── */
export default function SearchFeed() {
const { props } = usePage()
const { auth, initialQuery, trendingHashtags } = props
const { auth, initialQuery, trendingHashtags, seo } = props
const authUser = auth?.user ?? null
const [query, setQuery] = useState(initialQuery ?? '')
@@ -124,7 +125,9 @@ export default function SearchFeed() {
const hasResults = results.length > 0
return (
<div className="min-h-screen bg-[#080f1e]">
<>
<SeoHead seo={seo} />
<div className="min-h-screen bg-[#080f1e]">
<div className="max-w-5xl mx-auto px-4 pt-8 pb-16">
<div className="flex gap-8">
@@ -251,5 +254,6 @@ export default function SearchFeed() {
</div>
</div>
</div>
</>
)
}

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react'
import { usePage } from '@inertiajs/react'
import axios from 'axios'
import SeoHead from '../../components/seo/SeoHead'
import PostCard from '../../Components/Feed/PostCard'
import PostCardSkeleton from '../../Components/Feed/PostCardSkeleton'
@@ -36,7 +37,7 @@ function TrendingHashtagsSidebar({ hashtags, activeTag = null }) {
export default function TrendingFeed() {
const { props } = usePage()
const { auth, trendingHashtags } = props
const { auth, trendingHashtags, seo } = props
const authUser = auth?.user ?? null
const [posts, setPosts] = useState([])
@@ -65,7 +66,9 @@ export default function TrendingFeed() {
const handleDeleted = useCallback((id) => setPosts((prev) => prev.filter((p) => p.id !== id)), [])
return (
<div className="min-h-screen bg-[#080f1e]">
<>
<SeoHead seo={seo} />
<div className="min-h-screen bg-[#080f1e]">
<div className="max-w-5xl mx-auto px-4 pt-8 pb-16">
<div className="flex gap-8">
{/* ── Main feed ──────────────────────────────────────────────── */}
@@ -129,5 +132,6 @@ export default function TrendingFeed() {
</div>
</div>
</div>
</>
)
}

View File

@@ -3,8 +3,9 @@ import Breadcrumbs from '../../components/forum/Breadcrumbs'
import ThreadRow from '../../components/forum/ThreadRow'
import Pagination from '../../components/forum/Pagination'
import Button from '../../components/ui/Button'
import SeoHead from '../../components/seo/SeoHead'
export default function ForumCategory({ category, parentCategory = null, threads = [], pagination = {}, isAuthenticated = false }) {
export default function ForumCategory({ category, parentCategory = null, threads = [], pagination = {}, isAuthenticated = false, seo = {} }) {
const name = category?.name ?? 'Category'
const slug = category?.slug
@@ -16,7 +17,9 @@ export default function ForumCategory({ category, parentCategory = null, threads
]
return (
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-5xl mx-auto">
<>
<SeoHead seo={seo} />
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-5xl mx-auto">
{/* Breadcrumbs */}
<Breadcrumbs items={breadcrumbs} />
@@ -79,5 +82,6 @@ export default function ForumCategory({ category, parentCategory = null, threads
</div>
)}
</div>
</>
)
}

View File

@@ -4,8 +4,9 @@ import Button from '../../components/ui/Button'
import RichTextEditor from '../../components/forum/RichTextEditor'
import TurnstileField from '../../components/security/TurnstileField'
import { populateBotFingerprint } from '../../lib/security/botFingerprint'
import SeoHead from '../../components/seo/SeoHead'
export default function ForumEditPost({ post, thread, csrfToken, errors = {}, captcha = {} }) {
export default function ForumEditPost({ post, thread, csrfToken, errors = {}, captcha = {}, seo = {} }) {
const [content, setContent] = useState(post?.content ?? '')
const [captchaToken, setCaptchaToken] = useState('')
const [submitting, setSubmitting] = useState(false)
@@ -19,16 +20,22 @@ export default function ForumEditPost({ post, thread, csrfToken, errors = {}, ca
const handleSubmit = useCallback((e) => {
if (submitting) return
setSubmitting(true)
// Let the form submit normally for PRG
populateBotFingerprint(e.currentTarget).finally(() => {
e.currentTarget.submit()
})
e.preventDefault()
setSubmitting(true)
const form = e.currentTarget
// Let the form submit normally for PRG.
populateBotFingerprint(form).finally(() => {
form?.submit()
})
}, [submitting])
return (
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-3xl mx-auto">
<>
<SeoHead seo={seo} />
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-3xl mx-auto">
<Breadcrumbs items={breadcrumbs} />
{/* Header */}
@@ -101,5 +108,6 @@ export default function ForumEditPost({ post, thread, csrfToken, errors = {}, ca
</div>
</form>
</div>
</>
)
}

View File

@@ -1,7 +1,8 @@
import React from 'react'
import CategoryCard from '../../components/forum/CategoryCard'
import SeoHead from '../../components/seo/SeoHead'
export default function ForumIndex({ categories = [], trendingTopics = [], latestTopics = [] }) {
export default function ForumIndex({ categories = [], trendingTopics = [], latestTopics = [], seo = {} }) {
const totalThreads = categories.reduce((sum, cat) => sum + (Number(cat?.thread_count) || 0), 0)
const totalPosts = categories.reduce((sum, cat) => sum + (Number(cat?.post_count) || 0), 0)
const sortedByActivity = [...categories].sort((a, b) => {
@@ -12,7 +13,9 @@ export default function ForumIndex({ categories = [], trendingTopics = [], lates
const latestActive = sortedByActivity[0] ?? null
return (
<div className="pb-20">
<>
<SeoHead seo={seo} />
<div className="pb-20">
<section className="relative overflow-hidden border-b border-white/10 bg-[radial-gradient(circle_at_15%_20%,rgba(34,211,238,0.24),transparent_40%),radial-gradient(circle_at_80%_0%,rgba(56,189,248,0.16),transparent_42%),linear-gradient(180deg,rgba(10,14,26,0.96),rgba(8,12,22,0.92))]">
<div className="pointer-events-none absolute inset-0 opacity-40 [background-image:linear-gradient(rgba(255,255,255,0.06)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.06)_1px,transparent_1px)] [background-size:40px_40px]" />
@@ -93,6 +96,7 @@ export default function ForumIndex({ categories = [], trendingTopics = [], lates
<Panel title="Latest Topics" items={latestTopics} emptyLabel="Latest topics will appear here." />
</section>
</div>
</>
)
}

View File

@@ -5,8 +5,9 @@ import TextInput from '../../components/ui/TextInput'
import RichTextEditor from '../../components/forum/RichTextEditor'
import TurnstileField from '../../components/security/TurnstileField'
import { populateBotFingerprint } from '../../lib/security/botFingerprint'
import SeoHead from '../../components/seo/SeoHead'
export default function ForumNewThread({ category, csrfToken, errors = {}, oldValues = {}, captcha = {} }) {
export default function ForumNewThread({ category, csrfToken, errors = {}, oldValues = {}, captcha = {}, seo = {} }) {
const [title, setTitle] = useState(oldValues.title ?? '')
const [content, setContent] = useState(oldValues.content ?? '')
const [captchaToken, setCaptchaToken] = useState('')
@@ -33,7 +34,9 @@ export default function ForumNewThread({ category, csrfToken, errors = {}, oldVa
}, [submitting])
return (
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-3xl mx-auto">
<>
<SeoHead seo={seo} />
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-3xl mx-auto">
<Breadcrumbs items={breadcrumbs} />
{/* Header */}
@@ -115,5 +118,6 @@ export default function ForumNewThread({ category, csrfToken, errors = {}, oldVa
</div>
</form>
</div>
</>
)
}

View File

@@ -1,7 +1,8 @@
import React from 'react'
import Breadcrumbs from '../../components/forum/Breadcrumbs'
import SeoHead from '../../components/seo/SeoHead'
export default function ForumSection({ category, boards = [] }) {
export default function ForumSection({ category, boards = [], seo = {} }) {
const name = category?.name ?? 'Forum Section'
const description = category?.description
const preview = category?.preview_image ?? '/images/forum/default.jpg'
@@ -13,7 +14,9 @@ export default function ForumSection({ category, boards = [] }) {
]
return (
<div className="mx-auto max-w-6xl px-4 pb-20 pt-10 sm:px-6 lg:px-8">
<>
<SeoHead seo={seo} />
<div className="mx-auto max-w-6xl px-4 pb-20 pt-10 sm:px-6 lg:px-8">
<Breadcrumbs items={breadcrumbs} />
<section className="mt-5 overflow-hidden rounded-3xl border border-white/10 bg-nova-800/55 shadow-xl backdrop-blur">
@@ -64,5 +67,6 @@ export default function ForumSection({ category, boards = [] }) {
)}
</section>
</div>
</>
)
}

View File

@@ -3,6 +3,7 @@ import Breadcrumbs from '../../components/forum/Breadcrumbs'
import PostCard from '../../components/forum/PostCard'
import ReplyForm from '../../components/forum/ReplyForm'
import Pagination from '../../components/forum/Pagination'
import SeoHead from '../../components/seo/SeoHead'
export default function ForumThread({
thread,
@@ -21,6 +22,7 @@ export default function ForumThread({
csrfToken = '',
status = null,
captcha = {},
seo = {},
}) {
const [currentSort, setCurrentSort] = useState(sort)
@@ -41,7 +43,9 @@ export default function ForumThread({
}, [currentSort])
return (
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-5xl mx-auto space-y-5">
<>
<SeoHead seo={seo} />
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-5xl mx-auto space-y-5">
<Breadcrumbs items={breadcrumbs} />
{/* Status flash */}
@@ -176,6 +180,7 @@ export default function ForumThread({
</div>
)}
</div>
</>
)
}

View File

@@ -1,5 +1,6 @@
import React, { lazy, Suspense } from 'react'
import React, { lazy, Suspense, useEffect, useRef, useState } from 'react'
import { createRoot } from 'react-dom/client'
import HomepageAnnouncement from '../../components/homepage/HomepageAnnouncement'
// Below-fold — lazy-loaded to keep initial bundle small
const HomeWelcomeRow = lazy(() => import('./HomeWelcomeRow'))
@@ -102,80 +103,150 @@ function SectionFallback({ variant = 'gallery' }) {
)
}
function SectionPlaceholder({ variant = 'gallery' }) {
const heightClassName = variant === 'welcome'
? 'h-20'
: variant === 'tags'
? 'h-28'
: variant === 'cta'
? 'h-40'
: variant === 'news'
? 'h-48'
: variant === 'categories'
? 'h-44'
: variant === 'creators'
? 'h-72'
: variant === 'collections'
? 'h-80'
: variant === 'groups'
? 'h-80'
: 'h-[28rem]'
return (
<section className="mt-14 px-4 sm:px-6 lg:px-8" aria-hidden="true">
<div className={`rounded-[28px] border border-white/8 bg-nova-900/40 ${heightClassName}`} />
</section>
)
}
function DeferredSection({ children, fallback, variant = 'gallery', eager = false, rootMargin = '1200px 0px' }) {
const anchorRef = useRef(null)
const [isVisible, setIsVisible] = useState(eager)
useEffect(() => {
if (eager || isVisible) {
return undefined
}
const node = anchorRef.current
if (!node) {
return undefined
}
if (typeof window === 'undefined' || typeof window.IntersectionObserver !== 'function') {
setIsVisible(true)
return undefined
}
const observer = new window.IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) {
return
}
setIsVisible(true)
observer.disconnect()
})
}, { rootMargin, threshold: 0.01 })
observer.observe(node)
return () => observer.disconnect()
}, [eager, isVisible, rootMargin])
return (
<div ref={anchorRef}>
{isVisible
? <Suspense fallback={fallback}><>{children}</></Suspense>
: <SectionPlaceholder variant={variant} />}
</div>
)
}
function GuestHomePage(props) {
const { rising, trending, community_favorites, hall_of_fame, fresh, tags, creators, news, collections_featured, collections_trending, collections_editorial, collections_community, groups, world_spotlight } = props
return (
<>
<Suspense fallback={<SectionFallback variant="gallery" />}>
<DeferredSection eager fallback={<SectionFallback variant="gallery" />}>
<HomeRising items={rising} />
</Suspense>
<Suspense fallback={<SectionFallback variant="gallery" />}>
</DeferredSection>
<DeferredSection eager fallback={<SectionFallback variant="gallery" />}>
<HomeTrending items={trending} />
</Suspense>
<Suspense fallback={<SectionFallback variant="gallery" />}>
</DeferredSection>
<DeferredSection fallback={<SectionFallback variant="gallery" />}>
<HomeMedalHighlights
title="Community Favorites"
href="/explore/top-rated"
href="/explore?sort=top-rated"
description="Recent medal momentum from the community. This rail highlights the strongest 30-day medal signal."
items={community_favorites}
/>
</Suspense>
<Suspense fallback={<SectionFallback variant="gallery" />}>
</DeferredSection>
<DeferredSection fallback={<SectionFallback variant="gallery" />}>
<HomeMedalHighlights
title="Hall of Fame"
href="/explore/best"
description="All-time medal standouts that keep being remembered long after publication."
items={hall_of_fame}
/>
</Suspense>
</DeferredSection>
{/* 3. Fresh Uploads */}
<Suspense fallback={<SectionFallback variant="gallery" />}>
<DeferredSection fallback={<SectionFallback variant="gallery" />}>
<HomeFresh items={fresh} />
</Suspense>
</DeferredSection>
<Suspense fallback={<SectionFallback variant="collections" />}>
<DeferredSection variant="collections" fallback={<SectionFallback variant="collections" />}>
<HomeCollections
featured={collections_featured}
trending={collections_trending}
editorial={collections_editorial}
community={collections_community}
/>
</Suspense>
</DeferredSection>
<Suspense fallback={<SectionFallback variant="collections" />}>
<DeferredSection variant="collections" fallback={<SectionFallback variant="collections" />}>
<HomeWorldSpotlight world={world_spotlight} />
</Suspense>
</DeferredSection>
<Suspense fallback={<SectionFallback variant="groups" />}>
<DeferredSection variant="groups" fallback={<SectionFallback variant="groups" />}>
<HomeGroups groups={groups} />
</Suspense>
</DeferredSection>
{/* 4. Explore Categories */}
<Suspense fallback={<SectionFallback variant="categories" />}>
<DeferredSection variant="categories" fallback={<SectionFallback variant="categories" />}>
<HomeCategories />
</Suspense>
</DeferredSection>
{/* 5. Popular Tags */}
<Suspense fallback={<SectionFallback variant="tags" />}>
<DeferredSection variant="tags" fallback={<SectionFallback variant="tags" />}>
<HomeTags tags={tags} />
</Suspense>
</DeferredSection>
{/* 6. Top Creators */}
<Suspense fallback={<SectionFallback variant="creators" />}>
<DeferredSection variant="creators" fallback={<SectionFallback variant="creators" />}>
<HomeCreators creators={creators} />
</Suspense>
</DeferredSection>
{/* 7. News */}
<Suspense fallback={<SectionFallback variant="news" />}>
<DeferredSection variant="news" fallback={<SectionFallback variant="news" />}>
<HomeNews items={news} />
</Suspense>
</DeferredSection>
{/* 8. CTA Upload */}
<Suspense fallback={<SectionFallback variant="cta" />}>
<DeferredSection variant="cta" fallback={<SectionFallback variant="cta" />}>
<HomeCTA isLoggedIn={false} />
</Suspense>
</DeferredSection>
</>
)
}
@@ -208,57 +279,57 @@ function AuthHomePage(props) {
return (
<>
{/* P0. Welcome/status row — below hero so featured image sits at 0px */}
<Suspense fallback={<SectionFallback variant="welcome" />}>
<DeferredSection eager variant="welcome" fallback={<SectionFallback variant="welcome" />}>
<HomeWelcomeRow user_data={user_data} />
</Suspense>
</DeferredSection>
{/* P2. From Creators You Follow */}
<Suspense fallback={<SectionFallback variant="gallery" />}>
<DeferredSection eager fallback={<SectionFallback variant="gallery" />}>
<HomeFromFollowing items={from_following} />
</Suspense>
</DeferredSection>
{/* P3. Personalized For You preview */}
<Suspense fallback={<SectionFallback variant="gallery" />}>
<DeferredSection eager fallback={<SectionFallback variant="gallery" />}>
<HomeTrendingForYou items={for_you} preferences={preferences} />
</Suspense>
</DeferredSection>
{/* Rising Now */}
<Suspense fallback={<SectionFallback variant="gallery" />}>
<DeferredSection fallback={<SectionFallback variant="gallery" />}>
<HomeRising items={rising} />
</Suspense>
</DeferredSection>
{/* 2. Global Trending Now */}
<Suspense fallback={<SectionFallback variant="gallery" />}>
<DeferredSection fallback={<SectionFallback variant="gallery" />}>
<HomeTrending items={trending} />
</Suspense>
<Suspense fallback={<SectionFallback variant="gallery" />}>
</DeferredSection>
<DeferredSection fallback={<SectionFallback variant="gallery" />}>
<HomeMedalHighlights
title="Community Favorites"
href="/explore/top-rated"
href="/explore?sort=top-rated"
description="Recent medal momentum from the community. This rail highlights the strongest 30-day medal signal."
items={community_favorites}
/>
</Suspense>
<Suspense fallback={<SectionFallback variant="gallery" />}>
</DeferredSection>
<DeferredSection fallback={<SectionFallback variant="gallery" />}>
<HomeMedalHighlights
title="Hall of Fame"
href="/explore/best"
description="All-time medal standouts that keep being remembered long after publication."
items={hall_of_fame}
/>
</Suspense>
</DeferredSection>
{/* P4. Because You Like {top tag} — uses by_categories for variety */}
<Suspense fallback={<SectionFallback variant="gallery" />}>
<DeferredSection fallback={<SectionFallback variant="gallery" />}>
<HomeBecauseYouLike items={by_categories} preferences={preferences} />
</Suspense>
</DeferredSection>
{/* 3. Fresh Uploads */}
<Suspense fallback={<SectionFallback variant="gallery" />}>
<DeferredSection fallback={<SectionFallback variant="gallery" />}>
<HomeFresh items={fresh} />
</Suspense>
</DeferredSection>
<Suspense fallback={<SectionFallback variant="collections" />}>
<DeferredSection variant="collections" fallback={<SectionFallback variant="collections" />}>
<HomeCollections
featured={collections_featured}
recent={collections_recent}
@@ -267,45 +338,45 @@ function AuthHomePage(props) {
community={collections_community}
isLoggedIn
/>
</Suspense>
</DeferredSection>
<Suspense fallback={<SectionFallback variant="collections" />}>
<DeferredSection variant="collections" fallback={<SectionFallback variant="collections" />}>
<HomeWorldSpotlight world={world_spotlight} />
</Suspense>
</DeferredSection>
<Suspense fallback={<SectionFallback variant="groups" />}>
<DeferredSection variant="groups" fallback={<SectionFallback variant="groups" />}>
<HomeGroups groups={groups} />
</Suspense>
</DeferredSection>
{/* 4. Explore Categories */}
<Suspense fallback={<SectionFallback variant="categories" />}>
<DeferredSection variant="categories" fallback={<SectionFallback variant="categories" />}>
<HomeCategories />
</Suspense>
</DeferredSection>
{/* P5. Suggested Creators */}
<Suspense fallback={<SectionFallback variant="creators" />}>
<DeferredSection variant="creators" fallback={<SectionFallback variant="creators" />}>
<HomeSuggestedCreators creators={suggested_creators} />
</Suspense>
</DeferredSection>
{/* 5. Popular Tags */}
<Suspense fallback={<SectionFallback variant="tags" />}>
<DeferredSection variant="tags" fallback={<SectionFallback variant="tags" />}>
<HomeTags tags={tags} />
</Suspense>
</DeferredSection>
{/* 6. Top Creators */}
<Suspense fallback={<SectionFallback variant="creators" />}>
<DeferredSection variant="creators" fallback={<SectionFallback variant="creators" />}>
<HomeCreators creators={creators} />
</Suspense>
</DeferredSection>
{/* 7. News */}
<Suspense fallback={<SectionFallback variant="news" />}>
<DeferredSection variant="news" fallback={<SectionFallback variant="news" />}>
<HomeNews items={news} />
</Suspense>
</DeferredSection>
{/* 8. CTA Upload */}
<Suspense fallback={<SectionFallback variant="cta" />}>
<DeferredSection variant="cta" fallback={<SectionFallback variant="cta" />}>
<HomeCTA isLoggedIn />
</Suspense>
</DeferredSection>
</>
)
}
@@ -313,6 +384,7 @@ function AuthHomePage(props) {
function HomePage(props) {
return (
<div className="pb-24">
<HomepageAnnouncement announcement={props.announcement || null} />
{props.is_logged_in
? <AuthHomePage {...props} />
: <GuestHomePage {...props} />
@@ -322,6 +394,7 @@ function HomePage(props) {
}
// Auto-mount when the Blade view provides #homepage-root
if (typeof document !== 'undefined') {
const mountEl = document.getElementById('homepage-root')
if (mountEl) {
let props = {}
@@ -334,5 +407,6 @@ if (mountEl) {
createRoot(mountEl).render(<HomePage {...props} />)
}
}
export default HomePage

View File

@@ -725,6 +725,7 @@ function connectionBadgeLabel(realtimeEnabled, realtimeStatus) {
return 'Realtime disconnected'
}
if (typeof document !== 'undefined') {
const el = document.getElementById('messages-root')
if (el) {
@@ -744,5 +745,6 @@ if (el) {
/>,
)
}
}
export default MessagesPage

View File

@@ -2,6 +2,7 @@ import React from 'react'
import { usePage } from '@inertiajs/react'
import ProfileHero from '../../components/profile/ProfileHero'
import ProfileGalleryPanel from '../../components/profile/ProfileGalleryPanel'
import SeoHead from '../../components/seo/SeoHead'
export default function ProfileGallery() {
const { props } = usePage()
@@ -17,13 +18,16 @@ export default function ProfileGallery() {
countryName,
isOwner,
profileUrl,
seo = {},
} = props
const username = user.username || user.name
const displayName = user.name || user.username || 'Creator'
return (
<div className="min-h-screen pb-16">
<>
<SeoHead seo={seo} />
<div className="min-h-screen pb-16">
<ProfileHero
user={user}
profile={profile}
@@ -73,5 +77,6 @@ export default function ProfileGallery() {
/>
</div>
</div>
</>
)
}

View File

@@ -4,7 +4,7 @@ import SettingsLayout from '../../Layouts/SettingsLayout'
import TextInput from '../../components/ui/TextInput'
import Textarea from '../../components/ui/Textarea'
import Button from '../../components/ui/Button'
import Toggle from '../../components/ui/Toggle'
import Checkbox from '../../components/ui/Checkbox'
import NovaSelect from '../../components/ui/NovaSelect'
import Modal from '../../components/ui/Modal'
import { RadioGroup } from '../../components/ui/Radio'
@@ -1369,13 +1369,15 @@ export default function ProfileEdit() {
<p className="text-sm font-medium text-white/90">{label}</p>
<p className="text-xs text-slate-500">{hint}</p>
</div>
<Toggle
<Checkbox
checked={!!notificationForm[field]}
onChange={(e) => {
setNotificationForm((prev) => ({ ...prev, [field]: e.target.checked }))
clearSectionStatus('notifications')
}}
aria-label={label}
variant="accent"
size={20}
/>
</div>
))}
@@ -1450,13 +1452,15 @@ export default function ProfileEdit() {
<p className="text-sm font-medium text-white/90">Show warning before opening mature artwork pages</p>
<p className="text-xs text-slate-500">Display an interstitial on artwork detail pages before revealing mature media.</p>
</div>
<Toggle
<Checkbox
checked={!!contentForm.mature_content_warning_enabled}
onChange={(e) => {
setContentForm((prev) => ({ ...prev, mature_content_warning_enabled: e.target.checked }))
clearSectionStatus('content')
}}
aria-label="Show warning before opening mature artwork pages"
variant="accent"
size={20}
/>
</div>

View File

@@ -21,7 +21,8 @@ function formatShortDate(value) {
}
function TrendChart({ title, subtitle, points, colorClass, fillClass, icon }) {
const values = (points || []).map((point) => Number(point.value || 0))
const normalizedPoints = Array.isArray(points) ? points : []
const values = normalizedPoints.map((point) => Number(point.value || 0))
const maxValue = Math.max(...values, 1)
return (
@@ -36,21 +37,27 @@ function TrendChart({ title, subtitle, points, colorClass, fillClass, icon }) {
</div>
</div>
<div className="mt-5 flex h-52 items-end gap-2">
{(points || []).map((point) => {
{normalizedPoints.length === 0 ? (
<div className="mt-5 flex h-52 items-center justify-center rounded-[22px] border border-dashed border-white/10 bg-black/20 px-6 text-sm text-slate-400">
No analytics points are available for this time window yet.
</div>
) : (
<div className="mt-5 flex h-52 items-end gap-2">
{normalizedPoints.map((point) => {
const height = `${Math.max(8, Math.round((Number(point.value || 0) / maxValue) * 100))}%`
return (
<div key={point.date} className="flex min-w-0 flex-1 flex-col items-center justify-end gap-2">
<div key={point.date} className="flex h-full min-w-0 flex-1 flex-col items-center justify-end gap-2">
<div className="text-[10px] font-medium text-slate-500">{Number(point.value || 0).toLocaleString()}</div>
<div className="flex h-full w-full items-end rounded-t-[18px] bg-white/[0.03] px-[2px]">
<div className="flex w-full flex-1 items-end rounded-t-[18px] bg-white/[0.03] px-[2px]">
<div className={`w-full rounded-t-[16px] ${fillClass}`} style={{ height }} />
</div>
<div className="text-[10px] uppercase tracking-[0.14em] text-slate-500">{formatShortDate(point.date)}</div>
</div>
)
})}
</div>
})}
</div>
)}
</section>
)
}

View File

@@ -1,6 +1,7 @@
import React from 'react'
import { useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import DateTimePicker from '../../components/ui/DateTimePicker'
import NovaSelect from '../../components/ui/NovaSelect'
export default function StudioGroupChallengeEditor() {
@@ -82,8 +83,8 @@ export default function StudioGroupChallengeEditor() {
<NovaSelect value={form.data.status} onChange={(val) => form.setData('status', val)} options={props.statusOptions || []} searchable={false} />
</div>
<div className="grid gap-4 md:grid-cols-2">
<input type="datetime-local" value={form.data.start_at} onChange={(event) => form.setData('start_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input type="datetime-local" value={form.data.end_at} onChange={(event) => form.setData('end_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<DateTimePicker value={form.data.start_at} onChange={(nextValue) => form.setData('start_at', nextValue)} placeholder="Challenge start" clearable className="bg-black/20" />
<DateTimePicker value={form.data.end_at} onChange={(nextValue) => form.setData('end_at', nextValue)} placeholder="Challenge end" clearable className="bg-black/20" />
</div>
<textarea value={form.data.rules_text} onChange={(event) => form.setData('rules_text', event.target.value)} placeholder="Rules" rows={4} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={form.data.submission_instructions} onChange={(event) => form.setData('submission_instructions', event.target.value)} placeholder="Submission instructions" rows={4} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />

View File

@@ -2,6 +2,7 @@ import React, { useMemo, useRef, useState } from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import GroupStudioPromoCard from '../../components/groups/GroupStudioPromoCard'
import DateTimePicker from '../../components/ui/DateTimePicker'
import NovaSelect from '../../components/ui/NovaSelect'
function slugifyGroupValue(value) {
@@ -159,10 +160,10 @@ export default function StudioGroupCreate() {
<span>Type / category</span>
<input value={form.type} onChange={(event) => setForm((current) => ({ ...current, type: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-200">
<div className="grid gap-2 text-sm text-slate-200">
<span>Founded date</span>
<input type="date" value={form.founded_at} onChange={(event) => setForm((current) => ({ ...current, founded_at: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<DateTimePicker value={form.founded_at} onChange={(nextValue) => setForm((current) => ({ ...current, founded_at: nextValue }))} mode="date" placeholder="Pick the founding date" clearable className="bg-black/20" />
</div>
</div>
<label className="grid gap-2 text-sm text-slate-200">
<span>Website</span>

View File

@@ -2,6 +2,7 @@ import React from 'react'
import { useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import Checkbox from '../../components/ui/Checkbox'
import DateTimePicker from '../../components/ui/DateTimePicker'
import NovaSelect from '../../components/ui/NovaSelect'
export default function StudioGroupEventEditor() {
@@ -50,8 +51,8 @@ export default function StudioGroupEventEditor() {
<NovaSelect value={form.data.status} onChange={(val) => form.setData('status', val)} options={props.statusOptions || []} searchable={false} />
</div>
<div className="grid gap-4 md:grid-cols-2">
<input type="datetime-local" value={form.data.start_at} onChange={(event) => form.setData('start_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input type="datetime-local" value={form.data.end_at} onChange={(event) => form.setData('end_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<DateTimePicker value={form.data.start_at} onChange={(nextValue) => form.setData('start_at', nextValue)} placeholder="Event start" clearable className="bg-black/20" />
<DateTimePicker value={form.data.end_at} onChange={(nextValue) => form.setData('end_at', nextValue)} placeholder="Event end" clearable className="bg-black/20" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<input value={form.data.timezone} onChange={(event) => form.setData('timezone', event.target.value)} placeholder="Timezone" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />

View File

@@ -1,6 +1,7 @@
import React from 'react'
import { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import DateTimePicker from '../../components/ui/DateTimePicker'
import NovaSelect from '../../components/ui/NovaSelect'
function normalizeIds(values) {
@@ -53,8 +54,8 @@ export default function StudioGroupProjectEditor() {
<NovaSelect value={form.data.status} onChange={(val) => form.setData('status', val)} options={props.statusOptions || []} searchable={false} />
</div>
<div className="grid gap-4 md:grid-cols-2">
<input type="date" value={form.data.start_date} onChange={(event) => form.setData('start_date', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input type="date" value={form.data.target_date} onChange={(event) => form.setData('target_date', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<DateTimePicker value={form.data.start_date} onChange={(nextValue) => form.setData('start_date', nextValue)} mode="date" placeholder="Project start" clearable className="bg-black/20" />
<DateTimePicker value={form.data.target_date} onChange={(nextValue) => form.setData('target_date', nextValue)} mode="date" placeholder="Target date" clearable className="bg-black/20" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<NovaSelect value={String(form.data.lead_user_id || '')} onChange={(val) => form.setData('lead_user_id', val)} placeholder="No lead" options={(props.memberOptions || []).map((o) => ({ value: String(o.id), label: o.name || o.username }))} />
@@ -103,7 +104,7 @@ export default function StudioGroupProjectEditor() {
<textarea value={milestoneForm.data.summary} onChange={(event) => milestoneForm.setData('summary', event.target.value)} placeholder="Summary" rows={3} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="grid gap-3 md:grid-cols-2">
<NovaSelect value={milestoneForm.data.status} onChange={(val) => milestoneForm.setData('status', val)} searchable={false} options={['pending', 'active', 'blocked', 'completed', 'cancelled'].map((s) => ({ value: s, label: s }))} />
<input type="date" value={milestoneForm.data.due_date} onChange={(event) => milestoneForm.setData('due_date', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<DateTimePicker value={milestoneForm.data.due_date} onChange={(nextValue) => milestoneForm.setData('due_date', nextValue)} mode="date" placeholder="Due date" clearable className="bg-black/20" />
</div>
<NovaSelect value={String(milestoneForm.data.owner_user_id || '')} onChange={(val) => milestoneForm.setData('owner_user_id', val)} placeholder="No owner" options={(props.memberOptions || []).map((o) => ({ value: String(o.id), label: o.name || o.username }))} />
<textarea value={milestoneForm.data.notes} onChange={(event) => milestoneForm.setData('notes', event.target.value)} placeholder="Notes" rows={3} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />

View File

@@ -2,6 +2,7 @@ import React from 'react'
import { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import Checkbox from '../../components/ui/Checkbox'
import DateTimePicker from '../../components/ui/DateTimePicker'
import NovaSelect from '../../components/ui/NovaSelect'
function toDateTimeInput(value) {
@@ -56,7 +57,7 @@ export default function StudioGroupReleaseEditor() {
<NovaSelect value={form.data.status} onChange={(val) => form.setData('status', val)} options={props.statusOptions || []} searchable={false} />
<NovaSelect value={form.data.current_stage} onChange={(val) => form.setData('current_stage', val)} options={props.stageOptions || []} searchable={false} />
</div>
<input type="datetime-local" value={form.data.planned_release_at} onChange={(event) => form.setData('planned_release_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<DateTimePicker value={form.data.planned_release_at} onChange={(nextValue) => form.setData('planned_release_at', nextValue)} placeholder="Planned release" clearable className="bg-black/20" />
<div className="grid gap-4 md:grid-cols-2">
<NovaSelect value={String(form.data.lead_user_id || '')} onChange={(val) => form.setData('lead_user_id', val)} placeholder="No release lead" options={(props.memberOptions || []).map((o) => ({ value: String(o.id), label: o.name || o.username }))} />
<NovaSelect value={String(form.data.linked_project_id || '')} onChange={(val) => form.setData('linked_project_id', val)} placeholder="No linked project" options={(props.projectOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
@@ -116,7 +117,7 @@ export default function StudioGroupReleaseEditor() {
<textarea value={milestoneForm.data.summary} onChange={(event) => milestoneForm.setData('summary', event.target.value)} placeholder="Summary" rows={3} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="grid gap-3 md:grid-cols-2">
<NovaSelect value={milestoneForm.data.status} onChange={(val) => milestoneForm.setData('status', val)} searchable={false} options={['pending', 'active', 'blocked', 'completed', 'cancelled'].map((s) => ({ value: s, label: s }))} />
<input type="date" value={milestoneForm.data.due_date} onChange={(event) => milestoneForm.setData('due_date', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<DateTimePicker value={milestoneForm.data.due_date} onChange={(nextValue) => milestoneForm.setData('due_date', nextValue)} mode="date" placeholder="Due date" clearable className="bg-black/20" />
</div>
<NovaSelect value={String(milestoneForm.data.owner_user_id || '')} onChange={(val) => milestoneForm.setData('owner_user_id', val)} placeholder="No owner" options={(props.memberOptions || []).map((o) => ({ value: String(o.id), label: o.name || o.username }))} />
<textarea value={milestoneForm.data.notes} onChange={(event) => milestoneForm.setData('notes', event.target.value)} placeholder="Notes" rows={3} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />

View File

@@ -1,6 +1,7 @@
import React, { useMemo, useRef, useState } from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import DateTimePicker from '../../components/ui/DateTimePicker'
import NovaSelect from '../../components/ui/NovaSelect'
function resolveMediaPreviewUrl(path, filesCdnUrl) {
@@ -116,7 +117,7 @@ export default function StudioGroupSettings() {
<label className="grid gap-2 text-sm text-slate-200"><span>About</span><textarea value={form.bio} onChange={(event) => setForm((current) => ({ ...current, bio: event.target.value }))} rows={6} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /></label>
<div className="grid gap-5 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-200"><span>Type / category</span><input value={form.type} onChange={(event) => setForm((current) => ({ ...current, type: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /></label>
<label className="grid gap-2 text-sm text-slate-200"><span>Founded date</span><input type="date" value={form.founded_at} onChange={(event) => setForm((current) => ({ ...current, founded_at: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /></label>
<div className="grid gap-2 text-sm text-slate-200"><span>Founded date</span><DateTimePicker value={form.founded_at} onChange={(nextValue) => setForm((current) => ({ ...current, founded_at: nextValue }))} mode="date" placeholder="Pick the founding date" clearable className="bg-black/20" /></div>
</div>
<label className="grid gap-2 text-sm text-slate-200"><span>Website</span><input value={form.website_url} onChange={(event) => setForm((current) => ({ ...current, website_url: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /></label>
<div className="grid gap-5 md:grid-cols-2">

View File

@@ -21,29 +21,36 @@ function formatShortDate(value) {
}
function TrendBars({ title, subtitle, points, colorClass }) {
const values = (points || []).map((point) => Number(point.value || point.count || 0))
const normalizedPoints = Array.isArray(points) ? points : []
const values = normalizedPoints.map((point) => Number(point.value || point.count || 0))
const maxValue = Math.max(...values, 1)
return (
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">{title}</h2>
<p className="mt-1 text-sm text-slate-400">{subtitle}</p>
<div className="mt-5 flex h-52 items-end gap-2">
{(points || []).map((point) => {
{normalizedPoints.length === 0 ? (
<div className="mt-5 flex h-52 items-center justify-center rounded-[22px] border border-dashed border-white/10 bg-black/20 px-6 text-sm text-slate-400">
No growth points are available for this time window yet.
</div>
) : (
<div className="mt-5 flex h-52 items-end gap-2">
{normalizedPoints.map((point) => {
const value = Number(point.value || point.count || 0)
const height = `${Math.max(8, Math.round((value / maxValue) * 100))}%`
return (
<div key={point.date} className="flex min-w-0 flex-1 flex-col items-center justify-end gap-2">
<div key={point.date} className="flex h-full min-w-0 flex-1 flex-col items-center justify-end gap-2">
<div className="text-[10px] font-medium text-slate-500">{value.toLocaleString()}</div>
<div className="flex h-full w-full items-end rounded-t-[18px] bg-white/[0.03] px-[2px]">
<div className="flex w-full flex-1 items-end rounded-t-[18px] bg-white/[0.03] px-[2px]">
<div className={`w-full rounded-t-[16px] ${colorClass}`} style={{ height }} />
</div>
<div className="text-[10px] uppercase tracking-[0.14em] text-slate-500">{formatShortDate(point.date)}</div>
</div>
)
})}
</div>
})}
</div>
)}
</section>
)
}

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react'
import { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import Checkbox from '../../components/ui/Checkbox'
function replacePattern(pattern, token, value) {
return String(pattern || '').replace(token, String(value))
@@ -57,7 +58,7 @@ export default function StudioNewsTaxonomies() {
<textarea value={categoryForm.data.description} onChange={(event) => categoryForm.setData('description', event.target.value)} rows={3} placeholder="Description" className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="flex flex-wrap items-center gap-3">
<input type="number" value={categoryForm.data.position} onChange={(event) => categoryForm.setData('position', event.target.value)} min="0" className="w-28 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<label className="flex items-center gap-2 text-sm text-white"><input type="checkbox" checked={categoryForm.data.is_active} onChange={(event) => categoryForm.setData('is_active', event.target.checked)} /> Active</label>
<Checkbox checked={categoryForm.data.is_active} onChange={(event) => categoryForm.setData('is_active', event.target.checked)} label="Active" />
<button type="submit" className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">Create category</button>
</div>
</form>
@@ -72,7 +73,7 @@ export default function StudioNewsTaxonomies() {
<textarea value={category.description || ''} onChange={(event) => updateCategory(index, 'description', event.target.value)} rows={2} className="mt-3 w-full rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="mt-3 flex flex-wrap items-center gap-3 text-sm text-slate-300">
<input type="number" value={category.position || 0} min="0" onChange={(event) => updateCategory(index, 'position', event.target.value)} className="w-24 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(category.is_active)} onChange={(event) => updateCategory(index, 'is_active', event.target.checked)} /> Active</label>
<Checkbox checked={Boolean(category.is_active)} onChange={(event) => updateCategory(index, 'is_active', event.target.checked)} label="Active" />
<span className="text-xs uppercase tracking-[0.14em] text-slate-500">{Number(category.published_count || 0).toLocaleString()} published</span>
<button type="button" onClick={() => saveCategory(category)} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Save</button>
</div>

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import DateTimePicker from '../../components/ui/DateTimePicker'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
import { formatReleaseCountdown, formatScheduledDate } from '../../utils/scheduleCountdown'
import NovaSelect from '../../components/ui/NovaSelect'
@@ -151,14 +152,14 @@ export default function StudioScheduled() {
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Date range</span>
<NovaSelect value={filters.range || 'upcoming'} onChange={(val) => updateFilters({ range: val })} options={rangeOptions} searchable={false} />
</div>
<label className="space-y-2 text-sm text-slate-300">
<div className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Start date</span>
<input type="date" value={filters.start_date || ''} onChange={(event) => updateFilters({ range: 'custom', start_date: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white" />
</label>
<label className="space-y-2 text-sm text-slate-300">
<DateTimePicker value={filters.start_date || ''} onChange={(nextValue) => updateFilters({ range: 'custom', start_date: nextValue })} mode="date" placeholder="Start date" clearable className="bg-black/20" />
</div>
<div className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">End date</span>
<input type="date" value={filters.end_date || ''} onChange={(event) => updateFilters({ range: 'custom', end_date: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white" />
</label>
<DateTimePicker value={filters.end_date || ''} onChange={(nextValue) => updateFilters({ range: 'custom', end_date: nextValue })} mode="date" placeholder="End date" clearable className="bg-black/20" />
</div>
<div className="flex items-end">
<button type="button" onClick={() => updateFilters({ q: '', module: 'all', range: 'upcoming', start_date: '', end_date: '' })} className="w-full rounded-2xl border border-white/10 px-4 py-3 text-sm text-slate-200">Reset</button>
</div>

View File

@@ -0,0 +1,362 @@
import React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import StudioUploadQueue from '../StudioUploadQueue'
let pageMock = { props: {} }
vi.mock('@inertiajs/react', () => ({
usePage: () => pageMock,
}))
vi.mock('../../../Layouts/StudioLayout', () => ({
default: ({ children }) => <div>{children}</div>,
}))
function makeQueueProps(overrides = {}) {
return {
title: 'Upload Queue',
description: 'Queue drafts',
chunkSize: 5242880,
chunkRequestTimeoutMs: 45000,
contentTypes: [
{
name: 'Photography',
categories: [
{ id: 10, name: 'Portraits', children: [] },
],
},
],
queue: {
filters: { batch_id: 1, status: 'all', sort: 'newest' },
batches: [{ id: 1, name: 'Spring Set' }],
current_batch: {
id: 1,
name: 'Spring Set',
status: 'completed_with_errors',
total_items: 2,
ready_items: 1,
processing_items: 0,
needs_review_items: 1,
failed_items: 0,
updated_at: '2026-04-18T10:00:00Z',
},
status_options: [
{ value: 'all', label: 'All' },
{ value: 'ready', label: 'Ready' },
{ value: 'needs_review', label: 'Needs review' },
],
sort_options: [
{ value: 'newest', label: 'Newest first' },
{ value: 'filename', label: 'Filename' },
],
items: [
{
id: 101,
title: 'Ready draft',
original_filename: 'ready.webp',
status: 'ready',
processing_stage: 'finalized',
metadata_label: '100% complete',
is_ready_to_publish: true,
missing: [],
error_message: null,
updated_at: '2026-04-18T10:00:00Z',
edit_url: '/studio/artworks/1/edit',
actions: {
can_edit: true,
can_publish: true,
can_delete: true,
can_retry_processing: false,
can_generate_ai: true,
},
},
{
id: 102,
title: 'Needs review draft',
original_filename: 'review.webp',
status: 'needs_review',
processing_stage: 'finalized',
metadata_label: '75% complete',
is_ready_to_publish: false,
missing: ['Needs maturity review'],
error_message: null,
updated_at: '2026-04-18T11:00:00Z',
edit_url: '/studio/artworks/2/edit',
actions: {
can_edit: true,
can_publish: false,
can_delete: true,
can_retry_processing: false,
can_generate_ai: true,
},
},
],
...overrides.queue,
},
...overrides,
}
}
describe('StudioUploadQueue', () => {
beforeEach(() => {
pageMock = { props: makeQueueProps() }
window.axios = {
get: vi.fn().mockResolvedValue({ data: pageMock.props.queue }),
post: vi.fn().mockResolvedValue({ data: { success: 1, failed: 0, errors: [] } }),
}
window.confirm = vi.fn(() => true)
window.prompt = vi.fn(() => 'DELETE')
})
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
})
it('renders mixed queue states and item actions', () => {
render(<StudioUploadQueue />)
expect(screen.getByText('Ready draft')).not.toBeNull()
expect(screen.getByText('Needs review draft')).not.toBeNull()
expect(screen.getAllByText('Ready to publish')[0]).not.toBeNull()
expect(screen.getByText('Needs maturity review')).not.toBeNull()
expect(screen.getAllByRole('link', { name: /edit in studio/i })).toHaveLength(2)
expect(screen.getAllByRole('button', { name: /^generate ai$/i })).toHaveLength(3)
})
it('reloads the queue when filters change', async () => {
const user = userEvent.setup()
render(<StudioUploadQueue />)
await user.selectOptions(screen.getByRole('combobox', { name: /filter/i }), 'ready')
await waitFor(() => {
expect(window.axios.get).toHaveBeenCalledWith('/api/studio/upload-queue', {
params: expect.objectContaining({
batch_id: 1,
status: 'ready',
sort: 'newest',
}),
})
})
})
it('shows a publish confirmation summary before bulk publish', async () => {
const user = userEvent.setup()
render(<StudioUploadQueue />)
const checkboxes = screen.getAllByRole('checkbox')
const itemCheckboxes = checkboxes.slice(-2)
await user.click(itemCheckboxes[0])
await user.click(itemCheckboxes[1])
await user.click(screen.getByRole('button', { name: /publish selected/i }))
expect(window.confirm).toHaveBeenCalledWith([
'Publish 1 ready draft(s)?',
'Selected: 2',
'Ready now: 1',
'Blocked and skipped: 1',
'Needs review: 1',
'Blocked drafts will not be published.',
].join('\n'))
await waitFor(() => {
expect(window.axios.post).toHaveBeenCalledWith('/api/studio/upload-queue/bulk', expect.objectContaining({
action: 'publish',
item_ids: [101, 102],
}))
})
})
it('does not attempt bulk publish when no selected drafts are ready', async () => {
const user = userEvent.setup()
pageMock = {
props: makeQueueProps({
queue: {
items: [
{
id: 202,
title: 'Blocked draft',
original_filename: 'blocked.webp',
status: 'needs_metadata',
processing_stage: 'finalized',
metadata_label: '50% complete',
is_ready_to_publish: false,
missing: ['Missing title'],
error_message: null,
updated_at: '2026-04-18T10:00:00Z',
edit_url: '/studio/artworks/3/edit',
actions: {
can_edit: true,
can_publish: false,
can_delete: true,
can_retry_processing: false,
can_generate_ai: true,
},
},
],
},
}),
}
render(<StudioUploadQueue />)
const checkboxes = screen.getAllByRole('checkbox')
await user.click(checkboxes.at(-1))
await user.click(screen.getByRole('button', { name: /publish selected/i }))
expect(window.confirm).not.toHaveBeenCalled()
expect(window.axios.post).not.toHaveBeenCalledWith('/api/studio/upload-queue/bulk', expect.objectContaining({
action: 'publish',
}))
expect(screen.getByText('None of the selected drafts are ready to publish yet.')).not.toBeNull()
})
it('shows the correct Studio links and publish readiness state per item', () => {
render(<StudioUploadQueue />)
const studioLinks = screen.getAllByRole('link', { name: /edit in studio/i })
expect(studioLinks).toHaveLength(2)
expect(studioLinks[0].getAttribute('href')).toBe('/studio/artworks/1/edit')
expect(studioLinks[1].getAttribute('href')).toBe('/studio/artworks/2/edit')
expect(screen.getAllByRole('button', { name: /^publish$/i })).toHaveLength(1)
})
it('bulk actions apply only to selected queue items', async () => {
const user = userEvent.setup()
render(<StudioUploadQueue />)
const checkboxes = screen.getAllByRole('checkbox')
const itemCheckboxes = checkboxes.slice(-2)
await user.click(itemCheckboxes[0])
await user.click(screen.getAllByRole('button', { name: /^generate ai$/i })[0])
await waitFor(() => {
expect(window.axios.post).toHaveBeenCalledWith('/api/studio/upload-queue/bulk', expect.objectContaining({
action: 'generate_ai',
item_ids: [101],
}))
})
})
it('shows failed items clearly and lets creators retry them', async () => {
const user = userEvent.setup()
pageMock = {
props: makeQueueProps({
queue: {
current_batch: {
id: 1,
name: 'Spring Set',
status: 'completed_with_errors',
total_items: 1,
ready_items: 0,
processing_items: 0,
needs_review_items: 0,
failed_items: 1,
updated_at: '2026-04-18T12:00:00Z',
},
items: [
{
id: 303,
title: 'Broken draft',
original_filename: 'broken.webp',
status: 'failed',
processing_stage: 'finalized',
metadata_label: '25% complete',
is_ready_to_publish: false,
missing: ['Processing incomplete'],
error_message: 'Derivative generation failed.',
updated_at: '2026-04-18T12:00:00Z',
edit_url: '/studio/artworks/4/edit',
actions: {
can_edit: true,
can_publish: false,
can_delete: true,
can_retry_processing: true,
can_generate_ai: true,
},
},
],
},
}),
}
render(<StudioUploadQueue />)
expect(screen.getByText('Derivative generation failed.')).not.toBeNull()
expect(screen.getByText('Processing incomplete')).not.toBeNull()
await user.click(screen.getByRole('button', { name: /retry/i }))
await waitFor(() => {
expect(window.axios.post).toHaveBeenCalledWith('/api/studio/upload-queue/items/303/retry')
})
})
it('polls the queue while processing items are still running', async () => {
vi.useFakeTimers()
pageMock = {
props: makeQueueProps({
queue: {
current_batch: {
id: 1,
name: 'Spring Set',
status: 'processing',
total_items: 1,
ready_items: 0,
processing_items: 1,
needs_review_items: 0,
failed_items: 0,
updated_at: '2026-04-18T12:15:00Z',
},
items: [
{
id: 404,
title: 'Processing draft',
original_filename: 'processing.webp',
status: 'processing',
processing_stage: 'maturity_check',
metadata_label: '50% complete',
is_ready_to_publish: false,
missing: ['Maturity analysis pending'],
error_message: null,
updated_at: '2026-04-18T12:15:00Z',
edit_url: '/studio/artworks/5/edit',
actions: {
can_edit: true,
can_publish: false,
can_delete: true,
can_retry_processing: false,
can_generate_ai: true,
},
},
],
},
}),
}
render(<StudioUploadQueue />)
expect(screen.getByText('Maturity analysis pending')).not.toBeNull()
expect(screen.queryByRole('button', { name: /^publish$/i })).toBeNull()
await act(async () => {
vi.advanceTimersByTime(3000)
await Promise.resolve()
})
expect(window.axios.get).toHaveBeenCalledWith('/api/studio/upload-queue', {
params: expect.objectContaining({
batch_id: 1,
status: 'all',
sort: 'newest',
}),
})
})
})

27
resources/js/admin.jsx Normal file
View File

@@ -0,0 +1,27 @@
import { mountInertiaRoot } from './bootstrap'
import React from 'react'
import { createInertiaApp } from '@inertiajs/react'
const pages = import.meta.glob([
'./Pages/Admin/**/*.jsx',
'!./Pages/Admin/**/__tests__/**',
'!./Pages/Admin/**/*.test.jsx',
])
function resolvePage(name) {
const path = `./Pages/${name}.jsx`
const page = pages[path]
if (!page) {
throw new Error(`Unknown admin page: ${path}`)
}
return page().then((module) => module.default)
}
createInertiaApp({
resolve: resolvePage,
setup({ el, App, props }) {
mountInertiaRoot(el, App, props)
},
})

14
resources/js/artwork.jsx Normal file
View File

@@ -0,0 +1,14 @@
import { mountInertiaRoot } from './bootstrap'
import React from 'react'
import { createInertiaApp } from '@inertiajs/react'
import ArtworkPage from './Pages/ArtworkPage'
createInertiaApp({
resolve: (name) => {
const pages = { ArtworkPage }
return pages[name]
},
setup({ el, App, props }) {
mountInertiaRoot(el, App, props)
},
})

View File

@@ -1,16 +1,24 @@
import axios from 'axios'
import Echo from 'laravel-echo'
import Pusher from 'pusher-js'
import React from 'react'
import { createRoot, hydrateRoot } from 'react-dom/client'
window.axios = axios
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'
const browserWindow = typeof window !== 'undefined' ? window : null
const csrfToken = typeof document !== 'undefined'
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
: null
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
if (csrfToken) {
window.axios.defaults.headers.common['X-CSRF-TOKEN'] = csrfToken
axios.defaults.headers.common['X-CSRF-TOKEN'] = csrfToken
}
window.Pusher = Pusher
if (browserWindow) {
browserWindow.axios = axios
browserWindow.Pusher = Pusher
}
let echoInstance = null
@@ -23,16 +31,21 @@ export function getEcho() {
return echoInstance || null
}
if (!browserWindow) {
echoInstance = false
return null
}
const key = import.meta.env.VITE_REVERB_APP_KEY
if (!key) {
echoInstance = false
return null
}
const scheme = import.meta.env.VITE_REVERB_SCHEME || window.location.protocol.replace(':', '') || 'https'
const scheme = import.meta.env.VITE_REVERB_SCHEME || browserWindow.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 configuredHost = import.meta.env.VITE_REVERB_HOST || browserWindow.location.hostname
const publicHostname = browserWindow.location.hostname
const useWindowHost = !isLoopbackHost(publicHostname) && isLoopbackHost(configuredHost)
const resolvedHost = useWindowHost ? publicHostname : configuredHost
const resolvedPort = useWindowHost
@@ -59,7 +72,22 @@ export function getEcho() {
},
})
window.Echo = echoInstance
browserWindow.Echo = echoInstance
return echoInstance
}
export function mountInertiaRoot(el, App, props) {
if (!el) {
return null
}
const node = React.createElement(App, props)
const hasServerMarkup = el.childNodes.length > 0 && el.innerHTML.trim() !== ''
if (hasServerMarkup) {
return hydrateRoot(el, node)
}
return createRoot(el).render(node)
}

View File

@@ -1,6 +1,5 @@
import './bootstrap'
import { mountInertiaRoot } from './bootstrap'
import React from 'react'
import { createRoot } from 'react-dom/client'
import { createInertiaApp } from '@inertiajs/react'
const pages = {
...import.meta.glob([
@@ -39,7 +38,6 @@ function resolvePage(name) {
createInertiaApp({
resolve: resolvePage,
setup({ el, App, props }) {
const root = createRoot(el)
root.render(<App {...props} />)
mountInertiaRoot(el, App, props)
},
})

View File

@@ -3,6 +3,7 @@ import axios from 'axios'
import ShareArtworkModal from './ShareArtworkModal'
import LinkPreviewCard from './LinkPreviewCard'
import TagPeopleModal from './TagPeopleModal'
import DateTimePicker from '../ui/DateTimePicker'
import extractNativeEmoji from '../common/extractNativeEmoji'
import isEventWithinNode from '../common/isEventWithinNode'
@@ -274,13 +275,14 @@ export default function PostComposer({ user, onPosted }) {
<div className="flex items-center gap-2.5 p-3 rounded-xl bg-violet-500/10 border border-violet-500/20">
<i className="fa-regular fa-calendar-plus text-violet-400 text-sm fa-fw shrink-0" />
<div className="flex-1">
<label className="block text-[11px] text-slate-400 mb-1">Publish on</label>
<input
type="datetime-local"
<div className="block text-[11px] text-slate-400 mb-1">Publish on</div>
<DateTimePicker
value={scheduledAt}
onChange={(e) => setScheduledAt(e.target.value)}
min={new Date(Date.now() + 60_000).toISOString().slice(0, 16)}
className="bg-transparent text-sm text-white border-none outline-none w-full [color-scheme:dark]"
onChange={setScheduledAt}
minDateTime={new Date(Date.now() + 60_000).toISOString().slice(0, 16)}
placeholder="Pick a publish slot"
clearable
className="border-violet-300/20 bg-violet-500/10"
/>
<p className="text-[10px] text-slate-500 mt-1">
{Intl.DateTimeFormat().resolvedOptions().timeZone}

View File

@@ -2,6 +2,8 @@ import React, { useEffect, useState } from 'react'
import { router } from '@inertiajs/react'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
import ConfirmDangerModal from './ConfirmDangerModal'
import NovaSelect from '../ui/NovaSelect'
import Checkbox from '../ui/Checkbox'
function formatDate(value) {
if (!value) return 'Unscheduled'
@@ -79,6 +81,11 @@ function bulkErrorMessage(payload, fallback = 'Bulk action failed.') {
|| fallback
}
function stripHtml(value) {
if (typeof value !== 'string') return ''
return value.replace(/<[^>]*>/g, '').trim()
}
function ActionLink({ href, icon, label, onClick }) {
if (!href) return null
@@ -176,7 +183,7 @@ function GridCard({ item, onExecuteAction, busyKey }) {
)}
<p className="line-clamp-2 min-h-[2.5rem] text-sm text-slate-300/90">
{item.description || 'No description yet.'}
{stripHtml(item.description) || 'No description yet.'}
</p>
{Array.isArray(readiness?.missing) && readiness.missing.length > 0 && (
@@ -266,7 +273,7 @@ function ListRow({ item, onExecuteAction, busyKey }) {
</div>
<h3 className="mt-3 truncate text-lg font-semibold text-white">{item.title}</h3>
<p className="mt-1 text-sm text-slate-400">{item.subtitle || item.visibility || 'Untitled metadata'}</p>
<p className="mt-3 line-clamp-2 text-sm text-slate-300/90">{item.description || 'No description yet.'}</p>
<p className="mt-3 line-clamp-2 text-sm text-slate-300/90">{stripHtml(item.description) || 'No description yet.'}</p>
<div className="mt-3 flex flex-wrap gap-2">
{readiness && (
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium ${readinessClasses(readiness)}`}>
@@ -324,31 +331,48 @@ function materializeFilter(filter, pendingFilters) {
}
}
function selectOptions(options = []) {
return options.map((option) => ({
value: option.value,
label: option.label,
group: option.group,
disabled: option.disabled,
icon: option.icon,
}))
}
function FilterField({ label, children }) {
return (
<div className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{label}</span>
{children}
</div>
)
}
function AdvancedFilterControl({ filter, onChange, value }) {
const controlValue = value ?? filter.value
if (filter.type === 'select') {
const options = selectOptions(filter.options || [])
const searchable = filter.searchable ?? options.length > 8
return (
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{filter.label}</span>
<select
<FilterField label={filter.label}>
<NovaSelect
id={`studio-filter-${filter.key}`}
options={options}
value={controlValue || 'all'}
onChange={(event) => onChange(filter.key, event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40 focus:bg-black/30"
>
{(filter.options || []).map((option) => (
<option key={option.value} value={option.value} className="bg-slate-900">
{option.label}
</option>
))}
</select>
</label>
onChange={(nextValue) => onChange(filter.key, nextValue ?? 'all')}
placeholder={filter.label}
searchable={searchable}
/>
</FilterField>
)
}
return (
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{filter.label}</span>
<FilterField label={filter.label}>
<input
type="search"
value={controlValue || ''}
@@ -356,7 +380,7 @@ function AdvancedFilterControl({ filter, onChange, value }) {
placeholder={filter.placeholder || filter.label}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-sky-300/40 focus:bg-black/30"
/>
</label>
</FilterField>
)
}
@@ -817,8 +841,7 @@ export default function StudioContentBrowser({
<section className="rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(34,197,94,0.12),_transparent_35%),radial-gradient(circle_at_bottom_right,_rgba(56,189,248,0.12),_transparent_40%),linear-gradient(135deg,_rgba(15,23,42,0.86),_rgba(2,6,23,0.96))] p-5 shadow-[0_22px_60px_rgba(2,6,23,0.28)] lg:p-6">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
<div className={`grid gap-3 md:grid-cols-2 ${filterGridClass}`}>
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Search</span>
<FilterField label="Search">
<input
type="search"
value={pendingFilters.q}
@@ -826,56 +849,44 @@ export default function StudioContentBrowser({
placeholder="Title, description, module"
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-sky-300/40 focus:bg-black/30"
/>
</label>
</FilterField>
{!hideModuleFilter && (
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Module</span>
<select
<FilterField label="Module">
<NovaSelect
id="studio-filter-module"
options={selectOptions(listing?.module_options || [])}
value={filters.module || 'all'}
onChange={(event) => updateQuery({ module: event.target.value })}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40 focus:bg-black/30"
>
{(listing?.module_options || []).map((option) => (
<option key={option.value} value={option.value} className="bg-slate-900">
{option.label}
</option>
))}
</select>
</label>
onChange={(nextValue) => updateQuery({ module: nextValue ?? 'all' })}
placeholder="All content"
searchable={false}
/>
</FilterField>
)}
{!hideBucketFilter && (
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Status</span>
<select
<FilterField label="Status">
<NovaSelect
id="studio-filter-status"
options={selectOptions(listing?.bucket_options || [])}
value={pendingFilters.bucket}
onChange={(event) => setPendingFilter('bucket', event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40 focus:bg-black/30"
>
{(listing?.bucket_options || []).map((option) => (
<option key={option.value} value={option.value} className="bg-slate-900">
{option.label}
</option>
))}
</select>
</label>
onChange={(nextValue) => setPendingFilter('bucket', nextValue ?? 'all')}
placeholder="All"
searchable={false}
/>
</FilterField>
)}
<label className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Sort</span>
<select
<FilterField label="Sort">
<NovaSelect
id="studio-filter-sort"
options={selectOptions(listing?.sort_options || [])}
value={pendingFilters.sort}
onChange={(event) => setPendingFilter('sort', event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40 focus:bg-black/30"
>
{(listing?.sort_options || []).map((option) => (
<option key={option.value} value={option.value} className="bg-slate-900">
{option.label}
</option>
))}
</select>
</label>
onChange={(nextValue) => setPendingFilter('sort', nextValue ?? 'updated_desc')}
placeholder="Recently updated"
searchable={false}
/>
</FilterField>
{advancedFilters.map((filter) => {
const resolvedFilter = materializeFilter(filter, pendingFilters)
@@ -960,15 +971,13 @@ export default function StudioContentBrowser({
{viewMode === 'table' && supportsArtworkBulk && (
<section className="flex flex-wrap items-center justify-between gap-3 rounded-[24px] border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-300">
<div className="flex flex-wrap items-center gap-3">
<label className="inline-flex items-center gap-2 text-sm text-slate-300">
<input
type="checkbox"
<div className="inline-flex items-center gap-2 text-sm text-slate-300">
<Checkbox
checked={allVisibleSelected}
onChange={toggleSelectAllVisible}
className="h-4 w-4 rounded border-white/20 bg-slate-950/60 text-sky-400 focus:ring-sky-400/40"
label="Select page"
/>
<span>Select page</span>
</label>
</div>
<span className="text-slate-500">
{selectedIds.length > 0 ? `${selectedIds.length} selected` : 'Select artworks to run bulk actions'}
</span>
@@ -1014,11 +1023,9 @@ export default function StudioContentBrowser({
<tr>
{supportsArtworkBulk && (
<th scope="col" className="w-12 px-4 py-3">
<input
type="checkbox"
<Checkbox
checked={allVisibleSelected}
onChange={toggleSelectAllVisible}
className="h-4 w-4 rounded border-white/20 bg-slate-950/60 text-sky-400 focus:ring-sky-400/40"
aria-label="Select all artworks on this page"
/>
</th>
@@ -1039,11 +1046,9 @@ export default function StudioContentBrowser({
<tr key={item.id} className="align-top transition hover:bg-white/[0.03]">
{supportsArtworkBulk && (
<td className="px-4 py-4">
<input
type="checkbox"
<Checkbox
checked={isSelected}
onChange={() => toggleSelected(Number(item.numeric_id))}
className="mt-1 h-4 w-4 rounded border-white/20 bg-slate-950/60 text-sky-400 focus:ring-sky-400/40"
aria-label={`Select ${item.title}`}
/>
</td>

View File

@@ -0,0 +1,100 @@
import React from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render, screen } from '@testing-library/react'
import StudioContentBrowser from './StudioContentBrowser'
const routerGet = vi.fn()
const routerReload = vi.fn()
vi.mock('@inertiajs/react', () => ({
router: {
get: routerGet,
reload: routerReload,
},
}))
vi.mock('../../utils/studioEvents', () => ({
studioSurface: () => '/studio/artworks',
trackStudioEvent: vi.fn(),
}))
vi.mock('./ConfirmDangerModal', () => ({
default: () => null,
}))
describe('StudioContentBrowser filters', () => {
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
it('renders artwork filter dropdowns with NovaSelect instead of native selects', () => {
const { container } = render(
<StudioContentBrowser
hideModuleFilter
listing={{
filters: {
module: 'artworks',
bucket: 'all',
q: '',
sort: 'updated_desc',
content_type: 'all',
category: 'all',
tag: '',
},
items: [],
meta: {
current_page: 1,
last_page: 1,
per_page: 24,
total: 0,
},
bucket_options: [
{ value: 'all', label: 'All' },
{ value: 'published', label: 'Published' },
],
sort_options: [
{ value: 'updated_desc', label: 'Recently updated' },
{ value: 'views_desc', label: 'Most viewed' },
],
advanced_filters: [
{
key: 'content_type',
label: 'Content type',
type: 'select',
value: 'all',
options: [
{ value: 'all', label: 'All content types' },
{ value: '3d', label: '3D' },
],
},
{
key: 'category',
label: 'Category',
type: 'select',
value: 'all',
options: [
{ value: 'all', label: 'All categories' },
{ value: 'abstract', label: 'Abstract', content_type_slug: 'all' },
],
},
{
key: 'tag',
label: 'Tag',
type: 'search',
value: '',
placeholder: 'Filter by tag',
},
],
}}
/>,
)
expect(container.querySelectorAll('select')).toHaveLength(0)
expect(screen.getAllByRole('combobox')).toHaveLength(4)
expect(screen.getByText('Status')).not.toBeNull()
expect(screen.getByText('Sort')).not.toBeNull()
expect(screen.getByText('Content type')).not.toBeNull()
expect(screen.getByText('Category')).not.toBeNull()
})
})

View File

@@ -222,11 +222,27 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
// Count a view on every page load.
useEffect(() => {
if (!artwork?.id) return
fetch(`/api/art/${artwork.id}/view`, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' },
credentials: 'same-origin',
}).catch(() => {})
const postView = () => {
fetch(`/api/art/${artwork.id}/view`, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' },
credentials: 'same-origin',
keepalive: true,
}).catch(() => {})
}
if (typeof window === 'undefined') {
postView()
return undefined
}
if (typeof window.requestIdleCallback === 'function') {
const handle = window.requestIdleCallback(postView, { timeout: 1500 })
return () => window.cancelIdleCallback(handle)
}
const handle = window.setTimeout(postView, 1200)
return () => window.clearTimeout(handle)
}, [artwork?.id]) // eslint-disable-line react-hooks/exhaustive-deps
const postInteraction = async (url, body) => {
@@ -327,7 +343,7 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
].join(' ')}
>
<HeartIcon filled={favorited} />
<span className="tabular-nums">{favCount}</span>
<span className="tabular-nums" aria-hidden="true">{favCount}</span>
</button>
<button
@@ -342,7 +358,7 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
].join(' ')}
>
<BookmarkIcon filled={bookmarked} />
<span className="tabular-nums">{savedCount}</span>
<span className="tabular-nums" aria-hidden="true">{savedCount}</span>
</button>
{/* Share pill */}
@@ -403,7 +419,7 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
].join(' ')}
>
<HeartIcon filled={favorited} />
<span className="tabular-nums">{favCount}</span>
<span className="tabular-nums" aria-hidden="true">{favCount}</span>
</button>
<button
@@ -418,7 +434,7 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
].join(' ')}
>
<BookmarkIcon filled={bookmarked} />
<span className="tabular-nums">{savedCount}</span>
<span className="tabular-nums" aria-hidden="true">{savedCount}</span>
</button>
{/* Share */}

View File

@@ -2,6 +2,7 @@ import React, { useState } from 'react'
import FollowButton from '../social/FollowButton'
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/missing_sq.webp'
const NUMBER_FORMATTER = new Intl.NumberFormat('en-US')
export default function ArtworkAuthor({ artwork, presentSq }) {
const [following, setFollowing] = useState(Boolean(artwork?.viewer?.is_following_author))
@@ -34,7 +35,7 @@ export default function ArtworkAuthor({ artwork, presentSq }) {
{authorName}
</a>
{user.username && <p className="truncate text-xs text-soft">@{user.username}</p>}
<p className="mt-1 text-xs text-soft">{followersCount.toLocaleString()} followers</p>
<p className="mt-1 text-xs text-soft">{NUMBER_FORMATTER.format(followersCount)} followers</p>
</div>
</div>

View File

@@ -331,7 +331,7 @@ export default function ArtworkAwards({ artwork, initialAwards = null, isAuthent
{!isAuthenticated && (
<p className="mt-3 text-center text-xs text-soft">
<a href="/login" className="text-accent hover:underline">Sign in</a> to medal this artwork
<a href="/login" className="text-accent underline hover:no-underline">Sign in</a> to medal this artwork
</p>
)}

View File

@@ -20,7 +20,7 @@ function Crumb({ href, children, current = false }) {
if (current) {
return (
<span
className={`${base} text-white/30`}
className={`${base} text-white/55`}
aria-current="page"
>
{children}
@@ -30,7 +30,7 @@ function Crumb({ href, children, current = false }) {
return (
<a
href={href}
className={`${base} text-white/30 hover:text-white/60 transition-colors duration-150`}
className={`${base} text-white/55 hover:text-white/80 transition-colors duration-150`}
>
{children}
</a>

View File

@@ -4,10 +4,11 @@ import LevelBadge from '../xp/LevelBadge'
const IMAGE_FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
const numberFormatter = new Intl.NumberFormat(undefined, {
const numberFormatter = new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
})
const relativeTimeFormatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
function cx(...parts) {
return parts.filter(Boolean).join(' ')
@@ -29,27 +30,26 @@ function formatRelativeTime(value) {
const diffMs = date.getTime() - now.getTime()
const diffSeconds = Math.round(diffMs / 1000)
const absSeconds = Math.abs(diffSeconds)
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
if (absSeconds < 60) return rtf.format(diffSeconds, 'second')
if (absSeconds < 60) return relativeTimeFormatter.format(diffSeconds, 'second')
const diffMinutes = Math.round(diffSeconds / 60)
if (Math.abs(diffMinutes) < 60) return rtf.format(diffMinutes, 'minute')
if (Math.abs(diffMinutes) < 60) return relativeTimeFormatter.format(diffMinutes, 'minute')
const diffHours = Math.round(diffSeconds / 3600)
if (Math.abs(diffHours) < 24) return rtf.format(diffHours, 'hour')
if (Math.abs(diffHours) < 24) return relativeTimeFormatter.format(diffHours, 'hour')
const diffDays = Math.round(diffSeconds / 86400)
if (Math.abs(diffDays) < 7) return rtf.format(diffDays, 'day')
if (Math.abs(diffDays) < 7) return relativeTimeFormatter.format(diffDays, 'day')
const diffWeeks = Math.round(diffSeconds / 604800)
if (Math.abs(diffWeeks) < 5) return rtf.format(diffWeeks, 'week')
if (Math.abs(diffWeeks) < 5) return relativeTimeFormatter.format(diffWeeks, 'week')
const diffMonths = Math.round(diffSeconds / 2629800)
if (Math.abs(diffMonths) < 12) return rtf.format(diffMonths, 'month')
if (Math.abs(diffMonths) < 12) return relativeTimeFormatter.format(diffMonths, 'month')
const diffYears = Math.round(diffSeconds / 31557600)
return rtf.format(diffYears, 'year')
return relativeTimeFormatter.format(diffYears, 'year')
}
function slugify(value) {

View File

@@ -5,20 +5,44 @@ import ReactionBar from '../comments/ReactionBar'
import LevelBadge from '../xp/LevelBadge'
import { isFlood } from '../../utils/emojiFlood'
const ABSOLUTE_DATE_FORMATTER = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
})
const ABSOLUTE_DATE_TIME_FORMATTER = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZone: 'UTC',
})
// ── Helpers ───────────────────────────────────────────────────────────────────
function timeAgo(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr)
const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
if (seconds < 60) return 'just now'
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days < 365) return `${days}d ago`
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
function formatAbsoluteDate(value) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
return ABSOLUTE_DATE_FORMATTER.format(date)
}
function formatAbsoluteDateTime(value) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
return ABSOLUTE_DATE_TIME_FORMATTER.format(date)
}
function formatCommentTime(primaryLabel, createdAt) {
return primaryLabel || formatAbsoluteDate(createdAt)
}
/* ── Icons ─────────────────────────────────────────────────────────────────── */
@@ -135,10 +159,10 @@ function ReplyItem({ reply, parentId, artworkId, isLoggedIn, onReplyPosted, dept
<span className="text-white/15" aria-hidden="true">·</span>
<time
dateTime={reply.created_at}
title={reply.created_at ? new Date(reply.created_at).toLocaleString() : ''}
title={formatAbsoluteDateTime(reply.created_at)}
className="text-[10px] font-medium tracking-wide text-white/25 uppercase"
>
{reply.time_ago || timeAgo(reply.created_at)}
{formatCommentTime(reply.time_ago, reply.created_at)}
</time>
</div>
@@ -292,10 +316,10 @@ function CommentItem({ comment, isLoggedIn, artworkId, onReplyPosted }) {
<span className="text-white/15" aria-hidden="true">·</span>
<time
dateTime={comment.created_at}
title={comment.created_at ? new Date(comment.created_at).toLocaleString() : ''}
title={formatAbsoluteDateTime(comment.created_at)}
className="text-[11px] font-medium tracking-wide text-white/30 uppercase"
>
{comment.time_ago || timeAgo(comment.created_at)}
{formatCommentTime(comment.time_ago, comment.created_at)}
</time>
</div>

View File

@@ -2,11 +2,33 @@ import React, { useState } from 'react'
const COLLAPSE_AT = 560
function stripTags(value) {
return String(value || '')
.replace(/<\/?(?:html|head|body|title|meta|link|script|style)[^>]*>/gi, '')
.replace(/<[^>]*>/g, '')
.trim()
}
function sanitizeDescriptionHtml(value) {
const html = String(value || '').trim()
if (!html) {
return ''
}
if (/<\/?(?:html|head|body|title|meta|link|script|style)\b/i.test(html)) {
return ''
}
return html
}
export default function ArtworkDescription({ artwork }) {
const [expanded, setExpanded] = useState(false)
const content = (artwork?.description || '').trim()
const contentHtml = (artwork?.description_html || '').trim()
const contentHtml = sanitizeDescriptionHtml(artwork?.description_html || '')
const collapsed = content.length > COLLAPSE_AT && !expanded
const fallbackText = contentHtml ? stripTags(contentHtml) : content
if (content.length === 0) return null
@@ -20,7 +42,8 @@ export default function ArtworkDescription({ artwork }) {
>
<div
className="prose prose-invert max-w-none text-sm leading-7 prose-p:my-3 prose-p:text-white/50 prose-a:text-accent prose-a:no-underline hover:prose-a:underline prose-strong:text-white/80 prose-em:text-white/70 prose-code:text-white/80"
dangerouslySetInnerHTML={{ __html: contentHtml }}
suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: contentHtml || escapeHtml(fallbackText) }}
/>
</div>
@@ -36,3 +59,12 @@ export default function ArtworkDescription({ artwork }) {
</div>
)
}
function escapeHtml(value) {
return String(value || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}

View File

@@ -2,6 +2,13 @@ import React, { useMemo } from 'react'
import ArtworkBreadcrumbs from './ArtworkBreadcrumbs'
import ArtworkFormatBadges from './ArtworkFormatBadges'
const ABSOLUTE_DATE_FORMATTER = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
})
function formatCount(value) {
const number = Number(value || 0)
if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
@@ -12,7 +19,7 @@ function formatCount(value) {
function formatDate(value) {
if (!value) return '—'
try {
return new Date(value).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
return ABSOLUTE_DATE_FORMATTER.format(new Date(value))
} catch {
return '—'
}

View File

@@ -1,24 +1,33 @@
import React from 'react'
import React, { useEffect, useState } from 'react'
import ArtworkFormatBadges from './ArtworkFormatBadges'
const NUMBER_FORMATTER = new Intl.NumberFormat('en-US')
const ABSOLUTE_DATE_FORMATTER = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
})
function formatCount(value) {
const n = Number(value || 0)
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}k`
return n.toLocaleString()
return NUMBER_FORMATTER.format(n)
}
function formatDate(value) {
function formatDate(value, useRelative = true) {
if (!value) return '—'
try {
const d = new Date(value)
if (!useRelative) return ABSOLUTE_DATE_FORMATTER.format(d)
const now = Date.now()
const diff = now - d.getTime()
const days = Math.floor(diff / 86_400_000)
if (days === 0) return 'Today'
if (days === 1) return 'Yesterday'
if (days < 30) return `${days} days ago`
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
return ABSOLUTE_DATE_FORMATTER.format(d)
} catch {
return '—'
}
@@ -46,9 +55,14 @@ function InfoRow({ label, value }) {
}
export default function ArtworkDetailsPanel({ artwork, stats }) {
const [hydrated, setHydrated] = useState(false)
const width = artwork?.dimensions?.width || artwork?.width || 0
const height = artwork?.dimensions?.height || artwork?.height || 0
const resolution = width > 0 && height > 0 ? `${width.toLocaleString()} × ${height.toLocaleString()}` : null
const resolution = width > 0 && height > 0 ? `${NUMBER_FORMATTER.format(width)} × ${NUMBER_FORMATTER.format(height)}` : null
useEffect(() => {
setHydrated(true)
}, [])
return (
<section className="rounded-2xl border border-white/[0.06] bg-white/[0.03] p-5">
@@ -86,7 +100,7 @@ export default function ArtworkDetailsPanel({ artwork, stats }) {
<ArtworkFormatBadges width={width} height={height} className="mt-2" />
</div>
) : null}
<InfoRow label="Uploaded" value={formatDate(artwork?.published_at)} />
<InfoRow label="Uploaded" value={formatDate(artwork?.published_at, hydrated)} />
</div>
</section>
)

View File

@@ -84,6 +84,7 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
className="pointer-events-none absolute inset-0 h-full w-full scale-110 object-cover opacity-30 blur-3xl"
loading="eager"
decoding="async"
fetchPriority="high"
onError={(event) => {
event.currentTarget.onerror = null
setShowBackdrop(false)

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react'
import React, { useEffect, useMemo, useRef, useCallback } from 'react'
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
@@ -77,7 +77,7 @@ function RailCard({ item }) {
<img
src={item.thumb || FALLBACK}
srcSet={item.thumbSrcSet || undefined}
sizes="220px"
sizes="(min-width: 1280px) 210px, (min-width: 640px) 220px, 240px"
alt={item.title || 'Artwork'}
className={`h-full w-full object-cover transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04] ${shouldBlur ? 'scale-[1.02] blur-xl' : ''}`}
loading="lazy"
@@ -339,74 +339,18 @@ function Rail({ title, emoji, items, seeAllHref }) {
/* ── Main export ─────────────────────────────────────────────── */
export default function ArtworkRecommendationsRails({ artwork, related = [] }) {
const [similarApiItems, setSimilarApiItems] = useState([])
const [similarLoaded, setSimilarLoaded] = useState(false)
const [trendingItems, setTrendingItems] = useState([])
export default function ArtworkRecommendationsRails({ artwork, related = [], similarApiData = [], trendingData = [] }) {
const relatedCards = useMemo(() => {
return dedupeByUrl((Array.isArray(related) ? related : []).map(normalizeRelated).filter(Boolean))
}, [related])
useEffect(() => {
let isCancelled = false
const similarApiItems = useMemo(() => {
return dedupeByUrl((Array.isArray(similarApiData) ? similarApiData : []).map(normalizeSimilar).filter(Boolean))
}, [similarApiData])
const loadSimilar = async () => {
if (!artwork?.id) {
setSimilarApiItems([])
setSimilarLoaded(true)
return
}
try {
const response = await fetch(`/api/art/${artwork.id}/similar-ai`, { credentials: 'same-origin' })
if (!response.ok) throw new Error('similar fetch failed')
const payload = await response.json()
const items = dedupeByUrl((payload?.data || []).map(normalizeSimilar).filter(Boolean))
if (!isCancelled) {
setSimilarApiItems(items)
setSimilarLoaded(true)
}
} catch {
if (!isCancelled) {
setSimilarApiItems([])
setSimilarLoaded(true)
}
}
}
loadSimilar()
return () => {
isCancelled = true
}
}, [artwork?.id])
useEffect(() => {
let isCancelled = false
const loadTrending = async () => {
const categoryId = artwork?.categories?.[0]?.id
if (!categoryId) {
setTrendingItems([])
return
}
try {
const response = await fetch(`/api/rank/category/${categoryId}?type=trending`, { credentials: 'same-origin' })
if (!response.ok) throw new Error('trending fetch failed')
const payload = await response.json()
const items = dedupeByUrl((payload?.data || []).map(normalizeRankItem).filter(Boolean))
if (!isCancelled) setTrendingItems(items)
} catch {
if (!isCancelled) setTrendingItems([])
}
}
loadTrending()
return () => {
isCancelled = true
}
}, [artwork?.categories])
const trendingItems = useMemo(() => {
return dedupeByUrl((Array.isArray(trendingData) ? trendingData : []).map(normalizeRankItem).filter(Boolean))
}, [trendingData])
const authorName = String(artwork?.user?.name || artwork?.user?.username || '').trim().toLowerCase()
@@ -415,11 +359,10 @@ export default function ArtworkRecommendationsRails({ artwork, related = [] }) {
}, [relatedCards, authorName])
const similarItems = useMemo(() => {
if (!similarLoaded) return []
if (similarApiItems.length > 0) return similarApiItems.slice(0, 12)
if (tagBasedFallback.length > 0) return tagBasedFallback.slice(0, 12)
return trendingItems.slice(0, 12)
}, [similarLoaded, similarApiItems, tagBasedFallback, trendingItems])
}, [similarApiItems, tagBasedFallback, trendingItems])
const trendingRailItems = useMemo(() => trendingItems.slice(0, 12), [trendingItems])
@@ -428,11 +371,9 @@ export default function ArtworkRecommendationsRails({ artwork, related = [] }) {
const categoryName = artwork?.categories?.[0]?.name
const trendingLabel = categoryName
? `Trending in ${categoryName}`
: 'Trending'
: 'Trending on Skinbase'
const trendingHref = categoryName
? `/discover/trending`
: '/discover/trending'
const trendingHref = '/discover/trending'
const similarHref = artwork?.id ? `/art/${artwork.id}/similar` : null

View File

@@ -0,0 +1,64 @@
import React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import ArtworkRecommendationsRails from './ArtworkRecommendationsRails'
describe('ArtworkRecommendationsRails', () => {
beforeEach(() => {
global.fetch = vi.fn((url) => {
if (String(url).includes('/similar-ai')) {
return Promise.resolve({
ok: true,
json: async () => ({ data: [] }),
})
}
if (String(url).includes('/api/rank/category/5?type=trending')) {
return Promise.resolve({
ok: true,
json: async () => ({
data: [
{
id: 11,
title: 'Star map drift',
urls: { direct: '/art/11/star-map-drift' },
author: { name: 'Pilot' },
thumbnail_url: '/thumbs/11.webp',
},
],
}),
})
}
return Promise.resolve({
ok: true,
json: async () => ({ data: [] }),
})
})
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('loads recommendation rails after mount', async () => {
render(
<ArtworkRecommendationsRails
artwork={{
id: 69827,
user: { name: 'Pilot' },
categories: [{ id: 5, name: 'Sci-Fi' }],
}}
related={[]}
/>,
)
await waitFor(() => {
expect(screen.getByText('Trending in Sci-Fi')).not.toBeNull()
})
expect(global.fetch).toHaveBeenCalledWith('/api/art/69827/similar-ai', { credentials: 'same-origin' })
expect(global.fetch).toHaveBeenCalledWith('/api/rank/category/5?type=trending', { credentials: 'same-origin' })
})
})

View File

@@ -3,6 +3,7 @@ import AuthorBioPopover from './AuthorBioPopover'
import FollowButton from '../social/FollowButton'
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/missing_sq.webp'
const NUMBER_FORMATTER = new Intl.NumberFormat('en-US')
function formatCount(value) {
const n = Number(value || 0)
@@ -91,7 +92,7 @@ export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
) : null}
</div>
<p className="mt-1 text-xs font-medium text-white/30">
{followersCount.toLocaleString()} Followers
{NUMBER_FORMATTER.format(followersCount)} Followers
</p>
{/* Profile + Follow buttons */}
@@ -152,7 +153,7 @@ export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
<div className="mt-5 border-t border-white/[0.06] pt-5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-white/80">{isGroupPublisher ? 'More related works' : `More from ${authorName}`}</h3>
<a href={profileUrl} className="text-white/30 transition-colors hover:text-white/60">
<a href={profileUrl} aria-label={isGroupPublisher ? 'View more related works' : `View all from ${authorName}`} className="text-white/30 transition-colors hover:text-white/60">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4">
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>

View File

@@ -0,0 +1,18 @@
import React, { useState } from 'react'
import Checkbox from '../ui/Checkbox'
export default function RememberMeCheckbox({ initialChecked = false, label = 'Remember me', name = 'remember' }) {
const [checked, setChecked] = useState(Boolean(initialChecked))
return (
<Checkbox
name={name}
value="1"
checked={checked}
onChange={(event) => setChecked(event.target.checked)}
label={label}
variant="accent"
size={18}
/>
)
}

View File

@@ -13,6 +13,8 @@ import { common, createLowlight } from 'lowlight';
import tippy from 'tippy.js';
import { buildBotFingerprint } from '../../lib/security/botFingerprint';
import TurnstileField from '../security/TurnstileField';
import DateTimePicker from '../ui/DateTimePicker';
import Modal from '../ui/Modal';
import NovaSelect from '../ui/NovaSelect';
type StoryType = {
@@ -43,7 +45,6 @@ type StoryPayload = {
tags_csv: string;
meta_title: string;
meta_description: string;
canonical_url: string;
og_image: string;
status: string;
scheduled_for: string;
@@ -68,6 +69,8 @@ type Props = {
csrfToken: string;
};
type InsertDialogKind = 'image' | 'video' | 'download' | 'link' | null;
const EMPTY_DOC = {
type: 'doc',
content: [{ type: 'paragraph' }],
@@ -90,6 +93,108 @@ const CODE_BLOCK_LANGUAGES = [
{ value: 'markdown', label: 'Markdown' },
];
const INSERT_DIALOG_CONTENT = {
image: {
title: 'Add image from URL',
description: 'Paste a direct image URL to insert a full image block into the story body.',
confirmLabel: 'Insert image',
urlLabel: 'Image URL',
urlPlaceholder: 'https://images.example.com/story-scene.jpg',
urlHint: 'Use a direct image file URL when possible for the most reliable preview.',
},
video: {
title: 'Embed a video',
description: 'Paste a YouTube or Vimeo link. Common watch and share URLs will be converted to embed URLs automatically.',
confirmLabel: 'Embed video',
urlLabel: 'Video URL',
urlPlaceholder: 'https://www.youtube.com/watch?v=example',
urlHint: 'You can paste a normal watch URL, share URL, or a direct embed URL.',
},
download: {
title: 'Add a download link',
description: 'Create a downloadable asset button with a friendly label for readers.',
confirmLabel: 'Add download',
urlLabel: 'File URL',
urlPlaceholder: 'https://cdn.example.com/files/asset.zip',
urlHint: 'Point this at the exact file you want readers to download.',
},
link: {
title: 'Add link to selection',
description: 'Attach a link to the currently selected text in your story.',
confirmLabel: 'Save link',
urlLabel: 'Link URL',
urlPlaceholder: 'https://skinbase.org/help',
urlHint: 'Paste any http or https URL. Leave it empty and use Remove link to clear an existing link.',
},
};
const INSERT_DIALOG_INITIAL_STATE = {
kind: null as InsertDialogKind,
url: '',
title: '',
label: 'Download asset',
error: '',
};
function normalizeHttpUrl(rawValue: string): string | null {
const trimmed = rawValue.trim();
if (trimmed === '') {
return null;
}
const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed.replace(/^\/+/, '')}`;
try {
const parsed = new URL(withProtocol);
if (!['http:', 'https:'].includes(parsed.protocol)) {
return null;
}
return parsed.toString();
} catch {
return null;
}
}
function normalizeVideoEmbedUrl(rawValue: string): string | null {
const normalized = normalizeHttpUrl(rawValue);
if (!normalized) {
return null;
}
const parsed = new URL(normalized);
const host = parsed.hostname.replace(/^www\./i, '').toLowerCase();
const path = parsed.pathname;
if (host === 'youtu.be') {
const videoId = path.replace(/^\//, '').split('/')[0];
return videoId ? `https://www.youtube.com/embed/${videoId}` : normalized;
}
if (host === 'youtube.com' || host === 'm.youtube.com') {
if (path === '/watch') {
const videoId = parsed.searchParams.get('v');
return videoId ? `https://www.youtube.com/embed/${videoId}` : normalized;
}
const pathMatch = path.match(/^\/(embed|shorts|live)\/([^/?#]+)/i);
if (pathMatch?.[2]) {
return `https://www.youtube.com/embed/${pathMatch[2]}`;
}
}
if (host === 'vimeo.com') {
const videoId = path.replace(/^\//, '').split('/')[0];
return videoId ? `https://player.vimeo.com/video/${videoId}` : normalized;
}
if (host === 'player.vimeo.com') {
return normalized;
}
return normalized;
}
const ArtworkBlock = Node.create({
name: 'artworkEmbed',
group: 'block',
@@ -263,6 +368,7 @@ function createSlashCommandExtension(insert: {
code: () => void;
quote: () => void;
divider: () => void;
part: () => void;
gallery: () => void;
video: () => void;
download: () => void;
@@ -282,6 +388,7 @@ function createSlashCommandExtension(insert: {
{ title: 'Artwork', key: 'artwork' },
{ title: 'Code', key: 'code' },
{ title: 'Quote', key: 'quote' },
{ title: 'Add a new part', key: 'part' },
{ title: 'Divider', key: 'divider' },
{ title: 'Gallery', key: 'gallery' },
{ title: 'Video', key: 'video' },
@@ -295,6 +402,7 @@ function createSlashCommandExtension(insert: {
if (props.key === 'artwork') insert.artwork();
if (props.key === 'code') insert.code();
if (props.key === 'quote') insert.quote();
if (props.key === 'part') insert.part();
if (props.key === 'divider') insert.divider();
if (props.key === 'gallery') insert.gallery();
if (props.key === 'video') insert.video();
@@ -438,7 +546,6 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
const [tagsCsv, setTagsCsv] = useState(initialStory.tags_csv || '');
const [metaTitle, setMetaTitle] = useState(initialStory.meta_title || '');
const [metaDescription, setMetaDescription] = useState(initialStory.meta_description || '');
const [canonicalUrl, setCanonicalUrl] = useState(initialStory.canonical_url || '');
const [ogImage, setOgImage] = useState(initialStory.og_image || '');
const [status, setStatus] = useState(initialStory.status || 'draft');
const [scheduledFor, setScheduledFor] = useState(initialStory.scheduled_for || '');
@@ -449,14 +556,19 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
const [inlineToolbar, setInlineToolbar] = useState({ visible: false, top: 0, left: 0 });
const [fieldErrors, setFieldErrors] = useState<Record<string, string[]>>({});
const [generalError, setGeneralError] = useState('');
const [insertDialog, setInsertDialog] = useState(INSERT_DIALOG_INITIAL_STATE);
const [wordCount, setWordCount] = useState(0);
const [readMinutes, setReadMinutes] = useState(1);
const [codeBlockLanguage, setCodeBlockLanguage] = useState('bash');
const [isSubmitting, setIsSubmitting] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [focusMode, setFocusMode] = useState(false);
const [plusMenuOpen, setPlusMenuOpen] = useState(false);
const [plusButtonState, setPlusButtonState] = useState({ visible: false, top: 0, left: 0 });
const editorContainerRef = useRef<HTMLDivElement | null>(null);
const insertSelectionRef = useRef<{ from: number; to: number } | null>(null);
const titleInputRef = useRef<HTMLTextAreaElement | null>(null);
const excerptInputRef = useRef<HTMLTextAreaElement | null>(null);
const [captchaState, setCaptchaState] = useState({
required: false,
token: '',
@@ -534,17 +646,6 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
setFieldErrors({});
}, []);
const openLinkPrompt = useCallback((editor: any) => {
const prev = editor.getAttributes('link').href;
const url = window.prompt('Link URL', prev || 'https://');
if (url === null) return;
if (url.trim() === '') {
editor.chain().focus().unsetLink().run();
return;
}
editor.chain().focus().setLink({ href: url.trim() }).run();
}, []);
const fetchArtworks = useCallback(async (query: string) => {
const q = encodeURIComponent(query);
const response = await fetch(`${endpoints.artworks}?q=${q}`, {
@@ -612,12 +713,152 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
currentEditor.chain().focus().setCodeBlock({ language: codeBlockLanguage }).run();
}, [codeBlockLanguage]);
const closeInsertDialog = useCallback(() => {
insertSelectionRef.current = null;
setInsertDialog(INSERT_DIALOG_INITIAL_STATE);
}, []);
const openInsertDialog = useCallback((kind: Exclude<InsertDialogKind, null>) => {
const currentEditor = editorRef.current;
if (!currentEditor) {
return;
}
const { from, to } = currentEditor.state.selection;
insertSelectionRef.current = { from, to };
setInsertDialog({
kind,
url: '',
title: kind === 'video' ? 'Embedded video' : '',
label: 'Download asset',
error: '',
});
}, []);
const openLinkDialog = useCallback(() => {
const currentEditor = editorRef.current;
if (!currentEditor) {
return;
}
const { from, to } = currentEditor.state.selection;
if (from === to) {
return;
}
insertSelectionRef.current = { from, to };
setInsertDialog({
kind: 'link',
url: currentEditor.getAttributes('link').href || '',
title: '',
label: 'Download asset',
error: '',
});
}, []);
const removeSelectedLink = useCallback(() => {
const currentEditor = editorRef.current;
if (!currentEditor) {
closeInsertDialog();
return;
}
const selection = insertSelectionRef.current;
const chain = currentEditor.chain().focus();
if (selection) {
chain.setTextSelection(selection).extendMarkRange('link');
}
chain.unsetLink().run();
closeInsertDialog();
}, [closeInsertDialog]);
const submitInsertDialog = useCallback((event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
if (!insertDialog.kind) {
return;
}
const currentEditor = editorRef.current;
if (!currentEditor) {
closeInsertDialog();
return;
}
if (insertDialog.kind === 'link') {
const selection = insertSelectionRef.current;
const chain = currentEditor.chain().focus();
if (selection) {
chain.setTextSelection(selection).extendMarkRange('link');
}
const normalizedLink = normalizeHttpUrl(insertDialog.url);
if (!normalizedLink) {
setInsertDialog((previous) => ({
...previous,
error: 'Enter a valid http or https URL for the selected text.',
}));
return;
}
chain.setLink({ href: normalizedLink }).run();
closeInsertDialog();
return;
}
let normalizedUrl = normalizeHttpUrl(insertDialog.url);
if (insertDialog.kind === 'video') {
normalizedUrl = normalizeVideoEmbedUrl(insertDialog.url);
}
if (!normalizedUrl) {
setInsertDialog((previous) => ({
...previous,
error: insertDialog.kind === 'video'
? 'Enter a valid YouTube, Vimeo, or direct embed URL.'
: 'Enter a valid http or https URL.',
}));
return;
}
const selection = insertSelectionRef.current;
const chain = currentEditor.chain().focus();
if (selection) {
chain.setTextSelection(selection);
}
if (insertDialog.kind === 'image') {
chain.setImage({ src: normalizedUrl }).run();
closeInsertDialog();
return;
}
if (insertDialog.kind === 'video') {
chain.insertContent({
type: 'videoEmbed',
attrs: {
src: normalizedUrl,
title: insertDialog.title.trim() || 'Embedded video',
},
}).run();
closeInsertDialog();
return;
}
chain.insertContent({
type: 'downloadAsset',
attrs: {
url: normalizedUrl,
label: insertDialog.label.trim() || 'Download asset',
},
}).run();
closeInsertDialog();
}, [closeInsertDialog, insertDialog]);
const insertActions = useMemo(() => ({
image: () => {
const currentEditor = editorRef.current;
const url = window.prompt('Image URL', 'https://');
if (!url || !currentEditor) return;
currentEditor.chain().focus().setImage({ src: url }).run();
openInsertDialog('image');
},
uploadImage: () => bodyImageInputRef.current?.click(),
artwork: () => setArtworkModalOpen(true),
@@ -634,6 +875,11 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
if (!currentEditor) return;
currentEditor.chain().focus().setHorizontalRule().run();
},
part: () => {
const currentEditor = editorRef.current;
if (!currentEditor) return;
currentEditor.chain().focus().setHorizontalRule().run();
},
gallery: () => {
const currentEditor = editorRef.current;
if (!currentEditor) return;
@@ -642,21 +888,12 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
currentEditor.chain().focus().insertContent({ type: 'galleryBlock', attrs: { images } }).run();
},
video: () => {
const currentEditor = editorRef.current;
if (!currentEditor) return;
const src = window.prompt('Video embed URL (YouTube/Vimeo)', 'https://www.youtube.com/embed/');
if (!src) return;
currentEditor.chain().focus().insertContent({ type: 'videoEmbed', attrs: { src, title: 'Embedded video' } }).run();
openInsertDialog('video');
},
download: () => {
const currentEditor = editorRef.current;
if (!currentEditor) return;
const url = window.prompt('Download URL', 'https://');
if (!url) return;
const label = window.prompt('Button label', 'Download asset') || 'Download asset';
currentEditor.chain().focus().insertContent({ type: 'downloadAsset', attrs: { url, label } }).run();
openInsertDialog('download');
},
}), [toggleCodeBlockWithLanguage]);
}), [openInsertDialog, toggleCodeBlockWithLanguage]);
const editor = useEditor({
extensions: [
@@ -692,7 +929,7 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
content: initialStory.content || EMPTY_DOC,
editorProps: {
attributes: {
class: 'tiptap prose prose-lg prose-headings:text-white prose-headings:font-bold prose-headings:tracking-tight prose-p:text-white/90 prose-p:leading-[1.85] prose-strong:text-white prose-a:text-sky-400 prose-a:underline prose-blockquote:border-l-sky-400/60 prose-blockquote:text-white/65 prose-blockquote:italic prose-code:text-sky-300 prose-pre:bg-black/30 prose-pre:text-sky-100 max-w-none min-h-[32rem] bg-transparent px-0 py-0 text-white/90 focus:outline-none',
class: 'tiptap prose prose-xl prose-headings:text-white prose-headings:font-bold prose-headings:tracking-tight prose-p:text-white/90 prose-p:leading-[1.9] prose-li:leading-[1.9] prose-strong:text-white prose-a:text-sky-400 prose-a:underline prose-blockquote:border-l-sky-400/60 prose-blockquote:text-white/65 prose-blockquote:italic prose-code:text-sky-300 prose-pre:bg-black/30 prose-pre:text-sky-100 max-w-none min-h-[32rem] bg-transparent px-0 py-0 text-white/90 focus:outline-none',
},
handleDrop: (_view, event) => {
const file = event.dataTransfer?.files?.[0];
@@ -810,39 +1047,62 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
useEffect(() => {
if (!editor) return;
const hidePlusButton = () => {
setPlusButtonState({ visible: false, top: 0, left: 0 });
setPlusMenuOpen(false);
};
const updatePlusButton = () => {
const { from, to } = editor.state.selection;
if (from !== to) {
setPlusButtonState({ visible: false, top: 0, left: 0 });
setPlusMenuOpen(false);
if (from !== to || !editor.isFocused) {
hidePlusButton();
return;
}
const resolvedPos = editor.state.doc.resolve(from);
const parentNode = resolvedPos.parent;
if (parentNode.type.name === 'paragraph' && parentNode.content.size === 0) {
const coords = editor.view.coordsAtPos(from);
const containerRect = editorContainerRef.current?.getBoundingClientRect();
if (!containerRect) {
setPlusButtonState({ visible: false, top: 0, left: 0 });
return;
}
setPlusButtonState({
visible: true,
top: coords.top - 14,
left: containerRect.left - 48,
});
} else {
setPlusButtonState({ visible: false, top: 0, left: 0 });
setPlusMenuOpen(false);
const container = editorContainerRef.current;
if (!container) {
hidePlusButton();
return;
}
const domAtPos = editor.view.domAtPos(from);
const anchorNode = domAtPos.node instanceof Element ? domAtPos.node : domAtPos.node.parentElement;
const blockElement = anchorNode?.closest('p, h1, h2, h3, blockquote, pre, li');
if (!blockElement || !container.contains(blockElement)) {
hidePlusButton();
return;
}
const blockRect = blockElement.getBoundingClientRect();
const computedStyle = window.getComputedStyle(blockElement);
const parsedLineHeight = Number.parseFloat(computedStyle.lineHeight);
const lineHeight = Number.isFinite(parsedLineHeight) ? parsedLineHeight : 32;
setPlusButtonState({
visible: true,
top: blockRect.top + Math.max((lineHeight - 32) / 2, 0),
left: Math.max(16, blockRect.left - 44),
});
};
editor.on('selectionUpdate', updatePlusButton);
editor.on('update', updatePlusButton);
editor.on('focus', updatePlusButton);
editor.on('blur', hidePlusButton);
const frameId = window.requestAnimationFrame(updatePlusButton);
window.addEventListener('scroll', updatePlusButton, true);
window.addEventListener('resize', updatePlusButton);
return () => {
window.cancelAnimationFrame(frameId);
window.removeEventListener('scroll', updatePlusButton, true);
window.removeEventListener('resize', updatePlusButton);
editor.off('selectionUpdate', updatePlusButton);
editor.off('update', updatePlusButton);
editor.off('focus', updatePlusButton);
editor.off('blur', hidePlusButton);
};
}, [editor]);
@@ -856,12 +1116,11 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
tags: tagsCsv.split(',').map((tag) => tag.trim()).filter(Boolean),
meta_title: metaTitle || title,
meta_description: metaDescription || excerpt,
canonical_url: canonicalUrl,
og_image: ogImage || coverImage,
status,
scheduled_for: scheduledFor || null,
content: editor?.getJSON() || EMPTY_DOC,
}), [storyId, title, excerpt, coverImage, storyType, tagsCsv, metaTitle, metaDescription, canonicalUrl, ogImage, status, scheduledFor, editor]);
}), [storyId, title, excerpt, coverImage, storyType, tagsCsv, metaTitle, metaDescription, ogImage, status, scheduledFor, editor]);
useEffect(() => {
if (!editor) return;
@@ -993,6 +1252,84 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
const contentError = fieldErrors?.content?.[0] || '';
const excerptError = fieldErrors?.excerpt?.[0] || '';
const tagsError = fieldErrors?.tags_csv?.[0] || '';
const completedChecks = readinessChecks.filter((check) => check.ok).length;
const progressPercent = Math.max(20, Math.round((completedChecks / Math.max(readinessChecks.length, 1)) * 100));
const topActions = [
{
key: 'cover',
label: coverImage ? 'Change cover' : 'Add cover',
detail: coverImage ? 'Refresh the hero image.' : 'Give the story a visual anchor.',
onClick: () => coverImageInputRef.current?.click(),
tone: 'sky',
},
{
key: 'part',
label: 'New part',
detail: 'Drop in the three-dot chapter separator.',
onClick: () => insertActions.part(),
tone: 'violet',
},
{
key: 'settings',
label: 'Story settings',
detail: 'Manage SEO, workflow, and metadata.',
onClick: () => setSettingsOpen(true),
tone: 'slate',
},
];
const desktopInsertActions = [
{ key: 'uploadImage', label: 'Upload photo', detail: 'Drop a full-width image into the body.' },
{ key: 'artwork', label: 'Embed artwork', detail: 'Showcase one of your published pieces.' },
{ key: 'video', label: 'Embed video', detail: 'Paste YouTube or Vimeo and let Nova normalize it.' },
{ key: 'download', label: 'Download link', detail: 'Add a clear file CTA for readers.' },
{ key: 'part', label: 'Add a new part', detail: 'Break long stories into readable chapters.' },
] as Array<{ key: keyof typeof insertActions; label: string; detail: string }>;
const quickLinks = storyId ? [
{ key: 'preview', label: 'Preview story', href: `${endpoints.previewBase}/${storyId}/preview` },
{ key: 'analytics', label: 'Story analytics', href: `${endpoints.analyticsBase}/${storyId}/analytics` },
] : [];
const storySuggestions = [
!coverImage ? {
key: 'cover',
label: 'Add a cover image',
detail: 'A strong visual anchor makes the draft feel finished faster.',
onClick: () => coverImageInputRef.current?.click(),
tone: 'sky',
} : null,
excerpt.trim().length < 40 ? {
key: 'excerpt',
label: 'Sharpen the subtitle',
detail: 'Give readers one sentence that sets the tone before the first paragraph.',
onClick: () => excerptInputRef.current?.focus(),
tone: 'violet',
} : null,
wordCount >= 220 ? {
key: 'part',
label: 'Split the next chapter',
detail: 'This draft is long enough for a visual chapter break.',
onClick: () => insertActions.part(),
tone: 'emerald',
} : null,
tagsCsv.trim().length === '' ? {
key: 'tags',
label: 'Add discovery tags',
detail: 'Open settings and add a few tags so the story is easier to surface later.',
onClick: () => setSettingsOpen(true),
tone: 'amber',
} : null,
].filter(Boolean) as Array<{ key: string; label: string; detail: string; onClick: () => void; tone: string }>;
const topActionToneClasses: Record<string, string> = {
sky: 'border-sky-300/18 bg-sky-400/10 text-sky-100 hover:border-sky-300/35 hover:bg-sky-400/15',
violet: 'border-violet-300/18 bg-violet-400/10 text-violet-100 hover:border-violet-300/35 hover:bg-violet-400/15',
slate: 'border-white/10 bg-white/[0.045] text-white/78 hover:border-white/20 hover:bg-white/[0.08]',
};
const suggestionToneClasses: Record<string, string> = {
sky: 'border-sky-300/18 bg-sky-400/10 text-sky-100',
violet: 'border-violet-300/18 bg-violet-400/10 text-violet-100',
emerald: 'border-emerald-300/18 bg-emerald-400/10 text-emerald-100',
amber: 'border-amber-300/18 bg-amber-400/10 text-amber-100',
};
const insertArtwork = (item: Artwork) => {
if (!editor) return;
@@ -1009,7 +1346,8 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
};
return (
<div className="mx-auto max-w-4xl px-4 py-4 pb-24 md:px-8">
<div className={`min-h-screen px-4 py-4 pb-24 md:px-8 ${focusMode ? 'bg-[linear-gradient(180deg,rgba(6,10,16,0.99),rgba(4,7,12,1))]' : 'bg-[radial-gradient(circle_at_top,_rgba(56,189,248,0.09),_transparent_30%),radial-gradient(circle_at_20%_20%,_rgba(14,165,233,0.07),_transparent_24%),linear-gradient(180deg,rgba(7,11,18,0.98),rgba(4,7,12,1))]'}`}>
<div className={`mx-auto ${focusMode ? 'max-w-[1180px]' : 'max-w-[1400px]'}`}>
{/* ── Nova top bar ─────────────────────────────────────────────────── */}
<div className="sticky top-0 z-30 mb-6 flex h-14 items-center justify-between overflow-hidden rounded-2xl border border-white/10 bg-[linear-gradient(135deg,rgba(12,18,28,0.97),rgba(8,12,20,0.97))] px-5 shadow-[0_8px_32px_rgba(3,7,18,0.32)] backdrop-blur-xl">
<div className="flex items-center gap-4">
@@ -1022,6 +1360,13 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
</div>
<div className="flex items-center gap-2">
<span className="hidden text-xs text-white/55 lg:inline">{wordCount > 0 ? `${wordCount.toLocaleString()} words · ${readMinutes} min` : ''}</span>
<button
type="button"
onClick={() => setFocusMode((current) => !current)}
className={`rounded-full border px-3 py-1.5 text-sm transition ${focusMode ? 'border-sky-400/30 bg-sky-400/10 text-sky-100 hover:bg-sky-400/15' : 'border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white'}`}
>
{focusMode ? 'Exit focus' : 'Focus mode'}
</button>
<button
type="button"
onClick={() => setSettingsOpen(true)}
@@ -1049,8 +1394,75 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
</div>
</div>
<div className={`grid gap-6 ${focusMode ? '' : 'xl:grid-cols-[minmax(0,1fr)_300px] xl:items-start'}`}>
<main>
{!focusMode && (
<div className="mb-6 overflow-hidden rounded-[2rem] border border-white/10 bg-[linear-gradient(135deg,rgba(15,23,36,0.9),rgba(9,14,24,0.96))] shadow-[0_24px_80px_rgba(2,6,23,0.28)] backdrop-blur-xl">
<div className="flex flex-col gap-5 px-6 py-6 md:px-8 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/55">Story Studio</p>
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-white md:text-[2.35rem]">Shape the narrative before readers ever see the first line.</h1>
<p className="mt-3 max-w-2xl text-sm leading-7 text-slate-300/82 md:text-[15px]">Use the writing canvas for the draft itself, keep your metadata close, and drop in chapter breaks or rich media without leaving the flow.</p>
</div>
<div className="grid gap-3 sm:grid-cols-3 lg:min-w-[420px] lg:max-w-[460px] lg:flex-1">
{topActions.map((action) => (
<button
key={action.key}
type="button"
onClick={action.onClick}
className={`rounded-[1.35rem] border px-4 py-4 text-left transition ${topActionToneClasses[action.tone]}`}
>
<div className="text-sm font-semibold">{action.label}</div>
<div className="mt-1.5 text-xs leading-5 text-inherit/70">{action.detail}</div>
</button>
))}
</div>
</div>
</div>
)}
<div className="nb-scrollbar-none mb-5 overflow-x-auto overflow-y-hidden rounded-[1.6rem] border border-white/10 bg-[linear-gradient(180deg,rgba(11,17,27,0.94),rgba(7,10,17,0.96))] px-4 py-3 shadow-[0_18px_50px_rgba(2,6,23,0.22)] backdrop-blur-xl sm:px-5">
<div className="flex min-w-max items-center gap-2">
{desktopInsertActions.map((action) => (
<button
key={`top-toolbar-${action.key}`}
type="button"
onClick={() => insertActions[action.key]()}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white/78 transition hover:border-sky-400/28 hover:bg-sky-400/[0.08] hover:text-white"
>
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-white/[0.05] text-[11px] text-sky-200">+</span>
{action.label}
</button>
))}
<span className="mx-1 hidden h-5 w-px bg-white/10 md:block" />
<button
type="button"
onClick={() => setSettingsOpen(true)}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white/78 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
>
Story settings
</button>
<button
type="button"
onClick={() => setFocusMode((current) => !current)}
className={`inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm transition ${focusMode ? 'border-sky-400/28 bg-sky-400/[0.08] text-sky-100 hover:bg-sky-400/[0.14]' : 'border-white/10 bg-white/[0.04] text-white/78 hover:border-white/20 hover:bg-white/[0.08] hover:text-white'}`}
>
{focusMode ? 'Exit focus' : 'Focus mode'}
</button>
{quickLinks.map((link) => (
<a
key={`top-toolbar-${link.key}`}
href={link.href}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white/78 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
>
{link.label}
</a>
))}
</div>
</div>
{/* ── Writing canvas ───────────────────────────────────────────────── */}
<div className="mx-auto max-w-[760px] overflow-hidden rounded-[2rem] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.07),_transparent_32%),linear-gradient(180deg,rgba(14,18,27,0.99),rgba(9,12,19,0.98))] shadow-[0_24px_80px_rgba(4,8,20,0.36)]">
<div className={`mx-auto overflow-hidden rounded-[2rem] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.07),_transparent_32%),linear-gradient(180deg,rgba(14,18,27,0.99),rgba(9,12,19,0.98))] shadow-[0_24px_80px_rgba(4,8,20,0.36)] ${focusMode ? 'max-w-[920px]' : 'max-w-[780px]'}`}>
{coverImage ? (
<div className="group relative overflow-hidden rounded-t-2xl">
<img src={coverImage} alt="Story cover" className="h-64 w-full object-cover md:h-80" />
@@ -1110,6 +1522,7 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
{/* Title */}
<div className="mb-3">
<textarea
ref={titleInputRef}
value={title}
onChange={(event) => {
setTitle(event.target.value);
@@ -1130,6 +1543,7 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
{/* Excerpt / subtitle */}
<div className="mb-10 border-b border-white/[0.07] pb-8">
<textarea
ref={excerptInputRef}
value={excerpt}
onChange={(event) => {
setExcerpt(event.target.value);
@@ -1183,6 +1597,104 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
)}
</div>
</div>
</div>
</main>
{!focusMode ? (
<aside className="hidden xl:block">
<div className="sticky top-[5.5rem] space-y-4">
<div className="overflow-hidden rounded-[1.75rem] border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.96),rgba(8,12,20,0.96))] shadow-[0_20px_60px_rgba(2,6,23,0.32)] backdrop-blur-xl">
<div className="border-b border-white/10 px-5 py-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/40">Story pulse</p>
<div className="mt-3 flex items-end justify-between gap-3">
<div>
<p className="text-2xl font-semibold text-white">{completedChecks}/{readinessChecks.length}</p>
<p className="mt-1 text-sm text-slate-300/72">Publishing readiness</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2 text-right">
<div className="text-[11px] uppercase tracking-[0.18em] text-white/35">Rhythm</div>
<div className="mt-1 text-sm font-medium text-white/85">{wordCount > 0 ? `${wordCount.toLocaleString()} words` : 'Start writing'}</div>
<div className="mt-1 text-xs text-white/45">{readMinutes} min read</div>
</div>
</div>
<div className="mt-4 h-2 overflow-hidden rounded-full bg-white/[0.06]">
<div className="h-full rounded-full bg-[linear-gradient(90deg,rgba(56,189,248,0.9),rgba(59,130,246,0.92))]" style={{ width: `${progressPercent}%` }} />
</div>
</div>
<div className="space-y-2 px-5 py-4">
{readinessChecks.map((check) => (
<div key={check.label} className={`rounded-2xl border px-4 py-3 ${check.ok ? 'border-emerald-400/18 bg-emerald-500/10' : 'border-amber-400/18 bg-amber-500/10'}`}>
<div className="flex items-start gap-3">
<span className={`mt-0.5 inline-flex h-5 w-5 items-center justify-center rounded-full text-[11px] font-bold ${check.ok ? 'bg-emerald-400/20 text-emerald-200' : 'bg-amber-400/20 text-amber-200'}`}>{check.ok ? '✓' : '!'}</span>
<div>
<p className="text-sm font-medium text-white/88">{check.label}</p>
<p className="mt-1 text-xs leading-5 text-white/48">{check.hint}</p>
</div>
</div>
</div>
))}
</div>
</div>
{storySuggestions.length > 0 ? (
<div className="overflow-hidden rounded-[1.75rem] border border-white/10 bg-[linear-gradient(180deg,rgba(13,19,29,0.97),rgba(8,12,20,0.97))] shadow-[0_20px_60px_rgba(2,6,23,0.32)] backdrop-blur-xl">
<div className="border-b border-white/10 px-5 py-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/40">Suggestions</p>
<p className="mt-2 text-sm leading-6 text-slate-300/78">A few next moves based on the draft you have right now.</p>
</div>
<div className="space-y-2 px-5 py-4">
{storySuggestions.map((suggestion) => (
<button
key={suggestion.key}
type="button"
onClick={suggestion.onClick}
className={`w-full rounded-2xl border px-4 py-3 text-left transition hover:translate-x-0.5 ${suggestionToneClasses[suggestion.tone]}`}
>
<div className="text-sm font-semibold">{suggestion.label}</div>
<div className="mt-1 text-xs leading-5 text-inherit/70">{suggestion.detail}</div>
</button>
))}
</div>
</div>
) : null}
<div className="overflow-hidden rounded-[1.75rem] border border-white/10 bg-[linear-gradient(180deg,rgba(13,19,29,0.97),rgba(8,12,20,0.97))] shadow-[0_20px_60px_rgba(2,6,23,0.32)] backdrop-blur-xl">
<div className="border-b border-white/10 px-5 py-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/40">Desktop shortcuts</p>
<p className="mt-2 text-sm leading-6 text-slate-300/78">Keep the heavy-lift actions nearby while the canvas stays clean.</p>
</div>
<div className="space-y-2 px-5 py-4">
{desktopInsertActions.map((action) => (
<button
key={action.key}
type="button"
onClick={() => insertActions[action.key]()}
className="w-full rounded-2xl border border-white/10 bg-white/[0.035] px-4 py-3 text-left transition hover:border-sky-400/30 hover:bg-sky-400/[0.08]"
>
<div className="text-sm font-semibold text-white/88">{action.label}</div>
<div className="mt-1 text-xs leading-5 text-white/48">{action.detail}</div>
</button>
))}
</div>
{quickLinks.length > 0 ? (
<div className="border-t border-white/10 px-5 py-4">
<div className="space-y-2">
{quickLinks.map((link) => (
<a
key={link.key}
href={link.href}
className="block rounded-2xl border border-white/10 bg-white/[0.035] px-4 py-3 text-sm font-medium text-white/80 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
>
{link.label}
</a>
))}
</div>
</div>
) : null}
</div>
</div>
</aside>
) : null}
</div>
</div>
{/* ── Floating + block insertion button (fixed, always visible when on empty line) ── */}
@@ -1218,6 +1730,7 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
{ label: 'Blockquote', icon: '❝', key: 'quote' },
{ label: 'Code block', icon: '⌨', key: 'code' },
{ label: 'Download link', icon: '↓', key: 'download' },
{ label: 'Add a new part', icon: '⋯', key: 'part' },
{ label: 'Divider', icon: '—', key: 'divider' },
] as Array<{ label: string; icon: string; key: keyof typeof insertActions }>).map((item) => (
<button
@@ -1242,29 +1755,42 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
{/* ── Floating inline formatting toolbar ───────────────────────────── */}
{editor && inlineToolbar.visible && (
<div
className="fixed z-50 flex items-center gap-0.5 overflow-hidden rounded-2xl border border-white/10 bg-[linear-gradient(135deg,rgba(12,18,28,0.98),rgba(8,12,20,0.98))] p-1.5 shadow-[0_8px_32px_rgba(3,7,18,0.5)] backdrop-blur-xl"
className="fixed z-50 flex items-center overflow-hidden rounded-2xl border border-white/10 bg-[linear-gradient(135deg,rgba(12,18,28,0.98),rgba(8,12,20,0.98))] p-1.5 shadow-[0_8px_32px_rgba(3,7,18,0.5)] backdrop-blur-xl"
style={{ top: `${inlineToolbar.top}px`, left: `${inlineToolbar.left}px` }}
>
{([
{ label: 'B', title: 'Bold', action: () => editor.chain().focus().toggleBold().run(), active: editor.isActive('bold'), extra: 'font-bold' },
{ label: 'I', title: 'Italic', action: () => editor.chain().focus().toggleItalic().run(), active: editor.isActive('italic'), extra: 'italic' },
{ label: 'U', title: 'Underline', action: () => editor.chain().focus().toggleUnderline().run(), active: editor.isActive('underline'), extra: 'underline' },
{ label: 'H2', title: 'Heading 2', action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), active: editor.isActive('heading', { level: 2 }), extra: 'font-semibold text-xs' },
{ label: 'H3', title: 'Heading 3', action: () => editor.chain().focus().toggleHeading({ level: 3 }).run(), active: editor.isActive('heading', { level: 3 }), extra: 'font-semibold text-xs' },
{ label: '❝', title: 'Blockquote', action: () => editor.chain().focus().toggleBlockquote().run(), active: editor.isActive('blockquote'), extra: 'text-base font-serif' },
{ label: '', title: 'Link', action: () => openLinkPrompt(editor), active: editor.isActive('link'), extra: '' },
{ label: '</>', title: 'Inline code', action: () => editor.chain().focus().toggleCode().run(), active: editor.isActive('code'), extra: 'font-mono text-[10px]' },
] as Array<{ label: string; title: string; action: () => void; active: boolean; extra: string }>).map((item) => (
<button
key={item.title}
type="button"
title={item.title}
onMouseDown={(e) => e.preventDefault()}
onClick={item.action}
className={`flex h-8 min-w-[2rem] items-center justify-center rounded-xl px-1.5 text-sm transition ${item.extra} ${item.active ? 'bg-sky-500/25 text-sky-200' : 'text-white/70 hover:bg-white/[0.07] hover:text-white'}`}
>
{item.label}
</button>
[
{ label: 'B', title: 'Bold', action: () => editor.chain().focus().toggleBold().run(), active: editor.isActive('bold'), extra: 'font-bold' },
{ label: 'I', title: 'Italic', action: () => editor.chain().focus().toggleItalic().run(), active: editor.isActive('italic'), extra: 'italic' },
{ label: 'U', title: 'Underline', action: () => editor.chain().focus().toggleUnderline().run(), active: editor.isActive('underline'), extra: 'underline' },
],
[
{ label: 'H2', title: 'Heading 2', action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), active: editor.isActive('heading', { level: 2 }), extra: 'font-semibold text-xs' },
{ label: 'H3', title: 'Heading 3', action: () => editor.chain().focus().toggleHeading({ level: 3 }).run(), active: editor.isActive('heading', { level: 3 }), extra: 'font-semibold text-xs' },
{ label: '❝', title: 'Blockquote', action: () => editor.chain().focus().toggleBlockquote().run(), active: editor.isActive('blockquote'), extra: 'text-base font-serif' },
],
[
{ label: '⛓', title: 'Link', action: openLinkDialog, active: editor.isActive('link'), extra: '' },
{ label: '</>', title: 'Inline code', action: () => editor.chain().focus().toggleCode().run(), active: editor.isActive('code'), extra: 'font-mono text-[10px]' },
],
] as Array<Array<{ label: string; title: string; action: () => void; active: boolean; extra: string }>>).map((group, groupIndex) => (
<React.Fragment key={`inline-toolbar-group-${groupIndex}`}>
{groupIndex > 0 ? <span className="mx-1 h-6 w-px bg-white/10" aria-hidden="true" /> : null}
<div className="flex items-center gap-0.5 px-0.5">
{group.map((item) => (
<button
key={item.title}
type="button"
title={item.title}
onMouseDown={(e) => e.preventDefault()}
onClick={item.action}
className={`flex h-8 min-w-[2rem] items-center justify-center rounded-xl px-1.5 text-sm transition ${item.extra} ${item.active ? 'bg-sky-500/25 text-sky-200' : 'text-white/70 hover:bg-white/[0.07] hover:text-white'}`}
>
{item.label}
</button>
))}
</div>
</React.Fragment>
))}
</div>
)}
@@ -1348,7 +1874,13 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-widest text-white/35">Workflow</p>
<NovaSelect value={status} onChange={(val) => setStatus(val)} searchable={false} options={[{ value: 'draft', label: 'Draft' }, { value: 'pending_review', label: 'Pending Review' }, { value: 'published', label: 'Published' }, { value: 'scheduled', label: 'Scheduled' }, { value: 'archived', label: 'Archived' }]} />
<input type="datetime-local" value={scheduledFor} onChange={(e) => setScheduledFor(e.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white focus:border-white/20 focus:outline-none" />
<DateTimePicker
value={scheduledFor}
onChange={setScheduledFor}
placeholder="Pick a publish date"
clearable
className="bg-slate-950/60"
/>
</div>
{/* SEO */}
@@ -1357,7 +1889,6 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
<div className="space-y-2">
<input value={metaTitle} onChange={(e) => setMetaTitle(e.target.value)} placeholder="Meta title (defaults to story title)" className="w-full rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white placeholder:text-white/25 focus:border-white/20 focus:outline-none" />
<textarea value={metaDescription} onChange={(e) => setMetaDescription(e.target.value)} rows={3} placeholder="Meta description (defaults to excerpt)" className="w-full resize-none rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white placeholder:text-white/25 focus:border-white/20 focus:outline-none" />
<input value={canonicalUrl} onChange={(e) => setCanonicalUrl(e.target.value)} placeholder="Canonical URL (optional)" className="w-full rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white placeholder:text-white/25 focus:border-white/20 focus:outline-none" />
<input value={ogImage} onChange={(e) => setOgImage(e.target.value)} placeholder="OG image URL (defaults to cover)" className="w-full rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white placeholder:text-white/25 focus:border-white/20 focus:outline-none" />
</div>
</div>
@@ -1411,6 +1942,97 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
</div>
)}
<Modal
open={Boolean(insertDialog.kind)}
onClose={closeInsertDialog}
title={insertDialog.kind ? INSERT_DIALOG_CONTENT[insertDialog.kind].title : ''}
size="md"
footer={insertDialog.kind ? (
<div className="ml-auto flex items-center gap-2">
{insertDialog.kind === 'link' && (
<button
type="button"
onClick={removeSelectedLink}
className="rounded-xl border border-rose-400/20 bg-rose-500/10 px-4 py-2 text-sm text-rose-200 transition hover:bg-rose-500/20"
>
Remove link
</button>
)}
<button
type="button"
onClick={closeInsertDialog}
className="rounded-xl border border-white/10 bg-white/[0.04] px-4 py-2 text-sm text-white/70 transition hover:bg-white/[0.08] hover:text-white"
>
Cancel
</button>
<button
type="submit"
form="story-insert-dialog-form"
className="rounded-xl bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-[0_6px_20px_rgba(14,165,233,0.35)] transition hover:bg-sky-400"
>
{INSERT_DIALOG_CONTENT[insertDialog.kind].confirmLabel}
</button>
</div>
) : null}
>
{insertDialog.kind ? (
<form id="story-insert-dialog-form" onSubmit={submitInsertDialog} className="space-y-5">
<div className="space-y-2">
<p className="text-sm leading-6 text-slate-200">{INSERT_DIALOG_CONTENT[insertDialog.kind].description}</p>
<p className="text-xs leading-5 text-slate-400">{INSERT_DIALOG_CONTENT[insertDialog.kind].urlHint}</p>
</div>
<div className="space-y-2">
<label className="block text-xs font-semibold uppercase tracking-[0.18em] text-white/40">
{INSERT_DIALOG_CONTENT[insertDialog.kind].urlLabel}
</label>
<input
value={insertDialog.url}
onChange={(event) => setInsertDialog((previous) => ({ ...previous, url: event.target.value, error: '' }))}
placeholder={INSERT_DIALOG_CONTENT[insertDialog.kind].urlPlaceholder}
className="w-full rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white placeholder:text-white/25 focus:border-sky-400/40 focus:outline-none"
/>
</div>
{insertDialog.kind === 'video' && (
<div className="space-y-2">
<label className="block text-xs font-semibold uppercase tracking-[0.18em] text-white/40">
Accessible title
</label>
<input
value={insertDialog.title}
onChange={(event) => setInsertDialog((previous) => ({ ...previous, title: event.target.value }))}
placeholder="Embedded video"
className="w-full rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white placeholder:text-white/25 focus:border-sky-400/40 focus:outline-none"
/>
<p className="text-xs leading-5 text-slate-400">This helps screen readers describe the embedded video block.</p>
</div>
)}
{insertDialog.kind === 'download' && (
<div className="space-y-2">
<label className="block text-xs font-semibold uppercase tracking-[0.18em] text-white/40">
Button label
</label>
<input
value={insertDialog.label}
onChange={(event) => setInsertDialog((previous) => ({ ...previous, label: event.target.value }))}
placeholder="Download asset"
className="w-full rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white placeholder:text-white/25 focus:border-sky-400/40 focus:outline-none"
/>
<p className="text-xs leading-5 text-slate-400">Readers will see this label on the download button inside the story.</p>
</div>
)}
{insertDialog.error ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
{insertDialog.error}
</div>
) : null}
</form>
) : null}
</Modal>
{/* Hidden file inputs */}
<input ref={bodyImageInputRef} type="file" accept="image/*" className="hidden" onChange={handleBodyImagePicked} />
<input ref={coverImageInputRef} type="file" accept="image/*" className="hidden" onChange={handleCoverImagePicked} />

View File

@@ -238,6 +238,8 @@ export default function RichTextEditor({
const editor = useEditor({
extensions: [
StarterKit.configure({
link: false,
underline: false,
heading: { levels: [2, 3] },
codeBlock: {
HTMLAttributes: { class: 'forum-code-block' },
@@ -262,6 +264,7 @@ export default function RichTextEditor({
suggestion: mentionSuggestion,
}),
],
immediatelyRender: false,
content,
autofocus,
editorProps: {
@@ -291,6 +294,10 @@ export default function RichTextEditor({
useEffect(() => {
if (editor && content && !editor.getHTML().includes(content.slice(0, 30))) {
editor.commands.setContent(content, false)
// Keep the parent form state in sync with what we just rendered.
// setContent with emitUpdate=false silently resets TipTap without
// calling onUpdate, so form.data.content would lag behind the editor.
onChange?.(content)
}
}, [content]) // eslint-disable-line react-hooks/exhaustive-deps

View File

@@ -38,9 +38,9 @@ export default function GroupProfileSummary({ contributions = [], href = null })
</div>
<div className="mt-4 flex flex-wrap gap-4 text-xs text-slate-400">
<span>{Number(entry.counts?.artworks || 0).toLocaleString()} artworks</span>
<span>{Number(entry.counts?.releases || 0).toLocaleString()} releases</span>
<span>{Number(entry.counts?.projects || 0).toLocaleString()} projects</span>
<span>{Number(entry.counts?.artworks || 0).toLocaleString('en-US')} artworks</span>
<span>{Number(entry.counts?.releases || 0).toLocaleString('en-US')} releases</span>
<span>{Number(entry.counts?.projects || 0).toLocaleString('en-US')} projects</span>
</div>
</a>
))}

View File

@@ -1,6 +1,8 @@
import React from 'react'
import GroupBadgePill from './GroupBadgePill'
const NUMBER_FORMATTER = new Intl.NumberFormat('en-US')
export default function GroupSummaryPanel({ group, artwork }) {
if (!group) return null
@@ -26,15 +28,15 @@ export default function GroupSummaryPanel({ group, artwork }) {
<div className="mt-5 grid grid-cols-3 gap-2 rounded-2xl border border-white/10 bg-black/20 p-3 text-center">
<div>
<div className="text-lg font-semibold text-white">{Number(group.counts?.artworks || 0).toLocaleString()}</div>
<div className="text-lg font-semibold text-white">{NUMBER_FORMATTER.format(Number(group.counts?.artworks || 0))}</div>
<div className="text-[11px] uppercase tracking-[0.16em] text-slate-500">Artworks</div>
</div>
<div>
<div className="text-lg font-semibold text-white">{Number(group.counts?.members || 0).toLocaleString()}</div>
<div className="text-lg font-semibold text-white">{NUMBER_FORMATTER.format(Number(group.counts?.members || 0))}</div>
<div className="text-[11px] uppercase tracking-[0.16em] text-slate-500">Members</div>
</div>
<div>
<div className="text-lg font-semibold text-white">{Number(group.counts?.followers || 0).toLocaleString()}</div>
<div className="text-lg font-semibold text-white">{NUMBER_FORMATTER.format(Number(group.counts?.followers || 0))}</div>
<div className="text-[11px] uppercase tracking-[0.16em] text-slate-500">Followers</div>
</div>
</div>

View File

@@ -18,10 +18,13 @@ export default function ProfileCoverEditor({
const [removing, setRemoving] = useState(false)
const [position, setPosition] = useState(coverPosition ?? 50)
const csrfToken = useMemo(
() => document.querySelector('meta[name="csrf-token"]')?.content ?? '',
[]
)
const csrfToken = useMemo(() => {
if (typeof document === 'undefined') {
return ''
}
return document.querySelector('meta[name="csrf-token"]')?.content ?? ''
}, [])
if (!isOpen) {
return null

View File

@@ -1,22 +1,26 @@
import React, { useState } from 'react'
import { usePage } from '@inertiajs/react'
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'
import { shinyFlagUrl } from '../../utils/flagUrl'
function formatCompactNumber(value) {
const numeric = Number(value ?? 0)
return numeric.toLocaleString()
return numeric.toLocaleString('en-US')
}
export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing, followerCount, recentFollowers = [], followContext = null, heroBgUrl, countryName, leaderboardRank, extraActions = null }) {
const { props } = usePage()
const [following, setFollowing] = useState(viewerIsFollowing)
const [count, setCount] = useState(followerCount)
const [editorOpen, setEditorOpen] = useState(false)
const [coverUrl, setCoverUrl] = useState(user?.cover_url || heroBgUrl || null)
const [coverPosition, setCoverPosition] = useState(Number.isFinite(user?.cover_position) ? user.cover_position : 50)
const flagUrl = shinyFlagUrl(profile?.country_code, props?.cdn?.files_url)
const uname = user.username || user.name || 'Unknown'
const displayName = user.name || uname
@@ -118,9 +122,9 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
{!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 ? (
{flagUrl ? (
<img
src={`/gfx/flags/shiny/24/${encodeURIComponent(String(profile.country_code).toUpperCase())}.png`}
src={flagUrl}
alt={countryName}
className="h-auto w-4 rounded-sm"
onError={(event) => { event.target.style.display = 'none' }}

View File

@@ -16,6 +16,8 @@ function typeMeta(type) {
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 'world_reward':
return { icon: 'fa-solid fa-globe', label: 'World reward', tone: 'text-sky-100 bg-sky-400/12 border-sky-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':
@@ -46,6 +48,8 @@ function headline(activity) {
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 'world_reward':
return activity?.world_reward?.badge_label ? `Earned ${activity.world_reward.badge_label}` : 'Earned a new world reward'
case 'forum_post':
return activity?.forum?.thread?.title ? `Started forum thread ${activity.forum.thread.title}` : 'Started a new forum thread'
case 'forum_reply':
@@ -59,6 +63,7 @@ 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
if (activity?.world_reward?.note) return activity.world_reward.note
return ''
}
@@ -68,6 +73,7 @@ function cta(activity) {
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' }
if (activity?.world_reward?.world?.url) return { href: activity.world_reward.world.url, label: 'Open world' }
return null
}
@@ -173,6 +179,14 @@ export default function ActivityCard({ activity }) {
</div>
) : null}
{activity?.world_reward ? (
<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">World reward</div>
<div className="mt-1 text-sm font-medium text-white">{activity.world_reward.badge_label}</div>
{activity.world_reward.artwork?.title ? <div className="mt-2 text-sm text-slate-400">Artwork: {activity.world_reward.artwork.title}</div> : null}
</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>

View File

@@ -1,5 +1,7 @@
import React from 'react'
import { usePage } from '@inertiajs/react'
import CreatorJourneySection from '../CreatorJourneySection'
import { shinyFlagUrl } from '../../../utils/flagUrl'
const SOCIAL_ICONS = {
twitter: { icon: 'fa-brands fa-x-twitter', label: 'X / Twitter', hoverClass: 'hover:border-slate-300/30 hover:text-slate-100 hover:bg-white/[0.08]' },
@@ -226,11 +228,13 @@ function SectionCard({ icon, eyebrow, title, children, className = '' }) {
* TabAbout
* Bio, social links, metadata - replaces old sidebar profile card.
*/
export default function TabAbout({ user, profile, stats, achievements, artworks, creatorStories, profileComments, socialLinks, countryName, followerCount, recentFollowers, leaderboardRank, groupContributionHistory, journey }) {
export default function TabAbout({ user, profile, stats, achievements, worldRewards, artworks, creatorStories, profileComments, socialLinks, countryName, followerCount, recentFollowers, leaderboardRank, groupContributionHistory, journey }) {
const { props } = usePage()
const uname = user.username || user.name
const displayName = user.name || uname
const about = profile?.about
const website = profile?.website
const flagUrl = shinyFlagUrl(profile?.country_code, props?.cdn?.files_url)
const joinDate = user.created_at
? new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })
@@ -261,6 +265,7 @@ export default function TabAbout({ user, profile, stats, achievements, artworks,
: []
const followers = recentFollowers ?? []
const recentAchievements = Array.isArray(achievements?.recent) ? achievements.recent : []
const recentWorldRewards = Array.isArray(worldRewards?.recent) ? worldRewards.recent : []
const stories = Array.isArray(creatorStories) ? creatorStories : []
const comments = Array.isArray(profileComments) ? profileComments : []
const contributionHistory = Array.isArray(groupContributionHistory) ? groupContributionHistory : []
@@ -315,9 +320,9 @@ export default function TabAbout({ user, profile, stats, achievements, artworks,
{countryName ? (
<InfoRow icon="fa-earth-americas" label="Country">
<span className="flex items-center gap-2">
{profile?.country_code ? (
{flagUrl ? (
<img
src={`/gfx/flags/shiny/24/${encodeURIComponent(String(profile.country_code).toUpperCase())}.png`}
src={flagUrl}
alt={countryName}
className="h-auto w-4 rounded-sm"
onError={(e) => { e.target.style.display = 'none' }}
@@ -466,6 +471,31 @@ export default function TabAbout({ user, profile, stats, achievements, artworks,
</SectionCard>
) : null}
{recentWorldRewards.length > 0 ? (
<SectionCard icon="fa-solid fa-globe" eyebrow="World recognition" title="Latest world rewards">
<div className="grid gap-3 sm:grid-cols-2">
{recentWorldRewards.slice(0, 4).map((reward) => (
<a
key={reward.id}
href={reward.world?.url || reward.artwork?.url || '#'}
className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-4 transition hover:border-white/15 hover:bg-white/[0.06]"
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-white">{reward.badge_label}</div>
{reward.artwork?.title ? <div className="mt-1 text-sm text-slate-400">{reward.artwork.title}</div> : null}
</div>
<span className="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">
{reward.reward_label}
</span>
</div>
{reward.granted_at ? <div className="mt-3 text-xs text-slate-500">{formatShortDate(reward.granted_at) || 'Rewarded'}</div> : null}
</a>
))}
</div>
</SectionCard>
) : null}
{stories.length > 0 || comments.length > 0 ? (
<SectionCard icon="fa-solid fa-wave-square" eyebrow="Fresh from this creator" title="Recent activity">
<div className="grid gap-3 lg:grid-cols-2">

View File

@@ -42,6 +42,8 @@ export default function SeoHead({ seo = {}, title = null, description = null, js
{ogUrl ? <meta head-key="og:url" property="og:url" content={ogUrl} /> : null}
{ogImage ? <meta head-key="og:image" property="og:image" content={ogImage} /> : null}
{seo?.og_image_alt ? <meta head-key="og:image:alt" property="og:image:alt" content={seo.og_image_alt} /> : null}
{seo?.og_image_width ? <meta head-key="og:image:width" property="og:image:width" content={String(seo.og_image_width)} /> : null}
{seo?.og_image_height ? <meta head-key="og:image:height" property="og:image:height" content={String(seo.og_image_height)} /> : null}
<meta head-key="twitter:card" name="twitter:card" content={twitterCard} />
<meta head-key="twitter:title" name="twitter:title" content={twitterTitle} />
@@ -56,9 +58,8 @@ export default function SeoHead({ seo = {}, title = null, description = null, js
key={`jsonld-${schemaType}-${index}`}
head-key={`jsonld-${schemaType}-${index}`}
type="application/ld+json"
>
{JSON.stringify(schema)}
</script>
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
)
})}
</Head>

View File

@@ -58,6 +58,34 @@ function mergeDateTime(date, time) {
return `${date}T${time || '00:00'}`
}
function maxDateValue(a, b) {
if (!a) return b || ''
if (!b) return a || ''
return a > b ? a : b
}
function minDateValue(a, b) {
if (!a) return b || ''
if (!b) return a || ''
return a < b ? a : b
}
function clampTimeToBounds(date, time, minDateTime, maxDateTime) {
const nextTime = time || '00:00'
const minParts = splitDateTime(minDateTime)
const maxParts = splitDateTime(maxDateTime)
if (date && minParts.date === date && minParts.time && nextTime < minParts.time) {
return minParts.time
}
if (date && maxParts.date === date && maxParts.time && nextTime > maxParts.time) {
return maxParts.time
}
return nextTime
}
function formatDisplay(value) {
if (!value) return ''
@@ -147,15 +175,18 @@ export default function DateTimePicker({
value = '',
onChange,
label,
placeholder = 'Pick a date and time',
placeholder,
error,
hint,
required = false,
clearable = false,
id,
disabled = false,
mode = 'datetime',
minDate,
maxDate,
minDateTime,
maxDateTime,
className = '',
}) {
const today = new Date()
@@ -168,6 +199,7 @@ export default function DateTimePicker({
const [viewMonth, setViewMonth] = useState(initialDate.getMonth())
const [draftDate, setDraftDate] = useState(initial.date)
const [draftTime, setDraftTime] = useState(initial.time || '12:00')
const effectivePlaceholder = placeholder || (mode === 'date' ? 'Pick a date' : 'Pick a date and time')
const triggerRef = useRef(null)
const inputId = id ?? (label ? `dtp-${label.toLowerCase().replace(/\s+/g, '-')}` : 'date-time-picker')
@@ -239,16 +271,23 @@ export default function DateTimePicker({
}, [open, panelId])
const applyValue = useCallback((date, time) => {
onChange?.(date ? mergeDateTime(date, time) : '')
}, [onChange])
if (!date) {
onChange?.('')
return
}
onChange?.(mode === 'date' ? date : mergeDateTime(date, time))
}, [mode, onChange])
const handleDateSelect = (nextDate) => {
const nextTime = clampTimeToBounds(nextDate, draftTime, minDateTime, maxDateTime)
setDraftDate(nextDate)
applyValue(nextDate, draftTime)
setDraftTime(nextTime)
applyValue(nextDate, nextTime)
}
const handleTimeChange = (event) => {
const nextTime = event.target.value
const nextTime = clampTimeToBounds(draftDate, event.target.value, minDateTime, maxDateTime)
setDraftTime(nextTime)
applyValue(draftDate, nextTime)
}
@@ -293,6 +332,12 @@ export default function DateTimePicker({
].join(' ')
const selectedDate = parseDatePart(draftDate)
const minDateTimeParts = splitDateTime(minDateTime)
const maxDateTimeParts = splitDateTime(maxDateTime)
const effectiveMinDate = maxDateValue(minDate, minDateTimeParts.date)
const effectiveMaxDate = minDateValue(maxDate, maxDateTimeParts.date)
const minTime = draftDate && draftDate === minDateTimeParts.date ? minDateTimeParts.time || undefined : undefined
const maxTime = draftDate && draftDate === maxDateTimeParts.date ? maxDateTimeParts.time || undefined : undefined
return (
<div className="flex flex-col gap-1.5">
@@ -308,7 +353,7 @@ export default function DateTimePicker({
id={inputId}
role="button"
tabIndex={disabled ? -1 : 0}
aria-label={label ?? placeholder}
aria-label={label ?? effectivePlaceholder}
className={triggerClass}
onClick={openPicker}
onKeyDown={(event) => {
@@ -328,7 +373,7 @@ export default function DateTimePicker({
</svg>
<span className={`flex-1 truncate ${value ? 'text-white' : 'text-slate-500'}`}>
{value ? formatDisplay(value) : placeholder}
{value ? formatDisplay(value) : effectivePlaceholder}
</span>
{clearable && value && (
@@ -386,28 +431,32 @@ export default function DateTimePicker({
month={viewMonth}
selectedDate={selectedDate}
onSelect={handleDateSelect}
minDate={minDate}
maxDate={maxDate}
minDate={effectiveMinDate}
maxDate={effectiveMaxDate}
/>
<div className="border-t border-white/8 px-4 py-3">
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_7rem] sm:items-end">
<div className={`grid gap-3 ${mode === 'date' ? '' : 'sm:grid-cols-[minmax(0,1fr)_7rem] sm:items-end'}`}>
<div>
<div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Selected date</div>
<div className="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white">
{draftDate ? formatDisplay(mergeDateTime(draftDate, draftTime)).replace(` at ${draftTime}`, '') : 'Pick a day'}
{draftDate ? formatDisplay(draftDate) : 'Pick a day'}
</div>
</div>
<label className="grid gap-1.5 text-sm text-slate-300">
<span className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Time</span>
<input
type="time"
value={draftTime}
onChange={handleTimeChange}
className="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-white outline-none transition focus:border-accent/50 focus:ring-2 focus:ring-accent/40"
/>
</label>
{mode !== 'date' ? (
<label className="grid gap-1.5 text-sm text-slate-300">
<span className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Time</span>
<input
type="time"
value={draftTime}
onChange={handleTimeChange}
min={minTime}
max={maxTime}
className="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-white outline-none transition focus:border-accent/50 focus:ring-2 focus:ring-accent/40"
/>
</label>
) : null}
</div>
<div className="mt-3 flex items-center justify-between">

View File

@@ -26,6 +26,8 @@ import { createPortal } from 'react-dom'
* @prop {boolean} required - asterisk on label
* @prop {boolean} disabled
* @prop {function} renderOption - custom render fn: (option) => ReactNode
* @prop {function} renderValue - custom render fn for single-value trigger: (option) => ReactNode
* @prop {string} searchPlaceholder - placeholder shown in the dropdown search input
*/
export default function NovaSelect({
options = [],
@@ -41,8 +43,10 @@ export default function NovaSelect({
required = false,
disabled = false,
renderOption,
renderValue,
id,
className = '',
searchPlaceholder = 'Search…',
}) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
@@ -211,9 +215,10 @@ export default function NovaSelect({
}, [open, filtered, highlighted, search, multi, selected, selectOption, closeDropdown, openDropdown, onChange])
// Build display label(s)
const optionMap = useMemo(() => Object.fromEntries(options.map((o) => [String(o.value), o])), [options])
const labelMap = useMemo(() => Object.fromEntries(options.map((o) => [String(o.value), o.label])), [options])
const hasValue = selected.length > 0
const selectedOption = !multi && hasValue ? optionMap[String(selected[0])] ?? null : null
// Trigger appearance
const triggerClass = [
@@ -273,7 +278,9 @@ export default function NovaSelect({
))}
{!multi && hasValue && (
<span className="truncate text-white">{labelMap[String(selected[0])] ?? selected[0]}</span>
renderValue && selectedOption
? renderValue(selectedOption)
: <span className="truncate text-white">{labelMap[String(selected[0])] ?? selected[0]}</span>
)}
{!hasValue && (
@@ -339,7 +346,7 @@ export default function NovaSelect({
value={search}
onChange={(e) => { setSearch(e.target.value); setHigh(0) }}
onKeyDown={handleKeyDown}
placeholder="Search…"
placeholder={searchPlaceholder}
className="w-full pl-3 pr-7 py-1.5 rounded-lg bg-white/5 border border-white/8 text-white text-xs placeholder:text-slate-500 focus:outline-none focus:ring-1 focus:ring-accent/50"
autoComplete="off"
/>

View File

@@ -66,6 +66,8 @@ export default function PublishPanel({
allRootCategoryOptions = [],
actionLabel = 'Publish now',
showScheduleControls = true,
publishActionEnabled = true,
publishActionTitle = 'Complete all requirements first',
}) {
const pill = STATUS_PILL[machineState] ?? null
const hasPreview = Boolean(primaryPreviewUrl && !isArchive)
@@ -103,6 +105,7 @@ export default function PublishPanel({
const canSchedulePublish =
publishMode === 'schedule' ? Boolean(scheduledAt) && canPublish : canPublish
const canTriggerPublish = publishActionEnabled && canSchedulePublish
const rightsError = uploadReady && !hasRights ? 'Rights confirmation is required.' : null
@@ -257,12 +260,12 @@ export default function PublishPanel({
{/* Primary action button */}
<button
type="button"
disabled={!canSchedulePublish || isPublishing}
disabled={!canTriggerPublish || isPublishing}
onClick={() => onPublish?.()}
title={!canPublish ? 'Complete all requirements first' : undefined}
title={!publishActionEnabled ? publishActionTitle : !canPublish ? 'Complete all requirements first' : undefined}
className={[
'w-full rounded-2xl py-3 text-sm font-semibold transition',
canSchedulePublish && !isPublishing
canTriggerPublish && !isPublishing
? publishMode === 'schedule'
? 'bg-violet-500/80 text-white hover:bg-violet-500 shadow-[0_4px_16px_rgba(139,92,246,0.25)]'
: 'btn-primary'

View File

@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import DateTimePicker from '../ui/DateTimePicker'
/**
* SchedulePublishPicker
@@ -82,14 +83,18 @@ export default function SchedulePublishPicker({
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
)
const [dateStr, setDateStr] = useState(initial.date || '')
const [timeStr, setTimeStr] = useState(initial.time || '')
const [localDateTime, setLocalDateTime] = useState(initial.date && initial.time ? `${initial.date}T${initial.time}` : '')
const [error, setError] = useState('')
const minScheduleLocalDateTime = (() => {
const next = toLocalDateTimeString(new Date(Date.now() + MIN_FUTURE_MS).toISOString(), timezone)
return next.date && next.time ? `${next.date}T${next.time}` : ''
})()
const validate = useCallback(
(d, t) => {
if (!d || !t) return 'Date and time are required.'
const iso = localToUtcIso(d, t, timezone)
(value) => {
const [datePart = '', timePart = ''] = String(value || '').split('T')
if (!datePart || !timePart) return 'Date and time are required.'
const iso = localToUtcIso(datePart, timePart.slice(0, 5), timezone)
if (!iso) return 'Invalid date or time.'
const target = new Date(iso)
if (Number.isNaN(target.getTime())) return 'Invalid date or time.'
@@ -101,31 +106,38 @@ export default function SchedulePublishPicker({
[timezone]
)
useEffect(() => {
const next = toLocalDateTimeString(scheduledAt, timezone)
setLocalDateTime(next.date && next.time ? `${next.date}T${next.time}` : '')
}, [scheduledAt, timezone])
useEffect(() => {
if (mode !== 'schedule') {
setError('')
return
}
if (!dateStr && !timeStr) {
if (!localDateTime) {
setError('')
onScheduleAt?.(null)
return
}
const err = validate(dateStr, timeStr)
const err = validate(localDateTime)
setError(err)
if (!err) {
onScheduleAt?.(localToUtcIso(dateStr, timeStr, timezone))
const [datePart = '', timePart = ''] = localDateTime.split('T')
onScheduleAt?.(localToUtcIso(datePart, timePart.slice(0, 5), timezone))
} else {
onScheduleAt?.(null)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dateStr, timeStr, mode])
}, [localDateTime, mode, timezone])
const previewLabel = useMemo(() => {
if (mode !== 'schedule' || error) return null
const iso = localToUtcIso(dateStr, timeStr, timezone)
const [datePart = '', timePart = ''] = localDateTime.split('T')
const iso = localToUtcIso(datePart, timePart.slice(0, 5), timezone)
return formatPreviewLabel(iso, timezone)
}, [mode, error, dateStr, timeStr, timezone])
}, [mode, error, localDateTime, timezone])
return (
<div className="space-y-3">
@@ -167,45 +179,18 @@ export default function SchedulePublishPicker({
{mode === 'schedule' && (
<div className="space-y-2 rounded-xl border border-white/10 bg-white/[0.03] p-3">
<div className="flex flex-col gap-2 sm:flex-row">
<div className="flex-1">
<label className="block text-[10px] uppercase tracking-wide text-white/40 mb-1" htmlFor="schedule-date">
Date
</label>
<input
id="schedule-date"
type="date"
disabled={disabled}
value={dateStr}
onChange={(e) => setDateStr(e.target.value)}
min={new Date().toISOString().slice(0, 10)}
className="w-full rounded-lg border border-white/15 bg-white/8 px-3 py-1.5 text-sm text-white placeholder-white/30 focus:outline-none focus:ring-1 focus:ring-sky-400/60 disabled:opacity-50"
/>
</div>
<div className="w-28 shrink-0">
<label className="block text-[10px] uppercase tracking-wide text-white/40 mb-1" htmlFor="schedule-time">
Time
</label>
<input
id="schedule-time"
type="time"
disabled={disabled}
value={timeStr}
onChange={(e) => setTimeStr(e.target.value)}
className="w-full rounded-lg border border-white/15 bg-white/8 px-3 py-1.5 text-sm text-white placeholder-white/30 focus:outline-none focus:ring-1 focus:ring-sky-400/60 disabled:opacity-50"
/>
</div>
</div>
<p className="text-[10px] text-white/35">
Timezone: <span className="text-white/55">{timezone}</span>
</p>
{error && (
<p className="text-xs text-red-400" role="alert">
{error}
</p>
)}
<DateTimePicker
id="schedule-datetime"
label="Release date and time"
value={localDateTime}
onChange={setLocalDateTime}
placeholder="Pick a release slot"
disabled={disabled}
minDateTime={minScheduleLocalDateTime}
clearable
hint={`Timezone: ${timezone}`}
error={error}
/>
{previewLabel && (
<p className="text-xs text-emerald-300/80">

View File

@@ -92,8 +92,8 @@ export default function UploadActions({
}
return (
<footer data-testid="wizard-action-bar" className={`${mobileSticky ? 'sticky bottom-0 z-20 px-4 pb-3 lg:static lg:px-0 lg:pb-0' : ''}`}>
<div className="mx-auto w-full max-w-4xl rounded-[24px] border border-white/10 bg-[#08111c]/88 p-3 shadow-[0_-12px_32px_rgba(2,8,23,0.65)] backdrop-blur-sm sm:p-4 lg:shadow-none">
<footer data-testid="wizard-action-bar" className={`${mobileSticky ? 'pointer-events-none fixed inset-x-0 bottom-0 z-[70] px-4 pb-4 pt-3' : ''}`}>
<div className="pointer-events-auto mx-auto w-full max-w-7xl rounded-[24px] border border-white/10 bg-[#08111c]/92 p-3 shadow-[0_-12px_32px_rgba(2,8,23,0.65)] backdrop-blur-sm sm:p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-xs uppercase tracking-[0.18em] text-white/35">
{step === 1 ? 'Step 1 of 3' : step === 2 ? 'Step 2 of 3' : 'Step 3 of 3'}

View File

@@ -130,7 +130,6 @@ export default function UploadWizard({
const [publishMode, setPublishMode] = useState('now') // 'now' | 'schedule'
const [scheduledAt, setScheduledAt] = useState(null) // UTC ISO or null
const [visibility, setVisibility] = useState('public') // 'public'|'unlisted'|'private'
const [showMobilePublishPanel, setShowMobilePublishPanel] = useState(false)
const userTimezone = useMemo(() => {
try { return Intl.DateTimeFormat().resolvedOptions().timeZone } catch { return 'UTC' }
}, [])
@@ -393,6 +392,8 @@ export default function UploadWizard({
return true
}, [canPublish, reviewSubmissionMode, publishMode, scheduledAt])
const publishActionEnabled = activeStep === 3 && canScheduleSubmit
// ── Validation surface for parent ────────────────────────────────────────
const validationErrors = useMemo(
() => [...primaryErrors, ...screenshotErrors],
@@ -437,13 +438,6 @@ export default function UploadWizard({
clearPolling()
}
}, [abortAllRequests, clearPolling])
// ── ESC key closes mobile drawer (spec §7) ─────────────────────────────
useEffect(() => {
if (!showMobilePublishPanel) return
const handler = (e) => { if (e.key === 'Escape') setShowMobilePublishPanel(false) }
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [showMobilePublishPanel])
// ── Metadata helpers ──────────────────────────────────────────────────────
const setMeta = useCallback((patch) => setMetadata((prev) => ({ ...prev, ...patch })), [])
@@ -459,7 +453,6 @@ export default function UploadWizard({
setPublishMode('now')
setScheduledAt(null)
setVisibility('public')
setShowMobilePublishPanel(false)
setResolvedArtworkId(() => {
const parsed = Number(initialDraftId)
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null
@@ -705,11 +698,15 @@ export default function UploadWizard({
return machine.error || 'Publish is available when upload is ready and rights are confirmed.'
})()
const publishActionTitle = activeStep < 3
? 'Continue to the final publish step to choose Worlds and publish.'
: disableReason
// ─────────────────────────────────────────────────────────────────────────
return (
<section
ref={stepContentRef}
className="space-y-5 pb-32 text-white lg:pb-8"
className="space-y-5 pb-40 text-white lg:pb-40"
data-is-archive={isArchive ? 'true' : 'false'}
>
{notices.length > 0 && (
@@ -796,7 +793,7 @@ export default function UploadWizard({
step={activeStep}
canStart={canStartUpload && [machineStates.idle, machineStates.error, machineStates.cancelled].includes(machine.state)}
canContinue={detailsValid}
canPublish={canScheduleSubmit}
canPublish={publishActionEnabled}
canGoBack={activeStep > 1 && machine.state !== machineStates.complete}
canReset={Boolean(primaryFile || screenshots.length || metadata.title || metadata.description || metadata.tags.length)}
canCancel={activeStep === 1 && [
@@ -813,7 +810,7 @@ export default function UploadWizard({
disableReason={disableReason}
onStart={runUploadFlow}
onContinue={() => detailsValid && setActiveStep(3)}
onPublish={() => handlePublish(canPublish, { mode: reviewSubmissionMode ? 'now' : publishMode, publishAt: reviewSubmissionMode ? null : scheduledAt, timezone: userTimezone, visibility, action: reviewSubmissionMode ? 'submit_review' : 'publish' })}
onPublish={() => publishActionEnabled && handlePublish(canPublish, { mode: reviewSubmissionMode ? 'now' : publishMode, publishAt: reviewSubmissionMode ? null : scheduledAt, timezone: userTimezone, visibility, action: reviewSubmissionMode ? 'submit_review' : 'publish' })}
onBack={() => setActiveStep((s) => Math.max(1, s - 1))}
onCancel={handleCancel}
onReset={handleReset}
@@ -841,6 +838,8 @@ export default function UploadWizard({
machineState={machine.state}
uploadReady={uploadReady}
canPublish={canPublish}
publishActionEnabled={publishActionEnabled}
publishActionTitle={publishActionTitle}
isPublishing={machine.state === machineStates.publishing}
isArchiveRequiresScreenshot={isArchive}
publishMode={publishMode}
@@ -864,101 +863,6 @@ export default function UploadWizard({
)}
</div>
{/* ── Mobile: floating "Publish" button that opens bottom sheet ────── */}
{(primaryFile || resolvedArtworkId) && machine.state !== machineStates.complete && activeStep > 1 && (
<div className="fixed bottom-4 right-4 z-30 lg:hidden">
<button
type="button"
aria-label="Open publish panel"
onClick={() => setShowMobilePublishPanel((v) => !v)}
className="flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-500 px-4 py-2.5 text-sm font-semibold text-white shadow-[0_18px_50px_rgba(14,165,233,0.35)] transition hover:bg-sky-400 active:scale-95"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
{reviewSubmissionMode ? 'Review' : 'Publish'}
{!canPublish && (
<span className="ml-1 rounded-full bg-white/20 px-1.5 text-[10px]">
{[
...(!uploadReady ? [1] : []),
...(hasTitle ? [] : [1]),
...(hasCompleteCategory ? [] : [1]),
...(hasTag ? [] : [1]),
...(hasRequiredScreenshot ? [] : [1]),
...(metadata.rightsAccepted ? [] : [1]),
].length}
</span>
)}
</button>
</div>
)}
{/* ── Mobile Publish panel bottom-sheet overlay ────────────────────── */}
<AnimatePresence>
{showMobilePublishPanel && (
<>
{/* Backdrop */}
<motion.div
key="mobile-panel-backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden"
onClick={() => setShowMobilePublishPanel(false)}
/>
{/* Sheet */}
<motion.div
key="mobile-panel-sheet"
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={prefersReducedMotion ? { duration: 0 } : { type: 'spring', damping: 30, stiffness: 300 }}
className="fixed bottom-0 left-0 right-0 z-50 max-h-[80vh] overflow-y-auto rounded-t-2xl bg-slate-900 ring-1 ring-white/10 p-5 pb-8 lg:hidden"
>
<div className="mx-auto mb-4 h-1 w-12 rounded-full bg-white/20" aria-hidden="true" />
<PublishPanel
primaryPreviewUrl={primaryPreviewUrl}
isArchive={isArchive}
screenshots={screenshots}
selectedScreenshotIndex={selectedScreenshotIndex}
onSelectedScreenshotChange={setSelectedScreenshotIndex}
metadata={metadata}
machineState={machine.state}
uploadReady={uploadReady}
canPublish={canPublish}
isPublishing={machine.state === machineStates.publishing}
isArchiveRequiresScreenshot={isArchive}
publishMode={publishMode}
scheduledAt={scheduledAt}
timezone={userTimezone}
visibility={visibility}
actionLabel={publishActionLabel}
showScheduleControls={!reviewSubmissionMode}
showRightsConfirmation={activeStep === 3}
showVisibility={false}
onPublishModeChange={setPublishMode}
onScheduleAt={setScheduledAt}
onVisibilityChange={setVisibility}
onToggleRights={(checked) => setMeta({ rightsAccepted: Boolean(checked) })}
onPublish={() => {
setShowMobilePublishPanel(false)
handlePublish(canPublish, { mode: reviewSubmissionMode ? 'now' : publishMode, publishAt: reviewSubmissionMode ? null : scheduledAt, timezone: userTimezone, visibility, action: reviewSubmissionMode ? 'submit_review' : 'publish' })
}}
onCancel={() => {
setShowMobilePublishPanel(false)
handleCancel()
}}
onGoToStep={(s) => {
setShowMobilePublishPanel(false)
goToStep(s)
}}
allRootCategoryOptions={allRootCategoryOptions}
/>
</motion.div>
</>
)}
</AnimatePresence>
</section>
)
}

View File

@@ -478,12 +478,12 @@ describe('UploadWizard step flow', () => {
expect(studioEditLink.getAttribute('href')).toBe('/studio/artworks/315/edit')
})
it('keeps mobile sticky action bar visible class', async () => {
it('keeps the action bar fixed to the bottom', async () => {
installAxiosStubs()
await renderWizard({ initialDraftId: 306 })
const bar = screen.getByTestId('wizard-action-bar')
expect((bar.className || '').includes('sticky')).toBe(true)
expect((bar.className || '').includes('fixed')).toBe(true)
expect((bar.className || '').includes('bottom-0')).toBe(true)
})

View File

@@ -229,6 +229,7 @@ export default function Step3Publish({
options={eligibleWorlds}
onToggle={onToggleWorldSubmission}
onNoteChange={onChangeWorldSubmissionNote}
analyticsContext={{ sourceSurface: 'upload_flow', sourceDetail: 'publish_step' }}
/>
<section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">

View File

@@ -10,6 +10,21 @@ import { useNavContext } from '../../lib/useNavContext';
const preloadCache = new Set();
function scheduleIdleTask(callback, delay = 1200) {
if (typeof window === 'undefined') {
callback();
return () => {};
}
if (typeof window.requestIdleCallback === 'function') {
const handle = window.requestIdleCallback(callback, { timeout: delay });
return () => window.cancelIdleCallback(handle);
}
const handle = window.setTimeout(callback, delay);
return () => window.clearTimeout(handle);
}
function preloadImage(src) {
if (!src || preloadCache.has(src)) return;
preloadCache.add(src);
@@ -44,20 +59,33 @@ export default function ArtworkNavigator({ artworkId, onNavigate, onOpenViewer,
getNeighbors().then((n) => {
if (cancelled) return;
setNeighbors(n);
[n.prevId, n.nextId].forEach((id) => {
if (!id) return;
});
return () => { cancelled = true; };
}, [artworkId, getNeighbors]);
useEffect(() => {
const ids = [neighbors.prevId, neighbors.nextId].filter(Boolean);
if (ids.length === 0) return undefined;
let cancelled = false;
const cancelIdleTask = scheduleIdleTask(() => {
ids.forEach((id) => {
fetch(`/api/artworks/${id}/page`, { headers: { Accept: 'application/json' } })
.then((r) => r.ok ? r.json() : null)
.then((data) => {
if (!data) return;
if (cancelled || !data) return;
const imgUrl = data.thumbs?.lg?.url || data.thumbs?.md?.url;
if (imgUrl) preloadImage(imgUrl);
})
.catch(() => {});
});
});
return () => { cancelled = true; };
}, [artworkId, getNeighbors]);
return () => {
cancelled = true;
cancelIdleTask();
};
}, [neighbors.prevId, neighbors.nextId]);
// Stable navigate — reads state via refs, never recreated
const navigate = useCallback(async (targetId, targetUrl) => {

View File

@@ -1,6 +1,5 @@
import './bootstrap'
import { mountInertiaRoot } from './bootstrap'
import React from 'react'
import { createRoot } from 'react-dom/client'
import { createInertiaApp } from '@inertiajs/react'
import FollowingFeed from './Pages/Feed/FollowingFeed'
import TrendingFeed from './Pages/Feed/TrendingFeed'
@@ -19,7 +18,6 @@ const pages = {
createInertiaApp({
resolve: (name) => pages[name],
setup({ el, App, props }) {
const root = createRoot(el)
root.render(<App {...props} />)
mountInertiaRoot(el, App, props)
},
})

17
resources/js/forum.jsx Normal file
View File

@@ -0,0 +1,17 @@
import { mountInertiaRoot } from './bootstrap'
import React from 'react'
import { createInertiaApp } from '@inertiajs/react'
const pages = import.meta.glob('./Pages/Forum/*.jsx', { eager: true })
createInertiaApp({
resolve: (name) => {
const key = `./Pages/Forum/${name.replace('Forum/', '')}.jsx`
const module = pages[key]
if (!module) throw new Error(`Inertia forum page not found: ${name}`)
return module.default
},
setup({ el, App, props }) {
mountInertiaRoot(el, App, props)
},
})

View File

@@ -594,6 +594,7 @@ export default function useUploadMachine({
.map((world) => ({
world_id: Number(world.id),
note: typeof world.note === 'string' ? world.note : '',
source_surface: 'upload_flow',
}))
.filter((entry) => Number.isFinite(entry.world_id) && entry.world_id > 0)
: [],

View File

@@ -1,6 +1,5 @@
import './bootstrap'
import { mountInertiaRoot } from './bootstrap'
import React from 'react'
import { createRoot } from 'react-dom/client'
import { createInertiaApp } from '@inertiajs/react'
import LeaderboardPage from './Pages/Leaderboard/LeaderboardPage'
@@ -12,7 +11,6 @@ const pages = {
createInertiaApp({
resolve: (name) => pages[name],
setup({ el, App, props }) {
const root = createRoot(el)
root.render(<App {...props} />)
mountInertiaRoot(el, App, props)
},
})

View File

@@ -1,6 +1,5 @@
import './bootstrap'
import { mountInertiaRoot } from './bootstrap'
import React from 'react'
import { createRoot } from 'react-dom/client'
import { createInertiaApp } from '@inertiajs/react'
const pages = import.meta.glob('./Pages/Moderation/**/*.jsx')
@@ -16,7 +15,6 @@ createInertiaApp({
return page().then((module) => module.default)
},
setup({ el, App, props }) {
const root = createRoot(el)
root.render(<App {...props} />)
mountInertiaRoot(el, App, props)
},
})

View File

@@ -188,9 +188,36 @@ function mountStorySocial() {
});
}
function mountRememberMeCheckboxes() {
var roots = document.querySelectorAll('[data-remember-me-checkbox-root]');
if (!roots.length) return;
roots.forEach(function (rootEl) {
if (rootEl.dataset.reactMounted === 'true') return;
var props = safeParseJson(rootEl.getAttribute('data-props'), {});
rootEl.dataset.reactMounted = 'true';
void Promise.all([
import('./components/auth/RememberMeCheckbox.jsx'),
getReactRuntime(),
])
.then(function (resolved) {
var module = resolved[0];
var reactRuntime = resolved[1];
var Component = module.default;
reactRuntime.createRoot(rootEl).render(reactRuntime.React.createElement(Component, props));
})
.catch(function () {
rootEl.dataset.reactMounted = 'false';
});
});
}
mountToolbarMessages();
mountToolbarNotifications();
mountStorySocial();
mountRememberMeCheckboxes();
function initStorySyntaxHighlighting() {
var codeBlocks = Array.prototype.slice.call(document.querySelectorAll('.story-prose pre code'));

View File

@@ -1,6 +1,5 @@
import './bootstrap'
import { mountInertiaRoot } from './bootstrap'
import React from 'react'
import { createRoot } from 'react-dom/client'
import { createInertiaApp } from '@inertiajs/react'
import ProfileShow from './Pages/Profile/ProfileShow'
import ProfileGallery from './Pages/Profile/ProfileGallery'
@@ -23,7 +22,6 @@ const pages = {
createInertiaApp({
resolve: (name) => pages[name],
setup({ el, App, props }) {
const root = createRoot(el)
root.render(<App {...props} />)
mountInertiaRoot(el, App, props)
},
})

View File

@@ -1,6 +1,5 @@
import './bootstrap'
import { mountInertiaRoot } from './bootstrap'
import React from 'react'
import { createRoot } from 'react-dom/client'
import { createInertiaApp } from '@inertiajs/react'
import ProfileEdit from './Pages/Settings/ProfileEdit'
@@ -12,7 +11,6 @@ const pages = {
createInertiaApp({
resolve: (name) => pages[name],
setup({ el, App, props }) {
const root = createRoot(el)
root.render(<App {...props} />)
mountInertiaRoot(el, App, props)
},
})

34
resources/js/ssr.jsx Normal file
View File

@@ -0,0 +1,34 @@
import React from 'react'
import { createInertiaApp } from '@inertiajs/react'
import createServer from '@inertiajs/react/server'
import ReactDOMServer from 'react-dom/server'
// Eagerly import every Inertia page component so the SSR server can resolve
// any page name without async dynamic imports (Node.js + Vite SSR requirement).
const pages = import.meta.glob(['./Pages/**/*.jsx', '!./Pages/**/*.test.jsx', '!./Pages/**/__tests__/**'], { eager: true })
// Lightweight server-only placeholder for pages that must remain client-only.
// Returning this prevents an error-level stacktrace while still avoiding
// server-side rendering of browser-dependent components.
const ClientOnlyPlaceholder = () => null
createServer(page =>
createInertiaApp({
page,
render: ReactDOMServer.renderToString,
resolve: (name) => {
// Studio news pages are rendered client-side only. Return a minimal
// placeholder component instead of throwing so the SSR server
// produces a small, safe HTML shell without logging an error.
if (name.startsWith('Studio/StudioNews')) {
return ClientOnlyPlaceholder
}
const module = pages[`./Pages/${name}.jsx`]
if (!module) {
throw new Error(`[SSR] Unknown Inertia page: "./Pages/${name}.jsx"`)
}
return module.default
},
setup: ({ App, props }) => <App {...props} />,
}),
)

View File

@@ -1,6 +1,5 @@
import './bootstrap'
import { mountInertiaRoot } from './bootstrap'
import React from 'react'
import { createRoot } from 'react-dom/client'
import { createInertiaApp } from '@inertiajs/react'
const pages = import.meta.glob([
'./Pages/Studio/**/*.jsx',
@@ -22,7 +21,6 @@ function resolvePage(name) {
createInertiaApp({
resolve: resolvePage,
setup({ el, App, props }) {
const root = createRoot(el)
root.render(<App {...props} />)
mountInertiaRoot(el, App, props)
},
})

View File

@@ -1,6 +1,5 @@
import './bootstrap'
import { mountInertiaRoot } from './bootstrap'
import React from 'react'
import { createRoot } from 'react-dom/client'
import { createInertiaApp } from '@inertiajs/react'
import UploadPage from './Pages/Upload/Index'
@@ -11,7 +10,6 @@ const pages = {
createInertiaApp({
resolve: (name) => pages[name],
setup({ el, App, props }) {
const root = createRoot(el)
root.render(<App {...props} />)
mountInertiaRoot(el, App, props)
},
})

View File

@@ -0,0 +1,12 @@
export function shinyFlagUrl(countryCode, filesCdnUrl = '') {
const normalized = String(countryCode ?? '').trim().toUpperCase()
if (!/^[A-Z]{2}$/.test(normalized)) {
return null
}
const base = String(filesCdnUrl ?? '').replace(/\/+$/, '')
const relativePath = `/images/flags/shiny/24/${encodeURIComponent(normalized)}.png`
return base ? `${base}${relativePath}` : relativePath
}