feat: refactor several components and clean up ui

This commit is contained in:
Aarnav Tale
2026-03-09 22:09:58 -04:00
parent a53fd31d27
commit 6470f5a821
49 changed files with 1522 additions and 1355 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ export default function Button({ variant = "light", ...props }: ButtonProps) {
ref={ref}
{...buttonProps}
className={cn(
"w-fit rounded-lg px-3.5 py-2 text-sm leading-tight",
"w-fit rounded-md px-3.5 py-2 text-sm leading-tight",
"transition-colors duration-100",
"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",
+30 -24
View File
@@ -15,7 +15,6 @@ import {
} from "react-stately";
import Button, { ButtonProps } from "~/components/Button";
import Card from "~/components/Card";
import IconButton, { IconButtonProps } from "~/components/IconButton";
import Text from "~/components/Text";
import Title from "~/components/Title";
@@ -101,7 +100,13 @@ function Panel(props: DialogPanelProps) {
return (
<Form
{...dialogProps}
className={cn("outline-hidden rounded-lg w-full max-w-lg", "bg-white dark:bg-mist-900")}
className={cn(
"w-full max-w-lg rounded-xl p-4",
"outline-hidden",
"bg-white dark:bg-mist-900",
"border border-mist-200 dark:border-mist-800",
"shadow-overlay",
)}
method={method ?? "POST"}
onSubmit={(event) => {
if (onSubmit) {
@@ -112,25 +117,23 @@ function Panel(props: DialogPanelProps) {
}}
ref={ref}
>
<Card className="w-full max-w-lg" variant="flat">
{children}
<div className="mt-6 flex justify-end gap-4">
{variant === "unactionable" ? (
<Button onPress={close}>Close</Button>
) : (
<>
<Button onPress={close}>Cancel</Button>
<Button
isDisabled={isDisabled}
type="submit"
variant={variant === "destructive" ? "danger" : "heavy"}
>
Confirm
</Button>
</>
)}
</div>
</Card>
<div className="flex flex-col gap-4">{children}</div>
<div className="mt-5 flex justify-end gap-3">
{variant === "unactionable" ? (
<Button onPress={close}>Close</Button>
) : (
<>
<Button onPress={close}>Cancel</Button>
<Button
isDisabled={isDisabled}
type="submit"
variant={variant === "destructive" ? "danger" : "heavy"}
>
Confirm
</Button>
</>
)}
</div>
</Form>
);
}
@@ -155,9 +158,9 @@ function DModal(props: DModalProps) {
{...underlayProps}
aria-hidden="true"
className={cn(
"fixed inset-0 h-screen w-screen z-20",
"fixed inset-0 z-20 h-screen w-screen",
"flex items-center justify-center",
"bg-mist-900/15 dark:bg-mist-900/30",
"bg-mist-900/30 dark:bg-mist-950/60",
"entering:animate-in exiting:animate-out",
"entering:fade-in entering:duration-100 entering:ease-out",
"exiting:fade-out exiting:duration-50 exiting:ease-in",
@@ -165,7 +168,10 @@ function DModal(props: DModalProps) {
/>
<div
{...modalProps}
className={cn("fixed inset-0 h-screen w-screen z-20", "flex items-center justify-center")}
className={cn(
"fixed inset-0 z-20 h-screen w-screen",
"flex items-center justify-center p-4",
)}
>
{children}
</div>
-68
View File
@@ -1,68 +0,0 @@
import { CircleX } from "lucide-react";
import Link from "~/components/link";
import cn from "~/utils/cn";
interface FooterProps {
url: string;
debug: boolean;
healthy: boolean;
}
export default function Footer({ url, debug, healthy }: FooterProps) {
return (
<footer
className={cn(
"fixed w-full bottom-0 left-0 z-40 h-12",
"flex items-center justify-center",
"bg-mist-50 dark:bg-mist-950",
"dark:border-t dark:border-mist-800",
)}
>
<div
className={cn(
"grid grid-rows-1 items-center container",
!healthy && "md:grid-cols-[1fr_auto] grid-cols-1",
)}
>
<div className={cn("text-xs leading-none", !healthy && "hidden md:block")}>
<p>
Headplane is free. Please consider{" "}
<Link isExternal to="https://github.com/sponsors/tale" name="Aarnav's GitHub Sponsors">
donating
</Link>{" "}
to support development.{" "}
</p>
<p className="opacity-75">
Version: {__VERSION__}
{" — "}
Connecting to{" "}
<button
type="button"
tabIndex={0} // Allows keyboard focus
className={cn(
"blur-sm hover:blur-none focus:blur-none transition",
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1 rounded-xs",
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
)}
>
{url}
</button>
{debug && " (Debug mode enabled)"}
</p>
</div>
{!healthy ? (
<div
className={cn(
"flex gap-1.5 items-center p-2 rounded-md text-sm",
"bg-red-500 text-white font-semibold",
)}
>
<CircleX size={16} strokeWidth={3} />
<p className="text-nowrap">Headscale is unreachable</p>
</div>
) : undefined}
</div>
</footer>
);
}
-165
View File
@@ -1,165 +0,0 @@
import { CircleUser, Globe2, Lock, Server, Settings, Users } from "lucide-react";
import type { ReactNode } from "react";
import { NavLink, useSubmit } from "react-router";
import Logo from "~/components/Logo";
import Menu from "~/components/Menu";
import { AuthSession } from "~/server/web/sessions";
import cn from "~/utils/cn";
interface Props {
configAvailable: boolean;
onboarding: boolean;
user?: AuthSession["user"];
access: {
ui: boolean;
machines: boolean;
dns: boolean;
users: boolean;
policy: boolean;
settings: boolean;
};
}
interface LinkProps {
href: string;
text: string;
}
interface TabLinkProps {
name: string;
to: string;
icon: ReactNode;
}
function TabLink({ name, to, icon }: TabLinkProps) {
return (
<div className="relative py-2">
<NavLink
className={({ isActive }) =>
cn(
"px-3 py-2 flex items-center rounded-md text-nowrap gap-x-2.5",
"after:absolute after:bottom-0 after:left-3 after:right-3",
"after:h-0.5 after:bg-mist-900 dark:after:bg-mist-200",
"hover:bg-mist-200 dark:hover:bg-mist-900",
"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 ? "after:visible" : "after:invisible",
)
}
prefetch="intent"
to={to}
>
{icon} {name}
</NavLink>
</div>
);
}
function Link({ href, text }: LinkProps) {
return (
<a
className={cn(
"hidden sm:block hover:underline text-sm",
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1 rounded-md",
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
)}
href={href}
rel="noreferrer"
target="_blank"
>
{text}
</a>
);
}
export default function Header(data: Props) {
const submit = useSubmit();
return (
<header
className={cn(
"bg-mist-100 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 justify-between py-4">
<div className="flex items-center gap-x-2">
<Logo />
<h1 className="text-2xl font-semibold">headplane</h1>
</div>
<div className="flex items-center gap-x-4">
<Link href="https://tailscale.com/download" text="Download" />
<Link href="https://github.com/tale/headplane" text="GitHub" />
<Link href="https://github.com/juanfont/headscale" text="Headscale" />
{data.user ? (
<Menu>
<Menu.IconButton className={cn(data.user.picture ? "p-0" : "")} label="User">
{data.user.picture ? (
<img
alt={data.user.name}
className="h-8 w-8 rounded-full"
src={data.user.picture}
/>
) : (
<CircleUser />
)}
</Menu.IconButton>
<Menu.Panel
disabledKeys={["profile"]}
onAction={(key) => {
if (key === "logout") {
submit(
{},
{
method: "POST",
action: "/logout",
},
);
}
}}
>
<Menu.Section>
<Menu.Item key="profile" textValue="Profile">
<div className="text-black dark:text-mist-50">
<p className="font-bold">{data.user.name}</p>
<p>{data.user.email}</p>
</div>
</Menu.Item>
<Menu.Item key="logout" textValue="Logout">
<p className="text-red-500 dark:text-red-400">Logout</p>
</Menu.Item>
</Menu.Section>
</Menu.Panel>
</Menu>
) : undefined}
</div>
</div>
{data.access.ui && !data.onboarding ? (
<nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold">
{data.access.machines ? (
<TabLink icon={<Server className="w-5" />} name="Machines" to="/machines" />
) : undefined}
{data.access.users ? (
<TabLink icon={<Users className="w-5" />} name="Users" to="/users" />
) : undefined}
{data.access.policy ? (
<TabLink icon={<Lock className="w-5" />} name="Access Control" to="/acls" />
) : undefined}
{data.configAvailable ? (
<>
{data.access.dns ? (
<TabLink icon={<Globe2 className="w-5" />} name="DNS" to="/dns" />
) : undefined}
{data.access.settings ? (
<TabLink icon={<Settings className="w-5" />} name="Settings" to="/settings" />
) : undefined}
</>
) : undefined}
</nav>
) : undefined}
</header>
);
}
-10
View File
@@ -1,10 +0,0 @@
import cn from '~/utils/cn';
import LogoSvg from '../../public/logo-light.svg';
export interface LogoProps {
className?: string;
}
export default function Logo({ className }: LogoProps) {
return <img alt="Logo" className={cn(className)} src={LogoSvg} />;
}
+7 -7
View File
@@ -17,7 +17,6 @@ export function getErrorMessage(error: Error | unknown): {
const { statusCode, rawData, data, requestUrl } = error.data;
if (statusCode >= 500) {
return {
title: "Headscale API Error",
jsxMessage: (
<>
<Card.Text>
@@ -37,13 +36,13 @@ export function getErrorMessage(error: Error | unknown): {
)}
</>
),
title: "Headscale API Error",
};
}
const authError = error.data.statusCode === 401 || error.data.statusCode === 403;
return {
title: "Invalid response from Headscale API",
jsxMessage: (
<>
<Card.Text className="leading-snug">
@@ -84,13 +83,13 @@ export function getErrorMessage(error: Error | unknown): {
</pre>
</>
),
title: "Invalid response from Headscale API",
};
}
if (isConnectionError(error.data)) {
const { requestUrl, errorCode, errorMessage, extraData } = error.data;
return {
title: "Cannot connect to Headscale API",
jsxMessage: (
<>
<Card.Text className="leading-snug">
@@ -112,11 +111,11 @@ export function getErrorMessage(error: Error | unknown): {
</pre>
</>
),
title: "Cannot connect to Headscale API",
};
}
return {
title: `Error ${error.status}`,
jsxMessage: (
<>
There was an error processing your request.
@@ -126,18 +125,18 @@ export function getErrorMessage(error: Error | unknown): {
Status Text: <strong>{error.data}</strong>
</>
),
title: `Error ${error.status}`,
};
}
if (!(error instanceof Error)) {
return {
title: "Unexpected Error",
jsxMessage: (
<>
<Card.Text>
An unexpected error occurred which is most likely a bug. Please consider reporting
filing an issue on the{" "}
<Link isExternal name="Headplane GitHub" to="https://github.com/tale/headplane/issues">
<Link external styled to="https://github.com/tale/headplane/issues">
Headplane GitHub
</Link>{" "}
repository with the details below.
@@ -148,6 +147,7 @@ export function getErrorMessage(error: Error | unknown): {
</pre>
</>
),
title: "Unexpected Error",
};
}
@@ -166,11 +166,11 @@ export function getErrorMessage(error: Error | unknown): {
}
return {
jsxMessage: rootError.message,
title:
rootError.name.length > 0 && rootError.name !== "Error"
? `Error: ${rootError.name}`
: "Error",
jsxMessage: rootError.message,
};
}
+17 -20
View File
@@ -1,51 +1,48 @@
import { ExternalLink } from "lucide-react";
import type { JSX } from "react";
import type { JSX, ReactNode } from "react";
import { Link as RouterLink } from "react-router";
import cn from "~/utils/cn";
const linkStyles = cn(
"inline-flex items-center gap-x-0.5",
"text-blue-500 hover:text-blue-700",
"dark:text-blue-400 dark:hover:text-blue-300",
"focus:ring-offset-1 rounded-md",
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40",
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
);
export type LinkProps =
| {
isExternal: true;
external: true;
to: string;
name: string;
children: string;
children: ReactNode;
className?: string;
styled?: boolean;
}
| {
isExternal?: false;
external?: false;
to: string;
children?: string;
children?: ReactNode;
className?: string;
};
export default function Link(props: LinkProps): JSX.Element {
if (props.isExternal) {
if (props.external) {
return (
<a
href={props.to}
aria-label={props.name}
target="_blank"
rel="noreferrer"
className={cn(linkStyles, props.className)}
className={cn(
props.styled && [
"inline-flex items-center gap-x-0.5",
"text-blue-500 hover:text-blue-700",
"dark:text-blue-400 dark:hover:text-blue-300",
],
props.className,
)}
>
{props.children}
<ExternalLink className="w-3.5" />
{props.styled && <ExternalLink className="w-3.5" />}
</a>
);
}
return (
<RouterLink to={props.to} className={cn(linkStyles, props.className)}>
<RouterLink to={props.to} className={props.className}>
{props.children}
</RouterLink>
);
+103
View File
@@ -0,0 +1,103 @@
// oxlint-disable react/no-multi-comp
import { Menu as BaseMenu } from "@base-ui/react/menu";
import type { ComponentProps, JSX, ReactNode } from "react";
import cn from "~/utils/cn";
const SIDE_OFFSET = 8;
export const Menu = ({
children,
disabled,
}: {
children: ReactNode;
disabled?: boolean;
}): JSX.Element => <BaseMenu.Root disabled={disabled}>{children}</BaseMenu.Root>;
export const MenuTrigger = ({
className,
children,
disabled,
onClick,
}: Pick<
ComponentProps<typeof BaseMenu.Trigger>,
"className" | "children" | "disabled" | "onClick"
>): JSX.Element => (
<BaseMenu.Trigger
className={cn(
"inline-flex items-center justify-center",
"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",
"disabled:opacity-50 disabled:cursor-not-allowed",
className,
)}
disabled={disabled}
onClick={onClick}
>
{children}
</BaseMenu.Trigger>
);
export const MenuContent = ({
children,
className,
side = "bottom",
sideOffset = SIDE_OFFSET,
align = "start",
}: {
children: ReactNode;
className?: string;
side?: ComponentProps<typeof BaseMenu.Positioner>["side"];
sideOffset?: number;
align?: ComponentProps<typeof BaseMenu.Positioner>["align"];
}): JSX.Element => (
<BaseMenu.Portal>
<BaseMenu.Positioner side={side} sideOffset={sideOffset} align={align}>
<BaseMenu.Popup
className={cn(
"min-w-50 rounded-lg py-1",
"bg-white dark:bg-mist-900",
"shadow-lg dark:shadow-none",
"border border-mist-200 dark:border-mist-700",
"focus:outline-hidden",
className,
)}
>
{children}
</BaseMenu.Popup>
</BaseMenu.Positioner>
</BaseMenu.Portal>
);
export const MenuItem = ({
className,
variant,
children,
disabled,
onClick,
}: Pick<ComponentProps<typeof BaseMenu.Item>, "className" | "children" | "disabled" | "onClick"> & {
variant?: "danger";
}): JSX.Element => (
<BaseMenu.Item
className={cn(
"py-2 px-3 mx-1 rounded-md",
"select-none cursor-pointer",
"focus:outline-hidden",
"text-mist-700 dark:text-mist-300",
"data-highlighted:bg-mist-100/50 dark:data-highlighted:bg-mist-800",
"data-disabled:text-mist-400 dark:data-disabled:text-mist-600 data-disabled:cursor-default",
variant === "danger" && "text-red-500 dark:text-red-400",
className,
)}
disabled={disabled}
onClick={onClick}
>
{children}
</BaseMenu.Item>
);
export const MenuSeparator = ({ className }: { className?: string }): JSX.Element => (
<BaseMenu.Separator
className={cn("mx-2 my-1 border-t border-mist-200 dark:border-mist-800", className)}
/>
);