From 295734758369e446d10a4ba6f886dd65ac6c6973 Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Sat, 1 Aug 2026 13:31:46 +0500 Subject: [PATCH] new ui + search clients --- frontend/src/App.tsx | 210 +++++++++++++++++++++++---------- frontend/src/lib/api.ts | 13 +- frontend/src/pages/Clients.tsx | 81 +++++++++++-- 3 files changed, 233 insertions(+), 71 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8aab09c..b85a484 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,9 @@ -import { NavLink, Navigate, Route, Routes } from 'react-router-dom' +import { useEffect, useState } from 'react' +import { NavLink, Navigate, Route, Routes, useLocation } from 'react-router-dom' import { KeyRound, LogOut, + Menu, Moon, Router, ScrollText, @@ -9,6 +11,7 @@ import { UserCog, Users as UsersIcon, Wifi, + X, } from 'lucide-react' import { useAuth } from '@/auth/auth' import { useTheme } from '@/lib/theme' @@ -39,54 +42,145 @@ function ThemeToggle() { ) } -function Nav({ username, isAdmin }: { username: string; isAdmin: boolean }) { +type NavItem = { to: string; label: string; icon: typeof Wifi } + +const linkClass = ({ isActive }: { isActive: boolean }) => + cn( + 'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors', + isActive ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent', + ) + +function SidebarContent({ + username, + isAdmin, + onNavigate, +}: { + username: string + isAdmin: boolean + onNavigate?: () => void +}) { const { logout } = useAuth() - const linkClass = ({ isActive }: { isActive: boolean }) => - cn( - 'inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium transition-colors', - isActive ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent', - ) + + const items: NavItem[] = [ + { to: '/clients', label: 'Clients', icon: UsersIcon }, + { to: '/devices', label: 'Devices', icon: Router }, + { to: '/vlans', label: 'VLANs', icon: Wifi }, + ] + const adminItems: NavItem[] = [ + { to: '/users', label: 'Users', icon: UserCog }, + { to: '/apikeys', label: 'API Keys', icon: KeyRound }, + { to: '/logs', label: 'Activity Log', icon: ScrollText }, + ] + + const renderItem = ({ to, label, icon: Icon }: NavItem) => ( + + {label} + + ) return ( -
-
-
+
+
+ + RADIUS Admin +
+ +
+ + + {username} + + +
+
+ ) +} + +function Shell({ + username, + isAdmin, + children, +}: { + username: string + isAdmin: boolean + children: React.ReactNode +}) { + const [open, setOpen] = useState(false) + const location = useLocation() + + // Close the mobile drawer whenever the route changes. + useEffect(() => { + setOpen(false) + }, [location.pathname]) + + return ( +
+ {/* Desktop sidebar */} + + + {/* Mobile drawer */} + {open && ( +
+
setOpen(false)} + aria-hidden="true" + /> + +
+ )} + + {/* Mobile top bar */} +
+ +
RADIUS Admin
- - Clients - - - Devices - - - VLANs - - {isAdmin && ( - <> - - Users - - - API Keys - - - Activity Log - - - )} -
- - {username} - +
-
+
+ +
+ {/* Desktop top-right theme toggle */} +
+ +
+
{children}
-
+ ) } @@ -100,26 +194,20 @@ export default function App() { const admin = user.is_admin return ( -
-
+ + + } /> + } /> + } /> + } /> + : } /> + : } + /> + : } /> + } /> + + ) } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 5a3d108..55cad74 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -111,6 +111,11 @@ export interface ClientEdit { alias?: string } +export interface ClientFilters { + search?: string + status?: ClientStatus +} + export interface ClientImportRow { mac_address?: string group?: string @@ -260,8 +265,12 @@ export const api = { }, clients: { - list: (limit = 50, offset = 0) => - request>(`/client/?limit=${limit}&offset=${offset}`), + list: (limit = 50, offset = 0, filters: ClientFilters = {}) => { + const params = new URLSearchParams({ limit: String(limit), offset: String(offset) }) + if (filters.search?.trim()) params.set('search', filters.search.trim()) + if (filters.status) params.set('status', filters.status) + return request>(`/client/?${params.toString()}`) + }, get: (mac: string) => request(`/client/${encodeURIComponent(mac)}`), add: (body: ClientCreate) => request('/client/add', { method: 'POST', body: JSON.stringify(body) }), diff --git a/frontend/src/pages/Clients.tsx b/frontend/src/pages/Clients.tsx index ecc3bf4..ed7010a 100644 --- a/frontend/src/pages/Clients.tsx +++ b/frontend/src/pages/Clients.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState, type ReactNode } from 'react' import { Link } from 'react-router-dom' -import { Loader2, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react' +import { Loader2, Pencil, Plus, RefreshCw, Search, Trash2, X } from 'lucide-react' import { toast } from 'sonner' import { api, ApiError, type Client, type ClientStatus, type Vlan } from '@/lib/api' import { normalizePhone, phoneError } from '@/lib/phone' @@ -51,6 +51,13 @@ export function Clients() { const [editing, setEditing] = useState(null) const [deleting, setDeleting] = useState(null) + // Filters — `search` is the raw input; `debouncedSearch` is what we query with + // so we don't fire a request on every keystroke. + const [search, setSearch] = useState('') + const [debouncedSearch, setDebouncedSearch] = useState('') + const [status, setStatus] = useState('all') + const hasFilters = debouncedSearch.trim() !== '' || status !== 'all' + const [pageSize, setPageSize] = usePageSize() const [page, setPage] = useState(1) const pageCount = Math.max(1, Math.ceil(total / pageSize)) @@ -58,12 +65,20 @@ export function Clients() { // until at least one VLAN exists. const noVlans = !loading && vlans.length === 0 - // Server-side pagination: fetch only the current page's rows. + useEffect(() => { + const t = setTimeout(() => setDebouncedSearch(search), 300) + return () => clearTimeout(t) + }, [search]) + + // Server-side pagination + filtering: fetch only the current page's rows. const load = useCallback(async () => { setLoading(true) try { const [d, v] = await Promise.all([ - api.clients.list(pageSize, (page - 1) * pageSize), + api.clients.list(pageSize, (page - 1) * pageSize, { + search: debouncedSearch, + status: status === 'all' ? undefined : status, + }), api.vlans.list(), ]) setDevices(d.items) @@ -75,14 +90,14 @@ export function Clients() { } finally { setLoading(false) } - }, [page, pageSize]) + }, [page, pageSize, debouncedSearch, status]) useEffect(() => { load() }, [load]) - // Reset to the first page when the page size changes. - useEffect(() => setPage(1), [pageSize]) + // Reset to the first page when the page size or an active filter changes. + useEffect(() => setPage(1), [pageSize, debouncedSearch, status]) // Clamp the page when the total shrinks (e.g. after a delete). useEffect(() => { if (page > pageCount) setPage(pageCount) @@ -93,7 +108,9 @@ export function Clients() {

Clients

-

{total} registered

+

+ {total} {hasFilters ? 'match' : 'registered'} +

+
+
+ + setSearch(e.target.value)} + placeholder="Search by MAC, name, phone or alias…" + className="pl-9 pr-9" + /> + {search && ( + + )} +
+ + {hasFilters && ( + + )} +
+ {noVlans && (
No VLANs exist yet. Add a VLAN{' '} @@ -140,7 +203,9 @@ export function Clients() { ) : devices.length === 0 ? ( - No devices yet. Add one to get started. + {hasFilters + ? 'No clients match your filters.' + : 'No devices yet. Add one to get started.'} ) : (