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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user