From 1a2044537d9070f6c97e73d2394452bb096a45af Mon Sep 17 00:00:00 2001 From: nessi Date: Tue, 24 Mar 2026 17:47:47 +0100 Subject: [PATCH] feat: add lucide-react icons and redesign admin UI with enhanced visual hierarchy Add lucide-react dependency and create app-icons module with icon mappings for all admin sections. Replace logo images with inline SVG brand glyph component. Update Layout component with icon-enhanced navigation links including chevron indicators and hover states. Add topbar icon wrapper with gradient background styling. Redesign Page component header with icon, eyebrow text, and improved title block layout. Add page --- admin-web/package.json | 1 + admin-web/src/components/Layout.tsx | 69 +++++--- admin-web/src/components/Page.tsx | 19 ++- admin-web/src/components/app-icons.tsx | 50 ++++++ .../src/features/devices/DevicesPage.tsx | 53 +++--- admin-web/src/features/groups/GroupsPage.tsx | 14 +- .../src/features/policies/PoliciesPage.tsx | 24 ++- admin-web/src/styles/global.css | 153 ++++++++++++++++-- 8 files changed, 312 insertions(+), 71 deletions(-) create mode 100644 admin-web/src/components/app-icons.tsx diff --git a/admin-web/package.json b/admin-web/package.json index c4ded90..a305cec 100644 --- a/admin-web/package.json +++ b/admin-web/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@tanstack/react-query": "^5.66.8", + "lucide-react": "^0.511.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.30.0" diff --git a/admin-web/src/components/Layout.tsx b/admin-web/src/components/Layout.tsx index 4e81ebd..0c2f15d 100644 --- a/admin-web/src/components/Layout.tsx +++ b/admin-web/src/components/Layout.tsx @@ -1,27 +1,43 @@ +import { ChevronRight } from "lucide-react"; import { NavLink, Outlet } from "react-router-dom"; +import { appIconMap, topbarIcon, type AppIconKey } from "./app-icons"; + const items = [ - ["Dashboard", "/"], - ["Users", "/users"], - ["Groups", "/groups"], - ["Devices", "/devices"], - ["Services", "/services"], - ["Policies", "/policies"], - ["Gateways", "/gateways"], - ["Audit", "/audit"], - ["Settings", "/settings"] -]; + ["Dashboard", "/", "dashboard"], + ["Users", "/users", "users"], + ["Groups", "/groups", "groups"], + ["Devices", "/devices", "devices"], + ["Services", "/services", "services"], + ["Policies", "/policies", "policies"], + ["Gateways", "/gateways", "gateways"], + ["Audit", "/audit", "audit"], + ["Settings", "/settings", "settings"] +] as const satisfies ReadonlyArray<[string, string, AppIconKey]>; type LayoutProps = { onLogout: () => void; }; +const TopbarIcon = topbarIcon; + +function BrandGlyph() { + return ( + + ); +} + export function Layout({ onLogout }: LayoutProps) { return (
- NexaVPN mark -
+ +

Enterprise WireGuard

Self-hosted VPN management

diff --git a/admin-web/src/components/Page.tsx b/admin-web/src/components/Page.tsx index a44d1fc..3a595ef 100644 --- a/admin-web/src/components/Page.tsx +++ b/admin-web/src/components/Page.tsx @@ -1,5 +1,8 @@ +import { useLocation } from "react-router-dom"; import { PropsWithChildren, ReactNode } from "react"; +import { appIconMap, pageMetaByPath } from "./app-icons"; + type PageProps = PropsWithChildren<{ title: string; subtitle: string; @@ -7,11 +10,23 @@ type PageProps = PropsWithChildren<{ }>; export function Page({ title, subtitle, actions, children }: PageProps) { + const location = useLocation(); + const meta = pageMetaByPath[location.pathname] ?? pageMetaByPath["/"]; + const Icon = appIconMap[meta.icon]; + return (
-
-

{title}

+
+ +
+

{meta.eyebrow}

+

{title}

+
+
+

{subtitle}

{actions} diff --git a/admin-web/src/components/app-icons.tsx b/admin-web/src/components/app-icons.tsx new file mode 100644 index 0000000..6502dcf --- /dev/null +++ b/admin-web/src/components/app-icons.tsx @@ -0,0 +1,50 @@ +import { + Activity, + FileText, + FolderLock, + Gauge, + HardDrive, + LockKeyhole, + type LucideIcon, + Network, + Settings, + Shield, + Users +} from "lucide-react"; + +export type AppIconKey = + | "dashboard" + | "users" + | "groups" + | "devices" + | "services" + | "policies" + | "gateways" + | "audit" + | "settings"; + +export const appIconMap: Record = { + dashboard: Gauge, + users: Users, + groups: FolderLock, + devices: HardDrive, + services: Shield, + policies: LockKeyhole, + gateways: Network, + audit: FileText, + settings: Settings +}; + +export const pageMetaByPath: Record = { + "/": { icon: "dashboard", eyebrow: "Operations" }, + "/users": { icon: "users", eyebrow: "Identity" }, + "/groups": { icon: "groups", eyebrow: "Access Groups" }, + "/devices": { icon: "devices", eyebrow: "Endpoints" }, + "/services": { icon: "services", eyebrow: "Published Services" }, + "/policies": { icon: "policies", eyebrow: "Enforcement" }, + "/gateways": { icon: "gateways", eyebrow: "WireGuard Edge" }, + "/audit": { icon: "audit", eyebrow: "Traceability" }, + "/settings": { icon: "settings", eyebrow: "Platform" } +}; + +export const topbarIcon = Activity; diff --git a/admin-web/src/features/devices/DevicesPage.tsx b/admin-web/src/features/devices/DevicesPage.tsx index 5d4cc58..b373f0d 100644 --- a/admin-web/src/features/devices/DevicesPage.tsx +++ b/admin-web/src/features/devices/DevicesPage.tsx @@ -11,7 +11,8 @@ const columns = [ { key: "ip", label: "VPN IP" }, { key: "received", label: "Received" }, { key: "sent", label: "Sent" }, - { key: "status", label: "Status" } + { key: "status", label: "Status" }, + { key: "actions", label: "Actions" } ]; function formatDataSize(bytes: number) { @@ -37,21 +38,13 @@ export function DevicesPage() { queryKey: ["devices"], queryFn: api.devices }); - const profileQuery = useQuery({ - queryKey: ["device-profile", query.data?.[0]?.id ?? ""], - queryFn: () => api.deviceProfile(query.data?.[0]?.id ?? ""), - enabled: Boolean(query.data?.[0]?.id) - }); const revokeMutation = useMutation({ mutationFn: api.revokeDevice, onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["devices"] }) }); const deleteMutation = useMutation({ mutationFn: api.deleteDevice, - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["devices"] }); - void queryClient.invalidateQueries({ queryKey: ["device-profile"] }); - } + onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["devices"] }) }); const rotateMutation = useMutation({ mutationFn: api.rotateDevice, @@ -75,28 +68,26 @@ export function DevicesPage() { {row[column.key as keyof (typeof rows)[number]]}} + renderCell={(row, column) => { + if (column.key === "actions") { + return ( +
+ + + +
+ ); + } + + return {row[column.key as keyof (typeof rows)[number]]}; + }} /> - {rows.length > 0 ? ( -
-
-

Selected device profile

-

Admin view shows a debug template. The client private key stays on the device.

-
{profileQuery.data?.profile.content ?? "Loading profile..."}
-
-
-

Device actions

-

Target: {rows[0].name}

-

Total received: {rows[0].received}

-

Total sent: {rows[0].sent}

-
- - - -
-
-
- ) : null} ); } diff --git a/admin-web/src/features/groups/GroupsPage.tsx b/admin-web/src/features/groups/GroupsPage.tsx index bc00027..e2474d6 100644 --- a/admin-web/src/features/groups/GroupsPage.tsx +++ b/admin-web/src/features/groups/GroupsPage.tsx @@ -130,8 +130,13 @@ export function GroupsPage() {
{(usersQuery.data ?? []).map((user) => ( ))}
@@ -169,8 +174,13 @@ export function GroupsPage() {
{(usersQuery.data ?? []).map((user) => ( ))}
diff --git a/admin-web/src/features/policies/PoliciesPage.tsx b/admin-web/src/features/policies/PoliciesPage.tsx index 24da969..efd7850 100644 --- a/admin-web/src/features/policies/PoliciesPage.tsx +++ b/admin-web/src/features/policies/PoliciesPage.tsx @@ -234,8 +234,13 @@ export function PoliciesPage() {
{selectableTargets.map((target) => ( ))}
@@ -251,8 +256,11 @@ export function PoliciesPage() {
{selectableServices.map((service) => ( ))}
@@ -282,8 +290,13 @@ export function PoliciesPage() {
{editableTargets.map((target) => ( ))}
@@ -299,8 +312,11 @@ export function PoliciesPage() {
{selectableServices.map((service) => ( ))}
diff --git a/admin-web/src/styles/global.css b/admin-web/src/styles/global.css index 772f513..92c9107 100644 --- a/admin-web/src/styles/global.css +++ b/admin-web/src/styles/global.css @@ -90,15 +90,47 @@ button { align-items: flex-start; } +.brand-glyph, +.topbar-icon-wrap, +.page-title-icon { + display: grid; + place-items: center; + flex: 0 0 auto; + color: var(--accent); + border: 1px solid rgba(116, 224, 184, 0.18); + background: + radial-gradient(circle at top, rgba(116, 224, 184, 0.14), transparent 72%), + rgba(10, 18, 32, 0.88); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02); +} + +.brand-glyph { + width: 54px; + height: 54px; + border-radius: 18px; +} + +.brand-glyph svg { + width: 25px; + height: 25px; +} + .brand-copy { display: grid; gap: 6px; } +.brand-copy h1, +.topbar-copy h2, +.page-title-copy h3 { + margin: 0; + line-height: 1.08; +} + .brand-tagline { margin: 0; color: var(--muted); - max-width: 240px; + max-width: 220px; line-height: 1.5; } @@ -151,12 +183,17 @@ button { .nav { display: grid; gap: 10px; - margin-top: 40px; + margin-top: 36px; } .nav-link { - padding: 12px 14px; - border-radius: 14px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 13px 14px 13px 16px; + border-radius: 16px; + border: 1px solid transparent; color: var(--muted); transition: 180ms ease; } @@ -164,26 +201,85 @@ button { .nav-link:hover, .nav-link.active { color: var(--text); - background: rgba(116, 224, 184, 0.12); + border-color: rgba(116, 224, 184, 0.14); + background: rgba(116, 224, 184, 0.08); +} + +.nav-link-copy { + display: inline-flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.nav-link-icon, +.nav-link-chevron { + flex: 0 0 auto; +} + +.nav-link:hover .nav-link-icon, +.nav-link.active .nav-link-icon, +.nav-link:hover .nav-link-chevron, +.nav-link.active .nav-link-chevron { + color: var(--accent); } .content { padding: 28px; } -.topbar, -.page-header { +.topbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; } +.topbar-copy { + display: grid; + gap: 6px; +} + +.topbar-icon-wrap, +.page-title-icon { + width: 52px; + height: 52px; + border-radius: 18px; +} + .page { display: grid; gap: 22px; } +.page-header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 16px; +} + +.page-title-block { + display: flex; + align-items: center; + gap: 16px; +} + +.page-title-copy { + display: grid; + gap: 6px; +} + +.page-subtitle-block { + grid-column: 1 / 2; + padding-left: 68px; +} + +.page-subtitle-block p { + margin: 0; + max-width: 760px; +} + .grid { display: grid; gap: 18px; @@ -345,15 +441,52 @@ button { .selection-list { display: grid; gap: 10px; - max-height: 240px; + max-height: 260px; overflow: auto; } .selection-item { - display: flex; + display: grid; + grid-template-columns: 1fr auto; align-items: center; - gap: 10px; + gap: 12px; + padding: 14px 16px; + border: 1px solid var(--line); + border-radius: 16px; + background: rgba(8, 14, 26, 0.78); color: var(--text); + cursor: pointer; + transition: border-color 160ms ease, background 160ms ease, transform 160ms ease; +} + +.selection-item:hover { + border-color: rgba(116, 224, 184, 0.32); + background: rgba(14, 22, 38, 0.96); +} + +.selection-item input { + margin: 0; +} + +.selection-copy { + display: grid; + gap: 4px; + min-width: 0; +} + +.selection-title { + font-weight: 600; + color: var(--text); +} + +.selection-meta { + color: var(--muted); + font-size: 0.92rem; + line-height: 1.35; +} + +.selection-item input:checked + .selection-copy .selection-title { + color: var(--accent); } .modal-backdrop {