220 lines
9.1 KiB
JavaScript
220 lines
9.1 KiB
JavaScript
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>
|
||
)
|
||
}
|