Improve admin panel UX with toasts, evolution CRUD, sorting, drag-and-drop, and responsive layout
Add sonner toast notifications to all mutations, evolution management backend (CRUD endpoints with search/pagination) and frontend (form modal with pokemon selector, paginated list page), sortable AdminTable columns (Region/Gen/Year on Games), drag-and-drop route reordering via @dnd-kit, skeleton loading states, card-styled table wrappers, and responsive mobile nav in AdminLayout. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { type ReactNode, useMemo, useState } from 'react'
|
||||
|
||||
export interface Column<T> {
|
||||
header: string
|
||||
accessor: (row: T) => ReactNode
|
||||
className?: string
|
||||
sortKey?: (row: T) => string | number
|
||||
}
|
||||
|
||||
type SortDir = 'asc' | 'desc'
|
||||
|
||||
interface AdminTableProps<T> {
|
||||
columns: Column<T>[]
|
||||
data: T[]
|
||||
@@ -23,56 +26,127 @@ export function AdminTable<T>({
|
||||
onRowClick,
|
||||
keyFn,
|
||||
}: AdminTableProps<T>) {
|
||||
const [sortCol, setSortCol] = useState<string | null>(null)
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
||||
|
||||
const handleSort = (header: string) => {
|
||||
if (sortCol === header) {
|
||||
if (sortDir === 'asc') {
|
||||
setSortDir('desc')
|
||||
} else {
|
||||
// Third click: clear sort
|
||||
setSortCol(null)
|
||||
setSortDir('asc')
|
||||
}
|
||||
} else {
|
||||
setSortCol(header)
|
||||
setSortDir('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const sortedData = useMemo(() => {
|
||||
if (!sortCol) return data
|
||||
const col = columns.find((c) => c.header === sortCol)
|
||||
if (!col?.sortKey) return data
|
||||
const key = col.sortKey
|
||||
const sorted = [...data].sort((a, b) => {
|
||||
const va = key(a)
|
||||
const vb = key(b)
|
||||
if (va < vb) return -1
|
||||
if (va > vb) return 1
|
||||
return 0
|
||||
})
|
||||
return sortDir === 'desc' ? sorted.reverse() : sorted
|
||||
}, [data, sortCol, sortDir, columns])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
Loading...
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.header}
|
||||
className={`px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider ${col.className ?? ''}`}
|
||||
>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
{columns.map((col) => (
|
||||
<td key={col.header} className={`px-4 py-3 ${col.className ?? ''}`}>
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.header}
|
||||
className={`px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider ${col.className ?? ''}`}
|
||||
>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{data.map((row) => (
|
||||
<tr
|
||||
key={keyFn(row)}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
className={onRowClick ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800' : ''}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.header}
|
||||
className={`px-4 py-3 text-sm whitespace-nowrap ${col.className ?? ''}`}
|
||||
>
|
||||
{col.accessor(row)}
|
||||
</td>
|
||||
))}
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
{columns.map((col) => {
|
||||
const sortable = !!col.sortKey
|
||||
const active = sortCol === col.header
|
||||
return (
|
||||
<th
|
||||
key={col.header}
|
||||
onClick={sortable ? () => handleSort(col.header) : undefined}
|
||||
className={`px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider ${col.className ?? ''} ${sortable ? 'cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-200' : ''}`}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{col.header}
|
||||
{sortable && active && (
|
||||
<span className="text-blue-500">
|
||||
{sortDir === 'asc' ? '\u2191' : '\u2193'}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{sortedData.map((row) => (
|
||||
<tr
|
||||
key={keyFn(row)}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
className={onRowClick ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800' : ''}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.header}
|
||||
className={`px-4 py-3 text-sm whitespace-nowrap ${col.className ?? ''}`}
|
||||
>
|
||||
{col.accessor(row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user