Files
SkinbaseNova/resources/js/Pages/Admin/Users/Index.jsx

220 lines
9.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}