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:
@@ -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"
|
||||
|
||||
@@ -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 (
|
||||
<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) {
|
||||
return (
|
||||
<div className="shell">
|
||||
<aside className="sidebar">
|
||||
<div className="brand-block">
|
||||
<img className="brand-logo brand-logo-full" src="/NexaVPN_Logo.png" alt="NexaVPN" />
|
||||
<BrandGlyph />
|
||||
<div className="brand-copy">
|
||||
<p className="eyebrow">NexaVPN</p>
|
||||
<h1>Control Plane</h1>
|
||||
@@ -29,22 +45,31 @@ export function Layout({ onLogout }: LayoutProps) {
|
||||
</div>
|
||||
</div>
|
||||
<nav className="nav">
|
||||
{items.map(([label, path]) => (
|
||||
{items.map(([label, path, iconKey]) => {
|
||||
const Icon = appIconMap[iconKey];
|
||||
return (
|
||||
<NavLink
|
||||
key={path}
|
||||
to={path}
|
||||
className={({ isActive }) => (isActive ? "nav-link active" : "nav-link")}
|
||||
>
|
||||
{label}
|
||||
<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>
|
||||
</aside>
|
||||
<main className="content">
|
||||
<header className="topbar">
|
||||
<div className="topbar-brand">
|
||||
<img className="brand-logo brand-logo-mark" src="/NexaVPN_Logo_Only.png" alt="NexaVPN mark" />
|
||||
<div>
|
||||
<div className="topbar-icon-wrap" aria-hidden="true">
|
||||
<TopbarIcon size={22} strokeWidth={2} />
|
||||
</div>
|
||||
<div className="topbar-copy">
|
||||
<p className="eyebrow">Enterprise WireGuard</p>
|
||||
<h2>Self-hosted VPN management</h2>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<section className="page">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title-block">
|
||||
<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>
|
||||
</div>
|
||||
{actions}
|
||||
|
||||
50
admin-web/src/components/app-icons.tsx
Normal file
50
admin-web/src/components/app-icons.tsx
Normal 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;
|
||||
@@ -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() {
|
||||
<Table
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
renderCell={(row, column) => <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>
|
||||
renderCell={(row, column) => {
|
||||
if (column.key === "actions") {
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
);
|
||||
}
|
||||
|
||||
return <span>{row[column.key as keyof (typeof rows)[number]]}</span>;
|
||||
}}
|
||||
/>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -130,8 +130,13 @@ export function GroupsPage() {
|
||||
<div className="selection-list">
|
||||
{(usersQuery.data ?? []).map((user) => (
|
||||
<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)} />
|
||||
<span>{user.username}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
@@ -169,8 +174,13 @@ export function GroupsPage() {
|
||||
<div className="selection-list">
|
||||
{(usersQuery.data ?? []).map((user) => (
|
||||
<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)} />
|
||||
<span>{user.username}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -234,8 +234,13 @@ export function PoliciesPage() {
|
||||
<div className="selection-list">
|
||||
{selectableTargets.map((target) => (
|
||||
<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)} />
|
||||
<span>{"username" in target ? target.username : target.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
@@ -251,8 +256,11 @@ export function PoliciesPage() {
|
||||
<div className="selection-list">
|
||||
{selectableServices.map((service) => (
|
||||
<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)} />
|
||||
<span>{service.name} ({service.domain})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
@@ -282,8 +290,13 @@ export function PoliciesPage() {
|
||||
<div className="selection-list">
|
||||
{editableTargets.map((target) => (
|
||||
<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)} />
|
||||
<span>{"username" in target ? target.username : target.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
@@ -299,8 +312,11 @@ export function PoliciesPage() {
|
||||
<div className="selection-list">
|
||||
{selectableServices.map((service) => (
|
||||
<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)} />
|
||||
<span>{service.name} ({service.domain})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user