mirror of
https://github.com/tale/headplane.git
synced 2026-08-26 04:16:49 +00:00
feat: refactor several components and clean up ui
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
Reference in New Issue
Block a user