Optimize academy
This commit is contained in:
11
resources/js/Pages/Moderation/FeaturedArtworks.jsx
Normal file
11
resources/js/Pages/Moderation/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 ModerationFeaturedArtworks() {
|
||||
return (
|
||||
<AdminLayout>
|
||||
<FeaturedArtworksAdmin />
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
148
resources/js/Pages/Moderation/StaffApplications/Index.jsx
Normal file
148
resources/js/Pages/Moderation/StaffApplications/Index.jsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React from 'react'
|
||||
import { Head, router, Link } from '@inertiajs/react'
|
||||
import AdminLayout from '../../../Layouts/AdminLayout'
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeStyle: 'short' }).format(date)
|
||||
}
|
||||
|
||||
function StatCard({ label, value }) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{label}</div>
|
||||
<div className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white">{Number(value || 0).toLocaleString()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StaffApplicationsIndex({ title, items, stats, filters, topics, endpoints }) {
|
||||
const [state, setState] = React.useState(filters || { q: '', topic: 'all' })
|
||||
|
||||
React.useEffect(() => {
|
||||
setState(filters || { q: '', topic: 'all' })
|
||||
}, [filters])
|
||||
|
||||
function update(key, value) {
|
||||
setState((current) => ({ ...current, [key]: value }))
|
||||
}
|
||||
|
||||
function applyFilters(event) {
|
||||
event.preventDefault()
|
||||
router.get(endpoints.index, state, { preserveState: true, replace: true, preserveScroll: true })
|
||||
}
|
||||
|
||||
const rows = items?.data || []
|
||||
|
||||
return (
|
||||
<AdminLayout title={title || 'Staff Applications'} subtitle="Review staff and contact submissions without leaving moderation.">
|
||||
<Head title="Moderation · Staff Applications" />
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.12),transparent_34%),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.32)]">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/80">Moderation surface</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Staff Applications</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">Review staff and contact submissions in the same moderation workspace as the rest of Skinbase.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 text-xs uppercase tracking-[0.16em] text-slate-300">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2">Page {items?.current_page || 1} / {items?.last_page || 1}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2">{Number(items?.total || 0).toLocaleString()} submissions</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
<StatCard label="Total" value={stats?.total} />
|
||||
<StatCard label="Applications" value={stats?.applications} />
|
||||
<StatCard label="Bug reports" value={stats?.bug} />
|
||||
<StatCard label="Contact" value={stats?.contact} />
|
||||
<StatCard label="Other" value={stats?.other} />
|
||||
</div>
|
||||
|
||||
<form onSubmit={applyFilters} className="mt-6 grid gap-3 lg:grid-cols-[2fr_1fr_auto]">
|
||||
<input
|
||||
value={state.q || ''}
|
||||
onChange={(event) => update('q', event.target.value)}
|
||||
placeholder="Search name, email, role, or message"
|
||||
className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
|
||||
/>
|
||||
<select
|
||||
value={state.topic || 'all'}
|
||||
onChange={(event) => update('topic', event.target.value)}
|
||||
className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
|
||||
>
|
||||
<option value="all">All topics</option>
|
||||
{(topics || []).map((topic) => (
|
||||
<option key={topic} value={topic}>{String(topic).replaceAll('_', ' ')}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" className="rounded-2xl border border-white/10 bg-white/[0.06] px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.1]">Apply</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div className="mt-8 overflow-hidden rounded-[28px] border border-white/10 bg-[#08111d] shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-white/[0.03] text-left text-xs uppercase tracking-[0.18em] text-slate-400">
|
||||
<tr>
|
||||
<th className="px-5 py-4 font-medium">Received</th>
|
||||
<th className="px-5 py-4 font-medium">Topic</th>
|
||||
<th className="px-5 py-4 font-medium">Name</th>
|
||||
<th className="px-5 py-4 font-medium">Email</th>
|
||||
<th className="px-5 py-4 font-medium">Role</th>
|
||||
<th className="px-5 py-4 font-medium">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5 text-slate-200">
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-5 py-14 text-center text-slate-400">No staff applications matched the current filters.</td>
|
||||
</tr>
|
||||
) : rows.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-white/[0.02]">
|
||||
<td className="px-5 py-4 text-slate-400">{formatDateTime(item.created_at)}</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className="inline-flex rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">
|
||||
{String(item.topic || 'contact').replaceAll('_', ' ')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 font-medium text-white">{item.name}</td>
|
||||
<td className="px-5 py-4 text-slate-300">{item.email}</td>
|
||||
<td className="px-5 py-4 text-slate-300">{item.role || '—'}</td>
|
||||
<td className="px-5 py-4">
|
||||
<Link href={item.show_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
|
||||
View
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(items?.prev_page_url || items?.next_page_url) ? (
|
||||
<div className="mt-8 flex items-center justify-between gap-3 rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-slate-400">
|
||||
Showing page {items?.current_page || 1} of {items?.last_page || 1}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{items?.prev_page_url ? (
|
||||
<button type="button" onClick={() => router.get(items.prev_page_url, {}, { preserveScroll: true })} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
|
||||
Previous
|
||||
</button>
|
||||
) : null}
|
||||
{items?.next_page_url ? (
|
||||
<button type="button" onClick={() => router.get(items.next_page_url, {}, { preserveScroll: true })} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
|
||||
Next
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
87
resources/js/Pages/Moderation/StaffApplications/Show.jsx
Normal file
87
resources/js/Pages/Moderation/StaffApplications/Show.jsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React from 'react'
|
||||
import { Head, Link } from '@inertiajs/react'
|
||||
import AdminLayout from '../../../Layouts/AdminLayout'
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeStyle: 'short' }).format(date)
|
||||
}
|
||||
|
||||
function Field({ label, children }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{label}</div>
|
||||
<div className="mt-2 text-sm leading-7 text-slate-100">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StaffApplicationShow({ title, item, backUrl }) {
|
||||
const payload = item?.payload || {}
|
||||
|
||||
return (
|
||||
<AdminLayout title={title || 'Staff Application'} subtitle="Read the full submission in a moderation-friendly layout.">
|
||||
<Head title="Moderation · Staff Application" />
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(34,211,238,0.12),transparent_34%),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.32)]">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/80">Moderation surface</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">{item?.name || 'Staff application'}</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">Topic: {String(item?.topic || 'contact').replaceAll('_', ' ')} • Received {formatDateTime(item?.created_at)}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href={backUrl} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-5 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
|
||||
Back
|
||||
</Link>
|
||||
{item?.email ? (
|
||||
<a href={`mailto:${item.email}`} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/12 px-5 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-sky-50 transition hover:bg-sky-400/18">
|
||||
Email
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-8 grid gap-6 xl:grid-cols-[1.35fr_0.85fr]">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Name">{item?.name || '—'}</Field>
|
||||
<Field label="Email">{item?.email || '—'}</Field>
|
||||
<Field label="Role">{item?.role || '—'}</Field>
|
||||
<Field label="Portfolio">{item?.portfolio ? <a href={item.portfolio} className="text-sky-300 hover:text-sky-200" target="_blank" rel="noreferrer">{item.portfolio}</a> : '—'}</Field>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[28px] border border-white/10 bg-[#08111d] p-5 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Message</div>
|
||||
<div className="mt-3 whitespace-pre-wrap rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-4 text-sm leading-7 text-slate-100">
|
||||
{item?.message || 'No message included.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<div className="rounded-[28px] border border-white/10 bg-[#08111d] p-5 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Metadata</div>
|
||||
<div className="mt-4 space-y-3 text-sm text-slate-200">
|
||||
<div><span className="font-semibold text-white">Received:</span> {formatDateTime(item?.created_at)}</div>
|
||||
<div><span className="font-semibold text-white">IP:</span> {item?.ip || '—'}</div>
|
||||
<div><span className="font-semibold text-white">User agent:</span> {item?.user_agent || '—'}</div>
|
||||
<div><span className="font-semibold text-white">ID:</span> {item?.id || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[28px] border border-white/10 bg-[#08111d] p-5 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Payload</div>
|
||||
<pre className="mt-3 max-h-[420px] overflow-auto rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-4 text-xs leading-6 text-slate-200">
|
||||
{JSON.stringify(payload, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
168
resources/js/Pages/Moderation/Stories.jsx
Normal file
168
resources/js/Pages/Moderation/Stories.jsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import React from 'react'
|
||||
import { Head, router } from '@inertiajs/react'
|
||||
import AdminLayout from '../../Layouts/AdminLayout'
|
||||
|
||||
function badgeTone(status) {
|
||||
if (status === 'published') return 'border-emerald-300/20 bg-emerald-400/12 text-emerald-100'
|
||||
if (status === 'scheduled') return 'border-sky-300/20 bg-sky-400/12 text-sky-100'
|
||||
if (status === 'pending_review') return 'border-amber-300/20 bg-amber-400/12 text-amber-100'
|
||||
if (status === 'archived' || status === 'rejected') return 'border-rose-300/20 bg-rose-400/12 text-rose-100'
|
||||
return 'border-white/10 bg-white/[0.06] text-slate-200'
|
||||
}
|
||||
|
||||
function StatCard({ label, value }) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{label}</div>
|
||||
<div className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white">{Number(value || 0).toLocaleString()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Stories({ title, stories, filters, stats, endpoints }) {
|
||||
const [state, setState] = React.useState(filters || { q: '', status: 'all' })
|
||||
|
||||
React.useEffect(() => {
|
||||
setState(filters || { q: '', status: 'all' })
|
||||
}, [filters])
|
||||
|
||||
function update(key, value) {
|
||||
setState((current) => ({ ...current, [key]: value }))
|
||||
}
|
||||
|
||||
function applyFilters(event) {
|
||||
event.preventDefault()
|
||||
router.get(endpoints.index, state, { preserveState: true, replace: true, preserveScroll: true })
|
||||
}
|
||||
|
||||
const items = stories?.data || []
|
||||
|
||||
return (
|
||||
<AdminLayout title={title || 'Stories'} subtitle="Review creator stories from the moderation surface, without jumping back to the old CP layout.">
|
||||
<Head title="Moderation · Stories" />
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_34%),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.32)]">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/80">Moderation surface</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Stories</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">Browse creator stories, filter by status, and jump straight to the public view when it exists.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 text-xs uppercase tracking-[0.16em] text-slate-300">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2">Page {stories?.current_page || 1} / {stories?.last_page || 1}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2">{Number(stories?.total || 0).toLocaleString()} stories</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-6">
|
||||
<StatCard label="Total" value={stats?.total} />
|
||||
<StatCard label="Published" value={stats?.published} />
|
||||
<StatCard label="Draft" value={stats?.draft} />
|
||||
<StatCard label="Scheduled" value={stats?.scheduled} />
|
||||
<StatCard label="Pending review" value={stats?.pending_review} />
|
||||
<StatCard label="Archived" value={stats?.archived} />
|
||||
</div>
|
||||
|
||||
<form onSubmit={applyFilters} className="mt-6 grid gap-3 lg:grid-cols-[2fr_1fr_auto]">
|
||||
<input
|
||||
value={state.q || ''}
|
||||
onChange={(event) => update('q', event.target.value)}
|
||||
placeholder="Search title, slug, or creator"
|
||||
className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
|
||||
/>
|
||||
<select
|
||||
value={state.status || 'all'}
|
||||
onChange={(event) => update('status', event.target.value)}
|
||||
className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
|
||||
>
|
||||
<option value="all">All statuses</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="pending_review">Pending review</option>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="archived">Archived</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
<button type="submit" className="rounded-2xl border border-white/10 bg-white/[0.06] px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.1]">Apply</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div className="mt-8 grid gap-4 xl:grid-cols-2">
|
||||
{items.length === 0 ? (
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] px-6 py-12 text-center text-slate-300 xl:col-span-2">
|
||||
No stories matched the current filters.
|
||||
</div>
|
||||
) : items.map((story) => (
|
||||
<article key={story.id} className="overflow-hidden rounded-[28px] border border-white/10 bg-[#08111d] shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="grid gap-4 md:grid-cols-[180px_1fr]">
|
||||
<div className="aspect-[3/4] bg-black/30">
|
||||
{story.cover_url ? (
|
||||
<img src={story.cover_url} alt={story.title} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-white/20">
|
||||
<i className="fa-solid fa-feather-pointed text-4xl" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${badgeTone(story.status)}`}>
|
||||
{String(story.status || 'draft').replaceAll('_', ' ')}
|
||||
</span>
|
||||
{story.creator ? (
|
||||
<span className="inline-flex rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">
|
||||
@{story.creator.username}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.03em] text-white">{story.title}</h2>
|
||||
<p className="mt-2 text-sm text-slate-300">/{story.slug}{story.creator ? ` • ${story.creator.name}` : ''}</p>
|
||||
{story.excerpt ? <p className="mt-3 text-sm leading-6 text-slate-300">{story.excerpt}</p> : null}
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs uppercase tracking-[0.16em] text-slate-400">
|
||||
<span>{story.published_at ? new Date(story.published_at).toLocaleDateString() : 'Unpublished'}</span>
|
||||
<span>{story.created_at ? new Date(story.created_at).toLocaleDateString() : '—'}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
{story.open_url ? (
|
||||
<a href={story.open_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
|
||||
Open
|
||||
</a>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-slate-400">
|
||||
No public view
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{stories?.prev_page_url || stories?.next_page_url ? (
|
||||
<div className="mt-8 flex items-center justify-between gap-3 rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-slate-400">
|
||||
Showing page {stories?.current_page || 1} of {stories?.last_page || 1}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{stories?.prev_page_url ? (
|
||||
<button type="button" onClick={() => router.get(stories.prev_page_url, {}, { preserveScroll: true })} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
|
||||
Previous
|
||||
</button>
|
||||
) : null}
|
||||
{stories?.next_page_url ? (
|
||||
<button type="button" onClick={() => router.get(stories.next_page_url, {}, { preserveScroll: true })} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
|
||||
Next
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
233
resources/js/Pages/Moderation/UsernameQueue.jsx
Normal file
233
resources/js/Pages/Moderation/UsernameQueue.jsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import React from 'react'
|
||||
import { Head, router } from '@inertiajs/react'
|
||||
import AdminLayout from '../../Layouts/AdminLayout'
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeStyle: 'short' }).format(date)
|
||||
}
|
||||
|
||||
function StatCard({ label, value, tone = 'sky' }) {
|
||||
const tones = {
|
||||
sky: 'border-sky-300/15 bg-sky-400/10 text-sky-100',
|
||||
amber: 'border-amber-300/15 bg-amber-400/10 text-amber-100',
|
||||
emerald: 'border-emerald-300/15 bg-emerald-400/10 text-emerald-100',
|
||||
rose: 'border-rose-300/15 bg-rose-400/10 text-rose-100',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5 backdrop-blur-sm">
|
||||
<div className={`inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${tones[tone] || tones.sky}`}>{label}</div>
|
||||
<div className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white">{Number(value || 0).toLocaleString()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function badgeTone(status) {
|
||||
if (status === 'approved') return 'border-emerald-300/20 bg-emerald-400/12 text-emerald-100'
|
||||
if (status === 'rejected') return 'border-rose-300/20 bg-rose-400/12 text-rose-100'
|
||||
return 'border-amber-300/20 bg-amber-400/12 text-amber-100'
|
||||
}
|
||||
|
||||
async function requestJson(url, body) {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: JSON.stringify(body || {}),
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || 'Request failed.')
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
export default function UsernameQueue({ title, requests, stats, filters, options, endpoints }) {
|
||||
const [state, setState] = React.useState(filters || { q: '', status: 'pending' })
|
||||
const [notes, setNotes] = React.useState({})
|
||||
const [busy, setBusy] = React.useState('')
|
||||
const [notice, setNotice] = React.useState('')
|
||||
const [error, setError] = React.useState('')
|
||||
|
||||
React.useEffect(() => {
|
||||
setState(filters || { q: '', status: 'pending' })
|
||||
}, [filters])
|
||||
|
||||
function update(key, value) {
|
||||
setState((current) => ({ ...current, [key]: value }))
|
||||
}
|
||||
|
||||
function applyFilters(event) {
|
||||
event.preventDefault()
|
||||
router.get(endpoints.index, state, { preserveState: true, replace: true, preserveScroll: true })
|
||||
}
|
||||
|
||||
async function moderate(item, action) {
|
||||
const actionKey = `${action}-${item.id}`
|
||||
setBusy(actionKey)
|
||||
setError('')
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const payload = await requestJson(action === 'approve' ? item.approve_url : item.reject_url, {
|
||||
note: String(notes[item.id] || ''),
|
||||
})
|
||||
|
||||
setNotice(payload.message || `Request ${action}d.`)
|
||||
router.reload({ only: ['requests', 'stats'], preserveScroll: true })
|
||||
} catch (requestError) {
|
||||
setError(requestError.message || 'Request failed.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const items = requests?.data || []
|
||||
|
||||
return (
|
||||
<AdminLayout title={title || 'Username Queue'} subtitle="Review username changes in the same moderation surface as the rest of Skinbase.">
|
||||
<Head title="Moderation · Username Queue" />
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(244,114,182,0.12),transparent_34%),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.32)]">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-rose-200/80">Moderation surface</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Username Queue</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">Review pending username requests before they are applied to the account or history trail.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 text-xs uppercase tracking-[0.16em] text-slate-300">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2">Page {requests?.current_page || 1} / {requests?.last_page || 1}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2">{Number(requests?.total || 0).toLocaleString()} requests</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard label="Total" value={stats?.total} />
|
||||
<StatCard label="Pending" value={stats?.pending} tone="amber" />
|
||||
<StatCard label="Approved" value={stats?.approved} tone="emerald" />
|
||||
<StatCard label="Rejected" value={stats?.rejected} tone="rose" />
|
||||
</div>
|
||||
|
||||
<form onSubmit={applyFilters} className="mt-6 grid gap-3 lg:grid-cols-[2fr_1fr_auto]">
|
||||
<input
|
||||
value={state.q || ''}
|
||||
onChange={(event) => update('q', event.target.value)}
|
||||
placeholder="Search requested or current username"
|
||||
className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
|
||||
/>
|
||||
<select
|
||||
value={state.status || 'pending'}
|
||||
onChange={(event) => update('status', event.target.value)}
|
||||
className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
|
||||
>
|
||||
{(options?.statuses || []).map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" className="rounded-2xl border border-white/10 bg-white/[0.06] px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.1]">Apply</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{notice ? <div className="mt-6 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-50">{notice}</div> : null}
|
||||
{error ? <div className="mt-6 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
|
||||
|
||||
<div className="mt-8 space-y-4">
|
||||
{items.length === 0 ? (
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] px-6 py-12 text-center text-slate-300">No username requests matched the current filters.</div>
|
||||
) : items.map((item) => (
|
||||
<article key={item.id} className="rounded-[28px] border border-white/10 bg-[#08111d] p-5 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className={`inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${badgeTone(item.status)}`}>
|
||||
{String(item.status || 'pending').replaceAll('_', ' ')}
|
||||
</span>
|
||||
{item.context ? (
|
||||
<span className="inline-flex rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">
|
||||
{item.context.replaceAll('_', ' ')}
|
||||
</span>
|
||||
) : null}
|
||||
{item.similar_to ? (
|
||||
<span className="inline-flex rounded-full border border-amber-300/20 bg-amber-400/12 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100">
|
||||
Similar to {item.similar_to}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.03em] text-white">{item.requested_username}</h2>
|
||||
<p className="mt-2 text-sm text-slate-300">
|
||||
{item.current_username ? `Current: @${item.current_username}` : 'No current username'}
|
||||
{item.current_name ? ` • ${item.current_name}` : ''}
|
||||
</p>
|
||||
<p className="mt-2 text-xs uppercase tracking-[0.16em] text-slate-400">
|
||||
Requested {formatDateTime(item.created_at)}
|
||||
{item.reviewed_at ? ` • reviewed ${formatDateTime(item.reviewed_at)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-xl space-y-3">
|
||||
<textarea
|
||||
value={notes[item.id] || ''}
|
||||
onChange={(event) => setNotes((current) => ({ ...current, [item.id]: event.target.value }))}
|
||||
placeholder="Optional moderation note"
|
||||
rows={3}
|
||||
className="w-full rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moderate(item, 'approve')}
|
||||
disabled={busy === `approve-${item.id}`}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-emerald-300/20 bg-emerald-400/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-100 transition hover:bg-emerald-400/18 disabled:opacity-60"
|
||||
>
|
||||
{busy === `approve-${item.id}` ? 'Saving…' : 'Approve'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moderate(item, 'reject')}
|
||||
disabled={busy === `reject-${item.id}`}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-400/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-rose-100 transition hover:bg-rose-400/18 disabled:opacity-60"
|
||||
>
|
||||
{busy === `reject-${item.id}` ? 'Saving…' : 'Reject'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{requests?.prev_page_url || requests?.next_page_url ? (
|
||||
<div className="mt-8 flex items-center justify-between gap-3 rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-slate-400">
|
||||
Showing page {requests?.current_page || 1} of {requests?.last_page || 1}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{requests?.prev_page_url ? (
|
||||
<button type="button" onClick={() => router.get(requests.prev_page_url, {}, { preserveScroll: true })} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
|
||||
Previous
|
||||
</button>
|
||||
) : null}
|
||||
{requests?.next_page_url ? (
|
||||
<button type="button" onClick={() => router.get(requests.next_page_url, {}, { preserveScroll: true })} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
|
||||
Next
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user