Guest WiFi (Hotspot): one-click captive-portal wizard that creates the guest SSID (virtual AP per radio), VLAN/bridge segregation + L3 VLAN interface, pool, DHCP, portal, rate-limited guest profile, and masquerade; vouchers with limits + printable sheets; live guest sessions; walled garden. Network intelligence: per-client device fingerprinting (vendor/hostname → category) with a persisted per-client override on the detail page; rogue/neighbor AP detection from stored AP scans (evil-twin flagging); Mist-style anomaly insights (client-count/CPU vs same-hour 14-day baseline + error bursts) in the Operations engine. Firmware orchestration: staged wave-based RouterOS rollouts (canary first) with pre-upgrade backup, reboot-ride-out, version verification, halt-on-failure, and scheduling; live per-device progress + cancel. Platform & automation: scoped API tokens (mtm_, read/write, hashed at rest, Bearer auth in requireAuth); outbound webhooks (12 events, HMAC-signed) fired from the alert pipeline + firmware orchestrator; scheduled email reports. api-ssl: RouterOSClient negotiates TLS on port 8729 and accepts RouterOS's default self-signed certs (rejectUnauthorized:false), so SSL-secured API devices connect without manual cert trust. Fixes #57. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
543 lines
23 KiB
TypeScript
543 lines
23 KiB
TypeScript
import { useState, useRef, useEffect } from 'react';
|
||
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import {
|
||
Search, Wifi, Network, Users, X, Pencil, Trash2, ChevronUp, ChevronDown, ChevronsUpDown,
|
||
ChevronLeft, ChevronRight, RefreshCw,
|
||
} from 'lucide-react';
|
||
import { CATEGORY_META } from '../utils/clientCategories';
|
||
import { clientsApi } from '../services/api';
|
||
import type { Client } from '../types';
|
||
import { useCanWrite } from '../hooks/useCanWrite';
|
||
import { useSocket } from '../hooks/useSocket';
|
||
import { formatDistanceToNow } from 'date-fns';
|
||
import clsx from 'clsx';
|
||
|
||
|
||
const REFRESH_OPTIONS = [
|
||
{ label: 'Not Updating', value: null },
|
||
{ label: '30 seconds', value: 30_000 },
|
||
{ label: '1 minute', value: 60_000 },
|
||
{ label: '3 minutes', value: 180_000 },
|
||
] as const;
|
||
|
||
const STORAGE_KEY = 'clients-refresh-interval';
|
||
|
||
const PAGE_SIZE_OPTIONS = [25, 50, 100, 200] as const;
|
||
const PAGE_SIZE_KEY = 'clients-page-size';
|
||
|
||
function readStoredPageSize(): number {
|
||
const n = Number(localStorage.getItem(PAGE_SIZE_KEY));
|
||
return (PAGE_SIZE_OPTIONS as readonly number[]).includes(n) ? n : 50;
|
||
}
|
||
|
||
// Build a compact list of page numbers (1-based) with ellipses, always keeping
|
||
// the first, last, current page and its immediate neighbours visible.
|
||
function getPageList(current0: number, totalPages: number): (number | 'ellipsis')[] {
|
||
const cur = current0 + 1;
|
||
const wanted = new Set<number>([1, totalPages]);
|
||
for (let i = cur - 1; i <= cur + 1; i++) {
|
||
if (i >= 1 && i <= totalPages) wanted.add(i);
|
||
}
|
||
const sorted = Array.from(wanted).sort((a, b) => a - b);
|
||
const out: (number | 'ellipsis')[] = [];
|
||
let prev = 0;
|
||
for (const p of sorted) {
|
||
if (prev && p - prev > 1) out.push('ellipsis');
|
||
out.push(p);
|
||
prev = p;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function CategoryIcon({ category }: { category?: string }) {
|
||
const meta = category && category !== 'unknown' ? CATEGORY_META[category] : undefined;
|
||
if (!meta) return null;
|
||
const Icon = meta.icon;
|
||
return (
|
||
<span title={meta.label} className="flex-shrink-0 text-gray-400 dark:text-slate-500">
|
||
<Icon className="w-3.5 h-3.5" />
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function formatDataBytes(bytes: number): string {
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||
}
|
||
|
||
function readStoredInterval(): number | null {
|
||
const raw = localStorage.getItem(STORAGE_KEY);
|
||
if (raw === 'null') return null;
|
||
const n = Number(raw);
|
||
return REFRESH_OPTIONS.some((o) => o.value === n) ? n : 30_000;
|
||
}
|
||
|
||
function ClientModal({
|
||
client,
|
||
onClose,
|
||
}: {
|
||
client: Client;
|
||
onClose: () => void;
|
||
}) {
|
||
const queryClient = useQueryClient();
|
||
const [name, setName] = useState(client.custom_name || '');
|
||
|
||
const mutation = useMutation({
|
||
mutationFn: () => clientsApi.updateHostname(client.mac_address, name),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['clients'] });
|
||
onClose();
|
||
},
|
||
});
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-md p-6">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="text-base font-semibold text-gray-900 dark:text-white">Edit Client</h2>
|
||
<button onClick={onClose} className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300">
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-3 text-sm text-gray-500 dark:text-slate-400 mb-4">
|
||
<div className="flex justify-between">
|
||
<span>MAC</span>
|
||
<span className="font-mono text-gray-800 dark:text-white">{client.mac_address}</span>
|
||
</div>
|
||
{client.vendor && (
|
||
<div className="flex justify-between">
|
||
<span>Vendor</span>
|
||
<span className="text-gray-800 dark:text-white">{client.vendor}</span>
|
||
</div>
|
||
)}
|
||
{client.ip_address && (
|
||
<div className="flex justify-between">
|
||
<span>IP</span>
|
||
<span className="font-mono text-gray-800 dark:text-white">{client.ip_address}</span>
|
||
</div>
|
||
)}
|
||
{client.interface_name && (
|
||
<div className="flex justify-between">
|
||
<span>Port</span>
|
||
<span className="font-mono text-gray-800 dark:text-white">{client.interface_name}</span>
|
||
</div>
|
||
)}
|
||
{client.hostname && (
|
||
<div className="flex justify-between">
|
||
<span>Discovered Hostname</span>
|
||
<span className="font-mono text-gray-800 dark:text-white">{client.hostname}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1">
|
||
Custom Name
|
||
</label>
|
||
<input
|
||
type="text"
|
||
className="input w-full mb-1"
|
||
placeholder={client.hostname || 'e.g. Office Printer'}
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') mutation.mutate(); }}
|
||
autoFocus
|
||
/>
|
||
<p className="text-xs text-gray-400 dark:text-slate-500 mb-4">
|
||
Overrides the auto-discovered hostname. Persists even when the client is offline.
|
||
</p>
|
||
|
||
<div className="flex justify-end gap-2">
|
||
<button onClick={onClose} className="btn-secondary">Cancel</button>
|
||
<button
|
||
onClick={() => mutation.mutate()}
|
||
disabled={mutation.isPending}
|
||
className="btn-primary"
|
||
>
|
||
{mutation.isPending ? 'Saving…' : 'Save'}
|
||
</button>
|
||
</div>
|
||
{mutation.isError && (
|
||
<p className="mt-2 text-xs text-red-500">Failed to save name.</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function ClientsPage() {
|
||
const queryClient = useQueryClient();
|
||
const navigate = useNavigate();
|
||
const canWrite = useCanWrite();
|
||
const [search, setSearch] = useState('');
|
||
const [showAll, setShowAll] = useState(false);
|
||
const [page, setPage] = useState(0);
|
||
const [editingClient, setEditingClient] = useState<Client | null>(null);
|
||
const [sortCol, setSortCol] = useState<string>('last_seen');
|
||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
||
const [refreshInterval, setRefreshInterval] = useState<number | null>(readStoredInterval);
|
||
const refreshIntervalRef = useRef(refreshInterval);
|
||
useEffect(() => { refreshIntervalRef.current = refreshInterval; }, [refreshInterval]);
|
||
const [pageSize, setPageSize] = useState(readStoredPageSize);
|
||
|
||
const handleIntervalChange = (val: number | null) => {
|
||
setRefreshInterval(val);
|
||
refreshIntervalRef.current = val;
|
||
localStorage.setItem(STORAGE_KEY, String(val));
|
||
};
|
||
|
||
const handlePageSizeChange = (n: number) => {
|
||
setPageSize(n);
|
||
setPage(0);
|
||
localStorage.setItem(PAGE_SIZE_KEY, String(n));
|
||
};
|
||
|
||
// Sorting is server-side (across the whole dataset, not just the current
|
||
// page), so changing the sort returns to page 1 and refetches.
|
||
const toggleSort = (col: string) => {
|
||
if (sortCol === col) setSortDir((d) => d === 'asc' ? 'desc' : 'asc');
|
||
else { setSortCol(col); setSortDir('asc'); }
|
||
setPage(0);
|
||
};
|
||
|
||
const { data, isLoading } = useQuery({
|
||
queryKey: ['clients', { search, showAll, page, pageSize, sortCol, sortDir }],
|
||
queryFn: () =>
|
||
clientsApi
|
||
.list({ search: search || undefined, active: showAll ? undefined : true, limit: pageSize, offset: page * pageSize, sort: sortCol, dir: sortDir })
|
||
.then((r) => r.data),
|
||
refetchInterval: refreshInterval ?? false,
|
||
// Keep the current rows on screen while a sort/page/search refetch is in
|
||
// flight so the table doesn't blank out and jump.
|
||
placeholderData: keepPreviousData,
|
||
});
|
||
|
||
const [purgeResult, setPurgeResult] = useState('');
|
||
const purgeMutation = useMutation({
|
||
mutationFn: () => clientsApi.purgeStale(),
|
||
onSuccess: (r) => {
|
||
queryClient.invalidateQueries({ queryKey: ['clients'] });
|
||
setPurgeResult(r.data.message);
|
||
setTimeout(() => setPurgeResult(''), 5000);
|
||
},
|
||
});
|
||
|
||
useSocket({
|
||
'clients:updated': () => {
|
||
if (refreshIntervalRef.current !== null) {
|
||
queryClient.invalidateQueries({ queryKey: ['clients'] });
|
||
}
|
||
},
|
||
});
|
||
|
||
// Server returns rows already sorted + paginated for the active sort column.
|
||
const clients = data?.clients ?? [];
|
||
const total = data?.total ?? 0;
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{editingClient && (
|
||
<ClientModal client={editingClient} onClose={() => setEditingClient(null)} />
|
||
)}
|
||
|
||
<div className="flex items-center justify-between">
|
||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||
Clients
|
||
{total > 0 && (
|
||
<span className="ml-2 text-sm font-normal text-gray-500 dark:text-slate-400">
|
||
({total} {showAll ? 'total' : 'online'})
|
||
</span>
|
||
)}
|
||
</h1>
|
||
</div>
|
||
|
||
{/* Filters */}
|
||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||
<div className="relative w-full sm:flex-1 sm:max-w-sm">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||
<input
|
||
type="text"
|
||
className="input pl-9"
|
||
placeholder="Search hostname, MAC, IP, or vendor…"
|
||
value={search}
|
||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||
/>
|
||
</div>
|
||
<button
|
||
onClick={() => { setShowAll((s) => !s); setPage(0); }}
|
||
className={clsx(
|
||
'px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||
showAll
|
||
? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 border border-amber-300 dark:border-amber-700'
|
||
: 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300'
|
||
)}
|
||
>
|
||
{showAll ? 'Active Only' : 'Show Inactive'}
|
||
</button>
|
||
{canWrite && (
|
||
<button
|
||
onClick={() => {
|
||
if (confirm('Purge all inactive clients older than the configured retention period?')) {
|
||
purgeMutation.mutate();
|
||
}
|
||
}}
|
||
disabled={purgeMutation.isPending}
|
||
className="btn-secondary flex items-center gap-1.5 text-sm"
|
||
title="Delete inactive client records older than the retention period (set in Settings)"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
Purge Stale
|
||
</button>
|
||
)}
|
||
{purgeResult && (
|
||
<span className="text-xs text-green-600 dark:text-green-400">{purgeResult}</span>
|
||
)}
|
||
<div className="flex items-center gap-1.5 ml-auto">
|
||
<RefreshCw className="w-3.5 h-3.5 text-ink-4" />
|
||
<select
|
||
value={String(refreshInterval)}
|
||
onChange={(e) => {
|
||
const raw = e.target.value;
|
||
handleIntervalChange(raw === 'null' ? null : Number(raw));
|
||
}}
|
||
className="text-sm rounded-lg border border-line bg-surface-2 text-ink px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-accent"
|
||
>
|
||
{REFRESH_OPTIONS.map((o) => (
|
||
<option key={String(o.value)} value={String(o.value)}>
|
||
{o.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<div className="flex items-center justify-center h-48 text-gray-400">Loading...</div>
|
||
) : clients.length === 0 ? (
|
||
<div className="card p-12 flex flex-col items-center gap-3 text-center">
|
||
<Users className="w-12 h-12 text-gray-300 dark:text-slate-600" />
|
||
<p className="text-gray-500 dark:text-slate-400">
|
||
{showAll ? 'No clients found' : 'No active clients detected'}
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="card overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm table-fixed">
|
||
<colgroup>
|
||
<col style={{ width: '22%' }} />
|
||
<col style={{ width: '12%' }} />
|
||
<col style={{ width: '13%' }} />
|
||
<col style={{ width: '11%' }} />
|
||
<col style={{ width: '7%' }} />
|
||
<col style={{ width: '13%' }} />
|
||
<col style={{ width: '10%' }} />
|
||
<col style={{ width: '12%' }} />
|
||
{showAll && <col style={{ width: '6%' }} />}
|
||
</colgroup>
|
||
<thead>
|
||
<tr className="border-b border-gray-200 dark:border-slate-700">
|
||
{([
|
||
{ col: 'hostname', label: 'Host / Vendor / MAC', align: 'left' },
|
||
{ col: 'ip_address', label: 'IP Address', align: 'left' },
|
||
{ col: 'client_type', label: 'Type', align: 'left' },
|
||
{ col: 'interface_name', label: 'Port', align: 'left' },
|
||
{ col: 'vlan_id', label: 'VLAN', align: 'left' },
|
||
{ col: 'device_name', label: 'Device', align: 'left' },
|
||
{ col: 'traffic_today_bytes', label: 'Data (today)', align: 'left' },
|
||
{ col: 'last_seen', label: 'Last Seen', align: 'left' },
|
||
] as { col: string; label: string; align: 'left' | 'right' }[]).map(({ col, label, align }) => (
|
||
<th
|
||
key={col}
|
||
className={clsx('table-header px-4 py-2.5 cursor-pointer select-none whitespace-nowrap', `text-${align}`)}
|
||
onClick={() => toggleSort(col)}
|
||
>
|
||
<span className="inline-flex items-center gap-1">
|
||
{label}
|
||
{sortCol === col ? (
|
||
sortDir === 'asc'
|
||
? <ChevronUp className="w-3 h-3 text-blue-500" />
|
||
: <ChevronDown className="w-3 h-3 text-blue-500" />
|
||
) : (
|
||
<ChevronsUpDown className="w-3 h-3 text-gray-300 dark:text-slate-600" />
|
||
)}
|
||
</span>
|
||
</th>
|
||
))}
|
||
{showAll && (
|
||
<th className="table-header px-4 py-2.5 text-center">Active</th>
|
||
)}
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||
{clients.map((client) => (
|
||
<tr
|
||
key={client.id}
|
||
className="hover:bg-gray-50 dark:hover:bg-slate-700/30"
|
||
>
|
||
<td className="px-4 py-2.5">
|
||
<div className="flex items-center gap-1.5 min-w-0">
|
||
<CategoryIcon category={client.device_category} />
|
||
<button
|
||
onClick={() => navigate(`/clients/${encodeURIComponent(client.mac_address)}`)}
|
||
className="font-medium text-blue-600 dark:text-blue-400 hover:underline text-left truncate"
|
||
>
|
||
{client.custom_name || client.hostname || (
|
||
<span className="text-gray-400 dark:text-slate-500 italic">No hostname</span>
|
||
)}
|
||
</button>
|
||
{client.custom_name && (
|
||
<span className="text-xs text-blue-500 dark:text-blue-400 ml-1 flex-shrink-0">custom</span>
|
||
)}
|
||
{canWrite && (
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); setEditingClient(client); }}
|
||
className="p-0.5 rounded hover:bg-gray-100 dark:hover:bg-slate-700 flex-shrink-0"
|
||
title="Edit name"
|
||
>
|
||
<Pencil className="w-3 h-3 text-gray-300 dark:text-slate-600 hover:text-gray-500 dark:hover:text-slate-400" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
{client.vendor && (
|
||
<div className="text-xs text-gray-400 dark:text-slate-500 truncate">{client.vendor}</div>
|
||
)}
|
||
<div className="text-xs font-mono text-gray-400 dark:text-slate-500 truncate">
|
||
{client.mac_address}
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-2.5 font-mono text-gray-600 dark:text-slate-400 truncate">
|
||
{client.ip_address || '—'}
|
||
</td>
|
||
<td className="px-4 py-2.5">
|
||
<span
|
||
className={clsx(
|
||
'inline-flex items-center gap-1 text-xs font-medium',
|
||
client.client_type === 'wireless'
|
||
? 'text-purple-600 dark:text-purple-400'
|
||
: 'text-blue-600 dark:text-blue-400'
|
||
)}
|
||
>
|
||
{client.client_type === 'wireless' ? (
|
||
<Wifi className="w-3 h-3" />
|
||
) : (
|
||
<Network className="w-3 h-3" />
|
||
)}
|
||
{client.client_type}
|
||
{client.signal_strength != null && (
|
||
<span className="text-gray-400 ml-1">({client.signal_strength} dBm)</span>
|
||
)}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400 truncate">
|
||
{client.interface_name || '—'}
|
||
</td>
|
||
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400">
|
||
{client.vlan_id != null
|
||
? <span className="px-1.5 py-0.5 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded font-mono">{client.vlan_id}</span>
|
||
: '—'}
|
||
</td>
|
||
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400 truncate">
|
||
{client.device_name || '—'}
|
||
</td>
|
||
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400 whitespace-nowrap">
|
||
{client.traffic_today_bytes ? formatDataBytes(client.traffic_today_bytes) : '—'}
|
||
</td>
|
||
<td className="px-4 py-2.5 text-xs text-gray-400 dark:text-slate-500">
|
||
{client.last_seen
|
||
? formatDistanceToNow(new Date(client.last_seen), { addSuffix: true })
|
||
: '—'}
|
||
</td>
|
||
{showAll && (
|
||
<td className="px-4 py-2.5 text-center">
|
||
<span
|
||
className={clsx(
|
||
'w-2 h-2 rounded-full inline-block',
|
||
client.active ? 'bg-green-500' : 'bg-gray-300 dark:bg-slate-600'
|
||
)}
|
||
/>
|
||
</td>
|
||
)}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Pagination */}
|
||
{total > 0 && (() => {
|
||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||
return (
|
||
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 text-sm">
|
||
{/* Entries-per-page + range summary */}
|
||
<div className="flex items-center gap-2 text-gray-500 dark:text-slate-400">
|
||
<span>Show</span>
|
||
<select
|
||
value={pageSize}
|
||
onChange={(e) => handlePageSizeChange(Number(e.target.value))}
|
||
className="text-sm rounded-lg border border-line bg-surface-2 text-ink px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-accent"
|
||
>
|
||
{PAGE_SIZE_OPTIONS.map((n) => (
|
||
<option key={n} value={n}>{n}</option>
|
||
))}
|
||
</select>
|
||
<span>entries</span>
|
||
<span className="ml-1 hidden sm:inline">
|
||
({page * pageSize + 1}–{Math.min((page + 1) * pageSize, total)} of {total})
|
||
</span>
|
||
</div>
|
||
|
||
{/* Numbered page selection */}
|
||
{totalPages > 1 && (
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||
disabled={page === 0}
|
||
aria-label="Previous page"
|
||
className="inline-flex items-center justify-center w-8 h-8 rounded-lg text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||
>
|
||
<ChevronLeft className="w-4 h-4" />
|
||
</button>
|
||
{getPageList(page, totalPages).map((item, i) =>
|
||
item === 'ellipsis' ? (
|
||
<span key={`e${i}`} className="px-1.5 text-gray-400 dark:text-slate-500 select-none">…</span>
|
||
) : (
|
||
<button
|
||
key={item}
|
||
onClick={() => setPage(item - 1)}
|
||
aria-current={item - 1 === page ? 'page' : undefined}
|
||
className={clsx(
|
||
'inline-flex items-center justify-center min-w-[2rem] h-8 px-2 rounded-lg text-sm font-medium transition-colors',
|
||
item - 1 === page
|
||
? 'bg-blue-600 text-white shadow-sm'
|
||
: 'text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700'
|
||
)}
|
||
>
|
||
{item}
|
||
</button>
|
||
)
|
||
)}
|
||
<button
|
||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||
disabled={page >= totalPages - 1}
|
||
aria-label="Next page"
|
||
className="inline-flex items-center justify-center w-8 h-8 rounded-lg text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||
>
|
||
<ChevronRight className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})()}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|