Wire admin studio SSR and search infrastructure
This commit is contained in:
159
resources/js/Layouts/AdminLayout.jsx
Normal file
159
resources/js/Layouts/AdminLayout.jsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Link, usePage } from '@inertiajs/react'
|
||||
|
||||
const adminNavGroups = [
|
||||
{
|
||||
label: 'Overview',
|
||||
items: [
|
||||
{ label: 'Dashboard', href: '/moderation', icon: 'fa-solid fa-gauge-high', exact: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'People',
|
||||
items: [
|
||||
{ label: 'All Users', href: '/moderation/users', icon: 'fa-solid fa-users' },
|
||||
{ label: 'Staff', href: '/moderation/users?role=admin', icon: 'fa-solid fa-shield-halved' },
|
||||
{ label: 'Moderators', href: '/moderation/users?role=moderator', icon: 'fa-solid fa-user-shield' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Content',
|
||||
items: [
|
||||
{ label: 'Stories', href: '/moderation/stories', icon: 'fa-solid fa-feather-pointed' },
|
||||
{ label: 'Artworks', href: '/moderation/artworks', icon: 'fa-solid fa-images' },
|
||||
{ label: 'Featured Artworks', href: '/moderation/artworks/featured', icon: 'fa-solid fa-star' },
|
||||
{ label: 'Homepage Announcements', href: '/moderation/homepage/announcements', icon: 'fa-solid fa-bullhorn' },
|
||||
{ label: 'Upload Queue', href: '/moderation/uploads', icon: 'fa-solid fa-cloud-arrow-up' },
|
||||
{ label: 'Username Queue', href: '/moderation/usernames/moderation', icon: 'fa-solid fa-id-badge' },
|
||||
{ label: 'AI Biography', href: '/moderation/ai-biography', icon: 'fa-solid fa-wand-magic-sparkles' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'System',
|
||||
items: [
|
||||
{ label: 'Settings', href: '/moderation/settings', icon: 'fa-solid fa-gear' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
function NavLink({ item, active }) {
|
||||
const cls = `flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ${
|
||||
active
|
||||
? 'bg-rose-500/20 text-rose-300 shadow-sm shadow-rose-500/10'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`
|
||||
|
||||
return (
|
||||
<Link href={item.href} className={cls}>
|
||||
<i className={`${item.icon} w-5 text-center text-base`} />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar({ pathname }) {
|
||||
const isActive = (item) => {
|
||||
if (item.exact) return pathname === item.href
|
||||
return pathname.startsWith(item.href.split('?')[0])
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-64 flex-col overflow-y-auto border-r border-white/[0.07] bg-[rgba(10,14,22,0.98)] px-3 py-6">
|
||||
{/* Brand */}
|
||||
<div className="mb-8 px-3">
|
||||
<Link href="/moderation" className="flex items-center gap-2.5">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-rose-500/20">
|
||||
<i className="fa-solid fa-shield-halved text-sm text-rose-400" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-rose-400/80">Skinbase</p>
|
||||
<p className="text-sm font-bold text-white">Admin Panel</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Nav groups */}
|
||||
<nav className="flex-1 space-y-6">
|
||||
{adminNavGroups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<p className="mb-1.5 px-4 text-[10px] font-bold uppercase tracking-[0.2em] text-slate-600">
|
||||
{group.label}
|
||||
</p>
|
||||
<div className="space-y-0.5">
|
||||
{group.items.map((item) => (
|
||||
<NavLink key={item.href} item={item} active={isActive(item)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Footer links */}
|
||||
<div className="mt-6 space-y-0.5 border-t border-white/[0.06] pt-4">
|
||||
<Link
|
||||
href="/studio"
|
||||
className="flex items-center gap-3 rounded-xl px-4 py-2.5 text-sm text-slate-500 transition hover:bg-white/5 hover:text-slate-300"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-left w-5 text-center text-base" />
|
||||
<span>Back to Studio</span>
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminLayout({ children, title, subtitle }) {
|
||||
const { url } = usePage()
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const pathname = url.split('?')[0]
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-[radial-gradient(ellipse_at_top,_rgba(239,68,68,0.08),_transparent_40%),linear-gradient(180deg,#060a12_0%,#020409_100%)]">
|
||||
|
||||
{/* Desktop sidebar */}
|
||||
<div className="hidden lg:flex lg:w-64 lg:flex-shrink-0">
|
||||
<div className="fixed inset-y-0 left-0 w-64">
|
||||
<Sidebar pathname={pathname} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile header */}
|
||||
<div className="fixed inset-x-0 top-0 z-40 flex items-center justify-between border-b border-white/10 bg-[rgba(10,14,22,0.97)] px-4 py-3 backdrop-blur-xl lg:hidden">
|
||||
<Link href="/moderation" className="flex items-center gap-2">
|
||||
<i className="fa-solid fa-shield-halved text-rose-400" />
|
||||
<span className="text-sm font-bold text-white">Admin Panel</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
className="rounded-full border border-white/10 p-2 text-slate-400 hover:text-white"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<i className={mobileOpen ? 'fa-solid fa-xmark' : 'fa-solid fa-bars'} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile drawer */}
|
||||
{mobileOpen && (
|
||||
<div className="fixed inset-0 z-30 lg:hidden">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setMobileOpen(false)} />
|
||||
<div className="absolute left-0 top-0 h-full w-72 pt-14">
|
||||
<Sidebar pathname={pathname} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex flex-1 flex-col lg:pl-64">
|
||||
<main className="flex-1 px-6 py-8 pt-20 lg:pt-8">
|
||||
{(title || subtitle) && (
|
||||
<div className="mb-8">
|
||||
{title && <h1 className="text-2xl font-bold text-white">{title}</h1>}
|
||||
{subtitle && <p className="mt-1 text-sm text-slate-400">{subtitle}</p>}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -174,6 +174,95 @@ function NavLink({ item, active }) {
|
||||
)
|
||||
}
|
||||
|
||||
function studioContextSummary(option) {
|
||||
if (!option) return ''
|
||||
if (option.kind === 'personal') return option.description || 'Your creator workspace'
|
||||
|
||||
return [option.roleLabel, option.description].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
function studioContextInitials(label) {
|
||||
const initials = String(label || '')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((segment) => segment.charAt(0).toUpperCase())
|
||||
.join('')
|
||||
|
||||
return initials || 'SB'
|
||||
}
|
||||
|
||||
function StudioContextAvatar({ option, size = 'md' }) {
|
||||
const sizeClass = size === 'lg' ? 'h-11 w-11 text-sm' : 'h-9 w-9 text-xs'
|
||||
|
||||
if (option?.avatarUrl) {
|
||||
return (
|
||||
<img
|
||||
src={option.avatarUrl}
|
||||
alt={option.label}
|
||||
className={`${sizeClass} rounded-2xl object-cover ring-1 ring-white/10 shadow-[0_10px_24px_rgba(15,23,42,0.25)]`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const toneClass = option?.kind === 'personal'
|
||||
? 'border-sky-300/25 bg-sky-300/12 text-sky-100'
|
||||
: 'border-amber-300/20 bg-amber-300/10 text-amber-100'
|
||||
|
||||
return (
|
||||
<span className={`${sizeClass} inline-flex items-center justify-center rounded-2xl border font-semibold uppercase tracking-[0.16em] ${toneClass}`}>
|
||||
{option?.kind === 'personal' ? <i className="fa-solid fa-user text-[12px]" /> : studioContextInitials(option?.label)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function StudioContextOptionContent({ option, expanded = false }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<StudioContextAvatar option={option} size={expanded ? 'lg' : 'md'} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`truncate font-semibold ${expanded ? 'text-[15px]' : 'text-sm'} text-white`}>
|
||||
{option.label}
|
||||
</div>
|
||||
<div className="truncate text-xs text-slate-400">
|
||||
{studioContextSummary(option)}
|
||||
</div>
|
||||
</div>
|
||||
{expanded && option.kind === 'group' && option.status ? (
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-300">
|
||||
{String(option.status).replace(/_/g, ' ')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function buildStudioContextOptions(currentGroup, studioGroups, userLabel, userAvatarUrl) {
|
||||
return [
|
||||
{
|
||||
value: '',
|
||||
label: 'Personal studio',
|
||||
description: userLabel || 'Your creator workspace',
|
||||
avatarUrl: userAvatarUrl || null,
|
||||
group: 'Workspace',
|
||||
kind: 'personal',
|
||||
},
|
||||
...studioGroups.map((group) => ({
|
||||
value: group.slug,
|
||||
label: group.name,
|
||||
description: Number(group.artworks_count || 0) > 0
|
||||
? `${Number(group.artworks_count || 0).toLocaleString()} artwork${Number(group.artworks_count || 0) === 1 ? '' : 's'}`
|
||||
: 'Group workspace',
|
||||
roleLabel: group.role_label || 'Member',
|
||||
status: group.status,
|
||||
avatarUrl: group.avatar_url || null,
|
||||
group: 'Groups',
|
||||
kind: 'group',
|
||||
isCurrent: currentGroup?.slug === group.slug,
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
export default function StudioLayout({ children, title, subtitle, actions }) {
|
||||
const { url, props } = usePage()
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
@@ -181,9 +270,12 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
|
||||
const pathname = url.split('?')[0]
|
||||
const studioGroups = Array.isArray(props.studio_groups) ? props.studio_groups : []
|
||||
const currentGroup = props.studioGroup || null
|
||||
const userLabel = props.auth?.user?.name || props.auth?.user?.username || 'Your creator workspace'
|
||||
const userAvatarUrl = props.auth?.user?.avatar_url || null
|
||||
const canManageNews = Boolean(props.auth?.user?.is_admin || props.auth?.user?.is_moderator)
|
||||
const canManageWorlds = canManageNews
|
||||
const isStaff = Boolean(props.auth?.user?.is_staff)
|
||||
const studioContextOptions = buildStudioContextOptions(currentGroup, studioGroups, userLabel, userAvatarUrl)
|
||||
|
||||
const navGroups = baseNavGroups.map((group) => {
|
||||
if ((!canManageNews && !canManageWorlds) || group.label !== 'Content') {
|
||||
@@ -324,6 +416,7 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
|
||||
<StudioSidebarContent
|
||||
currentGroup={currentGroup}
|
||||
studioGroups={studioGroups}
|
||||
studioContextOptions={studioContextOptions}
|
||||
navGroups={navGroups}
|
||||
quickCreateItems={quickCreateItems}
|
||||
isActive={isActive}
|
||||
@@ -341,6 +434,7 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
|
||||
<StudioSidebarContent
|
||||
currentGroup={currentGroup}
|
||||
studioGroups={studioGroups}
|
||||
studioContextOptions={studioContextOptions}
|
||||
navGroups={navGroups}
|
||||
quickCreateItems={quickCreateItems}
|
||||
isActive={isActive}
|
||||
@@ -361,7 +455,7 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 lg:justify-end">
|
||||
{studioGroups.length > 0 ? <ContextSwitcher currentGroup={currentGroup} studioGroups={studioGroups} onContextChange={handleContextChange} /> : null}
|
||||
{studioGroups.length > 0 ? <ContextSwitcher currentGroup={currentGroup} studioContextOptions={studioContextOptions} onContextChange={handleContextChange} /> : null}
|
||||
{actions}
|
||||
<div className="relative">
|
||||
<button
|
||||
@@ -399,42 +493,45 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ContextSwitcher({ currentGroup, studioGroups, onContextChange }) {
|
||||
function ContextSwitcher({ currentGroup, studioContextOptions, onContextChange }) {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/20 px-3 py-2 text-sm text-slate-200">
|
||||
<i className="fa-solid fa-people-group text-sky-200" />
|
||||
<div className="inline-flex items-center gap-3 rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.88),rgba(7,14,28,0.94))] p-2 pr-3 text-sm text-slate-200 shadow-[0_18px_40px_rgba(2,6,23,0.24)]">
|
||||
<span className="inline-flex h-11 w-11 items-center justify-center rounded-[20px] border border-sky-300/14 bg-sky-300/10 text-sky-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]">
|
||||
<i className="fa-solid fa-people-group text-sm" />
|
||||
</span>
|
||||
<NovaSelect
|
||||
value={currentGroup?.slug || ''}
|
||||
onChange={(value) => onContextChange?.(value)}
|
||||
options={[
|
||||
{ value: '', label: 'Personal studio' },
|
||||
...studioGroups.map((group) => ({ value: group.slug, label: group.name })),
|
||||
]}
|
||||
searchable={false}
|
||||
options={studioContextOptions}
|
||||
searchable={true}
|
||||
searchPlaceholder="Search studios or groups…"
|
||||
renderOption={(option) => <StudioContextOptionContent option={option} expanded />}
|
||||
renderValue={(option) => <StudioContextOptionContent option={option} />}
|
||||
className="min-w-[280px] border-white/10 bg-white/[0.03] shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] hover:border-white/20"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StudioSidebarContent({ currentGroup, studioGroups, navGroups, quickCreateItems, isActive, isStaff, onNavigate, onQuickCreate, onContextChange }) {
|
||||
function StudioSidebarContent({ currentGroup, studioContextOptions, navGroups, quickCreateItems, isActive, isStaff, onNavigate, onQuickCreate, onContextChange }) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6 rounded-[26px] border border-white/10 bg-white/[0.04] p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/70">Skinbase Nova</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">Creator Studio</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-400">Create, manage, and grow from one modular workspace built for every creator surface.</p>
|
||||
{studioGroups.length > 0 ? (
|
||||
{studioContextOptions.length > 1 ? (
|
||||
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Context</p>
|
||||
<NovaSelect
|
||||
value={currentGroup?.slug || ''}
|
||||
onChange={(value) => onContextChange?.(value)}
|
||||
className="mt-2"
|
||||
options={[
|
||||
{ value: '', label: 'Personal studio' },
|
||||
...studioGroups.map((group) => ({ value: group.slug, label: group.name })),
|
||||
]}
|
||||
searchable={false}
|
||||
options={studioContextOptions}
|
||||
searchable={true}
|
||||
searchPlaceholder="Search studios or groups…"
|
||||
renderOption={(option) => <StudioContextOptionContent option={option} expanded />}
|
||||
renderValue={(option) => <StudioContextOptionContent option={option} />}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
11
resources/js/Pages/Admin/AiBiography.jsx
Normal file
11
resources/js/Pages/Admin/AiBiography.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
79
resources/js/Pages/Admin/Artworks.jsx
Normal file
79
resources/js/Pages/Admin/Artworks.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
73
resources/js/Pages/Admin/Dashboard.jsx
Normal file
73
resources/js/Pages/Admin/Dashboard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
resources/js/Pages/Admin/FeaturedArtworks.jsx
Normal file
11
resources/js/Pages/Admin/FeaturedArtworks.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
67
resources/js/Pages/Admin/Settings.jsx
Normal file
67
resources/js/Pages/Admin/Settings.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
resources/js/Pages/Admin/Stories.jsx
Normal file
74
resources/js/Pages/Admin/Stories.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
219
resources/js/Pages/Admin/Users/Index.jsx
Normal file
219
resources/js/Pages/Admin/Users/Index.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 || '{}') }))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
362
resources/js/Pages/Studio/__tests__/StudioUploadQueue.test.jsx
Normal file
362
resources/js/Pages/Studio/__tests__/StudioUploadQueue.test.jsx
Normal file
@@ -0,0 +1,362 @@
|
||||
import React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import StudioUploadQueue from '../StudioUploadQueue'
|
||||
|
||||
let pageMock = { props: {} }
|
||||
|
||||
vi.mock('@inertiajs/react', () => ({
|
||||
usePage: () => pageMock,
|
||||
}))
|
||||
|
||||
vi.mock('../../../Layouts/StudioLayout', () => ({
|
||||
default: ({ children }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
function makeQueueProps(overrides = {}) {
|
||||
return {
|
||||
title: 'Upload Queue',
|
||||
description: 'Queue drafts',
|
||||
chunkSize: 5242880,
|
||||
chunkRequestTimeoutMs: 45000,
|
||||
contentTypes: [
|
||||
{
|
||||
name: 'Photography',
|
||||
categories: [
|
||||
{ id: 10, name: 'Portraits', children: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
queue: {
|
||||
filters: { batch_id: 1, status: 'all', sort: 'newest' },
|
||||
batches: [{ id: 1, name: 'Spring Set' }],
|
||||
current_batch: {
|
||||
id: 1,
|
||||
name: 'Spring Set',
|
||||
status: 'completed_with_errors',
|
||||
total_items: 2,
|
||||
ready_items: 1,
|
||||
processing_items: 0,
|
||||
needs_review_items: 1,
|
||||
failed_items: 0,
|
||||
updated_at: '2026-04-18T10:00:00Z',
|
||||
},
|
||||
status_options: [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'ready', label: 'Ready' },
|
||||
{ value: 'needs_review', label: 'Needs review' },
|
||||
],
|
||||
sort_options: [
|
||||
{ value: 'newest', label: 'Newest first' },
|
||||
{ value: 'filename', label: 'Filename' },
|
||||
],
|
||||
items: [
|
||||
{
|
||||
id: 101,
|
||||
title: 'Ready draft',
|
||||
original_filename: 'ready.webp',
|
||||
status: 'ready',
|
||||
processing_stage: 'finalized',
|
||||
metadata_label: '100% complete',
|
||||
is_ready_to_publish: true,
|
||||
missing: [],
|
||||
error_message: null,
|
||||
updated_at: '2026-04-18T10:00:00Z',
|
||||
edit_url: '/studio/artworks/1/edit',
|
||||
actions: {
|
||||
can_edit: true,
|
||||
can_publish: true,
|
||||
can_delete: true,
|
||||
can_retry_processing: false,
|
||||
can_generate_ai: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
title: 'Needs review draft',
|
||||
original_filename: 'review.webp',
|
||||
status: 'needs_review',
|
||||
processing_stage: 'finalized',
|
||||
metadata_label: '75% complete',
|
||||
is_ready_to_publish: false,
|
||||
missing: ['Needs maturity review'],
|
||||
error_message: null,
|
||||
updated_at: '2026-04-18T11:00:00Z',
|
||||
edit_url: '/studio/artworks/2/edit',
|
||||
actions: {
|
||||
can_edit: true,
|
||||
can_publish: false,
|
||||
can_delete: true,
|
||||
can_retry_processing: false,
|
||||
can_generate_ai: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
...overrides.queue,
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('StudioUploadQueue', () => {
|
||||
beforeEach(() => {
|
||||
pageMock = { props: makeQueueProps() }
|
||||
window.axios = {
|
||||
get: vi.fn().mockResolvedValue({ data: pageMock.props.queue }),
|
||||
post: vi.fn().mockResolvedValue({ data: { success: 1, failed: 0, errors: [] } }),
|
||||
}
|
||||
window.confirm = vi.fn(() => true)
|
||||
window.prompt = vi.fn(() => 'DELETE')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renders mixed queue states and item actions', () => {
|
||||
render(<StudioUploadQueue />)
|
||||
|
||||
expect(screen.getByText('Ready draft')).not.toBeNull()
|
||||
expect(screen.getByText('Needs review draft')).not.toBeNull()
|
||||
expect(screen.getAllByText('Ready to publish')[0]).not.toBeNull()
|
||||
expect(screen.getByText('Needs maturity review')).not.toBeNull()
|
||||
expect(screen.getAllByRole('link', { name: /edit in studio/i })).toHaveLength(2)
|
||||
expect(screen.getAllByRole('button', { name: /^generate ai$/i })).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('reloads the queue when filters change', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<StudioUploadQueue />)
|
||||
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: /filter/i }), 'ready')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.axios.get).toHaveBeenCalledWith('/api/studio/upload-queue', {
|
||||
params: expect.objectContaining({
|
||||
batch_id: 1,
|
||||
status: 'ready',
|
||||
sort: 'newest',
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('shows a publish confirmation summary before bulk publish', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<StudioUploadQueue />)
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
const itemCheckboxes = checkboxes.slice(-2)
|
||||
await user.click(itemCheckboxes[0])
|
||||
await user.click(itemCheckboxes[1])
|
||||
await user.click(screen.getByRole('button', { name: /publish selected/i }))
|
||||
|
||||
expect(window.confirm).toHaveBeenCalledWith([
|
||||
'Publish 1 ready draft(s)?',
|
||||
'Selected: 2',
|
||||
'Ready now: 1',
|
||||
'Blocked and skipped: 1',
|
||||
'Needs review: 1',
|
||||
'Blocked drafts will not be published.',
|
||||
].join('\n'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.axios.post).toHaveBeenCalledWith('/api/studio/upload-queue/bulk', expect.objectContaining({
|
||||
action: 'publish',
|
||||
item_ids: [101, 102],
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('does not attempt bulk publish when no selected drafts are ready', async () => {
|
||||
const user = userEvent.setup()
|
||||
pageMock = {
|
||||
props: makeQueueProps({
|
||||
queue: {
|
||||
items: [
|
||||
{
|
||||
id: 202,
|
||||
title: 'Blocked draft',
|
||||
original_filename: 'blocked.webp',
|
||||
status: 'needs_metadata',
|
||||
processing_stage: 'finalized',
|
||||
metadata_label: '50% complete',
|
||||
is_ready_to_publish: false,
|
||||
missing: ['Missing title'],
|
||||
error_message: null,
|
||||
updated_at: '2026-04-18T10:00:00Z',
|
||||
edit_url: '/studio/artworks/3/edit',
|
||||
actions: {
|
||||
can_edit: true,
|
||||
can_publish: false,
|
||||
can_delete: true,
|
||||
can_retry_processing: false,
|
||||
can_generate_ai: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
render(<StudioUploadQueue />)
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
await user.click(checkboxes.at(-1))
|
||||
await user.click(screen.getByRole('button', { name: /publish selected/i }))
|
||||
|
||||
expect(window.confirm).not.toHaveBeenCalled()
|
||||
expect(window.axios.post).not.toHaveBeenCalledWith('/api/studio/upload-queue/bulk', expect.objectContaining({
|
||||
action: 'publish',
|
||||
}))
|
||||
expect(screen.getByText('None of the selected drafts are ready to publish yet.')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('shows the correct Studio links and publish readiness state per item', () => {
|
||||
render(<StudioUploadQueue />)
|
||||
|
||||
const studioLinks = screen.getAllByRole('link', { name: /edit in studio/i })
|
||||
expect(studioLinks).toHaveLength(2)
|
||||
expect(studioLinks[0].getAttribute('href')).toBe('/studio/artworks/1/edit')
|
||||
expect(studioLinks[1].getAttribute('href')).toBe('/studio/artworks/2/edit')
|
||||
expect(screen.getAllByRole('button', { name: /^publish$/i })).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('bulk actions apply only to selected queue items', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<StudioUploadQueue />)
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
const itemCheckboxes = checkboxes.slice(-2)
|
||||
await user.click(itemCheckboxes[0])
|
||||
await user.click(screen.getAllByRole('button', { name: /^generate ai$/i })[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.axios.post).toHaveBeenCalledWith('/api/studio/upload-queue/bulk', expect.objectContaining({
|
||||
action: 'generate_ai',
|
||||
item_ids: [101],
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('shows failed items clearly and lets creators retry them', async () => {
|
||||
const user = userEvent.setup()
|
||||
pageMock = {
|
||||
props: makeQueueProps({
|
||||
queue: {
|
||||
current_batch: {
|
||||
id: 1,
|
||||
name: 'Spring Set',
|
||||
status: 'completed_with_errors',
|
||||
total_items: 1,
|
||||
ready_items: 0,
|
||||
processing_items: 0,
|
||||
needs_review_items: 0,
|
||||
failed_items: 1,
|
||||
updated_at: '2026-04-18T12:00:00Z',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
id: 303,
|
||||
title: 'Broken draft',
|
||||
original_filename: 'broken.webp',
|
||||
status: 'failed',
|
||||
processing_stage: 'finalized',
|
||||
metadata_label: '25% complete',
|
||||
is_ready_to_publish: false,
|
||||
missing: ['Processing incomplete'],
|
||||
error_message: 'Derivative generation failed.',
|
||||
updated_at: '2026-04-18T12:00:00Z',
|
||||
edit_url: '/studio/artworks/4/edit',
|
||||
actions: {
|
||||
can_edit: true,
|
||||
can_publish: false,
|
||||
can_delete: true,
|
||||
can_retry_processing: true,
|
||||
can_generate_ai: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
render(<StudioUploadQueue />)
|
||||
|
||||
expect(screen.getByText('Derivative generation failed.')).not.toBeNull()
|
||||
expect(screen.getByText('Processing incomplete')).not.toBeNull()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /retry/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.axios.post).toHaveBeenCalledWith('/api/studio/upload-queue/items/303/retry')
|
||||
})
|
||||
})
|
||||
|
||||
it('polls the queue while processing items are still running', async () => {
|
||||
vi.useFakeTimers()
|
||||
pageMock = {
|
||||
props: makeQueueProps({
|
||||
queue: {
|
||||
current_batch: {
|
||||
id: 1,
|
||||
name: 'Spring Set',
|
||||
status: 'processing',
|
||||
total_items: 1,
|
||||
ready_items: 0,
|
||||
processing_items: 1,
|
||||
needs_review_items: 0,
|
||||
failed_items: 0,
|
||||
updated_at: '2026-04-18T12:15:00Z',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
id: 404,
|
||||
title: 'Processing draft',
|
||||
original_filename: 'processing.webp',
|
||||
status: 'processing',
|
||||
processing_stage: 'maturity_check',
|
||||
metadata_label: '50% complete',
|
||||
is_ready_to_publish: false,
|
||||
missing: ['Maturity analysis pending'],
|
||||
error_message: null,
|
||||
updated_at: '2026-04-18T12:15:00Z',
|
||||
edit_url: '/studio/artworks/5/edit',
|
||||
actions: {
|
||||
can_edit: true,
|
||||
can_publish: false,
|
||||
can_delete: true,
|
||||
can_retry_processing: false,
|
||||
can_generate_ai: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
render(<StudioUploadQueue />)
|
||||
|
||||
expect(screen.getByText('Maturity analysis pending')).not.toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /^publish$/i })).toBeNull()
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(3000)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(window.axios.get).toHaveBeenCalledWith('/api/studio/upload-queue', {
|
||||
params: expect.objectContaining({
|
||||
batch_id: 1,
|
||||
status: 'all',
|
||||
sort: 'newest',
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
27
resources/js/admin.jsx
Normal file
27
resources/js/admin.jsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { mountInertiaRoot } from './bootstrap'
|
||||
import React from 'react'
|
||||
import { createInertiaApp } from '@inertiajs/react'
|
||||
|
||||
const pages = import.meta.glob([
|
||||
'./Pages/Admin/**/*.jsx',
|
||||
'!./Pages/Admin/**/__tests__/**',
|
||||
'!./Pages/Admin/**/*.test.jsx',
|
||||
])
|
||||
|
||||
function resolvePage(name) {
|
||||
const path = `./Pages/${name}.jsx`
|
||||
const page = pages[path]
|
||||
|
||||
if (!page) {
|
||||
throw new Error(`Unknown admin page: ${path}`)
|
||||
}
|
||||
|
||||
return page().then((module) => module.default)
|
||||
}
|
||||
|
||||
createInertiaApp({
|
||||
resolve: resolvePage,
|
||||
setup({ el, App, props }) {
|
||||
mountInertiaRoot(el, App, props)
|
||||
},
|
||||
})
|
||||
14
resources/js/artwork.jsx
Normal file
14
resources/js/artwork.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { mountInertiaRoot } from './bootstrap'
|
||||
import React from 'react'
|
||||
import { createInertiaApp } from '@inertiajs/react'
|
||||
import ArtworkPage from './Pages/ArtworkPage'
|
||||
|
||||
createInertiaApp({
|
||||
resolve: (name) => {
|
||||
const pages = { ArtworkPage }
|
||||
return pages[name]
|
||||
},
|
||||
setup({ el, App, props }) {
|
||||
mountInertiaRoot(el, App, props)
|
||||
},
|
||||
})
|
||||
46
resources/js/bootstrap.js
vendored
46
resources/js/bootstrap.js
vendored
@@ -1,16 +1,24 @@
|
||||
import axios from 'axios'
|
||||
import Echo from 'laravel-echo'
|
||||
import Pusher from 'pusher-js'
|
||||
import React from 'react'
|
||||
import { createRoot, hydrateRoot } from 'react-dom/client'
|
||||
|
||||
window.axios = axios
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'
|
||||
const browserWindow = typeof window !== 'undefined' ? window : null
|
||||
const csrfToken = typeof document !== 'undefined'
|
||||
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||
: null
|
||||
|
||||
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'
|
||||
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||
if (csrfToken) {
|
||||
window.axios.defaults.headers.common['X-CSRF-TOKEN'] = csrfToken
|
||||
axios.defaults.headers.common['X-CSRF-TOKEN'] = csrfToken
|
||||
}
|
||||
|
||||
window.Pusher = Pusher
|
||||
if (browserWindow) {
|
||||
browserWindow.axios = axios
|
||||
browserWindow.Pusher = Pusher
|
||||
}
|
||||
|
||||
let echoInstance = null
|
||||
|
||||
@@ -23,16 +31,21 @@ export function getEcho() {
|
||||
return echoInstance || null
|
||||
}
|
||||
|
||||
if (!browserWindow) {
|
||||
echoInstance = false
|
||||
return null
|
||||
}
|
||||
|
||||
const key = import.meta.env.VITE_REVERB_APP_KEY
|
||||
if (!key) {
|
||||
echoInstance = false
|
||||
return null
|
||||
}
|
||||
|
||||
const scheme = import.meta.env.VITE_REVERB_SCHEME || window.location.protocol.replace(':', '') || 'https'
|
||||
const scheme = import.meta.env.VITE_REVERB_SCHEME || browserWindow.location.protocol.replace(':', '') || 'https'
|
||||
const forceTLS = scheme === 'https'
|
||||
const configuredHost = import.meta.env.VITE_REVERB_HOST || window.location.hostname
|
||||
const publicHostname = window.location.hostname
|
||||
const configuredHost = import.meta.env.VITE_REVERB_HOST || browserWindow.location.hostname
|
||||
const publicHostname = browserWindow.location.hostname
|
||||
const useWindowHost = !isLoopbackHost(publicHostname) && isLoopbackHost(configuredHost)
|
||||
const resolvedHost = useWindowHost ? publicHostname : configuredHost
|
||||
const resolvedPort = useWindowHost
|
||||
@@ -59,7 +72,22 @@ export function getEcho() {
|
||||
},
|
||||
})
|
||||
|
||||
window.Echo = echoInstance
|
||||
browserWindow.Echo = echoInstance
|
||||
|
||||
return echoInstance
|
||||
}
|
||||
|
||||
export function mountInertiaRoot(el, App, props) {
|
||||
if (!el) {
|
||||
return null
|
||||
}
|
||||
|
||||
const node = React.createElement(App, props)
|
||||
const hasServerMarkup = el.childNodes.length > 0 && el.innerHTML.trim() !== ''
|
||||
|
||||
if (hasServerMarkup) {
|
||||
return hydrateRoot(el, node)
|
||||
}
|
||||
|
||||
return createRoot(el).render(node)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import './bootstrap'
|
||||
import { mountInertiaRoot } from './bootstrap'
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { createInertiaApp } from '@inertiajs/react'
|
||||
const pages = {
|
||||
...import.meta.glob([
|
||||
@@ -39,7 +38,6 @@ function resolvePage(name) {
|
||||
createInertiaApp({
|
||||
resolve: resolvePage,
|
||||
setup({ el, App, props }) {
|
||||
const root = createRoot(el)
|
||||
root.render(<App {...props} />)
|
||||
mountInertiaRoot(el, App, props)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import axios from 'axios'
|
||||
import ShareArtworkModal from './ShareArtworkModal'
|
||||
import LinkPreviewCard from './LinkPreviewCard'
|
||||
import TagPeopleModal from './TagPeopleModal'
|
||||
import DateTimePicker from '../ui/DateTimePicker'
|
||||
import extractNativeEmoji from '../common/extractNativeEmoji'
|
||||
import isEventWithinNode from '../common/isEventWithinNode'
|
||||
|
||||
@@ -274,13 +275,14 @@ export default function PostComposer({ user, onPosted }) {
|
||||
<div className="flex items-center gap-2.5 p-3 rounded-xl bg-violet-500/10 border border-violet-500/20">
|
||||
<i className="fa-regular fa-calendar-plus text-violet-400 text-sm fa-fw shrink-0" />
|
||||
<div className="flex-1">
|
||||
<label className="block text-[11px] text-slate-400 mb-1">Publish on</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
<div className="block text-[11px] text-slate-400 mb-1">Publish on</div>
|
||||
<DateTimePicker
|
||||
value={scheduledAt}
|
||||
onChange={(e) => setScheduledAt(e.target.value)}
|
||||
min={new Date(Date.now() + 60_000).toISOString().slice(0, 16)}
|
||||
className="bg-transparent text-sm text-white border-none outline-none w-full [color-scheme:dark]"
|
||||
onChange={setScheduledAt}
|
||||
minDateTime={new Date(Date.now() + 60_000).toISOString().slice(0, 16)}
|
||||
placeholder="Pick a publish slot"
|
||||
clearable
|
||||
className="border-violet-300/20 bg-violet-500/10"
|
||||
/>
|
||||
<p className="text-[10px] text-slate-500 mt-1">
|
||||
{Intl.DateTimeFormat().resolvedOptions().timeZone}
|
||||
|
||||
@@ -2,6 +2,8 @@ import React, { useEffect, useState } from 'react'
|
||||
import { router } from '@inertiajs/react'
|
||||
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
|
||||
import ConfirmDangerModal from './ConfirmDangerModal'
|
||||
import NovaSelect from '../ui/NovaSelect'
|
||||
import Checkbox from '../ui/Checkbox'
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return 'Unscheduled'
|
||||
@@ -79,6 +81,11 @@ function bulkErrorMessage(payload, fallback = 'Bulk action failed.') {
|
||||
|| fallback
|
||||
}
|
||||
|
||||
function stripHtml(value) {
|
||||
if (typeof value !== 'string') return ''
|
||||
return value.replace(/<[^>]*>/g, '').trim()
|
||||
}
|
||||
|
||||
function ActionLink({ href, icon, label, onClick }) {
|
||||
if (!href) return null
|
||||
|
||||
@@ -176,7 +183,7 @@ function GridCard({ item, onExecuteAction, busyKey }) {
|
||||
)}
|
||||
|
||||
<p className="line-clamp-2 min-h-[2.5rem] text-sm text-slate-300/90">
|
||||
{item.description || 'No description yet.'}
|
||||
{stripHtml(item.description) || 'No description yet.'}
|
||||
</p>
|
||||
|
||||
{Array.isArray(readiness?.missing) && readiness.missing.length > 0 && (
|
||||
@@ -266,7 +273,7 @@ function ListRow({ item, onExecuteAction, busyKey }) {
|
||||
</div>
|
||||
<h3 className="mt-3 truncate text-lg font-semibold text-white">{item.title}</h3>
|
||||
<p className="mt-1 text-sm text-slate-400">{item.subtitle || item.visibility || 'Untitled metadata'}</p>
|
||||
<p className="mt-3 line-clamp-2 text-sm text-slate-300/90">{item.description || 'No description yet.'}</p>
|
||||
<p className="mt-3 line-clamp-2 text-sm text-slate-300/90">{stripHtml(item.description) || 'No description yet.'}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{readiness && (
|
||||
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium ${readinessClasses(readiness)}`}>
|
||||
@@ -324,31 +331,48 @@ function materializeFilter(filter, pendingFilters) {
|
||||
}
|
||||
}
|
||||
|
||||
function selectOptions(options = []) {
|
||||
return options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
group: option.group,
|
||||
disabled: option.disabled,
|
||||
icon: option.icon,
|
||||
}))
|
||||
}
|
||||
|
||||
function FilterField({ label, children }) {
|
||||
return (
|
||||
<div className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AdvancedFilterControl({ filter, onChange, value }) {
|
||||
const controlValue = value ?? filter.value
|
||||
|
||||
if (filter.type === 'select') {
|
||||
const options = selectOptions(filter.options || [])
|
||||
const searchable = filter.searchable ?? options.length > 8
|
||||
|
||||
return (
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{filter.label}</span>
|
||||
<select
|
||||
<FilterField label={filter.label}>
|
||||
<NovaSelect
|
||||
id={`studio-filter-${filter.key}`}
|
||||
options={options}
|
||||
value={controlValue || 'all'}
|
||||
onChange={(event) => onChange(filter.key, event.target.value)}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40 focus:bg-black/30"
|
||||
>
|
||||
{(filter.options || []).map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-slate-900">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
onChange={(nextValue) => onChange(filter.key, nextValue ?? 'all')}
|
||||
placeholder={filter.label}
|
||||
searchable={searchable}
|
||||
/>
|
||||
</FilterField>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{filter.label}</span>
|
||||
<FilterField label={filter.label}>
|
||||
<input
|
||||
type="search"
|
||||
value={controlValue || ''}
|
||||
@@ -356,7 +380,7 @@ function AdvancedFilterControl({ filter, onChange, value }) {
|
||||
placeholder={filter.placeholder || filter.label}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-sky-300/40 focus:bg-black/30"
|
||||
/>
|
||||
</label>
|
||||
</FilterField>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -817,8 +841,7 @@ export default function StudioContentBrowser({
|
||||
<section className="rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(34,197,94,0.12),_transparent_35%),radial-gradient(circle_at_bottom_right,_rgba(56,189,248,0.12),_transparent_40%),linear-gradient(135deg,_rgba(15,23,42,0.86),_rgba(2,6,23,0.96))] p-5 shadow-[0_22px_60px_rgba(2,6,23,0.28)] lg:p-6">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
|
||||
<div className={`grid gap-3 md:grid-cols-2 ${filterGridClass}`}>
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Search</span>
|
||||
<FilterField label="Search">
|
||||
<input
|
||||
type="search"
|
||||
value={pendingFilters.q}
|
||||
@@ -826,56 +849,44 @@ export default function StudioContentBrowser({
|
||||
placeholder="Title, description, module"
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-sky-300/40 focus:bg-black/30"
|
||||
/>
|
||||
</label>
|
||||
</FilterField>
|
||||
|
||||
{!hideModuleFilter && (
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Module</span>
|
||||
<select
|
||||
<FilterField label="Module">
|
||||
<NovaSelect
|
||||
id="studio-filter-module"
|
||||
options={selectOptions(listing?.module_options || [])}
|
||||
value={filters.module || 'all'}
|
||||
onChange={(event) => updateQuery({ module: event.target.value })}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40 focus:bg-black/30"
|
||||
>
|
||||
{(listing?.module_options || []).map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-slate-900">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
onChange={(nextValue) => updateQuery({ module: nextValue ?? 'all' })}
|
||||
placeholder="All content"
|
||||
searchable={false}
|
||||
/>
|
||||
</FilterField>
|
||||
)}
|
||||
|
||||
{!hideBucketFilter && (
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Status</span>
|
||||
<select
|
||||
<FilterField label="Status">
|
||||
<NovaSelect
|
||||
id="studio-filter-status"
|
||||
options={selectOptions(listing?.bucket_options || [])}
|
||||
value={pendingFilters.bucket}
|
||||
onChange={(event) => setPendingFilter('bucket', event.target.value)}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40 focus:bg-black/30"
|
||||
>
|
||||
{(listing?.bucket_options || []).map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-slate-900">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
onChange={(nextValue) => setPendingFilter('bucket', nextValue ?? 'all')}
|
||||
placeholder="All"
|
||||
searchable={false}
|
||||
/>
|
||||
</FilterField>
|
||||
)}
|
||||
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Sort</span>
|
||||
<select
|
||||
<FilterField label="Sort">
|
||||
<NovaSelect
|
||||
id="studio-filter-sort"
|
||||
options={selectOptions(listing?.sort_options || [])}
|
||||
value={pendingFilters.sort}
|
||||
onChange={(event) => setPendingFilter('sort', event.target.value)}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40 focus:bg-black/30"
|
||||
>
|
||||
{(listing?.sort_options || []).map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-slate-900">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
onChange={(nextValue) => setPendingFilter('sort', nextValue ?? 'updated_desc')}
|
||||
placeholder="Recently updated"
|
||||
searchable={false}
|
||||
/>
|
||||
</FilterField>
|
||||
|
||||
{advancedFilters.map((filter) => {
|
||||
const resolvedFilter = materializeFilter(filter, pendingFilters)
|
||||
@@ -960,15 +971,13 @@ export default function StudioContentBrowser({
|
||||
{viewMode === 'table' && supportsArtworkBulk && (
|
||||
<section className="flex flex-wrap items-center justify-between gap-3 rounded-[24px] border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-300">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="inline-flex items-center gap-2 text-sm text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
<div className="inline-flex items-center gap-2 text-sm text-slate-300">
|
||||
<Checkbox
|
||||
checked={allVisibleSelected}
|
||||
onChange={toggleSelectAllVisible}
|
||||
className="h-4 w-4 rounded border-white/20 bg-slate-950/60 text-sky-400 focus:ring-sky-400/40"
|
||||
label="Select page"
|
||||
/>
|
||||
<span>Select page</span>
|
||||
</label>
|
||||
</div>
|
||||
<span className="text-slate-500">
|
||||
{selectedIds.length > 0 ? `${selectedIds.length} selected` : 'Select artworks to run bulk actions'}
|
||||
</span>
|
||||
@@ -1014,11 +1023,9 @@ export default function StudioContentBrowser({
|
||||
<tr>
|
||||
{supportsArtworkBulk && (
|
||||
<th scope="col" className="w-12 px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
<Checkbox
|
||||
checked={allVisibleSelected}
|
||||
onChange={toggleSelectAllVisible}
|
||||
className="h-4 w-4 rounded border-white/20 bg-slate-950/60 text-sky-400 focus:ring-sky-400/40"
|
||||
aria-label="Select all artworks on this page"
|
||||
/>
|
||||
</th>
|
||||
@@ -1039,11 +1046,9 @@ export default function StudioContentBrowser({
|
||||
<tr key={item.id} className="align-top transition hover:bg-white/[0.03]">
|
||||
{supportsArtworkBulk && (
|
||||
<td className="px-4 py-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={() => toggleSelected(Number(item.numeric_id))}
|
||||
className="mt-1 h-4 w-4 rounded border-white/20 bg-slate-950/60 text-sky-400 focus:ring-sky-400/40"
|
||||
aria-label={`Select ${item.title}`}
|
||||
/>
|
||||
</td>
|
||||
|
||||
100
resources/js/components/Studio/StudioContentBrowser.test.jsx
Normal file
100
resources/js/components/Studio/StudioContentBrowser.test.jsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import StudioContentBrowser from './StudioContentBrowser'
|
||||
|
||||
const routerGet = vi.fn()
|
||||
const routerReload = vi.fn()
|
||||
|
||||
vi.mock('@inertiajs/react', () => ({
|
||||
router: {
|
||||
get: routerGet,
|
||||
reload: routerReload,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../utils/studioEvents', () => ({
|
||||
studioSurface: () => '/studio/artworks',
|
||||
trackStudioEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./ConfirmDangerModal', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
describe('StudioContentBrowser filters', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders artwork filter dropdowns with NovaSelect instead of native selects', () => {
|
||||
const { container } = render(
|
||||
<StudioContentBrowser
|
||||
hideModuleFilter
|
||||
listing={{
|
||||
filters: {
|
||||
module: 'artworks',
|
||||
bucket: 'all',
|
||||
q: '',
|
||||
sort: 'updated_desc',
|
||||
content_type: 'all',
|
||||
category: 'all',
|
||||
tag: '',
|
||||
},
|
||||
items: [],
|
||||
meta: {
|
||||
current_page: 1,
|
||||
last_page: 1,
|
||||
per_page: 24,
|
||||
total: 0,
|
||||
},
|
||||
bucket_options: [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'published', label: 'Published' },
|
||||
],
|
||||
sort_options: [
|
||||
{ value: 'updated_desc', label: 'Recently updated' },
|
||||
{ value: 'views_desc', label: 'Most viewed' },
|
||||
],
|
||||
advanced_filters: [
|
||||
{
|
||||
key: 'content_type',
|
||||
label: 'Content type',
|
||||
type: 'select',
|
||||
value: 'all',
|
||||
options: [
|
||||
{ value: 'all', label: 'All content types' },
|
||||
{ value: '3d', label: '3D' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
label: 'Category',
|
||||
type: 'select',
|
||||
value: 'all',
|
||||
options: [
|
||||
{ value: 'all', label: 'All categories' },
|
||||
{ value: 'abstract', label: 'Abstract', content_type_slug: 'all' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'tag',
|
||||
label: 'Tag',
|
||||
type: 'search',
|
||||
value: '',
|
||||
placeholder: 'Filter by tag',
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelectorAll('select')).toHaveLength(0)
|
||||
expect(screen.getAllByRole('combobox')).toHaveLength(4)
|
||||
expect(screen.getByText('Status')).not.toBeNull()
|
||||
expect(screen.getByText('Sort')).not.toBeNull()
|
||||
expect(screen.getByText('Content type')).not.toBeNull()
|
||||
expect(screen.getByText('Category')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -222,11 +222,27 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
// Count a view on every page load.
|
||||
useEffect(() => {
|
||||
if (!artwork?.id) return
|
||||
fetch(`/api/art/${artwork.id}/view`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
}).catch(() => {})
|
||||
const postView = () => {
|
||||
fetch(`/api/art/${artwork.id}/view`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
keepalive: true,
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
postView()
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (typeof window.requestIdleCallback === 'function') {
|
||||
const handle = window.requestIdleCallback(postView, { timeout: 1500 })
|
||||
return () => window.cancelIdleCallback(handle)
|
||||
}
|
||||
|
||||
const handle = window.setTimeout(postView, 1200)
|
||||
return () => window.clearTimeout(handle)
|
||||
}, [artwork?.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const postInteraction = async (url, body) => {
|
||||
@@ -327,7 +343,7 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
].join(' ')}
|
||||
>
|
||||
<HeartIcon filled={favorited} />
|
||||
<span className="tabular-nums">{favCount}</span>
|
||||
<span className="tabular-nums" aria-hidden="true">{favCount}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -342,7 +358,7 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
].join(' ')}
|
||||
>
|
||||
<BookmarkIcon filled={bookmarked} />
|
||||
<span className="tabular-nums">{savedCount}</span>
|
||||
<span className="tabular-nums" aria-hidden="true">{savedCount}</span>
|
||||
</button>
|
||||
|
||||
{/* Share pill */}
|
||||
@@ -403,7 +419,7 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
].join(' ')}
|
||||
>
|
||||
<HeartIcon filled={favorited} />
|
||||
<span className="tabular-nums">{favCount}</span>
|
||||
<span className="tabular-nums" aria-hidden="true">{favCount}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -418,7 +434,7 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
].join(' ')}
|
||||
>
|
||||
<BookmarkIcon filled={bookmarked} />
|
||||
<span className="tabular-nums">{savedCount}</span>
|
||||
<span className="tabular-nums" aria-hidden="true">{savedCount}</span>
|
||||
</button>
|
||||
|
||||
{/* Share */}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react'
|
||||
import FollowButton from '../social/FollowButton'
|
||||
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/missing_sq.webp'
|
||||
const NUMBER_FORMATTER = new Intl.NumberFormat('en-US')
|
||||
|
||||
export default function ArtworkAuthor({ artwork, presentSq }) {
|
||||
const [following, setFollowing] = useState(Boolean(artwork?.viewer?.is_following_author))
|
||||
@@ -34,7 +35,7 @@ export default function ArtworkAuthor({ artwork, presentSq }) {
|
||||
{authorName}
|
||||
</a>
|
||||
{user.username && <p className="truncate text-xs text-soft">@{user.username}</p>}
|
||||
<p className="mt-1 text-xs text-soft">{followersCount.toLocaleString()} followers</p>
|
||||
<p className="mt-1 text-xs text-soft">{NUMBER_FORMATTER.format(followersCount)} followers</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -331,7 +331,7 @@ export default function ArtworkAwards({ artwork, initialAwards = null, isAuthent
|
||||
|
||||
{!isAuthenticated && (
|
||||
<p className="mt-3 text-center text-xs text-soft">
|
||||
<a href="/login" className="text-accent hover:underline">Sign in</a> to medal this artwork
|
||||
<a href="/login" className="text-accent underline hover:no-underline">Sign in</a> to medal this artwork
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ function Crumb({ href, children, current = false }) {
|
||||
if (current) {
|
||||
return (
|
||||
<span
|
||||
className={`${base} text-white/30`}
|
||||
className={`${base} text-white/55`}
|
||||
aria-current="page"
|
||||
>
|
||||
{children}
|
||||
@@ -30,7 +30,7 @@ function Crumb({ href, children, current = false }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={`${base} text-white/30 hover:text-white/60 transition-colors duration-150`}
|
||||
className={`${base} text-white/55 hover:text-white/80 transition-colors duration-150`}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
|
||||
@@ -4,10 +4,11 @@ import LevelBadge from '../xp/LevelBadge'
|
||||
|
||||
const IMAGE_FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||
const numberFormatter = new Intl.NumberFormat(undefined, {
|
||||
const numberFormatter = new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
const relativeTimeFormatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
|
||||
|
||||
function cx(...parts) {
|
||||
return parts.filter(Boolean).join(' ')
|
||||
@@ -29,27 +30,26 @@ function formatRelativeTime(value) {
|
||||
const diffMs = date.getTime() - now.getTime()
|
||||
const diffSeconds = Math.round(diffMs / 1000)
|
||||
const absSeconds = Math.abs(diffSeconds)
|
||||
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
||||
|
||||
if (absSeconds < 60) return rtf.format(diffSeconds, 'second')
|
||||
if (absSeconds < 60) return relativeTimeFormatter.format(diffSeconds, 'second')
|
||||
|
||||
const diffMinutes = Math.round(diffSeconds / 60)
|
||||
if (Math.abs(diffMinutes) < 60) return rtf.format(diffMinutes, 'minute')
|
||||
if (Math.abs(diffMinutes) < 60) return relativeTimeFormatter.format(diffMinutes, 'minute')
|
||||
|
||||
const diffHours = Math.round(diffSeconds / 3600)
|
||||
if (Math.abs(diffHours) < 24) return rtf.format(diffHours, 'hour')
|
||||
if (Math.abs(diffHours) < 24) return relativeTimeFormatter.format(diffHours, 'hour')
|
||||
|
||||
const diffDays = Math.round(diffSeconds / 86400)
|
||||
if (Math.abs(diffDays) < 7) return rtf.format(diffDays, 'day')
|
||||
if (Math.abs(diffDays) < 7) return relativeTimeFormatter.format(diffDays, 'day')
|
||||
|
||||
const diffWeeks = Math.round(diffSeconds / 604800)
|
||||
if (Math.abs(diffWeeks) < 5) return rtf.format(diffWeeks, 'week')
|
||||
if (Math.abs(diffWeeks) < 5) return relativeTimeFormatter.format(diffWeeks, 'week')
|
||||
|
||||
const diffMonths = Math.round(diffSeconds / 2629800)
|
||||
if (Math.abs(diffMonths) < 12) return rtf.format(diffMonths, 'month')
|
||||
if (Math.abs(diffMonths) < 12) return relativeTimeFormatter.format(diffMonths, 'month')
|
||||
|
||||
const diffYears = Math.round(diffSeconds / 31557600)
|
||||
return rtf.format(diffYears, 'year')
|
||||
return relativeTimeFormatter.format(diffYears, 'year')
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
|
||||
@@ -5,20 +5,44 @@ import ReactionBar from '../comments/ReactionBar'
|
||||
import LevelBadge from '../xp/LevelBadge'
|
||||
import { isFlood } from '../../utils/emojiFlood'
|
||||
|
||||
const ABSOLUTE_DATE_FORMATTER = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
|
||||
const ABSOLUTE_DATE_TIME_FORMATTER = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function timeAgo(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
|
||||
if (seconds < 60) return 'just now'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 365) return `${days}d ago`
|
||||
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
function formatAbsoluteDate(value) {
|
||||
if (!value) return ''
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
|
||||
return ABSOLUTE_DATE_FORMATTER.format(date)
|
||||
}
|
||||
|
||||
function formatAbsoluteDateTime(value) {
|
||||
if (!value) return ''
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
|
||||
return ABSOLUTE_DATE_TIME_FORMATTER.format(date)
|
||||
}
|
||||
|
||||
function formatCommentTime(primaryLabel, createdAt) {
|
||||
return primaryLabel || formatAbsoluteDate(createdAt)
|
||||
}
|
||||
|
||||
/* ── Icons ─────────────────────────────────────────────────────────────────── */
|
||||
@@ -135,10 +159,10 @@ function ReplyItem({ reply, parentId, artworkId, isLoggedIn, onReplyPosted, dept
|
||||
<span className="text-white/15" aria-hidden="true">·</span>
|
||||
<time
|
||||
dateTime={reply.created_at}
|
||||
title={reply.created_at ? new Date(reply.created_at).toLocaleString() : ''}
|
||||
title={formatAbsoluteDateTime(reply.created_at)}
|
||||
className="text-[10px] font-medium tracking-wide text-white/25 uppercase"
|
||||
>
|
||||
{reply.time_ago || timeAgo(reply.created_at)}
|
||||
{formatCommentTime(reply.time_ago, reply.created_at)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
@@ -292,10 +316,10 @@ function CommentItem({ comment, isLoggedIn, artworkId, onReplyPosted }) {
|
||||
<span className="text-white/15" aria-hidden="true">·</span>
|
||||
<time
|
||||
dateTime={comment.created_at}
|
||||
title={comment.created_at ? new Date(comment.created_at).toLocaleString() : ''}
|
||||
title={formatAbsoluteDateTime(comment.created_at)}
|
||||
className="text-[11px] font-medium tracking-wide text-white/30 uppercase"
|
||||
>
|
||||
{comment.time_ago || timeAgo(comment.created_at)}
|
||||
{formatCommentTime(comment.time_ago, comment.created_at)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,11 +2,33 @@ import React, { useState } from 'react'
|
||||
|
||||
const COLLAPSE_AT = 560
|
||||
|
||||
function stripTags(value) {
|
||||
return String(value || '')
|
||||
.replace(/<\/?(?:html|head|body|title|meta|link|script|style)[^>]*>/gi, '')
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function sanitizeDescriptionHtml(value) {
|
||||
const html = String(value || '').trim()
|
||||
|
||||
if (!html) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (/<\/?(?:html|head|body|title|meta|link|script|style)\b/i.test(html)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
export default function ArtworkDescription({ artwork }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const content = (artwork?.description || '').trim()
|
||||
const contentHtml = (artwork?.description_html || '').trim()
|
||||
const contentHtml = sanitizeDescriptionHtml(artwork?.description_html || '')
|
||||
const collapsed = content.length > COLLAPSE_AT && !expanded
|
||||
const fallbackText = contentHtml ? stripTags(contentHtml) : content
|
||||
|
||||
if (content.length === 0) return null
|
||||
|
||||
@@ -20,7 +42,8 @@ export default function ArtworkDescription({ artwork }) {
|
||||
>
|
||||
<div
|
||||
className="prose prose-invert max-w-none text-sm leading-7 prose-p:my-3 prose-p:text-white/50 prose-a:text-accent prose-a:no-underline hover:prose-a:underline prose-strong:text-white/80 prose-em:text-white/70 prose-code:text-white/80"
|
||||
dangerouslySetInnerHTML={{ __html: contentHtml }}
|
||||
suppressHydrationWarning
|
||||
dangerouslySetInnerHTML={{ __html: contentHtml || escapeHtml(fallbackText) }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -36,3 +59,12 @@ export default function ArtworkDescription({ artwork }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
@@ -2,6 +2,13 @@ import React, { useMemo } from 'react'
|
||||
import ArtworkBreadcrumbs from './ArtworkBreadcrumbs'
|
||||
import ArtworkFormatBadges from './ArtworkFormatBadges'
|
||||
|
||||
const ABSOLUTE_DATE_FORMATTER = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
|
||||
function formatCount(value) {
|
||||
const number = Number(value || 0)
|
||||
if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
|
||||
@@ -12,7 +19,7 @@ function formatCount(value) {
|
||||
function formatDate(value) {
|
||||
if (!value) return '—'
|
||||
try {
|
||||
return new Date(value).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
return ABSOLUTE_DATE_FORMATTER.format(new Date(value))
|
||||
} catch {
|
||||
return '—'
|
||||
}
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
import React from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import ArtworkFormatBadges from './ArtworkFormatBadges'
|
||||
|
||||
const NUMBER_FORMATTER = new Intl.NumberFormat('en-US')
|
||||
const ABSOLUTE_DATE_FORMATTER = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
|
||||
function formatCount(value) {
|
||||
const n = Number(value || 0)
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}k`
|
||||
return n.toLocaleString()
|
||||
return NUMBER_FORMATTER.format(n)
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
function formatDate(value, useRelative = true) {
|
||||
if (!value) return '—'
|
||||
try {
|
||||
const d = new Date(value)
|
||||
if (!useRelative) return ABSOLUTE_DATE_FORMATTER.format(d)
|
||||
const now = Date.now()
|
||||
const diff = now - d.getTime()
|
||||
const days = Math.floor(diff / 86_400_000)
|
||||
if (days === 0) return 'Today'
|
||||
if (days === 1) return 'Yesterday'
|
||||
if (days < 30) return `${days} days ago`
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
return ABSOLUTE_DATE_FORMATTER.format(d)
|
||||
} catch {
|
||||
return '—'
|
||||
}
|
||||
@@ -46,9 +55,14 @@ function InfoRow({ label, value }) {
|
||||
}
|
||||
|
||||
export default function ArtworkDetailsPanel({ artwork, stats }) {
|
||||
const [hydrated, setHydrated] = useState(false)
|
||||
const width = artwork?.dimensions?.width || artwork?.width || 0
|
||||
const height = artwork?.dimensions?.height || artwork?.height || 0
|
||||
const resolution = width > 0 && height > 0 ? `${width.toLocaleString()} × ${height.toLocaleString()}` : null
|
||||
const resolution = width > 0 && height > 0 ? `${NUMBER_FORMATTER.format(width)} × ${NUMBER_FORMATTER.format(height)}` : null
|
||||
|
||||
useEffect(() => {
|
||||
setHydrated(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-white/[0.06] bg-white/[0.03] p-5">
|
||||
@@ -86,7 +100,7 @@ export default function ArtworkDetailsPanel({ artwork, stats }) {
|
||||
<ArtworkFormatBadges width={width} height={height} className="mt-2" />
|
||||
</div>
|
||||
) : null}
|
||||
<InfoRow label="Uploaded" value={formatDate(artwork?.published_at)} />
|
||||
<InfoRow label="Uploaded" value={formatDate(artwork?.published_at, hydrated)} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -84,6 +84,7 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
|
||||
className="pointer-events-none absolute inset-0 h-full w-full scale-110 object-cover opacity-30 blur-3xl"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
fetchPriority="high"
|
||||
onError={(event) => {
|
||||
event.currentTarget.onerror = null
|
||||
setShowBackdrop(false)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react'
|
||||
import React, { useEffect, useMemo, useRef, useCallback } from 'react'
|
||||
|
||||
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||
@@ -77,7 +77,7 @@ function RailCard({ item }) {
|
||||
<img
|
||||
src={item.thumb || FALLBACK}
|
||||
srcSet={item.thumbSrcSet || undefined}
|
||||
sizes="220px"
|
||||
sizes="(min-width: 1280px) 210px, (min-width: 640px) 220px, 240px"
|
||||
alt={item.title || 'Artwork'}
|
||||
className={`h-full w-full object-cover transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04] ${shouldBlur ? 'scale-[1.02] blur-xl' : ''}`}
|
||||
loading="lazy"
|
||||
@@ -339,74 +339,18 @@ function Rail({ title, emoji, items, seeAllHref }) {
|
||||
|
||||
/* ── Main export ─────────────────────────────────────────────── */
|
||||
|
||||
export default function ArtworkRecommendationsRails({ artwork, related = [] }) {
|
||||
const [similarApiItems, setSimilarApiItems] = useState([])
|
||||
const [similarLoaded, setSimilarLoaded] = useState(false)
|
||||
const [trendingItems, setTrendingItems] = useState([])
|
||||
|
||||
export default function ArtworkRecommendationsRails({ artwork, related = [], similarApiData = [], trendingData = [] }) {
|
||||
const relatedCards = useMemo(() => {
|
||||
return dedupeByUrl((Array.isArray(related) ? related : []).map(normalizeRelated).filter(Boolean))
|
||||
}, [related])
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
const similarApiItems = useMemo(() => {
|
||||
return dedupeByUrl((Array.isArray(similarApiData) ? similarApiData : []).map(normalizeSimilar).filter(Boolean))
|
||||
}, [similarApiData])
|
||||
|
||||
const loadSimilar = async () => {
|
||||
if (!artwork?.id) {
|
||||
setSimilarApiItems([])
|
||||
setSimilarLoaded(true)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/art/${artwork.id}/similar-ai`, { credentials: 'same-origin' })
|
||||
if (!response.ok) throw new Error('similar fetch failed')
|
||||
const payload = await response.json()
|
||||
const items = dedupeByUrl((payload?.data || []).map(normalizeSimilar).filter(Boolean))
|
||||
if (!isCancelled) {
|
||||
setSimilarApiItems(items)
|
||||
setSimilarLoaded(true)
|
||||
}
|
||||
} catch {
|
||||
if (!isCancelled) {
|
||||
setSimilarApiItems([])
|
||||
setSimilarLoaded(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadSimilar()
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [artwork?.id])
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
|
||||
const loadTrending = async () => {
|
||||
const categoryId = artwork?.categories?.[0]?.id
|
||||
if (!categoryId) {
|
||||
setTrendingItems([])
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/rank/category/${categoryId}?type=trending`, { credentials: 'same-origin' })
|
||||
if (!response.ok) throw new Error('trending fetch failed')
|
||||
const payload = await response.json()
|
||||
const items = dedupeByUrl((payload?.data || []).map(normalizeRankItem).filter(Boolean))
|
||||
if (!isCancelled) setTrendingItems(items)
|
||||
} catch {
|
||||
if (!isCancelled) setTrendingItems([])
|
||||
}
|
||||
}
|
||||
|
||||
loadTrending()
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [artwork?.categories])
|
||||
const trendingItems = useMemo(() => {
|
||||
return dedupeByUrl((Array.isArray(trendingData) ? trendingData : []).map(normalizeRankItem).filter(Boolean))
|
||||
}, [trendingData])
|
||||
|
||||
const authorName = String(artwork?.user?.name || artwork?.user?.username || '').trim().toLowerCase()
|
||||
|
||||
@@ -415,11 +359,10 @@ export default function ArtworkRecommendationsRails({ artwork, related = [] }) {
|
||||
}, [relatedCards, authorName])
|
||||
|
||||
const similarItems = useMemo(() => {
|
||||
if (!similarLoaded) return []
|
||||
if (similarApiItems.length > 0) return similarApiItems.slice(0, 12)
|
||||
if (tagBasedFallback.length > 0) return tagBasedFallback.slice(0, 12)
|
||||
return trendingItems.slice(0, 12)
|
||||
}, [similarLoaded, similarApiItems, tagBasedFallback, trendingItems])
|
||||
}, [similarApiItems, tagBasedFallback, trendingItems])
|
||||
|
||||
const trendingRailItems = useMemo(() => trendingItems.slice(0, 12), [trendingItems])
|
||||
|
||||
@@ -428,11 +371,9 @@ export default function ArtworkRecommendationsRails({ artwork, related = [] }) {
|
||||
const categoryName = artwork?.categories?.[0]?.name
|
||||
const trendingLabel = categoryName
|
||||
? `Trending in ${categoryName}`
|
||||
: 'Trending'
|
||||
: 'Trending on Skinbase'
|
||||
|
||||
const trendingHref = categoryName
|
||||
? `/discover/trending`
|
||||
: '/discover/trending'
|
||||
const trendingHref = '/discover/trending'
|
||||
|
||||
const similarHref = artwork?.id ? `/art/${artwork.id}/similar` : null
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import ArtworkRecommendationsRails from './ArtworkRecommendationsRails'
|
||||
|
||||
describe('ArtworkRecommendationsRails', () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn((url) => {
|
||||
if (String(url).includes('/similar-ai')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ data: [] }),
|
||||
})
|
||||
}
|
||||
|
||||
if (String(url).includes('/api/rank/category/5?type=trending')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{
|
||||
id: 11,
|
||||
title: 'Star map drift',
|
||||
urls: { direct: '/art/11/star-map-drift' },
|
||||
author: { name: 'Pilot' },
|
||||
thumbnail_url: '/thumbs/11.webp',
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ data: [] }),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('loads recommendation rails after mount', async () => {
|
||||
render(
|
||||
<ArtworkRecommendationsRails
|
||||
artwork={{
|
||||
id: 69827,
|
||||
user: { name: 'Pilot' },
|
||||
categories: [{ id: 5, name: 'Sci-Fi' }],
|
||||
}}
|
||||
related={[]}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Trending in Sci-Fi')).not.toBeNull()
|
||||
})
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/art/69827/similar-ai', { credentials: 'same-origin' })
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/rank/category/5?type=trending', { credentials: 'same-origin' })
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import AuthorBioPopover from './AuthorBioPopover'
|
||||
import FollowButton from '../social/FollowButton'
|
||||
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/missing_sq.webp'
|
||||
const NUMBER_FORMATTER = new Intl.NumberFormat('en-US')
|
||||
|
||||
function formatCount(value) {
|
||||
const n = Number(value || 0)
|
||||
@@ -91,7 +92,7 @@ export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 text-xs font-medium text-white/30">
|
||||
{followersCount.toLocaleString()} Followers
|
||||
{NUMBER_FORMATTER.format(followersCount)} Followers
|
||||
</p>
|
||||
|
||||
{/* Profile + Follow buttons */}
|
||||
@@ -152,7 +153,7 @@ export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
|
||||
<div className="mt-5 border-t border-white/[0.06] pt-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-white/80">{isGroupPublisher ? 'More related works' : `More from ${authorName}`}</h3>
|
||||
<a href={profileUrl} className="text-white/30 transition-colors hover:text-white/60">
|
||||
<a href={profileUrl} aria-label={isGroupPublisher ? 'View more related works' : `View all from ${authorName}`} className="text-white/30 transition-colors hover:text-white/60">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
|
||||
18
resources/js/components/auth/RememberMeCheckbox.jsx
Normal file
18
resources/js/components/auth/RememberMeCheckbox.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React, { useState } from 'react'
|
||||
import Checkbox from '../ui/Checkbox'
|
||||
|
||||
export default function RememberMeCheckbox({ initialChecked = false, label = 'Remember me', name = 'remember' }) {
|
||||
const [checked, setChecked] = useState(Boolean(initialChecked))
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
name={name}
|
||||
value="1"
|
||||
checked={checked}
|
||||
onChange={(event) => setChecked(event.target.checked)}
|
||||
label={label}
|
||||
variant="accent"
|
||||
size={18}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import { common, createLowlight } from 'lowlight';
|
||||
import tippy from 'tippy.js';
|
||||
import { buildBotFingerprint } from '../../lib/security/botFingerprint';
|
||||
import TurnstileField from '../security/TurnstileField';
|
||||
import DateTimePicker from '../ui/DateTimePicker';
|
||||
import Modal from '../ui/Modal';
|
||||
import NovaSelect from '../ui/NovaSelect';
|
||||
|
||||
type StoryType = {
|
||||
@@ -43,7 +45,6 @@ type StoryPayload = {
|
||||
tags_csv: string;
|
||||
meta_title: string;
|
||||
meta_description: string;
|
||||
canonical_url: string;
|
||||
og_image: string;
|
||||
status: string;
|
||||
scheduled_for: string;
|
||||
@@ -68,6 +69,8 @@ type Props = {
|
||||
csrfToken: string;
|
||||
};
|
||||
|
||||
type InsertDialogKind = 'image' | 'video' | 'download' | 'link' | null;
|
||||
|
||||
const EMPTY_DOC = {
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph' }],
|
||||
@@ -90,6 +93,108 @@ const CODE_BLOCK_LANGUAGES = [
|
||||
{ value: 'markdown', label: 'Markdown' },
|
||||
];
|
||||
|
||||
const INSERT_DIALOG_CONTENT = {
|
||||
image: {
|
||||
title: 'Add image from URL',
|
||||
description: 'Paste a direct image URL to insert a full image block into the story body.',
|
||||
confirmLabel: 'Insert image',
|
||||
urlLabel: 'Image URL',
|
||||
urlPlaceholder: 'https://images.example.com/story-scene.jpg',
|
||||
urlHint: 'Use a direct image file URL when possible for the most reliable preview.',
|
||||
},
|
||||
video: {
|
||||
title: 'Embed a video',
|
||||
description: 'Paste a YouTube or Vimeo link. Common watch and share URLs will be converted to embed URLs automatically.',
|
||||
confirmLabel: 'Embed video',
|
||||
urlLabel: 'Video URL',
|
||||
urlPlaceholder: 'https://www.youtube.com/watch?v=example',
|
||||
urlHint: 'You can paste a normal watch URL, share URL, or a direct embed URL.',
|
||||
},
|
||||
download: {
|
||||
title: 'Add a download link',
|
||||
description: 'Create a downloadable asset button with a friendly label for readers.',
|
||||
confirmLabel: 'Add download',
|
||||
urlLabel: 'File URL',
|
||||
urlPlaceholder: 'https://cdn.example.com/files/asset.zip',
|
||||
urlHint: 'Point this at the exact file you want readers to download.',
|
||||
},
|
||||
link: {
|
||||
title: 'Add link to selection',
|
||||
description: 'Attach a link to the currently selected text in your story.',
|
||||
confirmLabel: 'Save link',
|
||||
urlLabel: 'Link URL',
|
||||
urlPlaceholder: 'https://skinbase.org/help',
|
||||
urlHint: 'Paste any http or https URL. Leave it empty and use Remove link to clear an existing link.',
|
||||
},
|
||||
};
|
||||
|
||||
const INSERT_DIALOG_INITIAL_STATE = {
|
||||
kind: null as InsertDialogKind,
|
||||
url: '',
|
||||
title: '',
|
||||
label: 'Download asset',
|
||||
error: '',
|
||||
};
|
||||
|
||||
function normalizeHttpUrl(rawValue: string): string | null {
|
||||
const trimmed = rawValue.trim();
|
||||
if (trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed.replace(/^\/+/, '')}`;
|
||||
|
||||
try {
|
||||
const parsed = new URL(withProtocol);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeVideoEmbedUrl(rawValue: string): string | null {
|
||||
const normalized = normalizeHttpUrl(rawValue);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = new URL(normalized);
|
||||
const host = parsed.hostname.replace(/^www\./i, '').toLowerCase();
|
||||
const path = parsed.pathname;
|
||||
|
||||
if (host === 'youtu.be') {
|
||||
const videoId = path.replace(/^\//, '').split('/')[0];
|
||||
return videoId ? `https://www.youtube.com/embed/${videoId}` : normalized;
|
||||
}
|
||||
|
||||
if (host === 'youtube.com' || host === 'm.youtube.com') {
|
||||
if (path === '/watch') {
|
||||
const videoId = parsed.searchParams.get('v');
|
||||
return videoId ? `https://www.youtube.com/embed/${videoId}` : normalized;
|
||||
}
|
||||
|
||||
const pathMatch = path.match(/^\/(embed|shorts|live)\/([^/?#]+)/i);
|
||||
if (pathMatch?.[2]) {
|
||||
return `https://www.youtube.com/embed/${pathMatch[2]}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (host === 'vimeo.com') {
|
||||
const videoId = path.replace(/^\//, '').split('/')[0];
|
||||
return videoId ? `https://player.vimeo.com/video/${videoId}` : normalized;
|
||||
}
|
||||
|
||||
if (host === 'player.vimeo.com') {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const ArtworkBlock = Node.create({
|
||||
name: 'artworkEmbed',
|
||||
group: 'block',
|
||||
@@ -263,6 +368,7 @@ function createSlashCommandExtension(insert: {
|
||||
code: () => void;
|
||||
quote: () => void;
|
||||
divider: () => void;
|
||||
part: () => void;
|
||||
gallery: () => void;
|
||||
video: () => void;
|
||||
download: () => void;
|
||||
@@ -282,6 +388,7 @@ function createSlashCommandExtension(insert: {
|
||||
{ title: 'Artwork', key: 'artwork' },
|
||||
{ title: 'Code', key: 'code' },
|
||||
{ title: 'Quote', key: 'quote' },
|
||||
{ title: 'Add a new part', key: 'part' },
|
||||
{ title: 'Divider', key: 'divider' },
|
||||
{ title: 'Gallery', key: 'gallery' },
|
||||
{ title: 'Video', key: 'video' },
|
||||
@@ -295,6 +402,7 @@ function createSlashCommandExtension(insert: {
|
||||
if (props.key === 'artwork') insert.artwork();
|
||||
if (props.key === 'code') insert.code();
|
||||
if (props.key === 'quote') insert.quote();
|
||||
if (props.key === 'part') insert.part();
|
||||
if (props.key === 'divider') insert.divider();
|
||||
if (props.key === 'gallery') insert.gallery();
|
||||
if (props.key === 'video') insert.video();
|
||||
@@ -438,7 +546,6 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
const [tagsCsv, setTagsCsv] = useState(initialStory.tags_csv || '');
|
||||
const [metaTitle, setMetaTitle] = useState(initialStory.meta_title || '');
|
||||
const [metaDescription, setMetaDescription] = useState(initialStory.meta_description || '');
|
||||
const [canonicalUrl, setCanonicalUrl] = useState(initialStory.canonical_url || '');
|
||||
const [ogImage, setOgImage] = useState(initialStory.og_image || '');
|
||||
const [status, setStatus] = useState(initialStory.status || 'draft');
|
||||
const [scheduledFor, setScheduledFor] = useState(initialStory.scheduled_for || '');
|
||||
@@ -449,14 +556,19 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
const [inlineToolbar, setInlineToolbar] = useState({ visible: false, top: 0, left: 0 });
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string[]>>({});
|
||||
const [generalError, setGeneralError] = useState('');
|
||||
const [insertDialog, setInsertDialog] = useState(INSERT_DIALOG_INITIAL_STATE);
|
||||
const [wordCount, setWordCount] = useState(0);
|
||||
const [readMinutes, setReadMinutes] = useState(1);
|
||||
const [codeBlockLanguage, setCodeBlockLanguage] = useState('bash');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [focusMode, setFocusMode] = useState(false);
|
||||
const [plusMenuOpen, setPlusMenuOpen] = useState(false);
|
||||
const [plusButtonState, setPlusButtonState] = useState({ visible: false, top: 0, left: 0 });
|
||||
const editorContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const insertSelectionRef = useRef<{ from: number; to: number } | null>(null);
|
||||
const titleInputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const excerptInputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const [captchaState, setCaptchaState] = useState({
|
||||
required: false,
|
||||
token: '',
|
||||
@@ -534,17 +646,6 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
setFieldErrors({});
|
||||
}, []);
|
||||
|
||||
const openLinkPrompt = useCallback((editor: any) => {
|
||||
const prev = editor.getAttributes('link').href;
|
||||
const url = window.prompt('Link URL', prev || 'https://');
|
||||
if (url === null) return;
|
||||
if (url.trim() === '') {
|
||||
editor.chain().focus().unsetLink().run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().setLink({ href: url.trim() }).run();
|
||||
}, []);
|
||||
|
||||
const fetchArtworks = useCallback(async (query: string) => {
|
||||
const q = encodeURIComponent(query);
|
||||
const response = await fetch(`${endpoints.artworks}?q=${q}`, {
|
||||
@@ -612,12 +713,152 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
currentEditor.chain().focus().setCodeBlock({ language: codeBlockLanguage }).run();
|
||||
}, [codeBlockLanguage]);
|
||||
|
||||
const closeInsertDialog = useCallback(() => {
|
||||
insertSelectionRef.current = null;
|
||||
setInsertDialog(INSERT_DIALOG_INITIAL_STATE);
|
||||
}, []);
|
||||
|
||||
const openInsertDialog = useCallback((kind: Exclude<InsertDialogKind, null>) => {
|
||||
const currentEditor = editorRef.current;
|
||||
if (!currentEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { from, to } = currentEditor.state.selection;
|
||||
insertSelectionRef.current = { from, to };
|
||||
setInsertDialog({
|
||||
kind,
|
||||
url: '',
|
||||
title: kind === 'video' ? 'Embedded video' : '',
|
||||
label: 'Download asset',
|
||||
error: '',
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openLinkDialog = useCallback(() => {
|
||||
const currentEditor = editorRef.current;
|
||||
if (!currentEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { from, to } = currentEditor.state.selection;
|
||||
if (from === to) {
|
||||
return;
|
||||
}
|
||||
|
||||
insertSelectionRef.current = { from, to };
|
||||
setInsertDialog({
|
||||
kind: 'link',
|
||||
url: currentEditor.getAttributes('link').href || '',
|
||||
title: '',
|
||||
label: 'Download asset',
|
||||
error: '',
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeSelectedLink = useCallback(() => {
|
||||
const currentEditor = editorRef.current;
|
||||
if (!currentEditor) {
|
||||
closeInsertDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = insertSelectionRef.current;
|
||||
const chain = currentEditor.chain().focus();
|
||||
if (selection) {
|
||||
chain.setTextSelection(selection).extendMarkRange('link');
|
||||
}
|
||||
|
||||
chain.unsetLink().run();
|
||||
closeInsertDialog();
|
||||
}, [closeInsertDialog]);
|
||||
|
||||
const submitInsertDialog = useCallback((event?: React.FormEvent<HTMLFormElement>) => {
|
||||
event?.preventDefault();
|
||||
|
||||
if (!insertDialog.kind) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentEditor = editorRef.current;
|
||||
if (!currentEditor) {
|
||||
closeInsertDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
if (insertDialog.kind === 'link') {
|
||||
const selection = insertSelectionRef.current;
|
||||
const chain = currentEditor.chain().focus();
|
||||
if (selection) {
|
||||
chain.setTextSelection(selection).extendMarkRange('link');
|
||||
}
|
||||
|
||||
const normalizedLink = normalizeHttpUrl(insertDialog.url);
|
||||
if (!normalizedLink) {
|
||||
setInsertDialog((previous) => ({
|
||||
...previous,
|
||||
error: 'Enter a valid http or https URL for the selected text.',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
chain.setLink({ href: normalizedLink }).run();
|
||||
closeInsertDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
let normalizedUrl = normalizeHttpUrl(insertDialog.url);
|
||||
if (insertDialog.kind === 'video') {
|
||||
normalizedUrl = normalizeVideoEmbedUrl(insertDialog.url);
|
||||
}
|
||||
|
||||
if (!normalizedUrl) {
|
||||
setInsertDialog((previous) => ({
|
||||
...previous,
|
||||
error: insertDialog.kind === 'video'
|
||||
? 'Enter a valid YouTube, Vimeo, or direct embed URL.'
|
||||
: 'Enter a valid http or https URL.',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = insertSelectionRef.current;
|
||||
const chain = currentEditor.chain().focus();
|
||||
if (selection) {
|
||||
chain.setTextSelection(selection);
|
||||
}
|
||||
|
||||
if (insertDialog.kind === 'image') {
|
||||
chain.setImage({ src: normalizedUrl }).run();
|
||||
closeInsertDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
if (insertDialog.kind === 'video') {
|
||||
chain.insertContent({
|
||||
type: 'videoEmbed',
|
||||
attrs: {
|
||||
src: normalizedUrl,
|
||||
title: insertDialog.title.trim() || 'Embedded video',
|
||||
},
|
||||
}).run();
|
||||
closeInsertDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
chain.insertContent({
|
||||
type: 'downloadAsset',
|
||||
attrs: {
|
||||
url: normalizedUrl,
|
||||
label: insertDialog.label.trim() || 'Download asset',
|
||||
},
|
||||
}).run();
|
||||
closeInsertDialog();
|
||||
}, [closeInsertDialog, insertDialog]);
|
||||
|
||||
const insertActions = useMemo(() => ({
|
||||
image: () => {
|
||||
const currentEditor = editorRef.current;
|
||||
const url = window.prompt('Image URL', 'https://');
|
||||
if (!url || !currentEditor) return;
|
||||
currentEditor.chain().focus().setImage({ src: url }).run();
|
||||
openInsertDialog('image');
|
||||
},
|
||||
uploadImage: () => bodyImageInputRef.current?.click(),
|
||||
artwork: () => setArtworkModalOpen(true),
|
||||
@@ -634,6 +875,11 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
if (!currentEditor) return;
|
||||
currentEditor.chain().focus().setHorizontalRule().run();
|
||||
},
|
||||
part: () => {
|
||||
const currentEditor = editorRef.current;
|
||||
if (!currentEditor) return;
|
||||
currentEditor.chain().focus().setHorizontalRule().run();
|
||||
},
|
||||
gallery: () => {
|
||||
const currentEditor = editorRef.current;
|
||||
if (!currentEditor) return;
|
||||
@@ -642,21 +888,12 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
currentEditor.chain().focus().insertContent({ type: 'galleryBlock', attrs: { images } }).run();
|
||||
},
|
||||
video: () => {
|
||||
const currentEditor = editorRef.current;
|
||||
if (!currentEditor) return;
|
||||
const src = window.prompt('Video embed URL (YouTube/Vimeo)', 'https://www.youtube.com/embed/');
|
||||
if (!src) return;
|
||||
currentEditor.chain().focus().insertContent({ type: 'videoEmbed', attrs: { src, title: 'Embedded video' } }).run();
|
||||
openInsertDialog('video');
|
||||
},
|
||||
download: () => {
|
||||
const currentEditor = editorRef.current;
|
||||
if (!currentEditor) return;
|
||||
const url = window.prompt('Download URL', 'https://');
|
||||
if (!url) return;
|
||||
const label = window.prompt('Button label', 'Download asset') || 'Download asset';
|
||||
currentEditor.chain().focus().insertContent({ type: 'downloadAsset', attrs: { url, label } }).run();
|
||||
openInsertDialog('download');
|
||||
},
|
||||
}), [toggleCodeBlockWithLanguage]);
|
||||
}), [openInsertDialog, toggleCodeBlockWithLanguage]);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
@@ -692,7 +929,7 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
content: initialStory.content || EMPTY_DOC,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'tiptap prose prose-lg prose-headings:text-white prose-headings:font-bold prose-headings:tracking-tight prose-p:text-white/90 prose-p:leading-[1.85] prose-strong:text-white prose-a:text-sky-400 prose-a:underline prose-blockquote:border-l-sky-400/60 prose-blockquote:text-white/65 prose-blockquote:italic prose-code:text-sky-300 prose-pre:bg-black/30 prose-pre:text-sky-100 max-w-none min-h-[32rem] bg-transparent px-0 py-0 text-white/90 focus:outline-none',
|
||||
class: 'tiptap prose prose-xl prose-headings:text-white prose-headings:font-bold prose-headings:tracking-tight prose-p:text-white/90 prose-p:leading-[1.9] prose-li:leading-[1.9] prose-strong:text-white prose-a:text-sky-400 prose-a:underline prose-blockquote:border-l-sky-400/60 prose-blockquote:text-white/65 prose-blockquote:italic prose-code:text-sky-300 prose-pre:bg-black/30 prose-pre:text-sky-100 max-w-none min-h-[32rem] bg-transparent px-0 py-0 text-white/90 focus:outline-none',
|
||||
},
|
||||
handleDrop: (_view, event) => {
|
||||
const file = event.dataTransfer?.files?.[0];
|
||||
@@ -810,39 +1047,62 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
|
||||
const hidePlusButton = () => {
|
||||
setPlusButtonState({ visible: false, top: 0, left: 0 });
|
||||
setPlusMenuOpen(false);
|
||||
};
|
||||
|
||||
const updatePlusButton = () => {
|
||||
const { from, to } = editor.state.selection;
|
||||
if (from !== to) {
|
||||
setPlusButtonState({ visible: false, top: 0, left: 0 });
|
||||
setPlusMenuOpen(false);
|
||||
if (from !== to || !editor.isFocused) {
|
||||
hidePlusButton();
|
||||
return;
|
||||
}
|
||||
const resolvedPos = editor.state.doc.resolve(from);
|
||||
const parentNode = resolvedPos.parent;
|
||||
if (parentNode.type.name === 'paragraph' && parentNode.content.size === 0) {
|
||||
const coords = editor.view.coordsAtPos(from);
|
||||
const containerRect = editorContainerRef.current?.getBoundingClientRect();
|
||||
if (!containerRect) {
|
||||
setPlusButtonState({ visible: false, top: 0, left: 0 });
|
||||
return;
|
||||
}
|
||||
setPlusButtonState({
|
||||
visible: true,
|
||||
top: coords.top - 14,
|
||||
left: containerRect.left - 48,
|
||||
});
|
||||
} else {
|
||||
setPlusButtonState({ visible: false, top: 0, left: 0 });
|
||||
setPlusMenuOpen(false);
|
||||
|
||||
const container = editorContainerRef.current;
|
||||
if (!container) {
|
||||
hidePlusButton();
|
||||
return;
|
||||
}
|
||||
|
||||
const domAtPos = editor.view.domAtPos(from);
|
||||
const anchorNode = domAtPos.node instanceof Element ? domAtPos.node : domAtPos.node.parentElement;
|
||||
const blockElement = anchorNode?.closest('p, h1, h2, h3, blockquote, pre, li');
|
||||
|
||||
if (!blockElement || !container.contains(blockElement)) {
|
||||
hidePlusButton();
|
||||
return;
|
||||
}
|
||||
|
||||
const blockRect = blockElement.getBoundingClientRect();
|
||||
const computedStyle = window.getComputedStyle(blockElement);
|
||||
const parsedLineHeight = Number.parseFloat(computedStyle.lineHeight);
|
||||
const lineHeight = Number.isFinite(parsedLineHeight) ? parsedLineHeight : 32;
|
||||
|
||||
setPlusButtonState({
|
||||
visible: true,
|
||||
top: blockRect.top + Math.max((lineHeight - 32) / 2, 0),
|
||||
left: Math.max(16, blockRect.left - 44),
|
||||
});
|
||||
};
|
||||
|
||||
editor.on('selectionUpdate', updatePlusButton);
|
||||
editor.on('update', updatePlusButton);
|
||||
editor.on('focus', updatePlusButton);
|
||||
editor.on('blur', hidePlusButton);
|
||||
|
||||
const frameId = window.requestAnimationFrame(updatePlusButton);
|
||||
window.addEventListener('scroll', updatePlusButton, true);
|
||||
window.addEventListener('resize', updatePlusButton);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
window.removeEventListener('scroll', updatePlusButton, true);
|
||||
window.removeEventListener('resize', updatePlusButton);
|
||||
editor.off('selectionUpdate', updatePlusButton);
|
||||
editor.off('update', updatePlusButton);
|
||||
editor.off('focus', updatePlusButton);
|
||||
editor.off('blur', hidePlusButton);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
@@ -856,12 +1116,11 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
tags: tagsCsv.split(',').map((tag) => tag.trim()).filter(Boolean),
|
||||
meta_title: metaTitle || title,
|
||||
meta_description: metaDescription || excerpt,
|
||||
canonical_url: canonicalUrl,
|
||||
og_image: ogImage || coverImage,
|
||||
status,
|
||||
scheduled_for: scheduledFor || null,
|
||||
content: editor?.getJSON() || EMPTY_DOC,
|
||||
}), [storyId, title, excerpt, coverImage, storyType, tagsCsv, metaTitle, metaDescription, canonicalUrl, ogImage, status, scheduledFor, editor]);
|
||||
}), [storyId, title, excerpt, coverImage, storyType, tagsCsv, metaTitle, metaDescription, ogImage, status, scheduledFor, editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
@@ -993,6 +1252,84 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
const contentError = fieldErrors?.content?.[0] || '';
|
||||
const excerptError = fieldErrors?.excerpt?.[0] || '';
|
||||
const tagsError = fieldErrors?.tags_csv?.[0] || '';
|
||||
const completedChecks = readinessChecks.filter((check) => check.ok).length;
|
||||
const progressPercent = Math.max(20, Math.round((completedChecks / Math.max(readinessChecks.length, 1)) * 100));
|
||||
const topActions = [
|
||||
{
|
||||
key: 'cover',
|
||||
label: coverImage ? 'Change cover' : 'Add cover',
|
||||
detail: coverImage ? 'Refresh the hero image.' : 'Give the story a visual anchor.',
|
||||
onClick: () => coverImageInputRef.current?.click(),
|
||||
tone: 'sky',
|
||||
},
|
||||
{
|
||||
key: 'part',
|
||||
label: 'New part',
|
||||
detail: 'Drop in the three-dot chapter separator.',
|
||||
onClick: () => insertActions.part(),
|
||||
tone: 'violet',
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
label: 'Story settings',
|
||||
detail: 'Manage SEO, workflow, and metadata.',
|
||||
onClick: () => setSettingsOpen(true),
|
||||
tone: 'slate',
|
||||
},
|
||||
];
|
||||
const desktopInsertActions = [
|
||||
{ key: 'uploadImage', label: 'Upload photo', detail: 'Drop a full-width image into the body.' },
|
||||
{ key: 'artwork', label: 'Embed artwork', detail: 'Showcase one of your published pieces.' },
|
||||
{ key: 'video', label: 'Embed video', detail: 'Paste YouTube or Vimeo and let Nova normalize it.' },
|
||||
{ key: 'download', label: 'Download link', detail: 'Add a clear file CTA for readers.' },
|
||||
{ key: 'part', label: 'Add a new part', detail: 'Break long stories into readable chapters.' },
|
||||
] as Array<{ key: keyof typeof insertActions; label: string; detail: string }>;
|
||||
const quickLinks = storyId ? [
|
||||
{ key: 'preview', label: 'Preview story', href: `${endpoints.previewBase}/${storyId}/preview` },
|
||||
{ key: 'analytics', label: 'Story analytics', href: `${endpoints.analyticsBase}/${storyId}/analytics` },
|
||||
] : [];
|
||||
const storySuggestions = [
|
||||
!coverImage ? {
|
||||
key: 'cover',
|
||||
label: 'Add a cover image',
|
||||
detail: 'A strong visual anchor makes the draft feel finished faster.',
|
||||
onClick: () => coverImageInputRef.current?.click(),
|
||||
tone: 'sky',
|
||||
} : null,
|
||||
excerpt.trim().length < 40 ? {
|
||||
key: 'excerpt',
|
||||
label: 'Sharpen the subtitle',
|
||||
detail: 'Give readers one sentence that sets the tone before the first paragraph.',
|
||||
onClick: () => excerptInputRef.current?.focus(),
|
||||
tone: 'violet',
|
||||
} : null,
|
||||
wordCount >= 220 ? {
|
||||
key: 'part',
|
||||
label: 'Split the next chapter',
|
||||
detail: 'This draft is long enough for a visual chapter break.',
|
||||
onClick: () => insertActions.part(),
|
||||
tone: 'emerald',
|
||||
} : null,
|
||||
tagsCsv.trim().length === '' ? {
|
||||
key: 'tags',
|
||||
label: 'Add discovery tags',
|
||||
detail: 'Open settings and add a few tags so the story is easier to surface later.',
|
||||
onClick: () => setSettingsOpen(true),
|
||||
tone: 'amber',
|
||||
} : null,
|
||||
].filter(Boolean) as Array<{ key: string; label: string; detail: string; onClick: () => void; tone: string }>;
|
||||
|
||||
const topActionToneClasses: Record<string, string> = {
|
||||
sky: 'border-sky-300/18 bg-sky-400/10 text-sky-100 hover:border-sky-300/35 hover:bg-sky-400/15',
|
||||
violet: 'border-violet-300/18 bg-violet-400/10 text-violet-100 hover:border-violet-300/35 hover:bg-violet-400/15',
|
||||
slate: 'border-white/10 bg-white/[0.045] text-white/78 hover:border-white/20 hover:bg-white/[0.08]',
|
||||
};
|
||||
const suggestionToneClasses: Record<string, string> = {
|
||||
sky: 'border-sky-300/18 bg-sky-400/10 text-sky-100',
|
||||
violet: 'border-violet-300/18 bg-violet-400/10 text-violet-100',
|
||||
emerald: 'border-emerald-300/18 bg-emerald-400/10 text-emerald-100',
|
||||
amber: 'border-amber-300/18 bg-amber-400/10 text-amber-100',
|
||||
};
|
||||
|
||||
const insertArtwork = (item: Artwork) => {
|
||||
if (!editor) return;
|
||||
@@ -1009,7 +1346,8 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-4 pb-24 md:px-8">
|
||||
<div className={`min-h-screen px-4 py-4 pb-24 md:px-8 ${focusMode ? 'bg-[linear-gradient(180deg,rgba(6,10,16,0.99),rgba(4,7,12,1))]' : 'bg-[radial-gradient(circle_at_top,_rgba(56,189,248,0.09),_transparent_30%),radial-gradient(circle_at_20%_20%,_rgba(14,165,233,0.07),_transparent_24%),linear-gradient(180deg,rgba(7,11,18,0.98),rgba(4,7,12,1))]'}`}>
|
||||
<div className={`mx-auto ${focusMode ? 'max-w-[1180px]' : 'max-w-[1400px]'}`}>
|
||||
{/* ── Nova top bar ─────────────────────────────────────────────────── */}
|
||||
<div className="sticky top-0 z-30 mb-6 flex h-14 items-center justify-between overflow-hidden rounded-2xl border border-white/10 bg-[linear-gradient(135deg,rgba(12,18,28,0.97),rgba(8,12,20,0.97))] px-5 shadow-[0_8px_32px_rgba(3,7,18,0.32)] backdrop-blur-xl">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -1022,6 +1360,13 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="hidden text-xs text-white/55 lg:inline">{wordCount > 0 ? `${wordCount.toLocaleString()} words · ${readMinutes} min` : ''}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFocusMode((current) => !current)}
|
||||
className={`rounded-full border px-3 py-1.5 text-sm transition ${focusMode ? 'border-sky-400/30 bg-sky-400/10 text-sky-100 hover:bg-sky-400/15' : 'border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white'}`}
|
||||
>
|
||||
{focusMode ? 'Exit focus' : 'Focus mode'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
@@ -1049,8 +1394,75 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`grid gap-6 ${focusMode ? '' : 'xl:grid-cols-[minmax(0,1fr)_300px] xl:items-start'}`}>
|
||||
<main>
|
||||
{!focusMode && (
|
||||
<div className="mb-6 overflow-hidden rounded-[2rem] border border-white/10 bg-[linear-gradient(135deg,rgba(15,23,36,0.9),rgba(9,14,24,0.96))] shadow-[0_24px_80px_rgba(2,6,23,0.28)] backdrop-blur-xl">
|
||||
<div className="flex flex-col gap-5 px-6 py-6 md:px-8 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/55">Story Studio</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-white md:text-[2.35rem]">Shape the narrative before readers ever see the first line.</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-7 text-slate-300/82 md:text-[15px]">Use the writing canvas for the draft itself, keep your metadata close, and drop in chapter breaks or rich media without leaving the flow.</p>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3 lg:min-w-[420px] lg:max-w-[460px] lg:flex-1">
|
||||
{topActions.map((action) => (
|
||||
<button
|
||||
key={action.key}
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className={`rounded-[1.35rem] border px-4 py-4 text-left transition ${topActionToneClasses[action.tone]}`}
|
||||
>
|
||||
<div className="text-sm font-semibold">{action.label}</div>
|
||||
<div className="mt-1.5 text-xs leading-5 text-inherit/70">{action.detail}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="nb-scrollbar-none mb-5 overflow-x-auto overflow-y-hidden rounded-[1.6rem] border border-white/10 bg-[linear-gradient(180deg,rgba(11,17,27,0.94),rgba(7,10,17,0.96))] px-4 py-3 shadow-[0_18px_50px_rgba(2,6,23,0.22)] backdrop-blur-xl sm:px-5">
|
||||
<div className="flex min-w-max items-center gap-2">
|
||||
{desktopInsertActions.map((action) => (
|
||||
<button
|
||||
key={`top-toolbar-${action.key}`}
|
||||
type="button"
|
||||
onClick={() => insertActions[action.key]()}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white/78 transition hover:border-sky-400/28 hover:bg-sky-400/[0.08] hover:text-white"
|
||||
>
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-white/[0.05] text-[11px] text-sky-200">+</span>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
<span className="mx-1 hidden h-5 w-px bg-white/10 md:block" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white/78 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
Story settings
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFocusMode((current) => !current)}
|
||||
className={`inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm transition ${focusMode ? 'border-sky-400/28 bg-sky-400/[0.08] text-sky-100 hover:bg-sky-400/[0.14]' : 'border-white/10 bg-white/[0.04] text-white/78 hover:border-white/20 hover:bg-white/[0.08] hover:text-white'}`}
|
||||
>
|
||||
{focusMode ? 'Exit focus' : 'Focus mode'}
|
||||
</button>
|
||||
{quickLinks.map((link) => (
|
||||
<a
|
||||
key={`top-toolbar-${link.key}`}
|
||||
href={link.href}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white/78 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Writing canvas ───────────────────────────────────────────────── */}
|
||||
<div className="mx-auto max-w-[760px] overflow-hidden rounded-[2rem] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.07),_transparent_32%),linear-gradient(180deg,rgba(14,18,27,0.99),rgba(9,12,19,0.98))] shadow-[0_24px_80px_rgba(4,8,20,0.36)]">
|
||||
<div className={`mx-auto overflow-hidden rounded-[2rem] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.07),_transparent_32%),linear-gradient(180deg,rgba(14,18,27,0.99),rgba(9,12,19,0.98))] shadow-[0_24px_80px_rgba(4,8,20,0.36)] ${focusMode ? 'max-w-[920px]' : 'max-w-[780px]'}`}>
|
||||
{coverImage ? (
|
||||
<div className="group relative overflow-hidden rounded-t-2xl">
|
||||
<img src={coverImage} alt="Story cover" className="h-64 w-full object-cover md:h-80" />
|
||||
@@ -1110,6 +1522,7 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
{/* Title */}
|
||||
<div className="mb-3">
|
||||
<textarea
|
||||
ref={titleInputRef}
|
||||
value={title}
|
||||
onChange={(event) => {
|
||||
setTitle(event.target.value);
|
||||
@@ -1130,6 +1543,7 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
{/* Excerpt / subtitle */}
|
||||
<div className="mb-10 border-b border-white/[0.07] pb-8">
|
||||
<textarea
|
||||
ref={excerptInputRef}
|
||||
value={excerpt}
|
||||
onChange={(event) => {
|
||||
setExcerpt(event.target.value);
|
||||
@@ -1183,6 +1597,104 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{!focusMode ? (
|
||||
<aside className="hidden xl:block">
|
||||
<div className="sticky top-[5.5rem] space-y-4">
|
||||
<div className="overflow-hidden rounded-[1.75rem] border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.96),rgba(8,12,20,0.96))] shadow-[0_20px_60px_rgba(2,6,23,0.32)] backdrop-blur-xl">
|
||||
<div className="border-b border-white/10 px-5 py-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/40">Story pulse</p>
|
||||
<div className="mt-3 flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-white">{completedChecks}/{readinessChecks.length}</p>
|
||||
<p className="mt-1 text-sm text-slate-300/72">Publishing readiness</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2 text-right">
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-white/35">Rhythm</div>
|
||||
<div className="mt-1 text-sm font-medium text-white/85">{wordCount > 0 ? `${wordCount.toLocaleString()} words` : 'Start writing'}</div>
|
||||
<div className="mt-1 text-xs text-white/45">{readMinutes} min read</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 h-2 overflow-hidden rounded-full bg-white/[0.06]">
|
||||
<div className="h-full rounded-full bg-[linear-gradient(90deg,rgba(56,189,248,0.9),rgba(59,130,246,0.92))]" style={{ width: `${progressPercent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 px-5 py-4">
|
||||
{readinessChecks.map((check) => (
|
||||
<div key={check.label} className={`rounded-2xl border px-4 py-3 ${check.ok ? 'border-emerald-400/18 bg-emerald-500/10' : 'border-amber-400/18 bg-amber-500/10'}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`mt-0.5 inline-flex h-5 w-5 items-center justify-center rounded-full text-[11px] font-bold ${check.ok ? 'bg-emerald-400/20 text-emerald-200' : 'bg-amber-400/20 text-amber-200'}`}>{check.ok ? '✓' : '!'}</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white/88">{check.label}</p>
|
||||
<p className="mt-1 text-xs leading-5 text-white/48">{check.hint}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{storySuggestions.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-[1.75rem] border border-white/10 bg-[linear-gradient(180deg,rgba(13,19,29,0.97),rgba(8,12,20,0.97))] shadow-[0_20px_60px_rgba(2,6,23,0.32)] backdrop-blur-xl">
|
||||
<div className="border-b border-white/10 px-5 py-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/40">Suggestions</p>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-300/78">A few next moves based on the draft you have right now.</p>
|
||||
</div>
|
||||
<div className="space-y-2 px-5 py-4">
|
||||
{storySuggestions.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion.key}
|
||||
type="button"
|
||||
onClick={suggestion.onClick}
|
||||
className={`w-full rounded-2xl border px-4 py-3 text-left transition hover:translate-x-0.5 ${suggestionToneClasses[suggestion.tone]}`}
|
||||
>
|
||||
<div className="text-sm font-semibold">{suggestion.label}</div>
|
||||
<div className="mt-1 text-xs leading-5 text-inherit/70">{suggestion.detail}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="overflow-hidden rounded-[1.75rem] border border-white/10 bg-[linear-gradient(180deg,rgba(13,19,29,0.97),rgba(8,12,20,0.97))] shadow-[0_20px_60px_rgba(2,6,23,0.32)] backdrop-blur-xl">
|
||||
<div className="border-b border-white/10 px-5 py-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/40">Desktop shortcuts</p>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-300/78">Keep the heavy-lift actions nearby while the canvas stays clean.</p>
|
||||
</div>
|
||||
<div className="space-y-2 px-5 py-4">
|
||||
{desktopInsertActions.map((action) => (
|
||||
<button
|
||||
key={action.key}
|
||||
type="button"
|
||||
onClick={() => insertActions[action.key]()}
|
||||
className="w-full rounded-2xl border border-white/10 bg-white/[0.035] px-4 py-3 text-left transition hover:border-sky-400/30 hover:bg-sky-400/[0.08]"
|
||||
>
|
||||
<div className="text-sm font-semibold text-white/88">{action.label}</div>
|
||||
<div className="mt-1 text-xs leading-5 text-white/48">{action.detail}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{quickLinks.length > 0 ? (
|
||||
<div className="border-t border-white/10 px-5 py-4">
|
||||
<div className="space-y-2">
|
||||
{quickLinks.map((link) => (
|
||||
<a
|
||||
key={link.key}
|
||||
href={link.href}
|
||||
className="block rounded-2xl border border-white/10 bg-white/[0.035] px-4 py-3 text-sm font-medium text-white/80 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Floating + block insertion button (fixed, always visible when on empty line) ── */}
|
||||
@@ -1218,6 +1730,7 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
{ label: 'Blockquote', icon: '❝', key: 'quote' },
|
||||
{ label: 'Code block', icon: '⌨', key: 'code' },
|
||||
{ label: 'Download link', icon: '↓', key: 'download' },
|
||||
{ label: 'Add a new part', icon: '⋯', key: 'part' },
|
||||
{ label: 'Divider', icon: '—', key: 'divider' },
|
||||
] as Array<{ label: string; icon: string; key: keyof typeof insertActions }>).map((item) => (
|
||||
<button
|
||||
@@ -1242,29 +1755,42 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
{/* ── Floating inline formatting toolbar ───────────────────────────── */}
|
||||
{editor && inlineToolbar.visible && (
|
||||
<div
|
||||
className="fixed z-50 flex items-center gap-0.5 overflow-hidden rounded-2xl border border-white/10 bg-[linear-gradient(135deg,rgba(12,18,28,0.98),rgba(8,12,20,0.98))] p-1.5 shadow-[0_8px_32px_rgba(3,7,18,0.5)] backdrop-blur-xl"
|
||||
className="fixed z-50 flex items-center overflow-hidden rounded-2xl border border-white/10 bg-[linear-gradient(135deg,rgba(12,18,28,0.98),rgba(8,12,20,0.98))] p-1.5 shadow-[0_8px_32px_rgba(3,7,18,0.5)] backdrop-blur-xl"
|
||||
style={{ top: `${inlineToolbar.top}px`, left: `${inlineToolbar.left}px` }}
|
||||
>
|
||||
{([
|
||||
{ label: 'B', title: 'Bold', action: () => editor.chain().focus().toggleBold().run(), active: editor.isActive('bold'), extra: 'font-bold' },
|
||||
{ label: 'I', title: 'Italic', action: () => editor.chain().focus().toggleItalic().run(), active: editor.isActive('italic'), extra: 'italic' },
|
||||
{ label: 'U', title: 'Underline', action: () => editor.chain().focus().toggleUnderline().run(), active: editor.isActive('underline'), extra: 'underline' },
|
||||
{ label: 'H2', title: 'Heading 2', action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), active: editor.isActive('heading', { level: 2 }), extra: 'font-semibold text-xs' },
|
||||
{ label: 'H3', title: 'Heading 3', action: () => editor.chain().focus().toggleHeading({ level: 3 }).run(), active: editor.isActive('heading', { level: 3 }), extra: 'font-semibold text-xs' },
|
||||
{ label: '❝', title: 'Blockquote', action: () => editor.chain().focus().toggleBlockquote().run(), active: editor.isActive('blockquote'), extra: 'text-base font-serif' },
|
||||
{ label: '⛓', title: 'Link', action: () => openLinkPrompt(editor), active: editor.isActive('link'), extra: '' },
|
||||
{ label: '</>', title: 'Inline code', action: () => editor.chain().focus().toggleCode().run(), active: editor.isActive('code'), extra: 'font-mono text-[10px]' },
|
||||
] as Array<{ label: string; title: string; action: () => void; active: boolean; extra: string }>).map((item) => (
|
||||
<button
|
||||
key={item.title}
|
||||
type="button"
|
||||
title={item.title}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={item.action}
|
||||
className={`flex h-8 min-w-[2rem] items-center justify-center rounded-xl px-1.5 text-sm transition ${item.extra} ${item.active ? 'bg-sky-500/25 text-sky-200' : 'text-white/70 hover:bg-white/[0.07] hover:text-white'}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
[
|
||||
{ label: 'B', title: 'Bold', action: () => editor.chain().focus().toggleBold().run(), active: editor.isActive('bold'), extra: 'font-bold' },
|
||||
{ label: 'I', title: 'Italic', action: () => editor.chain().focus().toggleItalic().run(), active: editor.isActive('italic'), extra: 'italic' },
|
||||
{ label: 'U', title: 'Underline', action: () => editor.chain().focus().toggleUnderline().run(), active: editor.isActive('underline'), extra: 'underline' },
|
||||
],
|
||||
[
|
||||
{ label: 'H2', title: 'Heading 2', action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), active: editor.isActive('heading', { level: 2 }), extra: 'font-semibold text-xs' },
|
||||
{ label: 'H3', title: 'Heading 3', action: () => editor.chain().focus().toggleHeading({ level: 3 }).run(), active: editor.isActive('heading', { level: 3 }), extra: 'font-semibold text-xs' },
|
||||
{ label: '❝', title: 'Blockquote', action: () => editor.chain().focus().toggleBlockquote().run(), active: editor.isActive('blockquote'), extra: 'text-base font-serif' },
|
||||
],
|
||||
[
|
||||
{ label: '⛓', title: 'Link', action: openLinkDialog, active: editor.isActive('link'), extra: '' },
|
||||
{ label: '</>', title: 'Inline code', action: () => editor.chain().focus().toggleCode().run(), active: editor.isActive('code'), extra: 'font-mono text-[10px]' },
|
||||
],
|
||||
] as Array<Array<{ label: string; title: string; action: () => void; active: boolean; extra: string }>>).map((group, groupIndex) => (
|
||||
<React.Fragment key={`inline-toolbar-group-${groupIndex}`}>
|
||||
{groupIndex > 0 ? <span className="mx-1 h-6 w-px bg-white/10" aria-hidden="true" /> : null}
|
||||
<div className="flex items-center gap-0.5 px-0.5">
|
||||
{group.map((item) => (
|
||||
<button
|
||||
key={item.title}
|
||||
type="button"
|
||||
title={item.title}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={item.action}
|
||||
className={`flex h-8 min-w-[2rem] items-center justify-center rounded-xl px-1.5 text-sm transition ${item.extra} ${item.active ? 'bg-sky-500/25 text-sky-200' : 'text-white/70 hover:bg-white/[0.07] hover:text-white'}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -1348,7 +1874,13 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-widest text-white/35">Workflow</p>
|
||||
<NovaSelect value={status} onChange={(val) => setStatus(val)} searchable={false} options={[{ value: 'draft', label: 'Draft' }, { value: 'pending_review', label: 'Pending Review' }, { value: 'published', label: 'Published' }, { value: 'scheduled', label: 'Scheduled' }, { value: 'archived', label: 'Archived' }]} />
|
||||
<input type="datetime-local" value={scheduledFor} onChange={(e) => setScheduledFor(e.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white focus:border-white/20 focus:outline-none" />
|
||||
<DateTimePicker
|
||||
value={scheduledFor}
|
||||
onChange={setScheduledFor}
|
||||
placeholder="Pick a publish date"
|
||||
clearable
|
||||
className="bg-slate-950/60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* SEO */}
|
||||
@@ -1357,7 +1889,6 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
<div className="space-y-2">
|
||||
<input value={metaTitle} onChange={(e) => setMetaTitle(e.target.value)} placeholder="Meta title (defaults to story title)" className="w-full rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white placeholder:text-white/25 focus:border-white/20 focus:outline-none" />
|
||||
<textarea value={metaDescription} onChange={(e) => setMetaDescription(e.target.value)} rows={3} placeholder="Meta description (defaults to excerpt)" className="w-full resize-none rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white placeholder:text-white/25 focus:border-white/20 focus:outline-none" />
|
||||
<input value={canonicalUrl} onChange={(e) => setCanonicalUrl(e.target.value)} placeholder="Canonical URL (optional)" className="w-full rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white placeholder:text-white/25 focus:border-white/20 focus:outline-none" />
|
||||
<input value={ogImage} onChange={(e) => setOgImage(e.target.value)} placeholder="OG image URL (defaults to cover)" className="w-full rounded-xl border border-white/10 bg-slate-950/60 px-3 py-2.5 text-sm text-white placeholder:text-white/25 focus:border-white/20 focus:outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1411,6 +1942,97 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={Boolean(insertDialog.kind)}
|
||||
onClose={closeInsertDialog}
|
||||
title={insertDialog.kind ? INSERT_DIALOG_CONTENT[insertDialog.kind].title : ''}
|
||||
size="md"
|
||||
footer={insertDialog.kind ? (
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{insertDialog.kind === 'link' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeSelectedLink}
|
||||
className="rounded-xl border border-rose-400/20 bg-rose-500/10 px-4 py-2 text-sm text-rose-200 transition hover:bg-rose-500/20"
|
||||
>
|
||||
Remove link
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeInsertDialog}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.04] px-4 py-2 text-sm text-white/70 transition hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form="story-insert-dialog-form"
|
||||
className="rounded-xl bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-[0_6px_20px_rgba(14,165,233,0.35)] transition hover:bg-sky-400"
|
||||
>
|
||||
{INSERT_DIALOG_CONTENT[insertDialog.kind].confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
>
|
||||
{insertDialog.kind ? (
|
||||
<form id="story-insert-dialog-form" onSubmit={submitInsertDialog} className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm leading-6 text-slate-200">{INSERT_DIALOG_CONTENT[insertDialog.kind].description}</p>
|
||||
<p className="text-xs leading-5 text-slate-400">{INSERT_DIALOG_CONTENT[insertDialog.kind].urlHint}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-semibold uppercase tracking-[0.18em] text-white/40">
|
||||
{INSERT_DIALOG_CONTENT[insertDialog.kind].urlLabel}
|
||||
</label>
|
||||
<input
|
||||
value={insertDialog.url}
|
||||
onChange={(event) => setInsertDialog((previous) => ({ ...previous, url: event.target.value, error: '' }))}
|
||||
placeholder={INSERT_DIALOG_CONTENT[insertDialog.kind].urlPlaceholder}
|
||||
className="w-full rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white placeholder:text-white/25 focus:border-sky-400/40 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{insertDialog.kind === 'video' && (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-semibold uppercase tracking-[0.18em] text-white/40">
|
||||
Accessible title
|
||||
</label>
|
||||
<input
|
||||
value={insertDialog.title}
|
||||
onChange={(event) => setInsertDialog((previous) => ({ ...previous, title: event.target.value }))}
|
||||
placeholder="Embedded video"
|
||||
className="w-full rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white placeholder:text-white/25 focus:border-sky-400/40 focus:outline-none"
|
||||
/>
|
||||
<p className="text-xs leading-5 text-slate-400">This helps screen readers describe the embedded video block.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{insertDialog.kind === 'download' && (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-semibold uppercase tracking-[0.18em] text-white/40">
|
||||
Button label
|
||||
</label>
|
||||
<input
|
||||
value={insertDialog.label}
|
||||
onChange={(event) => setInsertDialog((previous) => ({ ...previous, label: event.target.value }))}
|
||||
placeholder="Download asset"
|
||||
className="w-full rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white placeholder:text-white/25 focus:border-sky-400/40 focus:outline-none"
|
||||
/>
|
||||
<p className="text-xs leading-5 text-slate-400">Readers will see this label on the download button inside the story.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{insertDialog.error ? (
|
||||
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
||||
{insertDialog.error}
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
{/* Hidden file inputs */}
|
||||
<input ref={bodyImageInputRef} type="file" accept="image/*" className="hidden" onChange={handleBodyImagePicked} />
|
||||
<input ref={coverImageInputRef} type="file" accept="image/*" className="hidden" onChange={handleCoverImagePicked} />
|
||||
|
||||
@@ -238,6 +238,8 @@ export default function RichTextEditor({
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
link: false,
|
||||
underline: false,
|
||||
heading: { levels: [2, 3] },
|
||||
codeBlock: {
|
||||
HTMLAttributes: { class: 'forum-code-block' },
|
||||
@@ -262,6 +264,7 @@ export default function RichTextEditor({
|
||||
suggestion: mentionSuggestion,
|
||||
}),
|
||||
],
|
||||
immediatelyRender: false,
|
||||
content,
|
||||
autofocus,
|
||||
editorProps: {
|
||||
@@ -291,6 +294,10 @@ export default function RichTextEditor({
|
||||
useEffect(() => {
|
||||
if (editor && content && !editor.getHTML().includes(content.slice(0, 30))) {
|
||||
editor.commands.setContent(content, false)
|
||||
// Keep the parent form state in sync with what we just rendered.
|
||||
// setContent with emitUpdate=false silently resets TipTap without
|
||||
// calling onUpdate, so form.data.content would lag behind the editor.
|
||||
onChange?.(content)
|
||||
}
|
||||
}, [content]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
|
||||
@@ -38,9 +38,9 @@ export default function GroupProfileSummary({ contributions = [], href = null })
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-4 text-xs text-slate-400">
|
||||
<span>{Number(entry.counts?.artworks || 0).toLocaleString()} artworks</span>
|
||||
<span>{Number(entry.counts?.releases || 0).toLocaleString()} releases</span>
|
||||
<span>{Number(entry.counts?.projects || 0).toLocaleString()} projects</span>
|
||||
<span>{Number(entry.counts?.artworks || 0).toLocaleString('en-US')} artworks</span>
|
||||
<span>{Number(entry.counts?.releases || 0).toLocaleString('en-US')} releases</span>
|
||||
<span>{Number(entry.counts?.projects || 0).toLocaleString('en-US')} projects</span>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react'
|
||||
import GroupBadgePill from './GroupBadgePill'
|
||||
|
||||
const NUMBER_FORMATTER = new Intl.NumberFormat('en-US')
|
||||
|
||||
export default function GroupSummaryPanel({ group, artwork }) {
|
||||
if (!group) return null
|
||||
|
||||
@@ -26,15 +28,15 @@ export default function GroupSummaryPanel({ group, artwork }) {
|
||||
|
||||
<div className="mt-5 grid grid-cols-3 gap-2 rounded-2xl border border-white/10 bg-black/20 p-3 text-center">
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-white">{Number(group.counts?.artworks || 0).toLocaleString()}</div>
|
||||
<div className="text-lg font-semibold text-white">{NUMBER_FORMATTER.format(Number(group.counts?.artworks || 0))}</div>
|
||||
<div className="text-[11px] uppercase tracking-[0.16em] text-slate-500">Artworks</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-white">{Number(group.counts?.members || 0).toLocaleString()}</div>
|
||||
<div className="text-lg font-semibold text-white">{NUMBER_FORMATTER.format(Number(group.counts?.members || 0))}</div>
|
||||
<div className="text-[11px] uppercase tracking-[0.16em] text-slate-500">Members</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-white">{Number(group.counts?.followers || 0).toLocaleString()}</div>
|
||||
<div className="text-lg font-semibold text-white">{NUMBER_FORMATTER.format(Number(group.counts?.followers || 0))}</div>
|
||||
<div className="text-[11px] uppercase tracking-[0.16em] text-slate-500">Followers</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,10 +18,13 @@ export default function ProfileCoverEditor({
|
||||
const [removing, setRemoving] = useState(false)
|
||||
const [position, setPosition] = useState(coverPosition ?? 50)
|
||||
|
||||
const csrfToken = useMemo(
|
||||
() => document.querySelector('meta[name="csrf-token"]')?.content ?? '',
|
||||
[]
|
||||
)
|
||||
const csrfToken = useMemo(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
return ''
|
||||
}
|
||||
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content ?? ''
|
||||
}, [])
|
||||
|
||||
if (!isOpen) {
|
||||
return null
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
import React, { useState } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import ProfileCoverEditor from './ProfileCoverEditor'
|
||||
import LevelBadge from '../xp/LevelBadge'
|
||||
import XPProgressBar from '../xp/XPProgressBar'
|
||||
import FollowButton from '../social/FollowButton'
|
||||
import FollowersPreview from '../social/FollowersPreview'
|
||||
import MutualFollowersBadge from '../social/MutualFollowersBadge'
|
||||
import { shinyFlagUrl } from '../../utils/flagUrl'
|
||||
|
||||
function formatCompactNumber(value) {
|
||||
const numeric = Number(value ?? 0)
|
||||
return numeric.toLocaleString()
|
||||
return numeric.toLocaleString('en-US')
|
||||
}
|
||||
|
||||
export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing, followerCount, recentFollowers = [], followContext = null, heroBgUrl, countryName, leaderboardRank, extraActions = null }) {
|
||||
const { props } = usePage()
|
||||
const [following, setFollowing] = useState(viewerIsFollowing)
|
||||
const [count, setCount] = useState(followerCount)
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [coverUrl, setCoverUrl] = useState(user?.cover_url || heroBgUrl || null)
|
||||
const [coverPosition, setCoverPosition] = useState(Number.isFinite(user?.cover_position) ? user.cover_position : 50)
|
||||
const flagUrl = shinyFlagUrl(profile?.country_code, props?.cdn?.files_url)
|
||||
|
||||
const uname = user.username || user.name || 'Unknown'
|
||||
const displayName = user.name || uname
|
||||
@@ -118,9 +122,9 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
|
||||
{!isOwner ? <MutualFollowersBadge context={followContext} /> : null}
|
||||
{countryName ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-slate-300">
|
||||
{profile?.country_code ? (
|
||||
{flagUrl ? (
|
||||
<img
|
||||
src={`/gfx/flags/shiny/24/${encodeURIComponent(String(profile.country_code).toUpperCase())}.png`}
|
||||
src={flagUrl}
|
||||
alt={countryName}
|
||||
className="h-auto w-4 rounded-sm"
|
||||
onError={(event) => { event.target.style.display = 'none' }}
|
||||
|
||||
@@ -16,6 +16,8 @@ function typeMeta(type) {
|
||||
return { icon: 'fa-solid fa-user-plus', label: 'Follow', tone: 'text-emerald-100 bg-emerald-400/12 border-emerald-300/20' }
|
||||
case 'achievement':
|
||||
return { icon: 'fa-solid fa-trophy', label: 'Achievement', tone: 'text-yellow-100 bg-yellow-400/12 border-yellow-300/20' }
|
||||
case 'world_reward':
|
||||
return { icon: 'fa-solid fa-globe', label: 'World reward', tone: 'text-sky-100 bg-sky-400/12 border-sky-300/20' }
|
||||
case 'forum_post':
|
||||
return { icon: 'fa-solid fa-signs-post', label: 'Forum thread', tone: 'text-violet-100 bg-violet-400/12 border-violet-300/20' }
|
||||
case 'forum_reply':
|
||||
@@ -46,6 +48,8 @@ function headline(activity) {
|
||||
return activity?.target_user ? `Started following @${activity.target_user.username || activity.target_user.name}` : 'Started following a creator'
|
||||
case 'achievement':
|
||||
return activity?.achievement?.name ? `Unlocked ${activity.achievement.name}` : 'Unlocked a new achievement'
|
||||
case 'world_reward':
|
||||
return activity?.world_reward?.badge_label ? `Earned ${activity.world_reward.badge_label}` : 'Earned a new world reward'
|
||||
case 'forum_post':
|
||||
return activity?.forum?.thread?.title ? `Started forum thread ${activity.forum.thread.title}` : 'Started a new forum thread'
|
||||
case 'forum_reply':
|
||||
@@ -59,6 +63,7 @@ function body(activity) {
|
||||
if (activity?.comment?.body) return activity.comment.body
|
||||
if (activity?.forum?.post?.excerpt) return activity.forum.post.excerpt
|
||||
if (activity?.achievement?.description) return activity.achievement.description
|
||||
if (activity?.world_reward?.note) return activity.world_reward.note
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -68,6 +73,7 @@ function cta(activity) {
|
||||
if (activity?.forum?.post?.url) return { href: activity.forum.post.url, label: 'Open reply' }
|
||||
if (activity?.forum?.thread?.url) return { href: activity.forum.thread.url, label: 'Open thread' }
|
||||
if (activity?.target_user?.profile_url) return { href: activity.target_user.profile_url, label: 'View profile' }
|
||||
if (activity?.world_reward?.world?.url) return { href: activity.world_reward.world.url, label: 'Open world' }
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -173,6 +179,14 @@ export default function ActivityCard({ activity }) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activity?.world_reward ? (
|
||||
<div className="mt-4 rounded-2xl border border-white/[0.06] bg-white/[0.03] p-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">World reward</div>
|
||||
<div className="mt-1 text-sm font-medium text-white">{activity.world_reward.badge_label}</div>
|
||||
{activity.world_reward.artwork?.title ? <div className="mt-2 text-sm text-slate-400">Artwork: {activity.world_reward.artwork.title}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activity?.forum?.thread ? (
|
||||
<div className="mt-4 rounded-2xl border border-white/[0.06] bg-white/[0.03] p-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Forum activity</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import CreatorJourneySection from '../CreatorJourneySection'
|
||||
import { shinyFlagUrl } from '../../../utils/flagUrl'
|
||||
|
||||
const SOCIAL_ICONS = {
|
||||
twitter: { icon: 'fa-brands fa-x-twitter', label: 'X / Twitter', hoverClass: 'hover:border-slate-300/30 hover:text-slate-100 hover:bg-white/[0.08]' },
|
||||
@@ -226,11 +228,13 @@ function SectionCard({ icon, eyebrow, title, children, className = '' }) {
|
||||
* TabAbout
|
||||
* Bio, social links, metadata - replaces old sidebar profile card.
|
||||
*/
|
||||
export default function TabAbout({ user, profile, stats, achievements, artworks, creatorStories, profileComments, socialLinks, countryName, followerCount, recentFollowers, leaderboardRank, groupContributionHistory, journey }) {
|
||||
export default function TabAbout({ user, profile, stats, achievements, worldRewards, artworks, creatorStories, profileComments, socialLinks, countryName, followerCount, recentFollowers, leaderboardRank, groupContributionHistory, journey }) {
|
||||
const { props } = usePage()
|
||||
const uname = user.username || user.name
|
||||
const displayName = user.name || uname
|
||||
const about = profile?.about
|
||||
const website = profile?.website
|
||||
const flagUrl = shinyFlagUrl(profile?.country_code, props?.cdn?.files_url)
|
||||
|
||||
const joinDate = user.created_at
|
||||
? new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })
|
||||
@@ -261,6 +265,7 @@ export default function TabAbout({ user, profile, stats, achievements, artworks,
|
||||
: []
|
||||
const followers = recentFollowers ?? []
|
||||
const recentAchievements = Array.isArray(achievements?.recent) ? achievements.recent : []
|
||||
const recentWorldRewards = Array.isArray(worldRewards?.recent) ? worldRewards.recent : []
|
||||
const stories = Array.isArray(creatorStories) ? creatorStories : []
|
||||
const comments = Array.isArray(profileComments) ? profileComments : []
|
||||
const contributionHistory = Array.isArray(groupContributionHistory) ? groupContributionHistory : []
|
||||
@@ -315,9 +320,9 @@ export default function TabAbout({ user, profile, stats, achievements, artworks,
|
||||
{countryName ? (
|
||||
<InfoRow icon="fa-earth-americas" label="Country">
|
||||
<span className="flex items-center gap-2">
|
||||
{profile?.country_code ? (
|
||||
{flagUrl ? (
|
||||
<img
|
||||
src={`/gfx/flags/shiny/24/${encodeURIComponent(String(profile.country_code).toUpperCase())}.png`}
|
||||
src={flagUrl}
|
||||
alt={countryName}
|
||||
className="h-auto w-4 rounded-sm"
|
||||
onError={(e) => { e.target.style.display = 'none' }}
|
||||
@@ -466,6 +471,31 @@ export default function TabAbout({ user, profile, stats, achievements, artworks,
|
||||
</SectionCard>
|
||||
) : null}
|
||||
|
||||
{recentWorldRewards.length > 0 ? (
|
||||
<SectionCard icon="fa-solid fa-globe" eyebrow="World recognition" title="Latest world rewards">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{recentWorldRewards.slice(0, 4).map((reward) => (
|
||||
<a
|
||||
key={reward.id}
|
||||
href={reward.world?.url || reward.artwork?.url || '#'}
|
||||
className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-4 transition hover:border-white/15 hover:bg-white/[0.06]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">{reward.badge_label}</div>
|
||||
{reward.artwork?.title ? <div className="mt-1 text-sm text-slate-400">{reward.artwork.title}</div> : null}
|
||||
</div>
|
||||
<span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-100">
|
||||
{reward.reward_label}
|
||||
</span>
|
||||
</div>
|
||||
{reward.granted_at ? <div className="mt-3 text-xs text-slate-500">{formatShortDate(reward.granted_at) || 'Rewarded'}</div> : null}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
|
||||
{stories.length > 0 || comments.length > 0 ? (
|
||||
<SectionCard icon="fa-solid fa-wave-square" eyebrow="Fresh from this creator" title="Recent activity">
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
|
||||
@@ -42,6 +42,8 @@ export default function SeoHead({ seo = {}, title = null, description = null, js
|
||||
{ogUrl ? <meta head-key="og:url" property="og:url" content={ogUrl} /> : null}
|
||||
{ogImage ? <meta head-key="og:image" property="og:image" content={ogImage} /> : null}
|
||||
{seo?.og_image_alt ? <meta head-key="og:image:alt" property="og:image:alt" content={seo.og_image_alt} /> : null}
|
||||
{seo?.og_image_width ? <meta head-key="og:image:width" property="og:image:width" content={String(seo.og_image_width)} /> : null}
|
||||
{seo?.og_image_height ? <meta head-key="og:image:height" property="og:image:height" content={String(seo.og_image_height)} /> : null}
|
||||
|
||||
<meta head-key="twitter:card" name="twitter:card" content={twitterCard} />
|
||||
<meta head-key="twitter:title" name="twitter:title" content={twitterTitle} />
|
||||
@@ -56,9 +58,8 @@ export default function SeoHead({ seo = {}, title = null, description = null, js
|
||||
key={`jsonld-${schemaType}-${index}`}
|
||||
head-key={`jsonld-${schemaType}-${index}`}
|
||||
type="application/ld+json"
|
||||
>
|
||||
{JSON.stringify(schema)}
|
||||
</script>
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Head>
|
||||
|
||||
@@ -58,6 +58,34 @@ function mergeDateTime(date, time) {
|
||||
return `${date}T${time || '00:00'}`
|
||||
}
|
||||
|
||||
function maxDateValue(a, b) {
|
||||
if (!a) return b || ''
|
||||
if (!b) return a || ''
|
||||
return a > b ? a : b
|
||||
}
|
||||
|
||||
function minDateValue(a, b) {
|
||||
if (!a) return b || ''
|
||||
if (!b) return a || ''
|
||||
return a < b ? a : b
|
||||
}
|
||||
|
||||
function clampTimeToBounds(date, time, minDateTime, maxDateTime) {
|
||||
const nextTime = time || '00:00'
|
||||
const minParts = splitDateTime(minDateTime)
|
||||
const maxParts = splitDateTime(maxDateTime)
|
||||
|
||||
if (date && minParts.date === date && minParts.time && nextTime < minParts.time) {
|
||||
return minParts.time
|
||||
}
|
||||
|
||||
if (date && maxParts.date === date && maxParts.time && nextTime > maxParts.time) {
|
||||
return maxParts.time
|
||||
}
|
||||
|
||||
return nextTime
|
||||
}
|
||||
|
||||
function formatDisplay(value) {
|
||||
if (!value) return ''
|
||||
|
||||
@@ -147,15 +175,18 @@ export default function DateTimePicker({
|
||||
value = '',
|
||||
onChange,
|
||||
label,
|
||||
placeholder = 'Pick a date and time',
|
||||
placeholder,
|
||||
error,
|
||||
hint,
|
||||
required = false,
|
||||
clearable = false,
|
||||
id,
|
||||
disabled = false,
|
||||
mode = 'datetime',
|
||||
minDate,
|
||||
maxDate,
|
||||
minDateTime,
|
||||
maxDateTime,
|
||||
className = '',
|
||||
}) {
|
||||
const today = new Date()
|
||||
@@ -168,6 +199,7 @@ export default function DateTimePicker({
|
||||
const [viewMonth, setViewMonth] = useState(initialDate.getMonth())
|
||||
const [draftDate, setDraftDate] = useState(initial.date)
|
||||
const [draftTime, setDraftTime] = useState(initial.time || '12:00')
|
||||
const effectivePlaceholder = placeholder || (mode === 'date' ? 'Pick a date' : 'Pick a date and time')
|
||||
|
||||
const triggerRef = useRef(null)
|
||||
const inputId = id ?? (label ? `dtp-${label.toLowerCase().replace(/\s+/g, '-')}` : 'date-time-picker')
|
||||
@@ -239,16 +271,23 @@ export default function DateTimePicker({
|
||||
}, [open, panelId])
|
||||
|
||||
const applyValue = useCallback((date, time) => {
|
||||
onChange?.(date ? mergeDateTime(date, time) : '')
|
||||
}, [onChange])
|
||||
if (!date) {
|
||||
onChange?.('')
|
||||
return
|
||||
}
|
||||
|
||||
onChange?.(mode === 'date' ? date : mergeDateTime(date, time))
|
||||
}, [mode, onChange])
|
||||
|
||||
const handleDateSelect = (nextDate) => {
|
||||
const nextTime = clampTimeToBounds(nextDate, draftTime, minDateTime, maxDateTime)
|
||||
setDraftDate(nextDate)
|
||||
applyValue(nextDate, draftTime)
|
||||
setDraftTime(nextTime)
|
||||
applyValue(nextDate, nextTime)
|
||||
}
|
||||
|
||||
const handleTimeChange = (event) => {
|
||||
const nextTime = event.target.value
|
||||
const nextTime = clampTimeToBounds(draftDate, event.target.value, minDateTime, maxDateTime)
|
||||
setDraftTime(nextTime)
|
||||
applyValue(draftDate, nextTime)
|
||||
}
|
||||
@@ -293,6 +332,12 @@ export default function DateTimePicker({
|
||||
].join(' ')
|
||||
|
||||
const selectedDate = parseDatePart(draftDate)
|
||||
const minDateTimeParts = splitDateTime(minDateTime)
|
||||
const maxDateTimeParts = splitDateTime(maxDateTime)
|
||||
const effectiveMinDate = maxDateValue(minDate, minDateTimeParts.date)
|
||||
const effectiveMaxDate = minDateValue(maxDate, maxDateTimeParts.date)
|
||||
const minTime = draftDate && draftDate === minDateTimeParts.date ? minDateTimeParts.time || undefined : undefined
|
||||
const maxTime = draftDate && draftDate === maxDateTimeParts.date ? maxDateTimeParts.time || undefined : undefined
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -308,7 +353,7 @@ export default function DateTimePicker({
|
||||
id={inputId}
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-label={label ?? placeholder}
|
||||
aria-label={label ?? effectivePlaceholder}
|
||||
className={triggerClass}
|
||||
onClick={openPicker}
|
||||
onKeyDown={(event) => {
|
||||
@@ -328,7 +373,7 @@ export default function DateTimePicker({
|
||||
</svg>
|
||||
|
||||
<span className={`flex-1 truncate ${value ? 'text-white' : 'text-slate-500'}`}>
|
||||
{value ? formatDisplay(value) : placeholder}
|
||||
{value ? formatDisplay(value) : effectivePlaceholder}
|
||||
</span>
|
||||
|
||||
{clearable && value && (
|
||||
@@ -386,28 +431,32 @@ export default function DateTimePicker({
|
||||
month={viewMonth}
|
||||
selectedDate={selectedDate}
|
||||
onSelect={handleDateSelect}
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
minDate={effectiveMinDate}
|
||||
maxDate={effectiveMaxDate}
|
||||
/>
|
||||
|
||||
<div className="border-t border-white/8 px-4 py-3">
|
||||
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_7rem] sm:items-end">
|
||||
<div className={`grid gap-3 ${mode === 'date' ? '' : 'sm:grid-cols-[minmax(0,1fr)_7rem] sm:items-end'}`}>
|
||||
<div>
|
||||
<div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Selected date</div>
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white">
|
||||
{draftDate ? formatDisplay(mergeDateTime(draftDate, draftTime)).replace(` at ${draftTime}`, '') : 'Pick a day'}
|
||||
{draftDate ? formatDisplay(draftDate) : 'Pick a day'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="grid gap-1.5 text-sm text-slate-300">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Time</span>
|
||||
<input
|
||||
type="time"
|
||||
value={draftTime}
|
||||
onChange={handleTimeChange}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-white outline-none transition focus:border-accent/50 focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
</label>
|
||||
{mode !== 'date' ? (
|
||||
<label className="grid gap-1.5 text-sm text-slate-300">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Time</span>
|
||||
<input
|
||||
type="time"
|
||||
value={draftTime}
|
||||
onChange={handleTimeChange}
|
||||
min={minTime}
|
||||
max={maxTime}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-white outline-none transition focus:border-accent/50 focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
|
||||
@@ -26,6 +26,8 @@ import { createPortal } from 'react-dom'
|
||||
* @prop {boolean} required - asterisk on label
|
||||
* @prop {boolean} disabled
|
||||
* @prop {function} renderOption - custom render fn: (option) => ReactNode
|
||||
* @prop {function} renderValue - custom render fn for single-value trigger: (option) => ReactNode
|
||||
* @prop {string} searchPlaceholder - placeholder shown in the dropdown search input
|
||||
*/
|
||||
export default function NovaSelect({
|
||||
options = [],
|
||||
@@ -41,8 +43,10 @@ export default function NovaSelect({
|
||||
required = false,
|
||||
disabled = false,
|
||||
renderOption,
|
||||
renderValue,
|
||||
id,
|
||||
className = '',
|
||||
searchPlaceholder = 'Search…',
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
@@ -211,9 +215,10 @@ export default function NovaSelect({
|
||||
}, [open, filtered, highlighted, search, multi, selected, selectOption, closeDropdown, openDropdown, onChange])
|
||||
|
||||
// Build display label(s)
|
||||
const optionMap = useMemo(() => Object.fromEntries(options.map((o) => [String(o.value), o])), [options])
|
||||
const labelMap = useMemo(() => Object.fromEntries(options.map((o) => [String(o.value), o.label])), [options])
|
||||
|
||||
const hasValue = selected.length > 0
|
||||
const selectedOption = !multi && hasValue ? optionMap[String(selected[0])] ?? null : null
|
||||
|
||||
// Trigger appearance
|
||||
const triggerClass = [
|
||||
@@ -273,7 +278,9 @@ export default function NovaSelect({
|
||||
))}
|
||||
|
||||
{!multi && hasValue && (
|
||||
<span className="truncate text-white">{labelMap[String(selected[0])] ?? selected[0]}</span>
|
||||
renderValue && selectedOption
|
||||
? renderValue(selectedOption)
|
||||
: <span className="truncate text-white">{labelMap[String(selected[0])] ?? selected[0]}</span>
|
||||
)}
|
||||
|
||||
{!hasValue && (
|
||||
@@ -339,7 +346,7 @@ export default function NovaSelect({
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setHigh(0) }}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search…"
|
||||
placeholder={searchPlaceholder}
|
||||
className="w-full pl-3 pr-7 py-1.5 rounded-lg bg-white/5 border border-white/8 text-white text-xs placeholder:text-slate-500 focus:outline-none focus:ring-1 focus:ring-accent/50"
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
@@ -66,6 +66,8 @@ export default function PublishPanel({
|
||||
allRootCategoryOptions = [],
|
||||
actionLabel = 'Publish now',
|
||||
showScheduleControls = true,
|
||||
publishActionEnabled = true,
|
||||
publishActionTitle = 'Complete all requirements first',
|
||||
}) {
|
||||
const pill = STATUS_PILL[machineState] ?? null
|
||||
const hasPreview = Boolean(primaryPreviewUrl && !isArchive)
|
||||
@@ -103,6 +105,7 @@ export default function PublishPanel({
|
||||
|
||||
const canSchedulePublish =
|
||||
publishMode === 'schedule' ? Boolean(scheduledAt) && canPublish : canPublish
|
||||
const canTriggerPublish = publishActionEnabled && canSchedulePublish
|
||||
|
||||
const rightsError = uploadReady && !hasRights ? 'Rights confirmation is required.' : null
|
||||
|
||||
@@ -257,12 +260,12 @@ export default function PublishPanel({
|
||||
{/* Primary action button */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSchedulePublish || isPublishing}
|
||||
disabled={!canTriggerPublish || isPublishing}
|
||||
onClick={() => onPublish?.()}
|
||||
title={!canPublish ? 'Complete all requirements first' : undefined}
|
||||
title={!publishActionEnabled ? publishActionTitle : !canPublish ? 'Complete all requirements first' : undefined}
|
||||
className={[
|
||||
'w-full rounded-2xl py-3 text-sm font-semibold transition',
|
||||
canSchedulePublish && !isPublishing
|
||||
canTriggerPublish && !isPublishing
|
||||
? publishMode === 'schedule'
|
||||
? 'bg-violet-500/80 text-white hover:bg-violet-500 shadow-[0_4px_16px_rgba(139,92,246,0.25)]'
|
||||
: 'btn-primary'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import DateTimePicker from '../ui/DateTimePicker'
|
||||
|
||||
/**
|
||||
* SchedulePublishPicker
|
||||
@@ -82,14 +83,18 @@ export default function SchedulePublishPicker({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
)
|
||||
const [dateStr, setDateStr] = useState(initial.date || '')
|
||||
const [timeStr, setTimeStr] = useState(initial.time || '')
|
||||
const [localDateTime, setLocalDateTime] = useState(initial.date && initial.time ? `${initial.date}T${initial.time}` : '')
|
||||
const [error, setError] = useState('')
|
||||
const minScheduleLocalDateTime = (() => {
|
||||
const next = toLocalDateTimeString(new Date(Date.now() + MIN_FUTURE_MS).toISOString(), timezone)
|
||||
return next.date && next.time ? `${next.date}T${next.time}` : ''
|
||||
})()
|
||||
|
||||
const validate = useCallback(
|
||||
(d, t) => {
|
||||
if (!d || !t) return 'Date and time are required.'
|
||||
const iso = localToUtcIso(d, t, timezone)
|
||||
(value) => {
|
||||
const [datePart = '', timePart = ''] = String(value || '').split('T')
|
||||
if (!datePart || !timePart) return 'Date and time are required.'
|
||||
const iso = localToUtcIso(datePart, timePart.slice(0, 5), timezone)
|
||||
if (!iso) return 'Invalid date or time.'
|
||||
const target = new Date(iso)
|
||||
if (Number.isNaN(target.getTime())) return 'Invalid date or time.'
|
||||
@@ -101,31 +106,38 @@ export default function SchedulePublishPicker({
|
||||
[timezone]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const next = toLocalDateTimeString(scheduledAt, timezone)
|
||||
setLocalDateTime(next.date && next.time ? `${next.date}T${next.time}` : '')
|
||||
}, [scheduledAt, timezone])
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'schedule') {
|
||||
setError('')
|
||||
return
|
||||
}
|
||||
if (!dateStr && !timeStr) {
|
||||
if (!localDateTime) {
|
||||
setError('')
|
||||
onScheduleAt?.(null)
|
||||
return
|
||||
}
|
||||
const err = validate(dateStr, timeStr)
|
||||
const err = validate(localDateTime)
|
||||
setError(err)
|
||||
if (!err) {
|
||||
onScheduleAt?.(localToUtcIso(dateStr, timeStr, timezone))
|
||||
const [datePart = '', timePart = ''] = localDateTime.split('T')
|
||||
onScheduleAt?.(localToUtcIso(datePart, timePart.slice(0, 5), timezone))
|
||||
} else {
|
||||
onScheduleAt?.(null)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dateStr, timeStr, mode])
|
||||
}, [localDateTime, mode, timezone])
|
||||
|
||||
const previewLabel = useMemo(() => {
|
||||
if (mode !== 'schedule' || error) return null
|
||||
const iso = localToUtcIso(dateStr, timeStr, timezone)
|
||||
const [datePart = '', timePart = ''] = localDateTime.split('T')
|
||||
const iso = localToUtcIso(datePart, timePart.slice(0, 5), timezone)
|
||||
return formatPreviewLabel(iso, timezone)
|
||||
}, [mode, error, dateStr, timeStr, timezone])
|
||||
}, [mode, error, localDateTime, timezone])
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -167,45 +179,18 @@ export default function SchedulePublishPicker({
|
||||
|
||||
{mode === 'schedule' && (
|
||||
<div className="space-y-2 rounded-xl border border-white/10 bg-white/[0.03] p-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<div className="flex-1">
|
||||
<label className="block text-[10px] uppercase tracking-wide text-white/40 mb-1" htmlFor="schedule-date">
|
||||
Date
|
||||
</label>
|
||||
<input
|
||||
id="schedule-date"
|
||||
type="date"
|
||||
disabled={disabled}
|
||||
value={dateStr}
|
||||
onChange={(e) => setDateStr(e.target.value)}
|
||||
min={new Date().toISOString().slice(0, 10)}
|
||||
className="w-full rounded-lg border border-white/15 bg-white/8 px-3 py-1.5 text-sm text-white placeholder-white/30 focus:outline-none focus:ring-1 focus:ring-sky-400/60 disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-28 shrink-0">
|
||||
<label className="block text-[10px] uppercase tracking-wide text-white/40 mb-1" htmlFor="schedule-time">
|
||||
Time
|
||||
</label>
|
||||
<input
|
||||
id="schedule-time"
|
||||
type="time"
|
||||
disabled={disabled}
|
||||
value={timeStr}
|
||||
onChange={(e) => setTimeStr(e.target.value)}
|
||||
className="w-full rounded-lg border border-white/15 bg-white/8 px-3 py-1.5 text-sm text-white placeholder-white/30 focus:outline-none focus:ring-1 focus:ring-sky-400/60 disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-white/35">
|
||||
Timezone: <span className="text-white/55">{timezone}</span>
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-400" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<DateTimePicker
|
||||
id="schedule-datetime"
|
||||
label="Release date and time"
|
||||
value={localDateTime}
|
||||
onChange={setLocalDateTime}
|
||||
placeholder="Pick a release slot"
|
||||
disabled={disabled}
|
||||
minDateTime={minScheduleLocalDateTime}
|
||||
clearable
|
||||
hint={`Timezone: ${timezone}`}
|
||||
error={error}
|
||||
/>
|
||||
|
||||
{previewLabel && (
|
||||
<p className="text-xs text-emerald-300/80">
|
||||
|
||||
@@ -92,8 +92,8 @@ export default function UploadActions({
|
||||
}
|
||||
|
||||
return (
|
||||
<footer data-testid="wizard-action-bar" className={`${mobileSticky ? 'sticky bottom-0 z-20 px-4 pb-3 lg:static lg:px-0 lg:pb-0' : ''}`}>
|
||||
<div className="mx-auto w-full max-w-4xl rounded-[24px] border border-white/10 bg-[#08111c]/88 p-3 shadow-[0_-12px_32px_rgba(2,8,23,0.65)] backdrop-blur-sm sm:p-4 lg:shadow-none">
|
||||
<footer data-testid="wizard-action-bar" className={`${mobileSticky ? 'pointer-events-none fixed inset-x-0 bottom-0 z-[70] px-4 pb-4 pt-3' : ''}`}>
|
||||
<div className="pointer-events-auto mx-auto w-full max-w-7xl rounded-[24px] border border-white/10 bg-[#08111c]/92 p-3 shadow-[0_-12px_32px_rgba(2,8,23,0.65)] backdrop-blur-sm sm:p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-white/35">
|
||||
{step === 1 ? 'Step 1 of 3' : step === 2 ? 'Step 2 of 3' : 'Step 3 of 3'}
|
||||
|
||||
@@ -130,7 +130,6 @@ export default function UploadWizard({
|
||||
const [publishMode, setPublishMode] = useState('now') // 'now' | 'schedule'
|
||||
const [scheduledAt, setScheduledAt] = useState(null) // UTC ISO or null
|
||||
const [visibility, setVisibility] = useState('public') // 'public'|'unlisted'|'private'
|
||||
const [showMobilePublishPanel, setShowMobilePublishPanel] = useState(false)
|
||||
const userTimezone = useMemo(() => {
|
||||
try { return Intl.DateTimeFormat().resolvedOptions().timeZone } catch { return 'UTC' }
|
||||
}, [])
|
||||
@@ -393,6 +392,8 @@ export default function UploadWizard({
|
||||
return true
|
||||
}, [canPublish, reviewSubmissionMode, publishMode, scheduledAt])
|
||||
|
||||
const publishActionEnabled = activeStep === 3 && canScheduleSubmit
|
||||
|
||||
// ── Validation surface for parent ────────────────────────────────────────
|
||||
const validationErrors = useMemo(
|
||||
() => [...primaryErrors, ...screenshotErrors],
|
||||
@@ -437,13 +438,6 @@ export default function UploadWizard({
|
||||
clearPolling()
|
||||
}
|
||||
}, [abortAllRequests, clearPolling])
|
||||
// ── ESC key closes mobile drawer (spec §7) ─────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!showMobilePublishPanel) return
|
||||
const handler = (e) => { if (e.key === 'Escape') setShowMobilePublishPanel(false) }
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [showMobilePublishPanel])
|
||||
// ── Metadata helpers ──────────────────────────────────────────────────────
|
||||
const setMeta = useCallback((patch) => setMetadata((prev) => ({ ...prev, ...patch })), [])
|
||||
|
||||
@@ -459,7 +453,6 @@ export default function UploadWizard({
|
||||
setPublishMode('now')
|
||||
setScheduledAt(null)
|
||||
setVisibility('public')
|
||||
setShowMobilePublishPanel(false)
|
||||
setResolvedArtworkId(() => {
|
||||
const parsed = Number(initialDraftId)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null
|
||||
@@ -705,11 +698,15 @@ export default function UploadWizard({
|
||||
return machine.error || 'Publish is available when upload is ready and rights are confirmed.'
|
||||
})()
|
||||
|
||||
const publishActionTitle = activeStep < 3
|
||||
? 'Continue to the final publish step to choose Worlds and publish.'
|
||||
: disableReason
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<section
|
||||
ref={stepContentRef}
|
||||
className="space-y-5 pb-32 text-white lg:pb-8"
|
||||
className="space-y-5 pb-40 text-white lg:pb-40"
|
||||
data-is-archive={isArchive ? 'true' : 'false'}
|
||||
>
|
||||
{notices.length > 0 && (
|
||||
@@ -796,7 +793,7 @@ export default function UploadWizard({
|
||||
step={activeStep}
|
||||
canStart={canStartUpload && [machineStates.idle, machineStates.error, machineStates.cancelled].includes(machine.state)}
|
||||
canContinue={detailsValid}
|
||||
canPublish={canScheduleSubmit}
|
||||
canPublish={publishActionEnabled}
|
||||
canGoBack={activeStep > 1 && machine.state !== machineStates.complete}
|
||||
canReset={Boolean(primaryFile || screenshots.length || metadata.title || metadata.description || metadata.tags.length)}
|
||||
canCancel={activeStep === 1 && [
|
||||
@@ -813,7 +810,7 @@ export default function UploadWizard({
|
||||
disableReason={disableReason}
|
||||
onStart={runUploadFlow}
|
||||
onContinue={() => detailsValid && setActiveStep(3)}
|
||||
onPublish={() => handlePublish(canPublish, { mode: reviewSubmissionMode ? 'now' : publishMode, publishAt: reviewSubmissionMode ? null : scheduledAt, timezone: userTimezone, visibility, action: reviewSubmissionMode ? 'submit_review' : 'publish' })}
|
||||
onPublish={() => publishActionEnabled && handlePublish(canPublish, { mode: reviewSubmissionMode ? 'now' : publishMode, publishAt: reviewSubmissionMode ? null : scheduledAt, timezone: userTimezone, visibility, action: reviewSubmissionMode ? 'submit_review' : 'publish' })}
|
||||
onBack={() => setActiveStep((s) => Math.max(1, s - 1))}
|
||||
onCancel={handleCancel}
|
||||
onReset={handleReset}
|
||||
@@ -841,6 +838,8 @@ export default function UploadWizard({
|
||||
machineState={machine.state}
|
||||
uploadReady={uploadReady}
|
||||
canPublish={canPublish}
|
||||
publishActionEnabled={publishActionEnabled}
|
||||
publishActionTitle={publishActionTitle}
|
||||
isPublishing={machine.state === machineStates.publishing}
|
||||
isArchiveRequiresScreenshot={isArchive}
|
||||
publishMode={publishMode}
|
||||
@@ -864,101 +863,6 @@ export default function UploadWizard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Mobile: floating "Publish" button that opens bottom sheet ────── */}
|
||||
{(primaryFile || resolvedArtworkId) && machine.state !== machineStates.complete && activeStep > 1 && (
|
||||
<div className="fixed bottom-4 right-4 z-30 lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open publish panel"
|
||||
onClick={() => setShowMobilePublishPanel((v) => !v)}
|
||||
className="flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-500 px-4 py-2.5 text-sm font-semibold text-white shadow-[0_18px_50px_rgba(14,165,233,0.35)] transition hover:bg-sky-400 active:scale-95"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{reviewSubmissionMode ? 'Review' : 'Publish'}
|
||||
{!canPublish && (
|
||||
<span className="ml-1 rounded-full bg-white/20 px-1.5 text-[10px]">
|
||||
{[
|
||||
...(!uploadReady ? [1] : []),
|
||||
...(hasTitle ? [] : [1]),
|
||||
...(hasCompleteCategory ? [] : [1]),
|
||||
...(hasTag ? [] : [1]),
|
||||
...(hasRequiredScreenshot ? [] : [1]),
|
||||
...(metadata.rightsAccepted ? [] : [1]),
|
||||
].length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Mobile Publish panel bottom-sheet overlay ────────────────────── */}
|
||||
<AnimatePresence>
|
||||
{showMobilePublishPanel && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
key="mobile-panel-backdrop"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden"
|
||||
onClick={() => setShowMobilePublishPanel(false)}
|
||||
/>
|
||||
{/* Sheet */}
|
||||
<motion.div
|
||||
key="mobile-panel-sheet"
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '100%' }}
|
||||
transition={prefersReducedMotion ? { duration: 0 } : { type: 'spring', damping: 30, stiffness: 300 }}
|
||||
className="fixed bottom-0 left-0 right-0 z-50 max-h-[80vh] overflow-y-auto rounded-t-2xl bg-slate-900 ring-1 ring-white/10 p-5 pb-8 lg:hidden"
|
||||
>
|
||||
<div className="mx-auto mb-4 h-1 w-12 rounded-full bg-white/20" aria-hidden="true" />
|
||||
<PublishPanel
|
||||
primaryPreviewUrl={primaryPreviewUrl}
|
||||
isArchive={isArchive}
|
||||
screenshots={screenshots}
|
||||
selectedScreenshotIndex={selectedScreenshotIndex}
|
||||
onSelectedScreenshotChange={setSelectedScreenshotIndex}
|
||||
metadata={metadata}
|
||||
machineState={machine.state}
|
||||
uploadReady={uploadReady}
|
||||
canPublish={canPublish}
|
||||
isPublishing={machine.state === machineStates.publishing}
|
||||
isArchiveRequiresScreenshot={isArchive}
|
||||
publishMode={publishMode}
|
||||
scheduledAt={scheduledAt}
|
||||
timezone={userTimezone}
|
||||
visibility={visibility}
|
||||
actionLabel={publishActionLabel}
|
||||
showScheduleControls={!reviewSubmissionMode}
|
||||
showRightsConfirmation={activeStep === 3}
|
||||
showVisibility={false}
|
||||
onPublishModeChange={setPublishMode}
|
||||
onScheduleAt={setScheduledAt}
|
||||
onVisibilityChange={setVisibility}
|
||||
onToggleRights={(checked) => setMeta({ rightsAccepted: Boolean(checked) })}
|
||||
onPublish={() => {
|
||||
setShowMobilePublishPanel(false)
|
||||
handlePublish(canPublish, { mode: reviewSubmissionMode ? 'now' : publishMode, publishAt: reviewSubmissionMode ? null : scheduledAt, timezone: userTimezone, visibility, action: reviewSubmissionMode ? 'submit_review' : 'publish' })
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowMobilePublishPanel(false)
|
||||
handleCancel()
|
||||
}}
|
||||
onGoToStep={(s) => {
|
||||
setShowMobilePublishPanel(false)
|
||||
goToStep(s)
|
||||
}}
|
||||
allRootCategoryOptions={allRootCategoryOptions}
|
||||
/>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -478,12 +478,12 @@ describe('UploadWizard step flow', () => {
|
||||
expect(studioEditLink.getAttribute('href')).toBe('/studio/artworks/315/edit')
|
||||
})
|
||||
|
||||
it('keeps mobile sticky action bar visible class', async () => {
|
||||
it('keeps the action bar fixed to the bottom', async () => {
|
||||
installAxiosStubs()
|
||||
await renderWizard({ initialDraftId: 306 })
|
||||
|
||||
const bar = screen.getByTestId('wizard-action-bar')
|
||||
expect((bar.className || '').includes('sticky')).toBe(true)
|
||||
expect((bar.className || '').includes('fixed')).toBe(true)
|
||||
expect((bar.className || '').includes('bottom-0')).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -229,6 +229,7 @@ export default function Step3Publish({
|
||||
options={eligibleWorlds}
|
||||
onToggle={onToggleWorldSubmission}
|
||||
onNoteChange={onChangeWorldSubmissionNote}
|
||||
analyticsContext={{ sourceSurface: 'upload_flow', sourceDetail: 'publish_step' }}
|
||||
/>
|
||||
|
||||
<section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
|
||||
|
||||
@@ -10,6 +10,21 @@ import { useNavContext } from '../../lib/useNavContext';
|
||||
|
||||
const preloadCache = new Set();
|
||||
|
||||
function scheduleIdleTask(callback, delay = 1200) {
|
||||
if (typeof window === 'undefined') {
|
||||
callback();
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (typeof window.requestIdleCallback === 'function') {
|
||||
const handle = window.requestIdleCallback(callback, { timeout: delay });
|
||||
return () => window.cancelIdleCallback(handle);
|
||||
}
|
||||
|
||||
const handle = window.setTimeout(callback, delay);
|
||||
return () => window.clearTimeout(handle);
|
||||
}
|
||||
|
||||
function preloadImage(src) {
|
||||
if (!src || preloadCache.has(src)) return;
|
||||
preloadCache.add(src);
|
||||
@@ -44,20 +59,33 @@ export default function ArtworkNavigator({ artworkId, onNavigate, onOpenViewer,
|
||||
getNeighbors().then((n) => {
|
||||
if (cancelled) return;
|
||||
setNeighbors(n);
|
||||
[n.prevId, n.nextId].forEach((id) => {
|
||||
if (!id) return;
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [artworkId, getNeighbors]);
|
||||
|
||||
useEffect(() => {
|
||||
const ids = [neighbors.prevId, neighbors.nextId].filter(Boolean);
|
||||
if (ids.length === 0) return undefined;
|
||||
|
||||
let cancelled = false;
|
||||
const cancelIdleTask = scheduleIdleTask(() => {
|
||||
ids.forEach((id) => {
|
||||
fetch(`/api/artworks/${id}/page`, { headers: { Accept: 'application/json' } })
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((data) => {
|
||||
if (!data) return;
|
||||
if (cancelled || !data) return;
|
||||
const imgUrl = data.thumbs?.lg?.url || data.thumbs?.md?.url;
|
||||
if (imgUrl) preloadImage(imgUrl);
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [artworkId, getNeighbors]);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelIdleTask();
|
||||
};
|
||||
}, [neighbors.prevId, neighbors.nextId]);
|
||||
|
||||
// Stable navigate — reads state via refs, never recreated
|
||||
const navigate = useCallback(async (targetId, targetUrl) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import './bootstrap'
|
||||
import { mountInertiaRoot } from './bootstrap'
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { createInertiaApp } from '@inertiajs/react'
|
||||
import FollowingFeed from './Pages/Feed/FollowingFeed'
|
||||
import TrendingFeed from './Pages/Feed/TrendingFeed'
|
||||
@@ -19,7 +18,6 @@ const pages = {
|
||||
createInertiaApp({
|
||||
resolve: (name) => pages[name],
|
||||
setup({ el, App, props }) {
|
||||
const root = createRoot(el)
|
||||
root.render(<App {...props} />)
|
||||
mountInertiaRoot(el, App, props)
|
||||
},
|
||||
})
|
||||
|
||||
17
resources/js/forum.jsx
Normal file
17
resources/js/forum.jsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { mountInertiaRoot } from './bootstrap'
|
||||
import React from 'react'
|
||||
import { createInertiaApp } from '@inertiajs/react'
|
||||
|
||||
const pages = import.meta.glob('./Pages/Forum/*.jsx', { eager: true })
|
||||
|
||||
createInertiaApp({
|
||||
resolve: (name) => {
|
||||
const key = `./Pages/Forum/${name.replace('Forum/', '')}.jsx`
|
||||
const module = pages[key]
|
||||
if (!module) throw new Error(`Inertia forum page not found: ${name}`)
|
||||
return module.default
|
||||
},
|
||||
setup({ el, App, props }) {
|
||||
mountInertiaRoot(el, App, props)
|
||||
},
|
||||
})
|
||||
@@ -594,6 +594,7 @@ export default function useUploadMachine({
|
||||
.map((world) => ({
|
||||
world_id: Number(world.id),
|
||||
note: typeof world.note === 'string' ? world.note : '',
|
||||
source_surface: 'upload_flow',
|
||||
}))
|
||||
.filter((entry) => Number.isFinite(entry.world_id) && entry.world_id > 0)
|
||||
: [],
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import './bootstrap'
|
||||
import { mountInertiaRoot } from './bootstrap'
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { createInertiaApp } from '@inertiajs/react'
|
||||
|
||||
import LeaderboardPage from './Pages/Leaderboard/LeaderboardPage'
|
||||
@@ -12,7 +11,6 @@ const pages = {
|
||||
createInertiaApp({
|
||||
resolve: (name) => pages[name],
|
||||
setup({ el, App, props }) {
|
||||
const root = createRoot(el)
|
||||
root.render(<App {...props} />)
|
||||
mountInertiaRoot(el, App, props)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import './bootstrap'
|
||||
import { mountInertiaRoot } from './bootstrap'
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { createInertiaApp } from '@inertiajs/react'
|
||||
|
||||
const pages = import.meta.glob('./Pages/Moderation/**/*.jsx')
|
||||
@@ -16,7 +15,6 @@ createInertiaApp({
|
||||
return page().then((module) => module.default)
|
||||
},
|
||||
setup({ el, App, props }) {
|
||||
const root = createRoot(el)
|
||||
root.render(<App {...props} />)
|
||||
mountInertiaRoot(el, App, props)
|
||||
},
|
||||
})
|
||||
@@ -188,9 +188,36 @@ function mountStorySocial() {
|
||||
});
|
||||
}
|
||||
|
||||
function mountRememberMeCheckboxes() {
|
||||
var roots = document.querySelectorAll('[data-remember-me-checkbox-root]');
|
||||
if (!roots.length) return;
|
||||
|
||||
roots.forEach(function (rootEl) {
|
||||
if (rootEl.dataset.reactMounted === 'true') return;
|
||||
|
||||
var props = safeParseJson(rootEl.getAttribute('data-props'), {});
|
||||
rootEl.dataset.reactMounted = 'true';
|
||||
|
||||
void Promise.all([
|
||||
import('./components/auth/RememberMeCheckbox.jsx'),
|
||||
getReactRuntime(),
|
||||
])
|
||||
.then(function (resolved) {
|
||||
var module = resolved[0];
|
||||
var reactRuntime = resolved[1];
|
||||
var Component = module.default;
|
||||
reactRuntime.createRoot(rootEl).render(reactRuntime.React.createElement(Component, props));
|
||||
})
|
||||
.catch(function () {
|
||||
rootEl.dataset.reactMounted = 'false';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
mountToolbarMessages();
|
||||
mountToolbarNotifications();
|
||||
mountStorySocial();
|
||||
mountRememberMeCheckboxes();
|
||||
|
||||
function initStorySyntaxHighlighting() {
|
||||
var codeBlocks = Array.prototype.slice.call(document.querySelectorAll('.story-prose pre code'));
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import './bootstrap'
|
||||
import { mountInertiaRoot } from './bootstrap'
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { createInertiaApp } from '@inertiajs/react'
|
||||
import ProfileShow from './Pages/Profile/ProfileShow'
|
||||
import ProfileGallery from './Pages/Profile/ProfileGallery'
|
||||
@@ -23,7 +22,6 @@ const pages = {
|
||||
createInertiaApp({
|
||||
resolve: (name) => pages[name],
|
||||
setup({ el, App, props }) {
|
||||
const root = createRoot(el)
|
||||
root.render(<App {...props} />)
|
||||
mountInertiaRoot(el, App, props)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import './bootstrap'
|
||||
import { mountInertiaRoot } from './bootstrap'
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { createInertiaApp } from '@inertiajs/react'
|
||||
|
||||
import ProfileEdit from './Pages/Settings/ProfileEdit'
|
||||
@@ -12,7 +11,6 @@ const pages = {
|
||||
createInertiaApp({
|
||||
resolve: (name) => pages[name],
|
||||
setup({ el, App, props }) {
|
||||
const root = createRoot(el)
|
||||
root.render(<App {...props} />)
|
||||
mountInertiaRoot(el, App, props)
|
||||
},
|
||||
})
|
||||
|
||||
34
resources/js/ssr.jsx
Normal file
34
resources/js/ssr.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react'
|
||||
import { createInertiaApp } from '@inertiajs/react'
|
||||
import createServer from '@inertiajs/react/server'
|
||||
import ReactDOMServer from 'react-dom/server'
|
||||
|
||||
// Eagerly import every Inertia page component so the SSR server can resolve
|
||||
// any page name without async dynamic imports (Node.js + Vite SSR requirement).
|
||||
const pages = import.meta.glob(['./Pages/**/*.jsx', '!./Pages/**/*.test.jsx', '!./Pages/**/__tests__/**'], { eager: true })
|
||||
|
||||
// Lightweight server-only placeholder for pages that must remain client-only.
|
||||
// Returning this prevents an error-level stacktrace while still avoiding
|
||||
// server-side rendering of browser-dependent components.
|
||||
const ClientOnlyPlaceholder = () => null
|
||||
|
||||
createServer(page =>
|
||||
createInertiaApp({
|
||||
page,
|
||||
render: ReactDOMServer.renderToString,
|
||||
resolve: (name) => {
|
||||
// Studio news pages are rendered client-side only. Return a minimal
|
||||
// placeholder component instead of throwing so the SSR server
|
||||
// produces a small, safe HTML shell without logging an error.
|
||||
if (name.startsWith('Studio/StudioNews')) {
|
||||
return ClientOnlyPlaceholder
|
||||
}
|
||||
const module = pages[`./Pages/${name}.jsx`]
|
||||
if (!module) {
|
||||
throw new Error(`[SSR] Unknown Inertia page: "./Pages/${name}.jsx"`)
|
||||
}
|
||||
return module.default
|
||||
},
|
||||
setup: ({ App, props }) => <App {...props} />,
|
||||
}),
|
||||
)
|
||||
@@ -1,6 +1,5 @@
|
||||
import './bootstrap'
|
||||
import { mountInertiaRoot } from './bootstrap'
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { createInertiaApp } from '@inertiajs/react'
|
||||
const pages = import.meta.glob([
|
||||
'./Pages/Studio/**/*.jsx',
|
||||
@@ -22,7 +21,6 @@ function resolvePage(name) {
|
||||
createInertiaApp({
|
||||
resolve: resolvePage,
|
||||
setup({ el, App, props }) {
|
||||
const root = createRoot(el)
|
||||
root.render(<App {...props} />)
|
||||
mountInertiaRoot(el, App, props)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import './bootstrap'
|
||||
import { mountInertiaRoot } from './bootstrap'
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { createInertiaApp } from '@inertiajs/react'
|
||||
import UploadPage from './Pages/Upload/Index'
|
||||
|
||||
@@ -11,7 +10,6 @@ const pages = {
|
||||
createInertiaApp({
|
||||
resolve: (name) => pages[name],
|
||||
setup({ el, App, props }) {
|
||||
const root = createRoot(el)
|
||||
root.render(<App {...props} />)
|
||||
mountInertiaRoot(el, App, props)
|
||||
},
|
||||
})
|
||||
|
||||
12
resources/js/utils/flagUrl.js
Normal file
12
resources/js/utils/flagUrl.js
Normal file
@@ -0,0 +1,12 @@
|
||||
export function shinyFlagUrl(countryCode, filesCdnUrl = '') {
|
||||
const normalized = String(countryCode ?? '').trim().toUpperCase()
|
||||
|
||||
if (!/^[A-Z]{2}$/.test(normalized)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const base = String(filesCdnUrl ?? '').replace(/\/+$/, '')
|
||||
const relativePath = `/images/flags/shiny/24/${encodeURIComponent(normalized)}.png`
|
||||
|
||||
return base ? `${base}${relativePath}` : relativePath
|
||||
}
|
||||
Reference in New Issue
Block a user