| // src/components/admin/AdminUsersTable.tsx | |
| import { useState } from "react"; | |
| import { Trash2, Shield, User, ShieldAlert, Check, X, RefreshCw } from "lucide-react"; | |
| import { toast } from "react-toastify"; | |
| interface UserItem { | |
| id: number; | |
| username: string; | |
| email: string; | |
| fullName?: string; | |
| role: string; | |
| active: boolean; | |
| createdAt: string; | |
| } | |
| interface AdminUsersTableProps { | |
| users: UserItem[]; | |
| onToggleActive: (id: number, current: boolean) => Promise<void>; | |
| onDelete: (id: number) => Promise<void>; | |
| onChangeRole?: (id: number, role: string) => Promise<void>; | |
| } | |
| const ROLES = ["user", "author", "moderator", "admin"]; | |
| export function AdminUsersTable({ users, onToggleActive, onDelete, onChangeRole }: AdminUsersTableProps) { | |
| const [loadingStates, setLoadingStates] = useState<Record<number, boolean>>({}); | |
| const handleToggle = async (id: number, currentActive: boolean) => { | |
| setLoadingStates((prev) => ({ ...prev, [id]: true })); | |
| try { | |
| await onToggleActive(id, !currentActive); | |
| toast.success(`User status updated successfully`); | |
| } catch (err) { | |
| toast.error("Failed to update status"); | |
| } finally { | |
| setLoadingStates((prev) => ({ ...prev, [id]: false })); | |
| } | |
| }; | |
| const handleRoleChange = async (id: number, newRole: string) => { | |
| if (!onChangeRole) return; | |
| setLoadingStates((prev) => ({ ...prev, [id]: true })); | |
| try { | |
| await onChangeRole(id, newRole); | |
| toast.success(`User role updated to ${newRole}`); | |
| } catch (err) { | |
| toast.error("Failed to update user role"); | |
| } finally { | |
| setLoadingStates((prev) => ({ ...prev, [id]: false })); | |
| } | |
| }; | |
| const handleDelete = async (id: number) => { | |
| setLoadingStates((prev) => ({ ...prev, [id]: true })); | |
| try { | |
| await onDelete(id); | |
| toast.success("User deleted successfully"); | |
| } catch (err) { | |
| // toast alert is handled inside hook or default | |
| } finally { | |
| setLoadingStates((prev) => ({ ...prev, [id]: false })); | |
| } | |
| }; | |
| if (!users || users.length === 0) { | |
| return ( | |
| <div className="text-center py-12 bg-white/40 dark:bg-gray-900/40 backdrop-blur-md border border-gray-200 dark:border-gray-800 rounded-2xl text-gray-500"> | |
| No registered users found. | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="bg-white/40 dark:bg-gray-900/40 backdrop-blur-md border border-gray-200 dark:border-gray-800 rounded-2xl shadow-xl overflow-hidden"> | |
| <div className="overflow-x-auto"> | |
| <table className="w-full text-left border-collapse"> | |
| <thead> | |
| <tr className="bg-gray-100/50 dark:bg-gray-950/50 border-b border-gray-200 dark:border-gray-800"> | |
| <th className="p-4 text-xs font-semibold uppercase tracking-wider text-gray-500">User info</th> | |
| <th className="p-4 text-xs font-semibold uppercase tracking-wider text-gray-500">Role</th> | |
| <th className="p-4 text-xs font-semibold uppercase tracking-wider text-gray-500">Status</th> | |
| <th className="p-4 text-xs font-semibold uppercase tracking-wider text-gray-500">Joined</th> | |
| <th className="p-4 text-xs font-semibold uppercase tracking-wider text-gray-500 text-right">Actions</th> | |
| </tr> | |
| </thead> | |
| <tbody className="divide-y divide-gray-100 dark:divide-gray-800/60"> | |
| {users.map((user) => { | |
| const isLoading = loadingStates[user.id] || false; | |
| return ( | |
| <tr | |
| key={user.id} | |
| className="hover:bg-gray-50/40 dark:hover:bg-gray-800/20 transition duration-150" | |
| > | |
| {/* User info */} | |
| <td className="p-4"> | |
| <div className="flex items-center gap-3"> | |
| <div className="w-10 h-10 rounded-full bg-gradient-to-tr from-primary/10 to-violet-500/10 dark:from-primary/20 dark:to-violet-500/20 text-primary flex items-center justify-center font-bold text-sm shrink-0"> | |
| {user.username.slice(0, 2).toUpperCase()} | |
| </div> | |
| <div className="min-w-0"> | |
| <div className="font-semibold text-sm truncate text-gray-800 dark:text-gray-200"> | |
| {user.fullName || user.username} | |
| </div> | |
| <div className="text-xs text-gray-500 truncate"> | |
| @{user.username} · {user.email} | |
| </div> | |
| </div> | |
| </div> | |
| </td> | |
| {/* Role dropdown */} | |
| <td className="p-4"> | |
| <div className="flex items-center gap-1.5"> | |
| {user.role === "admin" ? ( | |
| <ShieldAlert className="w-3.5 h-3.5 text-red-500" /> | |
| ) : user.role === "moderator" ? ( | |
| <Shield className="w-3.5 h-3.5 text-amber-500" /> | |
| ) : ( | |
| <User className="w-3.5 h-3.5 text-blue-500" /> | |
| )} | |
| <select | |
| value={user.role} | |
| disabled={isLoading || !onChangeRole} | |
| onChange={(e) => handleRoleChange(user.id, e.target.value)} | |
| className="bg-transparent text-sm border-none focus:ring-0 text-gray-800 dark:text-gray-250 cursor-pointer font-medium hover:underline" | |
| > | |
| {ROLES.map((r) => ( | |
| <option key={r} value={r} className="bg-white dark:bg-gray-900 text-gray-850 dark:text-gray-150"> | |
| {r.charAt(0).toUpperCase() + r.slice(1)} | |
| </option> | |
| ))} | |
| </select> | |
| </div> | |
| </td> | |
| {/* Status Toggle */} | |
| <td className="p-4"> | |
| <button | |
| onClick={() => handleToggle(user.id, user.active)} | |
| disabled={isLoading} | |
| className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold transition ${ | |
| user.active | |
| ? "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400" | |
| : "bg-rose-100 text-rose-700 dark:bg-rose-500/10 dark:text-rose-400" | |
| }`} | |
| > | |
| {user.active ? <Check className="w-3 h-3" /> : <X className="w-3 h-3" />} | |
| {user.active ? "Active" : "Locked"} | |
| </button> | |
| </td> | |
| {/* Joined Date */} | |
| <td className="p-4 text-xs text-gray-500 font-medium"> | |
| {user.createdAt ? new Date(user.createdAt).toLocaleDateString() : "N/A"} | |
| </td> | |
| {/* Actions */} | |
| <td className="p-4 text-right"> | |
| <div className="flex justify-end gap-2"> | |
| {isLoading ? ( | |
| <RefreshCw className="w-4 h-4 text-gray-400 animate-spin mr-2" /> | |
| ) : ( | |
| <button | |
| onClick={() => handleDelete(user.id)} | |
| className="p-1.5 text-gray-400 hover:text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-500/10 rounded-lg transition" | |
| title="Delete User" | |
| > | |
| <Trash2 className="w-4 h-4" /> | |
| </button> | |
| )} | |
| </div> | |
| </td> | |
| </tr> | |
| ); | |
| })} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| ); | |
| } |
Xet Storage Details
- Size:
- 7.87 kB
- Xet hash:
- 0d6b9dfbe75fe4e6d7c863b8fb587fa07e94abf7773e0c217ad75ddfd60cfb4f
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.