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,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',
}),
})
})
})