Wire admin studio SSR and search infrastructure
This commit is contained in:
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',
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user