Files
headplane/app/layout/header.tsx
T
2026-04-26 20:33:48 -04:00

231 lines
8.2 KiB
TypeScript

import {
Check,
CircleQuestionMark,
CircleUser,
Globe,
Lock,
Monitor,
Moon,
Server,
Settings,
Sun,
Users,
} from "lucide-react";
import { NavLink, unstable_useRoute as useRoute, useLocation, useSubmit } from "react-router";
import Link from "~/components/link";
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu";
import logoBg from "~/logo/dark-bg.svg";
import logoDark from "~/logo/dark.svg";
import logoLight from "~/logo/light.svg";
import cn from "~/utils/cn";
import type { ColorScheme } from "~/utils/color-scheme";
export interface HeaderProps {
user: {
subject: string;
name: string;
email?: string;
username?: string;
picture?: string;
};
access: {
ui: boolean;
machines: boolean;
dns: boolean;
users: boolean;
policy: boolean;
settings: boolean;
};
configAvailable: boolean;
}
const tabs = [
{ to: "/machines", icon: Server, label: "Machines", key: "machines" },
{ to: "/users", icon: Users, label: "Users", key: "users" },
{ to: "/acls", icon: Lock, label: "Access Control", key: "policy" },
{ to: "/dns", icon: Globe, label: "DNS", key: "dns" },
{ to: "/settings", icon: Settings, label: "Settings", key: "settings" },
] as const;
const colorSchemes = [
{ value: "system", label: "System", icon: Monitor },
{ value: "light", label: "Light", icon: Sun },
{ value: "dark", label: "Dark", icon: Moon },
] as const satisfies ReadonlyArray<{
value: ColorScheme;
label: string;
icon: typeof Monitor;
}>;
export default function Header({ user, access, configAvailable }: HeaderProps) {
const submit = useSubmit();
const showTabs = access.ui;
const rootRoute = useRoute("root");
const currentColorScheme: ColorScheme = rootRoute?.loaderData?.colorScheme ?? "system";
// useLocation returns the path with the basename already stripped, which is
// what `redirect()` expects — react-router re-applies the basename when
// following the redirect on the client.
const location = useLocation();
const returnTo = location.pathname + location.search;
return (
<header
className={cn(
"bg-mist-200 dark:bg-mist-950 text-mist-800 dark:text-mist-200",
"dark:border-b dark:border-mist-800 shadow-inner",
)}
>
<div className="container flex items-center gap-x-4 py-4">
<div className="flex min-w-0 items-center gap-x-4">
<div className="flex items-center gap-x-2">
<picture className="min-w-8">
<source srcSet={logoLight} media="(prefers-color-scheme: dark)" />
<source srcSet={logoDark} media="(prefers-color-scheme: light)" />
<img src={logoBg} alt="Headplane logo" />
</picture>
<h1 className="text-2xl font-semibold">headplane</h1>
</div>
{showTabs && (
<nav className="hidden items-center gap-x-2 overflow-x-auto p-1 text-sm font-medium md:flex">
{tabs.map((tab) => {
if (!access[tab.key]) return null;
if ((tab.key === "dns" || tab.key === "settings") && !configAvailable) return null;
return (
<NavLink
key={tab.to}
className={({ isActive }) =>
cn(
"px-3 py-1.5 flex items-center gap-x-1.5 rounded-md text-nowrap",
"hover:bg-mist-300/50 dark:hover:bg-mist-800",
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
isActive
? "bg-mist-300/70 dark:bg-mist-800 text-mist-900 dark:text-mist-50"
: "text-mist-600 dark:text-mist-300",
)
}
prefetch="intent"
to={tab.to}
>
<tab.icon className="w-4" />
{tab.label}
</NavLink>
);
})}
</nav>
)}
</div>
<div className="ml-auto grid shrink-0 grid-cols-2 gap-x-4">
<Menu>
<MenuTrigger className="size-8 rounded-full p-1">
<CircleQuestionMark className="w-5" />
</MenuTrigger>
<MenuContent align="end">
<MenuItem>
<Link external to="https://headplane.net">
Docs
</Link>
</MenuItem>
<MenuItem>
<Link external to="https://headscale.net">
Headscale
</Link>
</MenuItem>
<MenuItem>
<Link external to="https://tailscale.com/download">
Download
</Link>
</MenuItem>
</MenuContent>
</Menu>
<Menu>
<MenuTrigger className="size-8 overflow-hidden rounded-full">
{user.picture ? (
<img alt={user.name} className="size-8" src={user.picture} />
) : (
<CircleUser className="size-8" />
)}
</MenuTrigger>
<MenuContent align="end">
<MenuItem disabled>
<div className="text-mist-900 dark:text-mist-50">
{user.subject === "api_key" ? (
<>
<p className="font-bold">API Key</p>
<p>{user.name}</p>
</>
) : (
<>
<p className="font-bold">{user.name}</p>
{user.email && <p>{user.email}</p>}
</>
)}
</div>
</MenuItem>
<MenuSeparator />
{colorSchemes.map(({ value, label, icon: Icon }) => (
<MenuItem
key={value}
onClick={() =>
submit(
{ colorScheme: value, returnTo },
{ action: "/api/color-scheme", method: "POST" },
)
}
>
<div className="flex items-center gap-x-2">
<Icon className="size-4" />
<span className="flex-1">{label}</span>
{currentColorScheme === value && <Check className="size-4" />}
</div>
</MenuItem>
))}
<MenuSeparator />
<MenuItem
variant="danger"
onClick={() => submit({}, { action: "/logout", method: "POST" })}
>
Logout
</MenuItem>
</MenuContent>
</Menu>
</div>
</div>
{showTabs && (
<div className="block overflow-x-auto p-2 md:hidden">
<nav className="flex items-center gap-x-2 text-sm font-medium">
{tabs.map((tab) => {
if (!access[tab.key]) return null;
if ((tab.key === "dns" || tab.key === "settings") && !configAvailable) return null;
return (
<NavLink
key={tab.to}
className={({ isActive }) =>
cn(
"relative px-3 py-1.5 flex items-center gap-x-1.5 rounded-md text-nowrap",
"hover:bg-mist-300/50 dark:hover:bg-mist-800",
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
"text-mist-600 dark:text-mist-300",
isActive &&
"text-mist-900 dark:text-mist-50 after:content-[''] after:absolute after:-bottom-2 after:inset-x-1 after:h-0.5 after:rounded-full after:bg-indigo-500",
)
}
prefetch="intent"
to={tab.to}
>
<tab.icon className="w-4" />
{tab.label}
</NavLink>
);
})}
</nav>
</div>
)}
</header>
);
}