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
This commit is contained in:
2026-03-24 17:47:47 +01:00
parent 4b6b49ac31
commit 1a2044537d
8 changed files with 312 additions and 71 deletions

View File

@@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.66.8", "@tanstack/react-query": "^5.66.8",
"lucide-react": "^0.511.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.30.0" "react-router-dom": "^6.30.0"

View File

@@ -1,27 +1,43 @@
import { ChevronRight } from "lucide-react";
import { NavLink, Outlet } from "react-router-dom"; import { NavLink, Outlet } from "react-router-dom";
import { appIconMap, topbarIcon, type AppIconKey } from "./app-icons";
const items = [ const items = [
["Dashboard", "/"], ["Dashboard", "/", "dashboard"],
["Users", "/users"], ["Users", "/users", "users"],
["Groups", "/groups"], ["Groups", "/groups", "groups"],
["Devices", "/devices"], ["Devices", "/devices", "devices"],
["Services", "/services"], ["Services", "/services", "services"],
["Policies", "/policies"], ["Policies", "/policies", "policies"],
["Gateways", "/gateways"], ["Gateways", "/gateways", "gateways"],
["Audit", "/audit"], ["Audit", "/audit", "audit"],
["Settings", "/settings"] ["Settings", "/settings", "settings"]
]; ] as const satisfies ReadonlyArray<[string, string, AppIconKey]>;
type LayoutProps = { type LayoutProps = {
onLogout: () => void; onLogout: () => void;
}; };
const TopbarIcon = topbarIcon;
function BrandGlyph() {
return (
<div className="brand-glyph" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3 5.5 5.8v5.4c0 4.5 2.6 7.9 6.5 9.8 3.9-1.9 6.5-5.3 6.5-9.8V5.8L12 3Z" />
<path d="m9.5 12 1.7 1.7 3.8-4.2" />
</svg>
</div>
);
}
export function Layout({ onLogout }: LayoutProps) { export function Layout({ onLogout }: LayoutProps) {
return ( return (
<div className="shell"> <div className="shell">
<aside className="sidebar"> <aside className="sidebar">
<div className="brand-block"> <div className="brand-block">
<img className="brand-logo brand-logo-full" src="/NexaVPN_Logo.png" alt="NexaVPN" /> <BrandGlyph />
<div className="brand-copy"> <div className="brand-copy">
<p className="eyebrow">NexaVPN</p> <p className="eyebrow">NexaVPN</p>
<h1>Control Plane</h1> <h1>Control Plane</h1>
@@ -29,22 +45,31 @@ export function Layout({ onLogout }: LayoutProps) {
</div> </div>
</div> </div>
<nav className="nav"> <nav className="nav">
{items.map(([label, path]) => ( {items.map(([label, path, iconKey]) => {
<NavLink const Icon = appIconMap[iconKey];
key={path} return (
to={path} <NavLink
className={({ isActive }) => (isActive ? "nav-link active" : "nav-link")} key={path}
> to={path}
{label} className={({ isActive }) => (isActive ? "nav-link active" : "nav-link")}
</NavLink> >
))} <span className="nav-link-copy">
<Icon className="nav-link-icon" size={18} strokeWidth={2} />
<span>{label}</span>
</span>
<ChevronRight className="nav-link-chevron" size={16} strokeWidth={2} />
</NavLink>
);
})}
</nav> </nav>
</aside> </aside>
<main className="content"> <main className="content">
<header className="topbar"> <header className="topbar">
<div className="topbar-brand"> <div className="topbar-brand">
<img className="brand-logo brand-logo-mark" src="/NexaVPN_Logo_Only.png" alt="NexaVPN mark" /> <div className="topbar-icon-wrap" aria-hidden="true">
<div> <TopbarIcon size={22} strokeWidth={2} />
</div>
<div className="topbar-copy">
<p className="eyebrow">Enterprise WireGuard</p> <p className="eyebrow">Enterprise WireGuard</p>
<h2>Self-hosted VPN management</h2> <h2>Self-hosted VPN management</h2>
</div> </div>

View File

@@ -1,5 +1,8 @@
import { useLocation } from "react-router-dom";
import { PropsWithChildren, ReactNode } from "react"; import { PropsWithChildren, ReactNode } from "react";
import { appIconMap, pageMetaByPath } from "./app-icons";
type PageProps = PropsWithChildren<{ type PageProps = PropsWithChildren<{
title: string; title: string;
subtitle: string; subtitle: string;
@@ -7,11 +10,23 @@ type PageProps = PropsWithChildren<{
}>; }>;
export function Page({ title, subtitle, actions, children }: PageProps) { export function Page({ title, subtitle, actions, children }: PageProps) {
const location = useLocation();
const meta = pageMetaByPath[location.pathname] ?? pageMetaByPath["/"];
const Icon = appIconMap[meta.icon];
return ( return (
<section className="page"> <section className="page">
<div className="page-header"> <div className="page-header">
<div> <div className="page-title-block">
<h3>{title}</h3> <div className="page-title-icon" aria-hidden="true">
<Icon size={22} strokeWidth={2} />
</div>
<div className="page-title-copy">
<p className="eyebrow">{meta.eyebrow}</p>
<h3>{title}</h3>
</div>
</div>
<div className="page-subtitle-block">
<p>{subtitle}</p> <p>{subtitle}</p>
</div> </div>
{actions} {actions}

View File

@@ -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<AppIconKey, LucideIcon> = {
dashboard: Gauge,
users: Users,
groups: FolderLock,
devices: HardDrive,
services: Shield,
policies: LockKeyhole,
gateways: Network,
audit: FileText,
settings: Settings
};
export const pageMetaByPath: Record<string, { icon: AppIconKey; eyebrow: string }> = {
"/": { 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;

View File

@@ -11,7 +11,8 @@ const columns = [
{ key: "ip", label: "VPN IP" }, { key: "ip", label: "VPN IP" },
{ key: "received", label: "Received" }, { key: "received", label: "Received" },
{ key: "sent", label: "Sent" }, { key: "sent", label: "Sent" },
{ key: "status", label: "Status" } { key: "status", label: "Status" },
{ key: "actions", label: "Actions" }
]; ];
function formatDataSize(bytes: number) { function formatDataSize(bytes: number) {
@@ -37,21 +38,13 @@ export function DevicesPage() {
queryKey: ["devices"], queryKey: ["devices"],
queryFn: api.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({ const revokeMutation = useMutation({
mutationFn: api.revokeDevice, mutationFn: api.revokeDevice,
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["devices"] }) onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["devices"] })
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: api.deleteDevice, mutationFn: api.deleteDevice,
onSuccess: () => { onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["devices"] })
void queryClient.invalidateQueries({ queryKey: ["devices"] });
void queryClient.invalidateQueries({ queryKey: ["device-profile"] });
}
}); });
const rotateMutation = useMutation({ const rotateMutation = useMutation({
mutationFn: api.rotateDevice, mutationFn: api.rotateDevice,
@@ -75,28 +68,26 @@ export function DevicesPage() {
<Table <Table
columns={columns} columns={columns}
rows={rows} rows={rows}
renderCell={(row, column) => <span>{row[column.key as keyof (typeof rows)[number]]}</span>} renderCell={(row, column) => {
if (column.key === "actions") {
return (
<div className="action-row">
<button className="ghost-button" type="button" onClick={() => rotateMutation.mutate(row.id)}>
Rotate
</button>
<button className="ghost-button" type="button" onClick={() => revokeMutation.mutate(row.id)}>
Revoke
</button>
<button className="ghost-button" type="button" onClick={() => deleteMutation.mutate(row.id)}>
Delete
</button>
</div>
);
}
return <span>{row[column.key as keyof (typeof rows)[number]]}</span>;
}}
/> />
{rows.length > 0 ? (
<div className="grid two">
<div className="card">
<h4>Selected device profile</h4>
<p className="notice">Admin view shows a debug template. The client private key stays on the device.</p>
<pre className="code-block">{profileQuery.data?.profile.content ?? "Loading profile..."}</pre>
</div>
<div className="card">
<h4>Device actions</h4>
<p>Target: {rows[0].name}</p>
<p>Total received: {rows[0].received}</p>
<p>Total sent: {rows[0].sent}</p>
<div className="action-row">
<button className="button" onClick={() => rotateMutation.mutate(rows[0].id)}>Rotate profile</button>
<button className="ghost-button" onClick={() => revokeMutation.mutate(rows[0].id)}>Revoke device</button>
<button className="ghost-button" onClick={() => deleteMutation.mutate(rows[0].id)}>Delete device</button>
</div>
</div>
</div>
) : null}
</Page> </Page>
); );
} }

View File

@@ -130,8 +130,13 @@ export function GroupsPage() {
<div className="selection-list"> <div className="selection-list">
{(usersQuery.data ?? []).map((user) => ( {(usersQuery.data ?? []).map((user) => (
<label className="selection-item" key={user.id}> <label className="selection-item" key={user.id}>
<div className="selection-copy">
<span className="selection-title">{user.username}</span>
<span className="selection-meta">
{user.display_name || "No display name"} · {user.email || "No email"}
</span>
</div>
<input type="checkbox" checked={form.user_ids.includes(user.id)} onChange={() => toggleUser(user.id)} /> <input type="checkbox" checked={form.user_ids.includes(user.id)} onChange={() => toggleUser(user.id)} />
<span>{user.username}</span>
</label> </label>
))} ))}
</div> </div>
@@ -169,8 +174,13 @@ export function GroupsPage() {
<div className="selection-list"> <div className="selection-list">
{(usersQuery.data ?? []).map((user) => ( {(usersQuery.data ?? []).map((user) => (
<label className="selection-item" key={user.id}> <label className="selection-item" key={user.id}>
<div className="selection-copy">
<span className="selection-title">{user.username}</span>
<span className="selection-meta">
{user.display_name || "No display name"} · {user.email || "No email"}
</span>
</div>
<input type="checkbox" checked={editForm.user_ids.includes(user.id)} onChange={() => toggleUser(user.id, true)} /> <input type="checkbox" checked={editForm.user_ids.includes(user.id)} onChange={() => toggleUser(user.id, true)} />
<span>{user.username}</span>
</label> </label>
))} ))}
</div> </div>

View File

@@ -234,8 +234,13 @@ export function PoliciesPage() {
<div className="selection-list"> <div className="selection-list">
{selectableTargets.map((target) => ( {selectableTargets.map((target) => (
<label className="selection-item" key={target.id}> <label className="selection-item" key={target.id}>
<div className="selection-copy">
<span className="selection-title">{"username" in target ? target.username : target.name}</span>
<span className="selection-meta">
{"username" in target ? (target.display_name || target.email || "User target") : (target.description || "Group target")}
</span>
</div>
<input type="checkbox" checked={form.targetIds.includes(target.id)} onChange={() => toggleTarget(target.id)} /> <input type="checkbox" checked={form.targetIds.includes(target.id)} onChange={() => toggleTarget(target.id)} />
<span>{"username" in target ? target.username : target.name}</span>
</label> </label>
))} ))}
</div> </div>
@@ -251,8 +256,11 @@ export function PoliciesPage() {
<div className="selection-list"> <div className="selection-list">
{selectableServices.map((service) => ( {selectableServices.map((service) => (
<label className="selection-item" key={service.id}> <label className="selection-item" key={service.id}>
<div className="selection-copy">
<span className="selection-title">{service.name}</span>
<span className="selection-meta">{service.domain}</span>
</div>
<input type="checkbox" checked={form.serviceIds.includes(service.id)} onChange={() => toggleService(service.id)} /> <input type="checkbox" checked={form.serviceIds.includes(service.id)} onChange={() => toggleService(service.id)} />
<span>{service.name} ({service.domain})</span>
</label> </label>
))} ))}
</div> </div>
@@ -282,8 +290,13 @@ export function PoliciesPage() {
<div className="selection-list"> <div className="selection-list">
{editableTargets.map((target) => ( {editableTargets.map((target) => (
<label className="selection-item" key={target.id}> <label className="selection-item" key={target.id}>
<div className="selection-copy">
<span className="selection-title">{"username" in target ? target.username : target.name}</span>
<span className="selection-meta">
{"username" in target ? (target.display_name || target.email || "User target") : (target.description || "Group target")}
</span>
</div>
<input type="checkbox" checked={editForm.targetIds.includes(target.id)} onChange={() => toggleTarget(target.id, true)} /> <input type="checkbox" checked={editForm.targetIds.includes(target.id)} onChange={() => toggleTarget(target.id, true)} />
<span>{"username" in target ? target.username : target.name}</span>
</label> </label>
))} ))}
</div> </div>
@@ -299,8 +312,11 @@ export function PoliciesPage() {
<div className="selection-list"> <div className="selection-list">
{selectableServices.map((service) => ( {selectableServices.map((service) => (
<label className="selection-item" key={service.id}> <label className="selection-item" key={service.id}>
<div className="selection-copy">
<span className="selection-title">{service.name}</span>
<span className="selection-meta">{service.domain}</span>
</div>
<input type="checkbox" checked={editForm.serviceIds.includes(service.id)} onChange={() => toggleService(service.id, true)} /> <input type="checkbox" checked={editForm.serviceIds.includes(service.id)} onChange={() => toggleService(service.id, true)} />
<span>{service.name} ({service.domain})</span>
</label> </label>
))} ))}
</div> </div>

View File

@@ -90,15 +90,47 @@ button {
align-items: flex-start; 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 { .brand-copy {
display: grid; display: grid;
gap: 6px; gap: 6px;
} }
.brand-copy h1,
.topbar-copy h2,
.page-title-copy h3 {
margin: 0;
line-height: 1.08;
}
.brand-tagline { .brand-tagline {
margin: 0; margin: 0;
color: var(--muted); color: var(--muted);
max-width: 240px; max-width: 220px;
line-height: 1.5; line-height: 1.5;
} }
@@ -151,12 +183,17 @@ button {
.nav { .nav {
display: grid; display: grid;
gap: 10px; gap: 10px;
margin-top: 40px; margin-top: 36px;
} }
.nav-link { .nav-link {
padding: 12px 14px; display: flex;
border-radius: 14px; align-items: center;
justify-content: space-between;
gap: 14px;
padding: 13px 14px 13px 16px;
border-radius: 16px;
border: 1px solid transparent;
color: var(--muted); color: var(--muted);
transition: 180ms ease; transition: 180ms ease;
} }
@@ -164,26 +201,85 @@ button {
.nav-link:hover, .nav-link:hover,
.nav-link.active { .nav-link.active {
color: var(--text); 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 { .content {
padding: 28px; padding: 28px;
} }
.topbar, .topbar {
.page-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
} }
.topbar-copy {
display: grid;
gap: 6px;
}
.topbar-icon-wrap,
.page-title-icon {
width: 52px;
height: 52px;
border-radius: 18px;
}
.page { .page {
display: grid; display: grid;
gap: 22px; 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 { .grid {
display: grid; display: grid;
gap: 18px; gap: 18px;
@@ -345,15 +441,52 @@ button {
.selection-list { .selection-list {
display: grid; display: grid;
gap: 10px; gap: 10px;
max-height: 240px; max-height: 260px;
overflow: auto; overflow: auto;
} }
.selection-item { .selection-item {
display: flex; display: grid;
grid-template-columns: 1fr auto;
align-items: center; 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); 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 { .modal-backdrop {