WGX shares its name with several other WireGuard tools, so the project becomes ihasvpn, alongside ihasmail. - Module github.com/Coffey-Labs/ihasvpn, command cmd/ihasvpn, image ghcr.io/coffey-labs/ihasvpn. - Environment variables move from WGX_* to IHASVPN_*. The default database is ihasvpn.db, the nftables table is `ihasvpn`, metrics are ihasvpn_*, and the session cookie and theme key are renamed, so existing sessions end. - The mark is the ihasmail cat peeking over the edge of a shield, drawn as a vector. docs/brand/generate.py builds the mark, mono mark, wordmarks, social card, favicons and app icons from that one drawing. - The console takes ihasmail's palette: the ihasmail.org teal-navy for dark, its contrast-checked light tiers with the site's light accent, received traffic in the cat's orange and sent in teal. The wordmark weight and font stack follow ihasmail.org. - Detail values wrap at spaces before breaking inside an address, so an IPv6 tunnel address no longer splits mid-number. - The README history note about the earlier WGX installer is gone with the name it explained. Screenshots retaken.
115 lines
3.7 KiB
TypeScript
115 lines
3.7 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import { Link, useLocation } from "wouter";
|
|
import { Activity, ClipboardList, LayoutDashboard, Settings, Users } from "lucide-react";
|
|
import { useLive } from "../state";
|
|
import { Mark } from "./Mark";
|
|
import { ThemeToggle } from "./ThemeSwitch";
|
|
import { UserMenu } from "./UserMenu";
|
|
import { Legal } from "./Legal";
|
|
|
|
// Account is reached through the user menu, so it is not a nav item.
|
|
const groups = [
|
|
{
|
|
label: "Overview",
|
|
items: [
|
|
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
|
|
{ href: "/peers", label: "Peers", icon: Activity },
|
|
],
|
|
},
|
|
{
|
|
label: "Administration",
|
|
items: [
|
|
{ href: "/settings", label: "Settings", icon: Settings },
|
|
{ href: "/users", label: "Users", icon: Users },
|
|
{ href: "/audit", label: "Audit log", icon: ClipboardList },
|
|
],
|
|
},
|
|
];
|
|
const all = groups.flatMap((g) => g.items);
|
|
|
|
function isActive(href: string, location: string) {
|
|
return href === "/" ? location === "/" : location.startsWith(href);
|
|
}
|
|
|
|
function LivePill() {
|
|
const { connected } = useLive();
|
|
return (
|
|
<span className={`live-pill${connected ? " on" : ""}`} title={connected ? "Live updates connected" : "Live updates reconnecting"}>
|
|
<i /> {connected ? "Live" : "Reconnecting"}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function Layout({ children }: { children: ReactNode }) {
|
|
const [location] = useLocation();
|
|
return (
|
|
<div className="shell">
|
|
{/* Phones and narrow windows: brand and account controls up top. */}
|
|
<header className="topbar">
|
|
<Link href="/" className="brand" aria-label="ihasvpn dashboard">
|
|
<Mark size={30} />
|
|
<span className="brand-name">ihasvpn</span>
|
|
</Link>
|
|
<div className="topbar-right">
|
|
<LivePill />
|
|
<ThemeToggle />
|
|
<UserMenu placement="down" />
|
|
</div>
|
|
</header>
|
|
|
|
{/* Desktop: everything lives in the sidebar. */}
|
|
<aside className="sidebar">
|
|
<Link href="/" className="brand" aria-label="ihasvpn dashboard">
|
|
<Mark size={34} />
|
|
<span>
|
|
<span className="brand-name">ihasvpn</span>
|
|
<span className="brand-sub">Self-hosted WireGuard</span>
|
|
</span>
|
|
</Link>
|
|
<nav className="nav" aria-label="Main">
|
|
{groups.map((g) => (
|
|
<div className="nav-group" key={g.label}>
|
|
<div className="nav-label">{g.label}</div>
|
|
{g.items.map((it) => {
|
|
const Icon = it.icon;
|
|
return (
|
|
<Link key={it.href} href={it.href} className={isActive(it.href, location) ? "active" : ""} aria-current={isActive(it.href, location) ? "page" : undefined}>
|
|
<Icon />
|
|
<span>{it.label}</span>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
</nav>
|
|
<div className="sidebar-foot">
|
|
<div className="sidebar-tools">
|
|
<LivePill />
|
|
<ThemeToggle />
|
|
</div>
|
|
<UserMenu placement="up" showName />
|
|
<Legal />
|
|
</div>
|
|
</aside>
|
|
|
|
<main className="main" id="main">
|
|
{children}
|
|
</main>
|
|
|
|
{/* Phones: primary navigation as a tab bar within thumb reach. */}
|
|
<nav className="tabbar" aria-label="Main">
|
|
{all.map((it) => {
|
|
const Icon = it.icon;
|
|
const active = isActive(it.href, location);
|
|
return (
|
|
<Link key={it.href} href={it.href} className={active ? "active" : ""} aria-current={active ? "page" : undefined}>
|
|
<Icon />
|
|
<span>{it.label}</span>
|
|
</Link>
|
|
);
|
|
})}
|
|
</nav>
|
|
</div>
|
|
);
|
|
}
|