feat: switch to mist color from tailwind

This commit is contained in:
Aarnav Tale
2026-03-08 23:48:03 -04:00
parent df5e04f239
commit 0ce411e92b
49 changed files with 2738 additions and 2991 deletions
+76 -79
View File
@@ -1,86 +1,83 @@
import { Check, Copy, Info } from 'lucide-react';
import cn from '~/utils/cn';
import toast from '~/utils/toast';
import Tooltip from './Tooltip';
import { Check, Copy, Info } from "lucide-react";
import cn from "~/utils/cn";
import toast from "~/utils/toast";
import Tooltip from "./Tooltip";
export interface AttributeProps {
name: string;
value: string;
tooltip?: string;
isCopyable?: boolean;
name: string;
value: string;
tooltip?: string;
isCopyable?: boolean;
}
export default function Attribute({
name,
value,
tooltip,
isCopyable,
}: AttributeProps) {
return (
<dl className="flex gap-1 items-center text-sm">
<dt
className={cn(
'w-1/3 sm:w-1/4 lg:w-1/3 shrink-0 min-w-0',
'text-headplane-500 dark:text-headplane-400',
tooltip ? 'flex items-center gap-1' : undefined,
)}
>
{name}
{tooltip ? (
<Tooltip>
<Info className="size-4" />
<Tooltip.Body>{tooltip}</Tooltip.Body>
</Tooltip>
) : undefined}
</dt>
<dd
className={cn(
'min-w-0 px-1.5 py-1 rounded-lg border border-transparent',
...(isCopyable
? [
'cursor-pointer hover:shadow-xs',
'hover:bg-headplane-50 dark:hover:bg-headplane-800',
'hover:border-headplane-100 dark:hover:border-headplane-700',
]
: []),
)}
>
{isCopyable ? (
<button
type="button"
className="flex items-center gap-1.5 relative min-w-0 w-full"
onClick={async (event) => {
const svgs = event.currentTarget.querySelectorAll('svg');
for (const svg of svgs) {
svg.toggleAttribute('data-copied', true);
}
export default function Attribute({ name, value, tooltip, isCopyable }: AttributeProps) {
return (
<dl className="flex items-center gap-1 text-sm">
<dt
className={cn(
"w-1/3 sm:w-1/4 lg:w-1/3 shrink-0 min-w-0",
"text-mist-500 dark:text-mist-400",
tooltip ? "flex items-center gap-1" : undefined,
)}
>
{name}
{tooltip ? (
<Tooltip>
<Info className="size-4" />
<Tooltip.Body>{tooltip}</Tooltip.Body>
</Tooltip>
) : undefined}
</dt>
<dd
className={cn(
"min-w-0 px-1.5 py-1 rounded-lg border border-transparent",
...(isCopyable
? [
"cursor-pointer hover:shadow-xs",
"hover:bg-mist-50 dark:hover:bg-mist-800",
"hover:border-mist-100 dark:hover:border-mist-700",
]
: []),
)}
>
{isCopyable ? (
<button
type="button"
className="relative flex w-full min-w-0 items-center gap-1.5"
onClick={async (event) => {
const svgs = event.currentTarget.querySelectorAll("svg");
for (const svg of svgs) {
svg.toggleAttribute("data-copied", true);
}
await navigator.clipboard.writeText(value);
toast(`Copied ${name} to clipboard`);
await navigator.clipboard.writeText(value);
toast(`Copied ${name} to clipboard`);
setTimeout(() => {
for (const svg of svgs) {
svg.toggleAttribute('data-copied', false);
}
}, 1000);
}}
>
<div suppressHydrationWarning className="truncate">
{value}
</div>
{isCopyable ? (
<div>
<Check className="size-4 hidden data-copied:block" />
<Copy className="size-4 block data-copied:hidden" />
</div>
) : undefined}
</button>
) : (
<div className="relative min-w-0 truncate" suppressHydrationWarning>
{value}
</div>
)}
</dd>
</dl>
);
setTimeout(() => {
for (const svg of svgs) {
svg.toggleAttribute("data-copied", false);
}
}, 1000);
}}
>
<div suppressHydrationWarning className="truncate">
{value}
</div>
{isCopyable ? (
<div>
<Check className="hidden size-4 data-copied:block" />
<Copy className="block size-4 data-copied:hidden" />
</div>
) : undefined}
</button>
) : (
<div className="relative min-w-0 truncate" suppressHydrationWarning>
{value}
</div>
)}
</dd>
</dl>
);
}
+40 -38
View File
@@ -1,43 +1,45 @@
import React, { useRef } from 'react';
import { type AriaButtonOptions, useButton } from 'react-aria';
import cn from '~/utils/cn';
import React, { useRef } from "react";
import { type AriaButtonOptions, useButton } from "react-aria";
export interface ButtonProps extends AriaButtonOptions<'button'> {
variant?: 'heavy' | 'light' | 'danger';
className?: string;
children?: React.ReactNode;
ref?: React.RefObject<HTMLButtonElement | null>;
import cn from "~/utils/cn";
export interface ButtonProps extends AriaButtonOptions<"button"> {
variant?: "heavy" | "light" | "danger";
className?: string;
children?: React.ReactNode;
ref?: React.RefObject<HTMLButtonElement | null>;
}
export default function Button({ variant = 'light', ...props }: ButtonProps) {
// In case the button is used as a trigger ref
const ref = props.ref ?? useRef<HTMLButtonElement | null>(null);
const { buttonProps } = useButton(props, ref);
export default function Button({ variant = "light", ...props }: ButtonProps) {
// In case the button is used as a trigger ref
const ref = props.ref ?? useRef<HTMLButtonElement | null>(null);
const { buttonProps } = useButton(props, ref);
return (
<button
ref={ref}
{...buttonProps}
className={cn(
'w-fit text-sm rounded-xl px-3 py-2',
'focus:outline-hidden focus:ring-3',
props.isDisabled && 'opacity-60 cursor-not-allowed',
...(variant === 'heavy'
? [
'bg-headplane-900 dark:bg-headplane-50 font-semibold',
'hover:bg-headplane-900/90 dark:hover:bg-headplane-50/90',
'text-headplane-200 dark:text-headplane-800',
]
: variant === 'danger'
? ['bg-red-500 text-white font-semibold', 'hover:bg-red-500/90']
: [
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
]),
props.className,
)}
>
{props.children}
</button>
);
return (
<button
ref={ref}
{...buttonProps}
className={cn(
"w-fit text-sm rounded-md px-3 py-2",
"focus:outline-hidden focus:ring-3",
props.isDisabled && "opacity-60 cursor-not-allowed",
...(variant === "heavy"
? [
"bg-mist-900 dark:bg-mist-50 font-semibold",
"hover:bg-mist-900/90 dark:hover:bg-mist-50/90",
"text-mist-200 dark:text-mist-800",
]
: variant === "danger"
? ["bg-red-500 text-white font-semibold", "hover:bg-red-500/90"]
: [
"bg-mist-100 dark:bg-mist-800/50 font-medium",
"border border-mist-200 dark:border-mist-700",
"hover:bg-mist-200/90 dark:hover:bg-mist-700/50",
]),
props.className,
)}
>
{props.children}
</button>
);
}
+22 -21
View File
@@ -1,28 +1,29 @@
import React from 'react';
import Text from '~/components/Text';
import Title from '~/components/Title';
import cn from '~/utils/cn';
import React from "react";
import Text from "~/components/Text";
import Title from "~/components/Title";
import cn from "~/utils/cn";
interface Props extends React.HTMLProps<HTMLDivElement> {
variant?: 'raised' | 'flat';
variant?: "raised" | "flat";
}
function Card({ variant = 'raised', ...props }: Props) {
return (
<div
{...props}
className={cn(
'w-full max-w-md rounded-3xl p-5',
variant === 'flat'
? 'bg-transparent shadow-none'
: 'bg-headplane-50/50 dark:bg-headplane-950/50 shadow-xs',
'border border-headplane-100 dark:border-headplane-800',
props.className,
)}
>
{props.children}
</div>
);
function Card({ variant = "raised", ...props }: Props) {
return (
<div
{...props}
className={cn(
"w-full max-w-md rounded-lg p-5",
variant === "flat"
? "bg-transparent shadow-none"
: "bg-mist-50/50 dark:bg-mist-950/50 shadow-surface",
"border border-mist-100 dark:border-mist-800",
props.className,
)}
>
{props.children}
</div>
);
}
export default Object.assign(Card, { Title, Text });
+23 -27
View File
@@ -1,32 +1,28 @@
import React from 'react';
import cn from '~/utils/cn';
import React from "react";
import cn from "~/utils/cn";
export interface ChipProps {
text: string;
className?: string;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
text: string;
className?: string;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
}
export default function Chip({
text,
className,
leftIcon,
rightIcon,
}: ChipProps) {
return (
<span
className={cn(
'h-5 text-xs py-0.5 px-1 rounded-md text-nowrap',
'text-headplane-700 dark:text-headplane-100',
'bg-headplane-100 dark:bg-headplane-700',
'inline-flex items-center gap-x-1',
className,
)}
>
{leftIcon}
{text}
{rightIcon}
</span>
);
export default function Chip({ text, className, leftIcon, rightIcon }: ChipProps) {
return (
<span
className={cn(
"h-5 text-xs py-0.5 px-1 rounded-md text-nowrap",
"text-mist-700 dark:text-mist-100",
"bg-mist-100 dark:bg-mist-700",
"inline-flex items-center gap-x-1",
className,
)}
>
{leftIcon}
{text}
{rightIcon}
</span>
);
}
+42 -41
View File
@@ -1,50 +1,51 @@
import { Check, Copy } from 'lucide-react';
import { HTMLProps } from 'react';
import cn from '~/utils/cn';
import toast from '~/utils/toast';
import { Check, Copy } from "lucide-react";
import { HTMLProps } from "react";
import cn from "~/utils/cn";
import toast from "~/utils/toast";
export interface CodeProps extends HTMLProps<HTMLSpanElement> {
isCopyable?: boolean;
children: string | string[] | number;
isCopyable?: boolean;
children: string | string[] | number;
}
export default function Code({ isCopyable, children, className }: CodeProps) {
return (
<code
className={cn(
'bg-headplane-100 dark:bg-headplane-800 px-1 py-0.5 font-mono',
'rounded-lg focus-within:outline-hidden focus-within:ring-2',
isCopyable && 'relative pr-7',
className,
)}
>
{children}
{isCopyable && (
<button
className="bottom-0 right-0 absolute"
onClick={async (event) => {
const text = Array.isArray(children) ? children.join('') : children;
return (
<code
className={cn(
"bg-mist-100 dark:bg-mist-800 px-1 py-0.5 font-mono",
"rounded-sm focus-within:outline-hidden focus-within:ring-2",
isCopyable && "relative pr-7",
className,
)}
>
{children}
{isCopyable && (
<button
className="absolute right-0 bottom-0"
onClick={async (event) => {
const text = Array.isArray(children) ? children.join("") : children;
const svgs = event.currentTarget.querySelectorAll('svg');
for (const svg of svgs) {
svg.toggleAttribute('data-copied', true);
}
const svgs = event.currentTarget.querySelectorAll("svg");
for (const svg of svgs) {
svg.toggleAttribute("data-copied", true);
}
await navigator.clipboard.writeText(text);
toast('Copied to clipboard');
await navigator.clipboard.writeText(text);
toast("Copied to clipboard");
setTimeout(() => {
for (const svg of svgs) {
svg.toggleAttribute('data-copied', false);
}
}, 1000);
}}
type="button"
>
<Check className="h-4.5 w-4.5 p-1 hidden data-copied:block" />
<Copy className="h-4.5 w-4.5 p-1 block data-copied:hidden" />
</button>
)}
</code>
);
setTimeout(() => {
for (const svg of svgs) {
svg.toggleAttribute("data-copied", false);
}
}, 1000);
}}
type="button"
>
<Check className="hidden h-4.5 w-4.5 p-1 data-copied:block" />
<Copy className="block h-4.5 w-4.5 p-1 data-copied:hidden" />
</button>
)}
</code>
);
}
+151 -163
View File
@@ -1,194 +1,182 @@
import React, { cloneElement, useEffect, useRef } from 'react';
import React, { cloneElement, useEffect, useRef } from "react";
import {
type AriaDialogProps,
type AriaModalOverlayProps,
Overlay,
useDialog,
useModalOverlay,
useOverlayTrigger,
} from 'react-aria';
import { Form, type HTMLFormMethod } from 'react-router';
type AriaDialogProps,
type AriaModalOverlayProps,
Overlay,
useDialog,
useModalOverlay,
useOverlayTrigger,
} from "react-aria";
import { Form, type HTMLFormMethod } from "react-router";
import {
type OverlayTriggerProps,
type OverlayTriggerState,
useOverlayTriggerState,
} 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';
import cn from '~/utils/cn';
import { useLiveData } from '~/utils/live-data';
type OverlayTriggerProps,
type OverlayTriggerState,
useOverlayTriggerState,
} 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";
import cn from "~/utils/cn";
import { useLiveData } from "~/utils/live-data";
export interface DialogProps extends OverlayTriggerProps {
children:
| [
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
React.ReactElement<DialogPanelProps>,
]
| React.ReactElement<DialogPanelProps>;
children:
| [
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
React.ReactElement<DialogPanelProps>,
]
| React.ReactElement<DialogPanelProps>;
}
function Dialog(props: DialogProps) {
const { pause, resume } = useLiveData();
const state = useOverlayTriggerState(props);
const { triggerProps, overlayProps } = useOverlayTrigger(
{
type: 'dialog',
},
state,
);
const { pause, resume } = useLiveData();
const state = useOverlayTriggerState(props);
const { triggerProps, overlayProps } = useOverlayTrigger(
{
type: "dialog",
},
state,
);
useEffect(() => {
if (state.isOpen) {
pause();
} else {
resume();
}
}, [state.isOpen]);
useEffect(() => {
if (state.isOpen) {
pause();
} else {
resume();
}
}, [state.isOpen]);
if (Array.isArray(props.children)) {
const [button, panel] = props.children;
return (
<>
{cloneElement(button, triggerProps)}
{state.isOpen && (
<DModal state={state}>
{cloneElement(panel, {
...overlayProps,
close: () => state.close(),
})}
</DModal>
)}
</>
);
}
if (Array.isArray(props.children)) {
const [button, panel] = props.children;
return (
<>
{cloneElement(button, triggerProps)}
{state.isOpen && (
<DModal state={state}>
{cloneElement(panel, {
...overlayProps,
close: () => state.close(),
})}
</DModal>
)}
</>
);
}
return (
<DModal state={state}>
{cloneElement(props.children, {
...overlayProps,
close: () => state.close(),
})}
</DModal>
);
return (
<DModal state={state}>
{cloneElement(props.children, {
...overlayProps,
close: () => state.close(),
})}
</DModal>
);
}
export interface DialogPanelProps extends AriaDialogProps {
children: React.ReactNode;
variant?: 'normal' | 'destructive' | 'unactionable';
onSubmit?: React.FormEventHandler<HTMLFormElement>;
method?: HTMLFormMethod;
isDisabled?: boolean;
children: React.ReactNode;
variant?: "normal" | "destructive" | "unactionable";
onSubmit?: React.FormEventHandler<HTMLFormElement>;
method?: HTMLFormMethod;
isDisabled?: boolean;
// Anonymous (passed by parent)
close?: () => void;
// Anonymous (passed by parent)
close?: () => void;
}
function Panel(props: DialogPanelProps) {
const {
children,
onSubmit,
isDisabled,
close,
variant,
method = 'POST',
} = props;
const ref = useRef<HTMLFormElement | null>(null);
const { dialogProps } = useDialog(
{
...props,
role: 'alertdialog',
},
ref,
);
const { children, onSubmit, isDisabled, close, variant, method = "POST" } = props;
const ref = useRef<HTMLFormElement | null>(null);
const { dialogProps } = useDialog(
{
...props,
role: "alertdialog",
},
ref,
);
return (
<Form
{...dialogProps}
className={cn(
'outline-hidden rounded-3xl w-full max-w-lg',
'bg-white dark:bg-headplane-900',
)}
method={method ?? 'POST'}
onSubmit={(event) => {
if (onSubmit) {
onSubmit(event);
}
return (
<Form
{...dialogProps}
className={cn("outline-hidden rounded-lg w-full max-w-lg", "bg-white dark:bg-mist-900")}
method={method ?? "POST"}
onSubmit={(event) => {
if (onSubmit) {
onSubmit(event);
}
close?.();
}}
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>
</Form>
);
close?.();
}}
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>
</Form>
);
}
interface DModalProps extends AriaModalOverlayProps {
children: React.ReactNode;
state: OverlayTriggerState;
children: React.ReactNode;
state: OverlayTriggerState;
}
function DModal(props: DModalProps) {
const { children, state } = props;
const ref = useRef<HTMLDivElement>(null);
const { modalProps, underlayProps } = useModalOverlay(props, state, ref);
const { children, state } = props;
const ref = useRef<HTMLDivElement>(null);
const { modalProps, underlayProps } = useModalOverlay(props, state, ref);
if (!state.isOpen) {
return null;
}
if (!state.isOpen) {
return null;
}
return (
<Overlay>
<div
{...underlayProps}
aria-hidden="true"
className={cn(
'fixed inset-0 h-screen w-screen z-20',
'flex items-center justify-center',
'bg-headplane-900/15 dark:bg-headplane-900/30',
'entering:animate-in exiting:animate-out',
'entering:fade-in entering:duration-100 entering:ease-out',
'exiting:fade-out exiting:duration-50 exiting:ease-in',
)}
/>
<div
{...modalProps}
className={cn(
'fixed inset-0 h-screen w-screen z-20',
'flex items-center justify-center',
)}
>
{children}
</div>
</Overlay>
);
return (
<Overlay>
<div
{...underlayProps}
aria-hidden="true"
className={cn(
"fixed inset-0 h-screen w-screen z-20",
"flex items-center justify-center",
"bg-mist-900/15 dark:bg-mist-900/30",
"entering:animate-in exiting:animate-out",
"entering:fade-in entering:duration-100 entering:ease-out",
"exiting:fade-out exiting:duration-50 exiting:ease-in",
)}
/>
<div
{...modalProps}
className={cn("fixed inset-0 h-screen w-screen z-20", "flex items-center justify-center")}
>
{children}
</div>
</Overlay>
);
}
export default Object.assign(Dialog, {
Button,
IconButton,
Panel,
Title,
Text,
Button,
IconButton,
Panel,
Title,
Text,
});
+61 -65
View File
@@ -1,71 +1,67 @@
import { CircleX } from 'lucide-react';
import Link from '~/components/Link';
import cn from '~/utils/cn';
import { CircleX } from "lucide-react";
import Link from "~/components/Link";
import cn from "~/utils/cn";
interface FooterProps {
url: string;
debug: boolean;
healthy: boolean;
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-headplane-50 dark:bg-headplane-950',
'dark:border-t dark:border-headplane-800',
)}
>
<div
className={cn(
'grid grid-rows-1 items-center container mx-auto',
!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
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 rounded-xs',
)}
>
{url}
</button>
{debug && ' (Debug mode enabled)'}
</p>
</div>
{!healthy ? (
<div
className={cn(
'flex gap-1.5 items-center p-2 rounded-xl 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>
);
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 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 rounded-xs",
)}
>
{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>
);
}
+144 -173
View File
@@ -1,192 +1,163 @@
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';
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;
};
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;
href: string;
text: string;
}
interface TabLinkProps {
name: string;
to: string;
icon: ReactNode;
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-headplane-900 dark:after:bg-headplane-200',
'hover:bg-headplane-200 dark:hover:bg-headplane-900',
'focus:outline-hidden focus:ring-3',
isActive ? 'after:visible' : 'after:invisible',
)
}
prefetch="intent"
to={to}
>
{icon} {name}
</NavLink>
</div>
);
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-3",
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-3 rounded-md',
)}
href={href}
rel="noreferrer"
target="_blank"
>
{text}
</a>
);
return (
<a
className={cn(
"hidden sm:block hover:underline text-sm",
"focus:outline-hidden focus:ring-3 rounded-md",
)}
href={href}
rel="noreferrer"
target="_blank"
>
{text}
</a>
);
}
export default function Header(data: Props) {
const submit = useSubmit();
const submit = useSubmit();
return (
<header
className={cn(
'bg-headplane-100 dark:bg-headplane-950',
'text-headplane-800 dark:text-headplane-200',
'dark:border-b dark:border-headplane-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="w-8 h-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-headplane-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>
);
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>
);
}
+39 -41
View File
@@ -1,46 +1,44 @@
import React, { useRef } from 'react';
import { type AriaButtonOptions, useButton } from 'react-aria';
import cn from '~/utils/cn';
import React, { useRef } from "react";
import { type AriaButtonOptions, useButton } from "react-aria";
export interface IconButtonProps extends AriaButtonOptions<'button'> {
variant?: 'heavy' | 'light';
className?: string;
children: React.ReactNode;
label: string;
ref?: React.RefObject<HTMLButtonElement | null>;
import cn from "~/utils/cn";
export interface IconButtonProps extends AriaButtonOptions<"button"> {
variant?: "heavy" | "light";
className?: string;
children: React.ReactNode;
label: string;
ref?: React.RefObject<HTMLButtonElement | null>;
}
export default function IconButton({
variant = 'light',
...props
}: IconButtonProps) {
// In case the button is used as a trigger ref
const ref = props.ref ?? useRef<HTMLButtonElement | null>(null);
const { buttonProps } = useButton(props, ref);
export default function IconButton({ variant = "light", ...props }: IconButtonProps) {
// In case the button is used as a trigger ref
const ref = props.ref ?? useRef<HTMLButtonElement | null>(null);
const { buttonProps } = useButton(props, ref);
return (
<button
ref={ref}
{...buttonProps}
aria-label={props.label}
className={cn(
'rounded-full flex items-center justify-center p-1',
'focus:outline-hidden focus:ring-3',
props.isDisabled && 'opacity-60 cursor-not-allowed',
...(variant === 'heavy'
? [
'bg-headplane-900 dark:bg-headplane-50 font-semibold',
'hover:bg-headplane-900/90 dark:hover:bg-headplane-50/90',
'text-headplane-200 dark:text-headplane-800',
]
: [
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
]),
props.className,
)}
>
{props.children}
</button>
);
return (
<button
ref={ref}
{...buttonProps}
aria-label={props.label}
className={cn(
"rounded-full flex items-center justify-center p-1",
"focus:outline-hidden focus:ring-3",
props.isDisabled && "opacity-60 cursor-not-allowed",
...(variant === "heavy"
? [
"bg-mist-900 dark:bg-mist-50 font-semibold",
"hover:bg-mist-900/90 dark:hover:bg-mist-50/90",
"text-mist-200 dark:text-mist-800",
]
: [
"bg-mist-100 dark:bg-mist-700/30 font-medium",
"hover:bg-mist-200/90 dark:hover:bg-mist-800/30",
]),
props.className,
)}
>
{props.children}
</button>
);
}
+74 -84
View File
@@ -1,93 +1,83 @@
import { Asterisk } from 'lucide-react';
import { useRef } from 'react';
import { type AriaTextFieldProps, useId, useTextField } from 'react-aria';
import cn from '~/utils/cn';
import { Asterisk } from "lucide-react";
import { useRef } from "react";
import { type AriaTextFieldProps, useId, useTextField } from "react-aria";
import cn from "~/utils/cn";
export interface InputProps extends AriaTextFieldProps<HTMLInputElement> {
label: string;
labelHidden?: boolean;
isRequired?: boolean;
className?: string;
isInvalid?: boolean;
errorMessage?: string;
label: string;
labelHidden?: boolean;
isRequired?: boolean;
className?: string;
isInvalid?: boolean;
errorMessage?: string;
}
export default function Input(props: InputProps) {
const {
label,
labelHidden,
className,
isInvalid: customIsInvalid,
errorMessage,
} = props;
const ref = useRef<HTMLInputElement | null>(null);
const id = useId(props.id);
const { label, labelHidden, className, isInvalid: customIsInvalid, errorMessage } = props;
const ref = useRef<HTMLInputElement | null>(null);
const id = useId(props.id);
const {
labelProps,
inputProps,
descriptionProps,
errorMessageProps,
isInvalid: ariaIsInvalid,
validationErrors,
} = useTextField(
{
...props,
label,
'aria-label': label,
},
ref,
);
const {
labelProps,
inputProps,
descriptionProps,
errorMessageProps,
isInvalid: ariaIsInvalid,
validationErrors,
} = useTextField(
{
...props,
label,
"aria-label": label,
},
ref,
);
const isInvalid = customIsInvalid ?? ariaIsInvalid;
const isInvalid = customIsInvalid ?? ariaIsInvalid;
return (
<div className="flex flex-col w-full">
<label
{...labelProps}
className={cn(
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
labelHidden && 'sr-only',
)}
htmlFor={id}
>
{label}
{props.isRequired && (
<Asterisk className="inline w-3.5 text-red-500 pb-1 ml-0.5" />
)}
</label>
<input
{...inputProps}
className={cn(
'rounded-xl px-3 py-2',
'focus:outline-hidden focus:ring-3',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
className,
)}
ref={ref}
required={props.isRequired}
/>
{props.description && (
<div
{...descriptionProps}
className={cn(
'text-xs px-3 mt-1',
'text-headplane-500 dark:text-headplane-400',
)}
>
{props.description}
</div>
)}
{isInvalid ? (
<div
{...errorMessageProps}
className={cn('text-xs px-3 mt-1', 'text-red-500 dark:text-red-400')}
>
{errorMessage ?? validationErrors.join(' ')}
</div>
) : null}
</div>
);
return (
<div className="flex w-full flex-col">
<label
{...labelProps}
className={cn(
"text-xs font-medium px-3 mb-0.5",
"text-mist-700 dark:text-mist-100",
labelHidden && "sr-only",
)}
htmlFor={id}
>
{label}
{props.isRequired && <Asterisk className="ml-0.5 inline w-3.5 pb-1 text-red-500" />}
</label>
<input
{...inputProps}
className={cn(
"rounded-md px-3 py-2",
"focus:outline-hidden focus:ring-3",
"bg-white dark:bg-mist-900",
"border border-mist-100 dark:border-mist-800",
className,
)}
ref={ref}
required={props.isRequired}
/>
{props.description && (
<div
{...descriptionProps}
className={cn("text-xs px-3 mt-1", "text-mist-500 dark:text-mist-400")}
>
{props.description}
</div>
)}
{isInvalid ? (
<div
{...errorMessageProps}
className={cn("text-xs px-3 mt-1", "text-red-500 dark:text-red-400")}
>
{errorMessage ?? validationErrors.join(" ")}
</div>
) : null}
</div>
);
}
+128 -134
View File
@@ -1,170 +1,164 @@
import React, { useRef, cloneElement } from 'react';
import { type AriaMenuProps, Key, Placement, useMenuTrigger } from 'react-aria';
import { useMenu, useMenuItem, useMenuSection, useSeparator } from 'react-aria';
import { Item, Section } from 'react-stately';
import React, { useRef, cloneElement } from "react";
import { type AriaMenuProps, Key, Placement, useMenuTrigger } from "react-aria";
import { useMenu, useMenuItem, useMenuSection, useSeparator } from "react-aria";
import { Item, Section } from "react-stately";
import {
type MenuTriggerProps,
Node,
TreeState,
useMenuTriggerState,
useTreeState,
} from 'react-stately';
import Button, { ButtonProps } from '~/components/Button';
import IconButton, { IconButtonProps } from '~/components/IconButton';
import Popover from '~/components/Popover';
import cn from '~/utils/cn';
type MenuTriggerProps,
Node,
TreeState,
useMenuTriggerState,
useTreeState,
} from "react-stately";
import Button, { ButtonProps } from "~/components/Button";
import IconButton, { IconButtonProps } from "~/components/IconButton";
import Popover from "~/components/Popover";
import cn from "~/utils/cn";
interface MenuProps extends MenuTriggerProps {
placement?: Placement;
isDisabled?: boolean;
disabledKeys?: Key[];
children: [
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
React.ReactElement<MenuPanelProps>,
];
placement?: Placement;
isDisabled?: boolean;
disabledKeys?: Key[];
children: [
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
React.ReactElement<MenuPanelProps>,
];
}
// TODO: onAction is called twice for some reason?
// TODO: isDisabled per-prop
function Menu(props: MenuProps) {
const { placement = 'bottom', isDisabled, disabledKeys = [] } = props;
const state = useMenuTriggerState(props);
const ref = useRef<HTMLButtonElement | null>(null);
const { menuTriggerProps, menuProps } = useMenuTrigger<object>(
{},
state,
ref,
);
const { placement = "bottom", isDisabled, disabledKeys = [] } = props;
const state = useMenuTriggerState(props);
const ref = useRef<HTMLButtonElement | null>(null);
const { menuTriggerProps, menuProps } = useMenuTrigger<object>({}, state, ref);
// cloneElement is necessary because the button is a union type
// of multiple things and we need to join props from our hooks
const [button, panel] = props.children;
return (
<div>
{cloneElement(button, {
...menuTriggerProps,
isDisabled: isDisabled,
ref,
})}
{state.isOpen && (
<Popover state={state} triggerRef={ref} placement={placement}>
{cloneElement(panel, {
...menuProps,
autoFocus: state.focusStrategy ?? true,
onClose: () => state.close(),
disabledKeys,
})}
</Popover>
)}
</div>
);
// cloneElement is necessary because the button is a union type
// of multiple things and we need to join props from our hooks
const [button, panel] = props.children;
return (
<div>
{cloneElement(button, {
...menuTriggerProps,
isDisabled: isDisabled,
ref,
})}
{state.isOpen && (
<Popover state={state} triggerRef={ref} placement={placement}>
{cloneElement(panel, {
...menuProps,
autoFocus: state.focusStrategy ?? true,
onClose: () => state.close(),
disabledKeys,
})}
</Popover>
)}
</div>
);
}
interface MenuPanelProps extends AriaMenuProps<object> {
onClose?: () => void;
disabledKeys?: Key[];
onClose?: () => void;
disabledKeys?: Key[];
}
function Panel(props: MenuPanelProps) {
const state = useTreeState(props);
const ref = useRef(null);
const state = useTreeState(props);
const ref = useRef(null);
const { menuProps } = useMenu(props, state, ref);
return (
<ul
{...menuProps}
ref={ref}
className="pt-1 pb-1 shadow-2xs rounded-md min-w-[200px] focus:outline-hidden"
>
{[...state.collection].map((item) => (
<MenuSection
key={item.key}
section={item}
state={state}
disabledKeys={props.disabledKeys}
/>
))}
</ul>
);
const { menuProps } = useMenu(props, state, ref);
return (
<ul
{...menuProps}
ref={ref}
className="min-w-[200px] rounded-md pt-1 pb-1 shadow-2xs focus:outline-hidden"
>
{[...state.collection].map((item) => (
<MenuSection
key={item.key}
section={item}
state={state}
disabledKeys={props.disabledKeys}
/>
))}
</ul>
);
}
interface MenuSectionProps<T> {
section: Node<T>;
state: TreeState<T>;
disabledKeys?: Key[];
section: Node<T>;
state: TreeState<T>;
disabledKeys?: Key[];
}
function MenuSection<T>({ section, state, disabledKeys }: MenuSectionProps<T>) {
const { itemProps, groupProps } = useMenuSection({
heading: section.rendered,
'aria-label': section['aria-label'],
});
const { itemProps, groupProps } = useMenuSection({
heading: section.rendered,
"aria-label": section["aria-label"],
});
const { separatorProps } = useSeparator({
elementType: 'li',
});
const { separatorProps } = useSeparator({
elementType: "li",
});
return (
<>
{section.key !== state.collection.getFirstKey() ? (
<li
{...separatorProps}
className={cn(
'mx-2 mt-1 mb-1 border-t',
'border-headplane-200 dark:border-headplane-800',
)}
/>
) : undefined}
<li {...itemProps}>
<ul {...groupProps}>
{[...section.childNodes].map((item) => (
<MenuItem
key={item.key}
item={item}
state={state}
isDisabled={disabledKeys?.includes(item.key)}
/>
))}
</ul>
</li>
</>
);
return (
<>
{section.key !== state.collection.getFirstKey() ? (
<li
{...separatorProps}
className={cn("mx-2 mt-1 mb-1 border-t", "border-mist-200 dark:border-mist-800")}
/>
) : undefined}
<li {...itemProps}>
<ul {...groupProps}>
{[...section.childNodes].map((item) => (
<MenuItem
key={item.key}
item={item}
state={state}
isDisabled={disabledKeys?.includes(item.key)}
/>
))}
</ul>
</li>
</>
);
}
interface MenuItemProps<T> {
item: Node<T>;
state: TreeState<T>;
isDisabled?: boolean;
item: Node<T>;
state: TreeState<T>;
isDisabled?: boolean;
}
function MenuItem<T>({ item, state, isDisabled }: MenuItemProps<T>) {
const ref = useRef<HTMLLIElement | null>(null);
const { menuItemProps } = useMenuItem({ key: item.key }, state, ref);
const ref = useRef<HTMLLIElement | null>(null);
const { menuItemProps } = useMenuItem({ key: item.key }, state, ref);
const isFocused = state.selectionManager.focusedKey === item.key;
const isFocused = state.selectionManager.focusedKey === item.key;
return (
<li
{...menuItemProps}
ref={ref}
className={cn(
'py-2 px-3 mx-1 rounded-lg',
'focus:outline-hidden select-none',
isFocused && 'bg-headplane-100/50 dark:bg-headplane-800',
isDisabled
? 'text-headplane-400 dark:text-headplane-600'
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800 cursor-pointer',
)}
>
{item.rendered}
</li>
);
return (
<li
{...menuItemProps}
ref={ref}
className={cn(
"py-2 px-3 mx-1 rounded-lg",
"focus:outline-hidden select-none",
isFocused && "bg-mist-100/50 dark:bg-mist-800",
isDisabled
? "text-mist-400 dark:text-mist-600"
: "hover:bg-mist-100/50 dark:hover:bg-mist-800 cursor-pointer",
)}
>
{item.rendered}
</li>
);
}
export default Object.assign(Menu, {
Button,
IconButton,
Panel,
Section,
Item,
Button,
IconButton,
Panel,
Section,
Item,
});
+76 -94
View File
@@ -1,102 +1,84 @@
import { Minus, Plus } from 'lucide-react';
import { useRef } from 'react';
import {
type AriaNumberFieldProps,
useId,
useLocale,
useNumberField,
} from 'react-aria';
import { useNumberFieldState } from 'react-stately';
import IconButton from '~/components/IconButton';
import cn from '~/utils/cn';
import { Minus, Plus } from "lucide-react";
import { useRef } from "react";
import { type AriaNumberFieldProps, useId, useLocale, useNumberField } from "react-aria";
import { useNumberFieldState } from "react-stately";
import IconButton from "~/components/IconButton";
import cn from "~/utils/cn";
export interface InputProps extends AriaNumberFieldProps {
isRequired?: boolean;
name?: string;
isRequired?: boolean;
name?: string;
}
export default function NumberInput(props: InputProps) {
const { label, name } = props;
const { locale } = useLocale();
const state = useNumberFieldState({ ...props, locale });
const ref = useRef<HTMLInputElement | null>(null);
const id = useId(props.id);
const { label, name } = props;
const { locale } = useLocale();
const state = useNumberFieldState({ ...props, locale });
const ref = useRef<HTMLInputElement | null>(null);
const id = useId(props.id);
const {
labelProps,
inputProps,
groupProps,
incrementButtonProps,
decrementButtonProps,
descriptionProps,
errorMessageProps,
isInvalid,
validationErrors,
} = useNumberField(props, state, ref);
const {
labelProps,
inputProps,
groupProps,
incrementButtonProps,
decrementButtonProps,
descriptionProps,
errorMessageProps,
isInvalid,
validationErrors,
} = useNumberField(props, state, ref);
return (
<div className="flex flex-col">
<label
{...labelProps}
htmlFor={id}
className={cn(
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
)}
>
{label}
</label>
<div
{...groupProps}
className={cn(
'flex items-center gap-1 rounded-xl pr-1',
'focus-within:outline-hidden focus-within:ring-3',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
)}
>
<input
{...inputProps}
required={props.isRequired}
ref={ref}
id={id}
className="w-full pl-3 py-2 rounded-l-xl bg-transparent focus:outline-hidden"
/>
<input type="hidden" name={name} value={state.numberValue} />
<IconButton
{...decrementButtonProps}
label="Decrement"
className="w-7.5 h-7.5 rounded-lg"
>
<Minus className="p-1" />
</IconButton>
<IconButton
{...incrementButtonProps}
label="Increment"
className="w-7.5 h-7.5 rounded-lg"
>
<Plus className="p-1" />
</IconButton>
</div>
{props.description && (
<div
{...descriptionProps}
className={cn(
'text-xs px-3 mt-1',
'text-headplane-500 dark:text-headplane-400',
)}
>
{props.description}
</div>
)}
{isInvalid && (
<div
{...errorMessageProps}
className={cn('text-xs px-3 mt-1', 'text-red-500 dark:text-red-400')}
>
{validationErrors.join(' ')}
</div>
)}
</div>
);
return (
<div className="flex flex-col">
<label
{...labelProps}
htmlFor={id}
className={cn("text-xs font-medium px-3 mb-0.5", "text-mist-700 dark:text-mist-100")}
>
{label}
</label>
<div
{...groupProps}
className={cn(
"flex items-center gap-1 rounded-md pr-1",
"focus-within:outline-hidden focus-within:ring-3",
"bg-white dark:bg-mist-900",
"border border-mist-100 dark:border-mist-800",
)}
>
<input
{...inputProps}
required={props.isRequired}
ref={ref}
id={id}
className="w-full rounded-l-md bg-transparent py-2 pl-3 focus:outline-hidden"
/>
<input type="hidden" name={name} value={state.numberValue} />
<IconButton {...decrementButtonProps} label="Decrement" className="h-7.5 w-7.5 rounded-lg">
<Minus className="p-1" />
</IconButton>
<IconButton {...incrementButtonProps} label="Increment" className="h-7.5 w-7.5 rounded-lg">
<Plus className="p-1" />
</IconButton>
</div>
{props.description && (
<div
{...descriptionProps}
className={cn("text-xs px-3 mt-1", "text-mist-500 dark:text-mist-400")}
>
{props.description}
</div>
)}
{isInvalid && (
<div
{...errorMessageProps}
className={cn("text-xs px-3 mt-1", "text-red-500 dark:text-red-400")}
>
{validationErrors.join(" ")}
</div>
)}
</div>
);
}
+47 -56
View File
@@ -1,78 +1,69 @@
import { useRef } from 'react';
import {
AriaTabListProps,
AriaTabPanelProps,
useTab,
useTabList,
useTabPanel,
} from 'react-aria';
import { Item, Node, TabListState, useTabListState } from 'react-stately';
import cn from '~/utils/cn';
import { useRef } from "react";
import { AriaTabListProps, AriaTabPanelProps, useTab, useTabList, useTabPanel } from "react-aria";
import { Item, Node, TabListState, useTabListState } from "react-stately";
import cn from "~/utils/cn";
export interface OptionsProps extends AriaTabListProps<object> {
label: string;
className?: string;
label: string;
className?: string;
}
function Options({ label, className, ...props }: OptionsProps) {
const state = useTabListState(props);
const ref = useRef<HTMLDivElement | null>(null);
const state = useTabListState(props);
const ref = useRef<HTMLDivElement | null>(null);
const { tabListProps } = useTabList(props, state, ref);
return (
<div className={cn('flex flex-col', className)}>
<div
{...tabListProps}
ref={ref}
className="flex items-center gap-2 overflow-x-scroll"
>
{[...state.collection].map((item) => (
<Option key={item.key} item={item} state={state} />
))}
</div>
<OptionsPanel key={state.selectedItem?.key} state={state} />
</div>
);
const { tabListProps } = useTabList(props, state, ref);
return (
<div className={cn("flex flex-col", className)}>
<div {...tabListProps} ref={ref} className="flex items-center gap-2 overflow-x-scroll">
{[...state.collection].map((item) => (
<Option key={item.key} item={item} state={state} />
))}
</div>
<OptionsPanel key={state.selectedItem?.key} state={state} />
</div>
);
}
export interface OptionsOptionProps {
item: Node<object>;
state: TabListState<object>;
item: Node<object>;
state: TabListState<object>;
}
function Option({ item, state }: OptionsOptionProps) {
const { key, rendered } = item;
const ref = useRef<HTMLDivElement | null>(null);
const { key, rendered } = item;
const ref = useRef<HTMLDivElement | null>(null);
const { tabProps } = useTab({ key }, state, ref);
return (
<div
{...tabProps}
ref={ref}
className={cn(
'pl-0.5 pr-2 py-0.5 rounded-lg cursor-pointer',
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
'focus:outline-hidden focus:ring-3 z-10',
'border border-headplane-100 dark:border-headplane-800',
)}
>
{rendered}
</div>
);
const { tabProps } = useTab({ key }, state, ref);
return (
<div
{...tabProps}
ref={ref}
className={cn(
"pl-0.5 pr-2 py-0.5 rounded-md cursor-pointer",
"aria-selected:bg-mist-100 dark:aria-selected:bg-mist-950",
"focus:outline-hidden focus:ring-3 z-10",
"border border-mist-100 dark:border-mist-800",
)}
>
{rendered}
</div>
);
}
export interface OptionsPanelProps extends AriaTabPanelProps {
state: TabListState<object>;
state: TabListState<object>;
}
function OptionsPanel({ state, ...props }: OptionsPanelProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { tabPanelProps } = useTabPanel(props, state, ref);
return (
<div {...tabPanelProps} ref={ref} className="w-full mt-2">
{state.selectedItem?.props.children}
</div>
);
const ref = useRef<HTMLDivElement | null>(null);
const { tabPanelProps } = useTabPanel(props, state, ref);
return (
<div {...tabPanelProps} ref={ref} className="mt-2 w-full">
{state.selectedItem?.props.children}
</div>
);
}
export default Object.assign(Options, { Item });
+39 -43
View File
@@ -1,49 +1,45 @@
import React, { useRef } from 'react';
import {
type AriaPopoverProps,
DismissButton,
Overlay,
usePopover,
} from 'react-aria';
import type { OverlayTriggerState } from 'react-stately';
import cn from '~/utils/cn';
import React, { useRef } from "react";
import { type AriaPopoverProps, DismissButton, Overlay, usePopover } from "react-aria";
import type { OverlayTriggerState } from "react-stately";
export interface PopoverProps extends Omit<AriaPopoverProps, 'popoverRef'> {
children: React.ReactNode;
state: OverlayTriggerState;
popoverRef?: React.RefObject<HTMLDivElement | null>;
className?: string;
import cn from "~/utils/cn";
export interface PopoverProps extends Omit<AriaPopoverProps, "popoverRef"> {
children: React.ReactNode;
state: OverlayTriggerState;
popoverRef?: React.RefObject<HTMLDivElement | null>;
className?: string;
}
export default function Popover(props: PopoverProps) {
const ref = props.popoverRef ?? useRef<HTMLDivElement | null>(null);
const { state, children, className } = props;
const { popoverProps, underlayProps } = usePopover(
{
...props,
popoverRef: ref,
offset: 8,
},
state,
);
const ref = props.popoverRef ?? useRef<HTMLDivElement | null>(null);
const { state, children, className } = props;
const { popoverProps, underlayProps } = usePopover(
{
...props,
popoverRef: ref,
offset: 8,
},
state,
);
return (
<Overlay>
<div {...underlayProps} className="fixed inset-0" />
<div
{...popoverProps}
ref={ref}
className={cn(
'z-10 shadow-xs rounded-xl',
'bg-white dark:bg-headplane-900',
'border border-headplane-200 dark:border-headplane-800',
className,
)}
>
<DismissButton onDismiss={state.close} />
{children}
<DismissButton onDismiss={state.close} />
</div>
</Overlay>
);
return (
<Overlay>
<div {...underlayProps} className="fixed inset-0" />
<div
{...popoverProps}
ref={ref}
className={cn(
"z-10 shadow-overlay rounded-lg",
"bg-white dark:bg-mist-900",
"border border-mist-200 dark:border-mist-800",
className,
)}
>
<DismissButton onDismiss={state.close} />
{children}
<DismissButton onDismiss={state.close} />
</div>
</Overlay>
);
}
+20 -19
View File
@@ -1,26 +1,27 @@
import { useProgressBar } from 'react-aria';
import cn from '~/utils/cn';
import { useProgressBar } from "react-aria";
import cn from "~/utils/cn";
export interface ProgressBarProps {
isVisible: boolean;
isVisible: boolean;
}
export default function ProgressBar(props: ProgressBarProps) {
const { isVisible } = props;
const { progressBarProps } = useProgressBar({
label: 'Loading...',
isIndeterminate: true,
});
const { isVisible } = props;
const { progressBarProps } = useProgressBar({
label: "Loading...",
isIndeterminate: true,
});
return (
<div
{...progressBarProps}
aria-hidden={!isVisible}
className={cn(
'fixed top-0 left-0 z-50 w-1/2 h-1 opacity-0',
'bg-headplane-950 dark:bg-headplane-50',
isVisible && 'animate-loading opacity-100',
)}
/>
);
return (
<div
{...progressBarProps}
aria-hidden={!isVisible}
className={cn(
"fixed top-0 left-0 z-50 w-1/2 h-1 opacity-0",
"bg-mist-950 dark:bg-mist-50",
isVisible && "animate-loading opacity-100",
)}
/>
);
}
+57 -64
View File
@@ -1,83 +1,76 @@
import React, { createContext, useContext, useRef } from 'react';
import {
AriaRadioGroupProps,
AriaRadioProps,
VisuallyHidden,
useFocusRing,
} from 'react-aria';
import { RadioGroupState } from 'react-stately';
import cn from '~/utils/cn';
import React, { createContext, useContext, useRef } from "react";
import { AriaRadioGroupProps, AriaRadioProps, VisuallyHidden, useFocusRing } from "react-aria";
import { useRadio, useRadioGroup } from "react-aria";
import { RadioGroupState } from "react-stately";
import { useRadioGroupState } from "react-stately";
import { useRadio, useRadioGroup } from 'react-aria';
import { useRadioGroupState } from 'react-stately';
import cn from "~/utils/cn";
interface RadioGroupProps extends AriaRadioGroupProps {
children: React.ReactElement<RadioProps>[];
label: string;
className?: string;
children: React.ReactElement<RadioProps>[];
label: string;
className?: string;
}
const RadioContext = createContext<RadioGroupState | null>(null);
function RadioGroup({ children, label, className, ...props }: RadioGroupProps) {
const state = useRadioGroupState(props);
const { radioGroupProps, labelProps } = useRadioGroup(
{
...props,
'aria-label': label,
},
state,
);
const state = useRadioGroupState(props);
const { radioGroupProps, labelProps } = useRadioGroup(
{
...props,
"aria-label": label,
},
state,
);
return (
<div {...radioGroupProps} className={cn('flex flex-col gap-2', className)}>
<VisuallyHidden>
<span {...labelProps}>{label}</span>
</VisuallyHidden>
<RadioContext.Provider value={state}>{children}</RadioContext.Provider>
</div>
);
return (
<div {...radioGroupProps} className={cn("flex flex-col gap-2", className)}>
<VisuallyHidden>
<span {...labelProps}>{label}</span>
</VisuallyHidden>
<RadioContext.Provider value={state}>{children}</RadioContext.Provider>
</div>
);
}
interface RadioProps extends AriaRadioProps {
label: string;
className?: string;
label: string;
className?: string;
}
function Radio({ children, label, className, ...props }: RadioProps) {
const state = useContext(RadioContext);
const ref = useRef(null);
const { inputProps, isSelected, isDisabled } = useRadio(
{
...props,
'aria-label': label,
},
state!,
ref,
);
const { isFocusVisible, focusProps } = useFocusRing();
const state = useContext(RadioContext);
const ref = useRef(null);
const { inputProps, isSelected, isDisabled } = useRadio(
{
...props,
"aria-label": label,
},
state!,
ref,
);
const { isFocusVisible, focusProps } = useFocusRing();
return (
<label className="flex items-center gap-2 text-sm">
<VisuallyHidden>
<input {...inputProps} {...focusProps} ref={ref} className="peer" />
</VisuallyHidden>
<div
aria-hidden="true"
className={cn(
'w-5 h-5 aspect-square rounded-full p-1 border-2',
'border border-headplane-600 dark:border-headplane-300',
isFocusVisible ? 'ring-4' : '',
isDisabled ? 'opacity-50 cursor-not-allowed' : '',
isSelected
? 'border-[6px] border-headplane-900 dark:border-headplane-100'
: '',
className,
)}
/>
{children}
</label>
);
return (
<label className="flex items-center gap-2 text-sm">
<VisuallyHidden>
<input {...inputProps} {...focusProps} ref={ref} className="peer" />
</VisuallyHidden>
<div
aria-hidden="true"
className={cn(
"w-5 h-5 aspect-square rounded-full p-1 border-2",
"border border-mist-600 dark:border-mist-300",
isFocusVisible ? "ring-4" : "",
isDisabled ? "opacity-50 cursor-not-allowed" : "",
isSelected ? "border-[6px] border-mist-900 dark:border-mist-100" : "",
className,
)}
/>
{children}
</label>
);
}
export default Object.assign(RadioGroup, { Radio });
+143 -148
View File
@@ -1,174 +1,169 @@
import { Check, ChevronDown } from 'lucide-react';
import { useRef } from 'react';
import { Check, ChevronDown } from "lucide-react";
import { useRef } from "react";
import {
AriaComboBoxProps,
AriaListBoxOptions,
useButton,
useComboBox,
useFilter,
useId,
useListBox,
useOption,
} from 'react-aria';
import { Item, ListState, Node, useComboBoxState } from 'react-stately';
import Popover from '~/components/Popover';
import cn from '~/utils/cn';
AriaComboBoxProps,
AriaListBoxOptions,
useButton,
useComboBox,
useFilter,
useId,
useListBox,
useOption,
} from "react-aria";
import { Item, ListState, Node, useComboBoxState } from "react-stately";
import Popover from "~/components/Popover";
import cn from "~/utils/cn";
export interface SelectProps extends AriaComboBoxProps<object> {
className?: string;
className?: string;
}
function Select(props: SelectProps) {
const { contains } = useFilter({ sensitivity: 'base' });
const state = useComboBoxState({ ...props, defaultFilter: contains });
const id = useId(props.id);
const { contains } = useFilter({ sensitivity: "base" });
const state = useComboBoxState({ ...props, defaultFilter: contains });
const id = useId(props.id);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const listBoxRef = useRef<HTMLUListElement | null>(null);
const popoverRef = useRef<HTMLDivElement | null>(null);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const listBoxRef = useRef<HTMLUListElement | null>(null);
const popoverRef = useRef<HTMLDivElement | null>(null);
const {
buttonProps: triggerProps,
inputProps,
listBoxProps,
labelProps,
descriptionProps,
} = useComboBox(
{
...props,
inputRef,
buttonRef,
listBoxRef,
popoverRef,
},
state,
);
const {
buttonProps: triggerProps,
inputProps,
listBoxProps,
labelProps,
descriptionProps,
} = useComboBox(
{
...props,
inputRef,
buttonRef,
listBoxRef,
popoverRef,
},
state,
);
const { buttonProps } = useButton(triggerProps, buttonRef);
return (
<div className={cn('flex flex-col', props.className)}>
<label
{...labelProps}
className={cn(
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
)}
htmlFor={id}
>
{props.label}
</label>
<div
className={cn(
'flex rounded-xl focus:outline-hidden focus-within:ring-3',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
props.isInvalid && 'ring-red-400',
)}
>
<input
{...inputProps}
className="outline-hidden px-3 py-2 rounded-l-xl w-full bg-transparent"
data-1p-ignore
id={id}
ref={inputRef}
/>
<button
{...buttonProps}
className={cn(
'flex items-center justify-center p-1 rounded-lg m-1',
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
props.isDisabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
)}
ref={buttonRef}
>
<ChevronDown className="p-0.5" />
</button>
</div>
{props.description && (
<div
{...descriptionProps}
className={cn(
'text-xs px-3 mt-1',
'text-headplane-500 dark:text-headplane-400',
)}
>
{props.description}
</div>
)}
{state.isOpen && (
<Popover
className="w-full max-w-xs"
isNonModal
placement="bottom start"
popoverRef={popoverRef}
state={state}
triggerRef={inputRef}
>
<ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} />
</Popover>
)}
</div>
);
const { buttonProps } = useButton(triggerProps, buttonRef);
return (
<div className={cn("flex flex-col", props.className)}>
<label
{...labelProps}
className={cn("text-xs font-medium px-3 mb-0.5", "text-mist-700 dark:text-mist-100")}
htmlFor={id}
>
{props.label}
</label>
<div
className={cn(
"flex rounded-md focus:outline-hidden focus-within:ring-3",
"bg-white dark:bg-mist-900",
"border border-mist-100 dark:border-mist-800",
props.isInvalid && "ring-red-400",
)}
>
<input
{...inputProps}
className="w-full rounded-l-md bg-transparent px-3 py-2 outline-hidden"
data-1p-ignore
id={id}
ref={inputRef}
/>
<button
{...buttonProps}
className={cn(
"flex items-center justify-center p-1 rounded-lg m-1",
"bg-mist-100 dark:bg-mist-700/30 font-medium",
props.isDisabled
? "opacity-50 cursor-not-allowed"
: "hover:bg-mist-200/90 dark:hover:bg-mist-800/30",
)}
ref={buttonRef}
>
<ChevronDown className="p-0.5" />
</button>
</div>
{props.description && (
<div
{...descriptionProps}
className={cn("text-xs px-3 mt-1", "text-mist-500 dark:text-mist-400")}
>
{props.description}
</div>
)}
{state.isOpen && (
<Popover
className="w-full max-w-xs"
isNonModal
placement="bottom start"
popoverRef={popoverRef}
state={state}
triggerRef={inputRef}
>
<ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} />
</Popover>
)}
</div>
);
}
interface ListBoxProps extends AriaListBoxOptions<object> {
listBoxRef: React.RefObject<HTMLUListElement | null>;
state: ListState<object>;
listBoxRef: React.RefObject<HTMLUListElement | null>;
state: ListState<object>;
}
function ListBox(props: ListBoxProps) {
const { listBoxRef, state } = props;
const { listBoxProps } = useListBox(props, state, listBoxRef);
const { listBoxRef, state } = props;
const { listBoxProps } = useListBox(props, state, listBoxRef);
return (
<ul
{...listBoxProps}
className="w-full max-h-72 overflow-auto outline-hidden pt-1"
ref={listBoxRef}
>
{[...state.collection].map((item) => (
<Option item={item} key={item.key} state={state} />
))}
</ul>
);
return (
<ul
{...listBoxProps}
className="max-h-72 w-full overflow-auto pt-1 outline-hidden"
ref={listBoxRef}
>
{[...state.collection].map((item) => (
<Option item={item} key={item.key} state={state} />
))}
</ul>
);
}
interface OptionProps {
item: Node<unknown>;
state: ListState<unknown>;
item: Node<unknown>;
state: ListState<unknown>;
}
function Option({ item, state }: OptionProps) {
const ref = useRef<HTMLLIElement | null>(null);
const { optionProps, isDisabled, isSelected, isFocused } = useOption(
{
key: item.key,
},
state,
ref,
);
const ref = useRef<HTMLLIElement | null>(null);
const { optionProps, isDisabled, isSelected, isFocused } = useOption(
{
key: item.key,
},
state,
ref,
);
return (
<li
{...optionProps}
className={cn(
'flex items-center justify-between',
'py-2 px-3 mx-1 rounded-lg mb-1',
'focus:outline-hidden select-none',
isFocused || isSelected
? 'bg-headplane-100/50 dark:bg-headplane-800'
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800',
isDisabled && 'text-headplane-300 dark:text-headplane-600',
)}
ref={ref}
>
{item.rendered}
{isSelected && <Check className="p-0.5" />}
</li>
);
return (
<li
{...optionProps}
className={cn(
"flex items-center justify-between",
"py-2 px-3 mx-1 rounded-lg mb-1",
"focus:outline-hidden select-none",
isFocused || isSelected
? "bg-mist-100/50 dark:bg-mist-800"
: "hover:bg-mist-100/50 dark:hover:bg-mist-800",
isDisabled && "text-mist-300 dark:text-mist-600",
)}
ref={ref}
>
{item.rendered}
{isSelected && <Check className="p-0.5" />}
</li>
);
}
export default Object.assign(Select, { Item });
+17 -22
View File
@@ -1,27 +1,22 @@
import cn from '~/utils/cn';
import cn from "~/utils/cn";
export interface StatusCircleProps {
isOnline: boolean;
className?: string;
isOnline: boolean;
className?: string;
}
export default function StatusCircle({
isOnline,
className,
}: StatusCircleProps) {
return (
<svg
className={cn(
isOnline
? 'text-green-600 dark:text-green-500'
: 'text-headplane-200 dark:text-headplane-800',
className,
)}
viewBox="0 0 24 24"
fill="currentColor"
>
<title>{isOnline ? 'Online' : 'Offline'}</title>
<circle cx="12" cy="12" r="8" />
</svg>
);
export default function StatusCircle({ isOnline, className }: StatusCircleProps) {
return (
<svg
className={cn(
isOnline ? "text-green-600 dark:text-green-500" : "text-mist-200 dark:text-mist-800",
className,
)}
viewBox="0 0 24 24"
fill="currentColor"
>
<title>{isOnline ? "Online" : "Offline"}</title>
<circle cx="12" cy="12" r="8" />
</svg>
);
}
+48 -57
View File
@@ -1,64 +1,55 @@
import { useRef } from 'react';
import {
AriaSwitchProps,
VisuallyHidden,
useFocusRing,
useSwitch,
} from 'react-aria';
import { useToggleState } from 'react-stately';
import cn from '~/utils/cn';
import { useRef } from "react";
import { AriaSwitchProps, VisuallyHidden, useFocusRing, useSwitch } from "react-aria";
import { useToggleState } from "react-stately";
import cn from "~/utils/cn";
export interface SwitchProps extends AriaSwitchProps {
label: string;
className?: string;
switchClassName?: string;
label: string;
className?: string;
switchClassName?: string;
}
export default function Switch(props: SwitchProps) {
const state = useToggleState(props);
const ref = useRef<HTMLInputElement | null>(null);
const { focusProps, isFocusVisible } = useFocusRing();
const { inputProps } = useSwitch(
{
...props,
'aria-label': props.label,
},
state,
ref,
);
const state = useToggleState(props);
const ref = useRef<HTMLInputElement | null>(null);
const { focusProps, isFocusVisible } = useFocusRing();
const { inputProps } = useSwitch(
{
...props,
"aria-label": props.label,
},
state,
ref,
);
return (
<label className="flex items-center gap-x-2">
<VisuallyHidden elementType="span">
<input
{...inputProps}
{...focusProps}
aria-label={props.label}
ref={ref}
/>
</VisuallyHidden>
<div
aria-hidden
className={cn(
'flex h-[28px] w-[46px] p-[4px] shrink-0 rounded-full',
'bg-headplane-300 dark:bg-headplane-700',
'border border-transparent dark:border-headplane-800',
state.isSelected && 'bg-headplane-900 dark:bg-headplane-950',
isFocusVisible && 'ring-2',
props.isDisabled && 'opacity-50',
props.className,
)}
>
<span
className={cn(
'h-[18px] w-[18px] transform rounded-full',
'bg-white transition duration-50 ease-in-out',
'translate-x-0 group-selected:translate-x-full',
state.isSelected && 'translate-x-full',
props.switchClassName,
)}
/>
</div>
</label>
);
return (
<label className="flex items-center gap-x-2">
<VisuallyHidden elementType="span">
<input {...inputProps} {...focusProps} aria-label={props.label} ref={ref} />
</VisuallyHidden>
<div
aria-hidden
className={cn(
"flex h-[28px] w-[46px] p-[4px] shrink-0 rounded-full",
"bg-mist-300 dark:bg-mist-700",
"border border-transparent dark:border-mist-800",
state.isSelected && "bg-mist-900 dark:bg-mist-950",
isFocusVisible && "ring-2",
props.isDisabled && "opacity-50",
props.className,
)}
>
<span
className={cn(
"h-[18px] w-[18px] transform rounded-full",
"bg-white transition duration-50 ease-in-out",
"translate-x-0 group-selected:translate-x-full",
state.isSelected && "translate-x-full",
props.switchClassName,
)}
/>
</div>
</label>
);
}
+23 -26
View File
@@ -1,34 +1,31 @@
import type { HTMLProps } from 'react';
import cn from '~/utils/cn';
import type { HTMLProps } from "react";
import cn from "~/utils/cn";
function TableList(props: HTMLProps<HTMLDivElement>) {
return (
<div
{...props}
className={cn(
'rounded-xl',
'border border-headplane-100 dark:border-headplane-800',
props.className,
)}
>
{props.children}
</div>
);
return (
<div
{...props}
className={cn("rounded-lg", "border border-mist-100 dark:border-mist-800", props.className)}
>
{props.children}
</div>
);
}
function Item(props: HTMLProps<HTMLDivElement>) {
return (
<div
{...props}
className={cn(
'flex items-center justify-between p-2 last:border-b-0',
'border-b border-headplane-100 dark:border-headplane-800',
props.className,
)}
>
{props.children}
</div>
);
return (
<div
{...props}
className={cn(
"flex items-center justify-between p-2 last:border-b-0",
"border-b border-mist-100 dark:border-mist-800",
props.className,
)}
>
{props.children}
</div>
);
}
export default Object.assign(TableList, { Item });
+63 -68
View File
@@ -1,90 +1,85 @@
import { useRef } from 'react';
import {
AriaTabListProps,
AriaTabPanelProps,
useTab,
useTabList,
useTabPanel,
} from 'react-aria';
import { Item, Node, TabListState, useTabListState } from 'react-stately';
import cn from '~/utils/cn';
import { useRef } from "react";
import { AriaTabListProps, AriaTabPanelProps, useTab, useTabList, useTabPanel } from "react-aria";
import { Item, Node, TabListState, useTabListState } from "react-stately";
import cn from "~/utils/cn";
export interface TabsProps extends AriaTabListProps<object> {
label: string;
className?: string;
label: string;
className?: string;
}
function Tabs({ label, className, ...props }: TabsProps) {
const state = useTabListState(props);
const ref = useRef<HTMLDivElement | null>(null);
const state = useTabListState(props);
const ref = useRef<HTMLDivElement | null>(null);
const { tabListProps } = useTabList(props, state, ref);
return (
<div className={cn('flex flex-col', className)}>
<div
{...tabListProps}
className={cn(
'flex items-center rounded-t-xl w-fit max-w-full overflow-x-auto',
'border-headplane-100 dark:border-headplane-800',
'border-t border-x',
)}
ref={ref}
>
{[...state.collection].map((item) => (
<Tab item={item} key={item.key} state={state} />
))}
</div>
<TabsPanel key={state.selectedItem?.key} state={state} />
</div>
);
const { tabListProps } = useTabList(props, state, ref);
return (
<div className={cn("flex flex-col", className)}>
<div
{...tabListProps}
className={cn(
"flex items-center rounded-t-lg w-fit max-w-full overflow-x-auto",
"border-mist-100 dark:border-mist-800",
"border-t border-x",
)}
ref={ref}
>
{[...state.collection].map((item) => (
<Tab item={item} key={item.key} state={state} />
))}
</div>
<TabsPanel key={state.selectedItem?.key} state={state} />
</div>
);
}
export interface TabsTabProps {
item: Node<object>;
state: TabListState<object>;
item: Node<object>;
state: TabListState<object>;
}
function Tab({ item, state }: TabsTabProps) {
const { key, rendered } = item;
const ref = useRef<HTMLDivElement | null>(null);
const { key, rendered } = item;
const ref = useRef<HTMLDivElement | null>(null);
const { tabProps } = useTab({ key }, state, ref);
return (
<div
{...tabProps}
className={cn(
'pl-2 pr-3 py-2.5',
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
'focus:outline-hidden focus:ring-3 z-10',
'border-r border-headplane-100 dark:border-headplane-800',
'first:rounded-tl-xl last:rounded-tr-xl last:border-r-0',
)}
ref={ref}
>
{rendered}
</div>
);
const { tabProps } = useTab({ key }, state, ref);
return (
<div
{...tabProps}
className={cn(
"pl-2 pr-3 py-2.5",
"aria-selected:bg-mist-100 dark:aria-selected:bg-mist-950",
"focus:outline-hidden focus:ring-3 z-10",
"border-r border-mist-100 dark:border-mist-800",
"first:rounded-tl-lg last:rounded-tr-lg last:border-r-0",
)}
ref={ref}
>
{rendered}
</div>
);
}
export interface TabsPanelProps extends AriaTabPanelProps {
state: TabListState<object>;
state: TabListState<object>;
}
function TabsPanel({ state, ...props }: TabsPanelProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { tabPanelProps } = useTabPanel(props, state, ref);
return (
<div
{...tabPanelProps}
className={cn(
'w-full overflow-clip rounded-b-xl rounded-r-xl',
'border border-headplane-100 dark:border-headplane-800',
)}
ref={ref}
>
{state.selectedItem?.props.children}
</div>
);
const ref = useRef<HTMLDivElement | null>(null);
const { tabPanelProps } = useTabPanel(props, state, ref);
return (
<div
{...tabPanelProps}
className={cn(
"w-full overflow-clip rounded-b-lg rounded-r-lg",
"border border-mist-100 dark:border-mist-800",
)}
ref={ref}
>
{state.selectedItem?.props.children}
</div>
);
}
export default Object.assign(Tabs, { Item });
+52 -66
View File
@@ -1,87 +1,73 @@
import {
AriaToastProps,
AriaToastRegionProps,
useToast,
useToastRegion,
} from '@react-aria/toast';
import { ToastQueue, ToastState, useToastQueue } from '@react-stately/toast';
import { X } from 'lucide-react';
import React, { useRef } from 'react';
import IconButton from '~/components/IconButton';
import cn from '~/utils/cn';
import { AriaToastProps, AriaToastRegionProps, useToast, useToastRegion } from "@react-aria/toast";
import { ToastQueue, ToastState, useToastQueue } from "@react-stately/toast";
import { X } from "lucide-react";
import React, { useRef } from "react";
import IconButton from "~/components/IconButton";
import cn from "~/utils/cn";
interface ToastProps extends AriaToastProps<React.ReactNode> {
state: ToastState<React.ReactNode>;
state: ToastState<React.ReactNode>;
}
function Toast({ state, ...props }: ToastProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { toastProps, contentProps, titleProps, closeButtonProps } = useToast(
props,
state,
ref,
);
const ref = useRef<HTMLDivElement | null>(null);
const { toastProps, contentProps, titleProps, closeButtonProps } = useToast(props, state, ref);
return (
<div
{...toastProps}
ref={ref}
className={cn(
'flex items-center justify-between gap-x-3 pl-4 pr-3',
'text-white shadow-lg dark:shadow-md rounded-xl py-3',
'bg-headplane-900 dark:bg-headplane-950',
)}
>
<div {...contentProps} className="flex flex-col gap-2">
<div {...titleProps}>{props.toast.content}</div>
</div>
<IconButton
{...closeButtonProps}
label="Close"
className={cn(
'bg-transparent hover:bg-headplane-700',
'dark:bg-transparent dark:hover:bg-headplane-800',
)}
>
<X className="p-1" />
</IconButton>
</div>
);
return (
<div
{...toastProps}
ref={ref}
className={cn(
"flex items-center justify-between gap-x-3 pl-4 pr-3",
"text-white shadow-overlay rounded-lg py-3",
"bg-mist-900 dark:bg-mist-950",
)}
>
<div {...contentProps} className="flex flex-col gap-2">
<div {...titleProps}>{props.toast.content}</div>
</div>
<IconButton
{...closeButtonProps}
label="Close"
className={cn(
"bg-transparent hover:bg-mist-700",
"dark:bg-transparent dark:hover:bg-mist-800",
)}
>
<X className="p-1" />
</IconButton>
</div>
);
}
interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<React.ReactNode>;
state: ToastState<React.ReactNode>;
}
function ToastRegion({ state, ...props }: ToastRegionProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { regionProps } = useToastRegion(props, state, ref);
const ref = useRef<HTMLDivElement | null>(null);
const { regionProps } = useToastRegion(props, state, ref);
return (
<div
{...regionProps}
ref={ref}
className={cn('fixed bottom-20 right-4', 'flex flex-col gap-4')}
>
{state.visibleToasts.map((toast) => (
<Toast key={toast.key} toast={toast} state={state} />
))}
</div>
);
return (
<div
{...regionProps}
ref={ref}
className={cn("fixed bottom-20 right-4", "flex flex-col gap-4")}
>
{state.visibleToasts.map((toast) => (
<Toast key={toast.key} toast={toast} state={state} />
))}
</div>
);
}
export interface ToastProviderProps extends AriaToastRegionProps {
queue: ToastQueue<React.ReactNode>;
queue: ToastQueue<React.ReactNode>;
}
export default function ToastProvider({ queue, ...props }: ToastProviderProps) {
const state = useToastQueue(queue);
const state = useToastQueue(queue);
return (
<>
{state.visibleToasts.length > 0 && (
<ToastRegion {...props} state={state} />
)}
</>
);
return <>{state.visibleToasts.length > 0 && <ToastRegion {...props} state={state} />}</>;
}
+62 -66
View File
@@ -1,83 +1,79 @@
import React, { cloneElement, useRef } from 'react';
import {
AriaTooltipProps,
mergeProps,
useTooltip,
useTooltipTrigger,
} from 'react-aria';
import { TooltipTriggerState, useTooltipTriggerState } from 'react-stately';
import cn from '~/utils/cn';
import React, { cloneElement, useRef } from "react";
import { AriaTooltipProps, mergeProps, useTooltip, useTooltipTrigger } from "react-aria";
import { TooltipTriggerState, useTooltipTriggerState } from "react-stately";
import cn from "~/utils/cn";
export interface TooltipProps extends AriaTooltipProps {
children: [React.ReactElement, React.ReactElement<TooltipBodyProps>];
children: [React.ReactElement, React.ReactElement<TooltipBodyProps>];
}
function Tooltip(props: TooltipProps) {
const state = useTooltipTriggerState({
...props,
delay: 0,
closeDelay: 0,
});
const state = useTooltipTriggerState({
...props,
delay: 0,
closeDelay: 0,
});
const ref = useRef<HTMLButtonElement | null>(null);
const { triggerProps, tooltipProps } = useTooltipTrigger(
{
...props,
delay: 0,
closeDelay: 0,
},
state,
ref,
);
const ref = useRef<HTMLButtonElement | null>(null);
const { triggerProps, tooltipProps } = useTooltipTrigger(
{
...props,
delay: 0,
closeDelay: 0,
},
state,
ref,
);
const [component, body] = props.children;
return (
<span className="relative">
<button
ref={ref}
{...triggerProps}
className={cn(
'flex items-center justify-center',
'focus:outline-hidden focus:ring-3 rounded-xl',
)}
>
{component}
</button>
{state.isOpen &&
cloneElement(body, {
...tooltipProps,
state,
})}
</span>
);
const [component, body] = props.children;
return (
<span className="relative">
<button
ref={ref}
{...triggerProps}
className={cn(
"flex items-center justify-center",
"focus:outline-hidden focus:ring-3 rounded-md",
)}
>
{component}
</button>
{state.isOpen &&
cloneElement(body, {
...tooltipProps,
state,
})}
</span>
);
}
interface TooltipBodyProps extends AriaTooltipProps {
children: React.ReactNode;
state?: TooltipTriggerState;
className?: string;
children: React.ReactNode;
state?: TooltipTriggerState;
className?: string;
}
function Body({ state, className, ...props }: TooltipBodyProps) {
const { tooltipProps } = useTooltip(props, state);
return (
<span
{...mergeProps(props, tooltipProps)}
className={cn(
'absolute z-50 p-3 top-full mt-1',
'outline-hidden rounded-3xl text-sm w-48',
'bg-white dark:bg-headplane-950',
'text-black dark:text-white',
'shadow-lg dark:shadow-md rounded-xl',
'border border-headplane-100 dark:border-headplane-800',
className,
)}
>
{props.children}
</span>
);
const { tooltipProps } = useTooltip(props, state);
return (
<span
{...mergeProps(props, tooltipProps)}
className={cn(
"absolute z-50 p-3 top-full mt-1",
"outline-hidden rounded-lg text-sm w-48",
"bg-white dark:bg-mist-950",
"text-black dark:text-white",
"shadow-overlay",
"border border-mist-100 dark:border-mist-800",
className,
)}
>
{props.children}
</span>
);
}
export default Object.assign(Tooltip, {
Body,
Body,
});
+175 -192
View File
@@ -1,211 +1,194 @@
import { AlertCircle } from 'lucide-react';
import { isRouteErrorResponse } from 'react-router';
import {
isApiError,
isConnectionError,
} from '~/server/headscale/api/error-client';
import cn from '~/utils/cn';
import Card from './Card';
import Code from './Code';
import Link from './Link';
import { AlertCircle } from "lucide-react";
import { isRouteErrorResponse } from "react-router";
import { isApiError, isConnectionError } from "~/server/headscale/api/error-client";
import cn from "~/utils/cn";
import Card from "./Card";
import Code from "./Code";
import Link from "./Link";
export function getErrorMessage(error: Error | unknown): {
title: string;
jsxMessage: React.ReactNode;
title: string;
jsxMessage: React.ReactNode;
} {
if (isRouteErrorResponse(error)) {
if (isApiError(error.data)) {
const { statusCode, rawData, data, requestUrl } = error.data;
if (statusCode >= 500) {
return {
title: 'Headscale API Error',
jsxMessage: (
<>
<Card.Text>
There was an error communicating with the Headscale API.
<br />
The server responded with a status code of{' '}
<strong>{statusCode}</strong>, indicating a server-side issue.
Please check the Headscale server status and try again later.
</Card.Text>
{(error.data.data != null || error.data.rawData != null) && (
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
{error.data.data != null ? (
<code>{JSON.stringify(error.data.data, null, 2)}</code>
) : (
<code>{error.data.rawData}</code>
)}
</pre>
)}
</>
),
};
}
if (isRouteErrorResponse(error)) {
if (isApiError(error.data)) {
const { statusCode, rawData, data, requestUrl } = error.data;
if (statusCode >= 500) {
return {
title: "Headscale API Error",
jsxMessage: (
<>
<Card.Text>
There was an error communicating with the Headscale API.
<br />
The server responded with a status code of <strong>{statusCode}</strong>, indicating
a server-side issue. Please check the Headscale server status and try again later.
</Card.Text>
{(error.data.data != null || error.data.rawData != null) && (
<pre className="mt-2 overflow-x-auto rounded-lg bg-mist-100 p-2 dark:bg-mist-800">
{error.data.data != null ? (
<code>{JSON.stringify(error.data.data, null, 2)}</code>
) : (
<code>{error.data.rawData}</code>
)}
</pre>
)}
</>
),
};
}
const authError =
error.data.statusCode === 401 || error.data.statusCode === 403;
const authError = error.data.statusCode === 401 || error.data.statusCode === 403;
return {
title: 'Invalid response from Headscale API',
jsxMessage: (
<>
<Card.Text className="leading-snug">
The Headscale API returned an unexpected response.
{authError ? (
<>
{' '}
The status code indicates an authentication error. Please
verify your API key and Headplane configuration.
</>
) : (
<>
{' '}
You may be using an unsupported version of Headscale or this
may be a bug.
</>
)}
</Card.Text>
<ul className="list-disc list-inside mt-2">
<li>
Request URL: <Code>{requestUrl}</Code>
</li>
<li>
Status Code:{' '}
<Code>
{/* @ts-expect-error */}
{data === null ? (
<>
{statusCode} {rawData}
</>
) : (
<>
{statusCode} {error.statusText}
</>
)}
</Code>
</li>
</ul>
<Card.Text className="text-lg font-semibold mt-4">
Error Details
</Card.Text>
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
<code>{JSON.stringify(error.data, null, 2)}</code>
</pre>
</>
),
};
}
return {
title: "Invalid response from Headscale API",
jsxMessage: (
<>
<Card.Text className="leading-snug">
The Headscale API returned an unexpected response.
{authError ? (
<>
{" "}
The status code indicates an authentication error. Please verify your API key and
Headplane configuration.
</>
) : (
<> You may be using an unsupported version of Headscale or this may be a bug.</>
)}
</Card.Text>
<ul className="mt-2 list-inside list-disc">
<li>
Request URL: <Code>{requestUrl}</Code>
</li>
<li>
Status Code:{" "}
<Code>
{/* @ts-expect-error */}
{data === null ? (
<>
{statusCode} {rawData}
</>
) : (
<>
{statusCode} {error.statusText}
</>
)}
</Code>
</li>
</ul>
<Card.Text className="mt-4 text-lg font-semibold">Error Details</Card.Text>
<pre className="mt-2 overflow-x-auto rounded-lg bg-mist-100 p-2 dark:bg-mist-800">
<code>{JSON.stringify(error.data, null, 2)}</code>
</pre>
</>
),
};
}
if (isConnectionError(error.data)) {
const { requestUrl, errorCode, errorMessage, extraData } = error.data;
return {
title: 'Cannot connect to Headscale API',
jsxMessage: (
<>
<Card.Text className="leading-snug">
Headplane was unable to reach the Headscale API. Please check your
network setup and configuration to ensure Headplane is able to
connect.
</Card.Text>
<Card.Text className="text-lg font-semibold mt-4">
Error Details
</Card.Text>
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
{requestUrl}
<br />
{errorCode}: {errorMessage}
{extraData != null && (
<>
<br />
<br />
<code>{JSON.stringify(extraData, null, 2)}</code>
</>
)}
</pre>
</>
),
};
}
if (isConnectionError(error.data)) {
const { requestUrl, errorCode, errorMessage, extraData } = error.data;
return {
title: "Cannot connect to Headscale API",
jsxMessage: (
<>
<Card.Text className="leading-snug">
Headplane was unable to reach the Headscale API. Please check your network setup and
configuration to ensure Headplane is able to connect.
</Card.Text>
<Card.Text className="mt-4 text-lg font-semibold">Error Details</Card.Text>
<pre className="mt-2 overflow-x-auto rounded-lg bg-mist-100 p-2 dark:bg-mist-800">
{requestUrl}
<br />
{errorCode}: {errorMessage}
{extraData != null && (
<>
<br />
<br />
<code>{JSON.stringify(extraData, null, 2)}</code>
</>
)}
</pre>
</>
),
};
}
return {
title: `Error ${error.status}`,
jsxMessage: (
<>
There was an error processing your request.
<br />
Status Code: <strong>{error.status}</strong>
<br />
Status Text: <strong>{error.data}</strong>
</>
),
};
}
return {
title: `Error ${error.status}`,
jsxMessage: (
<>
There was an error processing your request.
<br />
Status Code: <strong>{error.status}</strong>
<br />
Status Text: <strong>{error.data}</strong>
</>
),
};
}
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
name="Headplane GitHub"
to="https://github.com/tale/headplane/issues"
>
Headplane GitHub
</Link>{' '}
repository with the details below.
</Card.Text>
<Card.Text className="text-lg font-semibold mt-4">
Error Details
</Card.Text>
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
<code>{JSON.stringify(error, null, 2)}</code>
</pre>
</>
),
};
}
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 name="Headplane GitHub" to="https://github.com/tale/headplane/issues">
Headplane GitHub
</Link>{" "}
repository with the details below.
</Card.Text>
<Card.Text className="mt-4 text-lg font-semibold">Error Details</Card.Text>
<pre className="mt-2 overflow-x-auto rounded-lg bg-mist-100 p-2 dark:bg-mist-800">
<code>{JSON.stringify(error, null, 2)}</code>
</pre>
</>
),
};
}
// Traverse the error chain to find the root cause
let rootError = error;
if (error.cause != null) {
rootError = error.cause as Error;
while (rootError.cause != null) {
rootError = rootError.cause as Error;
}
}
// Traverse the error chain to find the root cause
let rootError = error;
if (error.cause != null) {
rootError = error.cause as Error;
while (rootError.cause != null) {
rootError = rootError.cause as Error;
}
}
// TODO: If we are aggregate, concat into a single message
if (rootError instanceof AggregateError) {
throw new Error('AggregateError handling not implemented yet');
}
// TODO: If we are aggregate, concat into a single message
if (rootError instanceof AggregateError) {
throw new Error("AggregateError handling not implemented yet");
}
return {
title:
rootError.name.length > 0 && rootError.name !== 'Error'
? `Error: ${rootError.name}`
: 'Error',
jsxMessage: rootError.message,
};
return {
title:
rootError.name.length > 0 && rootError.name !== "Error"
? `Error: ${rootError.name}`
: "Error",
jsxMessage: rootError.message,
};
}
interface ErrorBannerProps {
error: unknown;
className?: string;
error: unknown;
className?: string;
}
export function ErrorBanner({ error, className }: ErrorBannerProps) {
const { title, jsxMessage } = getErrorMessage(error);
const { title, jsxMessage } = getErrorMessage(error);
return (
<Card className={cn('w-screen', className)} variant="flat">
<div className="flex items-center justify-between gap-4">
<Card.Title>{title}</Card.Title>
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
</div>
{jsxMessage}
</Card>
);
return (
<Card className={cn("w-screen", className)} variant="flat">
<div className="flex items-center justify-between gap-4">
<Card.Title>{title}</Card.Title>
<AlertCircle className="mb-2 h-6 w-6 text-red-500" />
</div>
{jsxMessage}
</Card>
);
}
+29 -35
View File
@@ -1,42 +1,36 @@
import Chip from '../Chip';
import Tooltip from '../Tooltip';
import Chip from "../Chip";
import Tooltip from "../Tooltip";
export interface ExpiryTagProps {
variant: 'expired' | 'no-expiry';
expiry?: string;
variant: "expired" | "no-expiry";
expiry?: string;
}
export function ExpiryTag({ variant, expiry }: ExpiryTagProps) {
const formatter = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
const formatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
return (
<Tooltip>
<Chip
text={
variant === 'expired'
? `Expired ${formatter.format(new Date(expiry!))}`
: 'No expiry'
}
className="bg-headplane-200 text-headplane-800 dark:bg-headplane-800 dark:text-headplane-200"
/>
<Tooltip.Body>
{variant === 'expired' ? (
<>
This machine is expired and will not be able to connect to the
network. Re-authenticate with Tailscale on the machine to re-enable
it.
</>
) : (
<>
This machine has key expiry disabled and will never need to
re-authenticate.
</>
)}
</Tooltip.Body>
</Tooltip>
);
return (
<Tooltip>
<Chip
text={
variant === "expired" ? `Expired ${formatter.format(new Date(expiry!))}` : "No expiry"
}
className="bg-mist-200 text-mist-800 dark:bg-mist-800 dark:text-mist-200"
/>
<Tooltip.Body>
{variant === "expired" ? (
<>
This machine is expired and will not be able to connect to the network. Re-authenticate
with Tailscale on the machine to re-enable it.
</>
) : (
<>This machine has key expiry disabled and will never need to re-authenticate.</>
)}
</Tooltip.Body>
</Tooltip>
);
}