chore: switch to react-router v7

This commit is contained in:
Aarnav Tale
2024-12-31 10:30:14 +05:30
parent 39504e2487
commit aa9872a45b
101 changed files with 3825 additions and 6796 deletions
+26 -32
View File
@@ -1,16 +1,15 @@
import { CopyIcon } from '@primer/octicons-react'
import { toast } from './Toaster'
import { CopyIcon } from '@primer/octicons-react';
import { toast } from './Toaster';
interface Props {
name: string
value: string
isCopyable?: boolean
link?: string
name: string;
value: string;
isCopyable?: boolean;
link?: string;
}
export default function Attribute({ name, value, link, isCopyable }: Props) {
const canCopy = isCopyable ?? false
const canCopy = isCopyable ?? false;
return (
<dl className="flex gap-1 text-sm w-full">
<dt className="w-1/2 shrink-0 min-w-0 truncate text-gray-700 dark:text-gray-300 py-1">
@@ -18,31 +17,26 @@ export default function Attribute({ name, value, link, isCopyable }: Props) {
<a className="hover:underline" href={link}>
{name}
</a>
) : name}
) : (
name
)}
</dt>
{canCopy
? (
<button
type="button"
className="focus:outline-none flex items-center gap-x-1 truncate hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md"
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onClick={async () => {
await navigator.clipboard.writeText(value)
toast(`Copied ${name}`)
}}
>
<dd className="min-w-0 truncate px-2 py-1">
{value}
</dd>
<CopyIcon className="text-gray-600 dark:text-gray-200 pr-2 w-max h-3" />
</button>
)
: (
<dd className="min-w-0 truncate px-2 py-1">
{value}
</dd>
)}
{canCopy ? (
<button
type="button"
className="focus:outline-none flex items-center gap-x-1 truncate hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md"
onClick={async () => {
await navigator.clipboard.writeText(value);
toast(`Copied ${name}`);
}}
>
<dd className="min-w-0 truncate px-2 py-1">{value}</dd>
<CopyIcon className="text-gray-600 dark:text-gray-200 pr-2 w-max h-3" />
</button>
) : (
<dd className="min-w-0 truncate px-2 py-1">{value}</dd>
)}
</dl>
)
);
}
+22 -21
View File
@@ -1,37 +1,38 @@
import { type Dispatch, type SetStateAction } from 'react'
import { Button as AriaButton } from 'react-aria-components'
import { Dispatch, SetStateAction } from 'react';
import { Button as AriaButton } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
type Props = Parameters<typeof AriaButton>[0] & {
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
readonly variant?: 'heavy' | 'light';
};
type ButtonProperties = Parameters<typeof AriaButton>[0] & {
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>]
readonly variant?: 'heavy' | 'light'
}
export default function Button(properties: ButtonProperties) {
export default function Button(props: Props) {
return (
<AriaButton
{...properties}
{...props}
className={cn(
'w-fit text-sm rounded-lg px-4 py-2',
properties.variant === 'heavy'
props.variant === 'heavy'
? 'bg-main-700 dark:bg-main-800'
: 'bg-main-200 dark:bg-main-700/30',
properties.variant === 'heavy'
props.variant === 'heavy'
? 'hover:bg-main-800 dark:hover:bg-main-700'
: 'hover:bg-main-300 dark:hover:bg-main-600/30',
properties.variant === 'heavy'
props.variant === 'heavy'
? 'text-white'
: 'text-ui-700 dark:text-ui-300',
properties.isDisabled && 'opacity-50 cursor-not-allowed',
properties.className,
props.isDisabled && 'opacity-50 cursor-not-allowed',
props.className,
)}
// If control is passed, set the state value
onPress={properties.control
? () => {
properties.control?.[1](true)
}
: properties.onPress}
onPress={
props.control
? () => {
props.control?.[1](true);
}
: props.onPress
}
/>
)
);
}
+20 -30
View File
@@ -1,53 +1,43 @@
import { type HTMLProps } from 'react'
import { Heading as AriaHeading } from 'react-aria-components'
import { HTMLProps } from 'react';
import { Heading as AriaHeading } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
function Title(properties: Parameters<typeof AriaHeading>[0]) {
function Title(props: Parameters<typeof AriaHeading>[0]) {
return (
<AriaHeading
{...properties}
slot='title'
className={cn(
'text-lg font-semibold leading-6 mb-5',
properties.className
)}
{...props}
slot="title"
className={cn('text-lg font-semibold leading-6 mb-5', props.className)}
/>
)
);
}
function Text(properties: React.HTMLProps<HTMLParagraphElement>) {
function Text(props: React.HTMLProps<HTMLParagraphElement>) {
return (
<p
{...properties}
className={cn(
'text-base leading-6 my-0',
properties.className
)}
/>
)
<p {...props} className={cn('text-base leading-6 my-0', props.className)} />
);
}
type Properties = HTMLProps<HTMLDivElement> & {
type Props = HTMLProps<HTMLDivElement> & {
variant?: 'raised' | 'flat';
}
};
function Card(properties: Properties) {
function Card(props: Props) {
return (
<div
{...properties}
{...props}
className={cn(
'w-full max-w-md overflow-hidden rounded-xl p-4',
properties.variant === 'flat'
props.variant === 'flat'
? 'bg-transparent shadow-none'
: 'bg-ui-50 dark:bg-ui-900 shadow-sm',
'border border-ui-200 dark:border-ui-700',
properties.className
props.className,
)}
>
{properties.children}
{props.children}
</div>
)
);
}
export default Object.assign(Card, { Title, Text })
export default Object.assign(Card, { Title, Text });
+24 -22
View File
@@ -1,45 +1,47 @@
import { useState, HTMLProps } from 'react'
import { CopyIcon, CheckIcon } from '@primer/octicons-react'
import { cn } from '~/utils/cn'
import { toast } from '~/components/Toaster'
import { useState, HTMLProps } from 'react';
import { CopyIcon, CheckIcon } from '@primer/octicons-react';
import { cn } from '~/utils/cn';
import { toast } from '~/components/Toaster';
interface Props extends HTMLProps<HTMLSpanElement> {
isCopyable?: boolean
isCopyable?: boolean;
}
export default function Code(props: Props) {
const [isCopied, setIsCopied] = useState(false)
const [isCopied, setIsCopied] = useState(false);
return (
<>
<code className={cn(
'bg-ui-100 dark:bg-ui-800 p-0.5 rounded-md',
props.className
)}>
<code
className={cn(
'bg-ui-100 dark:bg-ui-800 p-0.5 rounded-md',
props.className,
)}
>
{props.children}
</code>
{props.isCopyable && (
{props.isCopyable && props.children ? (
<button
className={cn(
'ml-1 p-1 rounded-md',
'bg-ui-100 dark:bg-ui-800',
'text-ui-500 dark:text-ui-400',
'inline-flex items-center justify-center'
'inline-flex items-center justify-center',
)}
onClick={() => {
navigator.clipboard.writeText(props.children.join(''))
toast('Copied to clipboard')
setIsCopied(true)
setTimeout(() => setIsCopied(false), 1000)
navigator.clipboard.writeText(props.children.join(''));
toast('Copied to clipboard');
setIsCopied(true);
setTimeout(() => setIsCopied(false), 1000);
}}
>
{isCopied ?
<CheckIcon className="h-3 w-3" /> :
{isCopied ? (
<CheckIcon className="h-3 w-3" />
) : (
<CopyIcon className="h-3 w-3" />
}
)}
</button>
)}
) : undefined}
</>
)
);
}
+54 -65
View File
@@ -1,106 +1,99 @@
/* eslint-disable unicorn/no-keyword-prefix */
import { type Dispatch, type ReactNode, type SetStateAction } from 'react'
import { Dispatch, ReactNode, SetStateAction } from 'react';
import {
Button as AriaButton,
Dialog as AriaDialog,
DialogTrigger,
Heading as AriaHeading,
Modal,
ModalOverlay
} from 'react-aria-components'
ModalOverlay,
} from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
type ButtonProperties = Parameters<typeof AriaButton>[0] & {
type ButtonProps = Parameters<typeof AriaButton>[0] & {
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
}
};
function Button(properties: ButtonProperties) {
function Button(props: ButtonProps) {
return (
<AriaButton
{...properties}
aria-label='Dialog'
{...props}
aria-label="Dialog"
className={cn(
'w-fit text-sm rounded-lg px-4 py-2',
'bg-main-700 dark:bg-main-800 text-white',
'hover:bg-main-800 dark:hover:bg-main-700',
properties.isDisabled && 'opacity-50 cursor-not-allowed',
properties.className
props.isDisabled && 'opacity-50 cursor-not-allowed',
props.className,
)}
// If control is passed, set the state value
onPress={properties.control ? () => {
properties.control?.[1](true)
} : undefined}
onPress={
props.control
? () => {
props.control?.[1](true);
}
: undefined
}
/>
)
);
}
type ActionProperties = Parameters<typeof AriaButton>[0] & {
type ActionProps = Parameters<typeof AriaButton>[0] & {
readonly variant: 'cancel' | 'confirm';
}
};
function Action(properties: ActionProperties) {
function Action(props: ActionProps) {
return (
<AriaButton
{...properties}
type={properties.variant === 'confirm' ? 'submit' : 'button'}
{...props}
type={props.variant === 'confirm' ? 'submit' : 'button'}
className={cn(
'px-4 py-2 rounded-lg',
properties.isDisabled && 'opacity-50 cursor-not-allowed',
properties.variant === 'cancel'
props.isDisabled && 'opacity-50 cursor-not-allowed',
props.variant === 'cancel'
? 'text-ui-700 dark:text-ui-300'
: 'text-ui-300 dark:text-ui-300',
properties.variant === 'confirm'
props.variant === 'confirm'
? 'bg-main-700 dark:bg-main-700 pressed:bg-main-800 dark:pressed:bg-main-800'
: 'bg-ui-200 dark:bg-ui-800 pressed:bg-ui-300 dark:pressed:bg-ui-700',
properties.className
props.className,
)}
/>
)
);
}
function Title(properties: Parameters<typeof AriaHeading>[0]) {
function Title(props: Parameters<typeof AriaHeading>[0]) {
return (
<AriaHeading
{...properties}
slot='title'
className={cn(
'text-lg font-semibold leading-6 mb-5',
properties.className
)}
{...props}
slot="title"
className={cn('text-lg font-semibold leading-6 mb-5', props.className)}
/>
)
);
}
function Text(properties: React.HTMLProps<HTMLParagraphElement>) {
function Text(props: React.HTMLProps<HTMLParagraphElement>) {
return (
<p
{...properties}
className={cn(
'text-base leading-6 my-0',
properties.className
)}
/>
)
<p {...props} className={cn('text-base leading-6 my-0', props.className)} />
);
}
type PanelProperties = {
readonly children: (close: () => void) => ReactNode;
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
readonly className?: string;
interface PanelProps {
children: (close: () => void) => ReactNode;
control?: [boolean, Dispatch<SetStateAction<boolean>>];
className?: string;
}
function Panel({ children, control, className }: PanelProperties) {
function Panel({ children, control, className }: PanelProps) {
return (
<ModalOverlay
aria-hidden='true'
aria-hidden="true"
className={cn(
'fixed inset-0 h-screen w-screen z-50 bg-black/30',
'flex items-center justify-center dark:bg-black/70',
'entering:animate-in exiting:animate-out',
'entering:fade-in entering:duration-200 entering:ease-out',
'exiting:fade-out exiting:duration-100 exiting:ease-in',
className
className,
)}
isOpen={control ? control[0] : undefined}
onOpenChange={control ? control[1] : undefined}
@@ -112,32 +105,28 @@ function Panel({ children, control, className }: PanelProperties) {
'entering:animate-in exiting:animate-out',
'dark:border dark:border-ui-700',
'entering:zoom-in-95 entering:ease-out entering:duration-200',
'exiting:zoom-out-95 exiting:ease-in exiting:duration-100'
'exiting:zoom-out-95 exiting:ease-in exiting:duration-100',
)}
>
<AriaDialog role='alertdialog' className='outline-none relative'>
<AriaDialog role="alertdialog" className="outline-none relative">
{({ close }) => children(close)}
</AriaDialog>
</Modal>
</ModalOverlay>
)
);
}
type DialogProperties = {
readonly children: ReactNode;
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
interface DialogProps {
children: ReactNode;
control?: [boolean, Dispatch<SetStateAction<boolean>>];
}
function Dialog({ children, control }: DialogProperties) {
function Dialog({ children, control }: DialogProps) {
if (control) {
return children
return children;
}
return (
<DialogTrigger>
{children}
</DialogTrigger>
)
return <DialogTrigger>{children}</DialogTrigger>;
}
export default Object.assign(Dialog, { Button, Title, Text, Panel, Action })
export default Object.assign(Dialog, { Button, Title, Text, Panel, Action });
+19 -26
View File
@@ -1,19 +1,18 @@
import { AlertIcon } from '@primer/octicons-react'
import { isRouteErrorResponse, useRouteError } from '@remix-run/react'
import { AlertIcon } from '@primer/octicons-react';
import { isRouteErrorResponse, useRouteError } from 'react-router';
import { cn } from '~/utils/cn';
import Card from './Card';
import Code from './Code';
import { cn } from '~/utils/cn'
import Card from './Card'
import Code from './Code'
type Properties = {
readonly type?: 'full' | 'embedded';
interface Props {
type?: 'full' | 'embedded';
}
export function ErrorPopup({ type = 'full' }: Properties) {
const error = useRouteError()
const routing = isRouteErrorResponse(error)
const message = (error instanceof Error ? error.message : 'An unexpected error occurred')
export function ErrorPopup({ type = 'full' }: Props) {
const error = useRouteError();
const routing = isRouteErrorResponse(error);
const message =
error instanceof Error ? error.message : 'An unexpected error occurred';
return (
<div
@@ -21,26 +20,20 @@ export function ErrorPopup({ type = 'full' }: Properties) {
'flex items-center justify-center',
type === 'embedded'
? 'pointer-events-none mt-24'
: 'fixed inset-0 h-screen w-screen z-50'
: 'fixed inset-0 h-screen w-screen z-50',
)}
>
<Card>
<div className='flex items-center justify-between'>
<Card.Title className='text-3xl mb-0'>
<div className="flex items-center justify-between">
<Card.Title className="text-3xl mb-0">
{routing ? error.status : 'Error'}
</Card.Title>
<AlertIcon className='w-12 h-12 text-red-500'/>
<AlertIcon className="w-12 h-12 text-red-500" />
</div>
<Card.Text className='mt-4 text-lg'>
{routing ? (
error.statusText
) : (
<Code>
{message}
</Code>
)}
<Card.Text className="mt-4 text-lg">
{routing ? error.statusText : <Code>{message}</Code>}
</Card.Text>
</Card>
</div>
)
);
}
+17 -25
View File
@@ -1,44 +1,36 @@
import { cn } from '~/utils/cn'
import Link from '~/components/Link'
import { cn } from '~/utils/cn';
import Link from '~/components/Link';
interface FooterProps {
url: string
debug: boolean
url: string;
debug: boolean;
}
export default function Footer({ url, debug, integration }: FooterProps) {
return (
<footer className={cn(
'fixed bottom-0 left-0 z-50 w-full h-14',
'bg-ui-100 dark:bg-ui-900 text-ui-500',
'flex flex-col justify-center gap-1',
'border-t border-ui-200 dark:border-ui-800',
)}>
<footer
className={cn(
'fixed bottom-0 left-0 z-50 w-full h-14',
'bg-ui-100 dark:bg-ui-900 text-ui-500',
'flex flex-col justify-center gap-1',
'border-t border-ui-200 dark:border-ui-800',
)}
>
<p className="container text-xs">
Headplane is entirely free to use.
{' '}
If you find it useful, consider
{' '}
Headplane is entirely free to use. If you find it useful, consider{' '}
<Link
to="https://github.com/sponsors/tale"
name="Aarnav's GitHub Sponsors"
>
donating
</Link>
{' '}
to support development.
{' '}
</Link>{' '}
to support development.{' '}
</p>
<p className="container text-xs opacity-75">
Version: {__VERSION__}
{' | '}
Connecting to
{' '}
<strong>{url}</strong>
{' '}
{debug && '(Debug mode enabled)'}
Connecting to <strong>{url}</strong> {debug && '(Debug mode enabled)'}
</p>
</footer>
)
);
}
+102 -76
View File
@@ -1,42 +1,51 @@
import { GearIcon, GlobeIcon, LockIcon, PaperAirplaneIcon, PeopleIcon, PersonIcon, ServerIcon } from '@primer/octicons-react'
import { Form } from '@remix-run/react'
import {
GearIcon,
GlobeIcon,
LockIcon,
PaperAirplaneIcon,
PeopleIcon,
PersonIcon,
ServerIcon,
} from '@primer/octicons-react';
import { Form } from 'react-router';
import { cn } from '~/utils/cn'
import { HeadplaneContext } from '~/utils/config/headplane'
import { type SessionData } from '~/utils/sessions'
import { cn } from '~/utils/cn';
import { HeadplaneContext } from '~/utils/config/headplane';
import { SessionData } from '~/utils/sessions';
import Menu from './Menu'
import TabLink from './TabLink'
import Menu from './Menu';
import TabLink from './TabLink';
interface Properties {
readonly data?: {
config: HeadplaneContext['config']
user?: SessionData['user']
}
interface Props {
data?: {
config: HeadplaneContext['config'];
user?: SessionData['user'];
};
}
interface LinkProperties {
readonly href: string
readonly text: string
readonly isMenu?: boolean
interface LinkProps {
href: string;
text: string;
isMenu?: boolean;
}
function Link({ href, text, isMenu }: LinkProperties) {
function Link({ href, text, isMenu }: LinkProps) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className={cn(
!isMenu && 'text-ui-300 hover:text-ui-50 hover:underline hidden sm:block',
!isMenu &&
'text-ui-300 hover:text-ui-50 hover:underline hidden sm:block',
)}
>
{text}
</a>
)
);
}
export default function Header({ data }: Properties) {
export default function Header({ data }: Props) {
return (
<header className="bg-main-700 dark:bg-main-800 text-ui-50">
<div className="container flex items-center justify-between py-4">
@@ -48,69 +57,86 @@ export default function Header({ data }: Properties) {
<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.Button className={cn(
{data?.user ? (
<Menu>
<Menu.Button
className={cn(
'rounded-full h-9 w-9',
'border border-main-600 dark:border-main-700',
'hover:bg-main-600 dark:hover:bg-main-700',
)}
>
<PersonIcon className="h-5 w-5 mt-0.5" />
</Menu.Button>
<Menu.Items>
<Menu.Item className="text-right">
<p className="font-bold">{data.user.name}</p>
<p>{data.user.email}</p>
</Menu.Item>
<Menu.Item className="text-right sm:hidden">
<Link
isMenu
href="https://tailscale.com/download"
text="Download"
/>
</Menu.Item>
<Menu.Item className="text-right sm:hidden">
<Link
isMenu
href="https://github.com/tale/headplane"
text="GitHub"
/>
</Menu.Item>
<Menu.Item className="text-right sm:hidden">
<Link
isMenu
href="https://github.com/juanfont/headscale"
text="Headscale"
/>
</Menu.Item>
<Menu.Item className="text-red-500 dark:text-red-400">
<Form method="POST" action="/logout">
<button type="submit" className="w-full text-right">
Logout
</button>
</Form>
</Menu.Item>
</Menu.Items>
</Menu>
)
: undefined}
>
<PersonIcon className="h-5 w-5 mt-0.5" />
</Menu.Button>
<Menu.Items>
<Menu.Item className="text-right">
<p className="font-bold">{data.user.name}</p>
<p>{data.user.email}</p>
</Menu.Item>
<Menu.Item className="text-right sm:hidden">
<Link
isMenu
href="https://tailscale.com/download"
text="Download"
/>
</Menu.Item>
<Menu.Item className="text-right sm:hidden">
<Link
isMenu
href="https://github.com/tale/headplane"
text="GitHub"
/>
</Menu.Item>
<Menu.Item className="text-right sm:hidden">
<Link
isMenu
href="https://github.com/juanfont/headscale"
text="Headscale"
/>
</Menu.Item>
<Menu.Item className="text-red-500 dark:text-red-400">
<Form method="POST" action="/logout">
<button type="submit" className="w-full text-right">
Logout
</button>
</Form>
</Menu.Item>
</Menu.Items>
</Menu>
) : undefined}
</div>
</div>
<nav className="container flex items-center gap-x-4 overflow-x-auto">
<TabLink to="/machines" name="Machines" icon={<ServerIcon className="w-4 h-4" />} />
<TabLink to="/users" name="Users" icon={<PeopleIcon className="w-4 h-4" />} />
<TabLink to="/acls" name="Access Control" icon={<LockIcon className="w-4 h-4" />} />
{data?.config.read
? (
<>
<TabLink to="/dns" name="DNS" icon={<GlobeIcon className="w-4 h-4" />} />
<TabLink to="/settings" name="Settings" icon={<GearIcon className="w-4 h-4" />} />
</>
)
: undefined}
<TabLink
to="/machines"
name="Machines"
icon={<ServerIcon className="w-4 h-4" />}
/>
<TabLink
to="/users"
name="Users"
icon={<PeopleIcon className="w-4 h-4" />}
/>
<TabLink
to="/acls"
name="Access Control"
icon={<LockIcon className="w-4 h-4" />}
/>
{data?.config.read ? (
<>
<TabLink
to="/dns"
name="DNS"
icon={<GlobeIcon className="w-4 h-4" />}
/>
<TabLink
to="/settings"
name="Settings"
icon={<GearIcon className="w-4 h-4" />}
/>
</>
) : undefined}
</nav>
</header>
)
);
}
+7 -8
View File
@@ -1,12 +1,11 @@
import { LinkExternalIcon } from '@primer/octicons-react'
import { cn } from '~/utils/cn'
import { LinkExternalIcon } from '@primer/octicons-react';
import { cn } from '~/utils/cn';
interface Props {
to: string
name: string
children: string
className?: string
to: string;
name: string;
children: string;
className?: string;
}
export default function Link({ to, name: alt, children, className }: Props) {
@@ -26,5 +25,5 @@ export default function Link({ to, name: alt, children, className }: Props) {
{children}
<LinkExternalIcon className="h-3 w-3" />
</a>
)
);
}
+43 -50
View File
@@ -1,98 +1,91 @@
import { type Dispatch, type ReactNode, type SetStateAction } from 'react'
import { Dispatch, ReactNode, SetStateAction } from 'react';
import {
Button as AriaButton,
Menu as AriaMenu,
MenuItem,
MenuTrigger,
Popover
} from 'react-aria-components'
Popover,
} from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
function Button(properties: Parameters<typeof AriaButton>[0]) {
function Button(props: Parameters<typeof AriaButton>[0]) {
return (
<AriaButton
{...properties}
className={cn(
'outline-none',
properties.className
)}
aria-label='Menu'
{...props}
className={cn('outline-none', props.className)}
aria-label="Menu"
/>
)
);
}
function Items(properties: Parameters<typeof AriaMenu>[0]) {
function Items(props: Parameters<typeof AriaMenu>[0]) {
return (
<Popover className={cn(
'mt-2 rounded-md',
'bg-ui-50 dark:bg-ui-800',
'overflow-hidden z-50',
'border border-ui-200 dark:border-ui-600',
'entering:animate-in exiting:animate-out',
'entering:fade-in entering:zoom-in-95',
'exiting:fade-out exiting:zoom-out-95',
'fill-mode-forwards origin-left-right'
)}
<Popover
className={cn(
'mt-2 rounded-md',
'bg-ui-50 dark:bg-ui-800',
'overflow-hidden z-50',
'border border-ui-200 dark:border-ui-600',
'entering:animate-in exiting:animate-out',
'entering:fade-in entering:zoom-in-95',
'exiting:fade-out exiting:zoom-out-95',
'fill-mode-forwards origin-left-right',
)}
>
<AriaMenu
{...properties}
{...props}
className={cn(
'outline-none',
'divide-y divide-ui-200 dark:divide-ui-600',
properties.className
props.className,
)}
>
{properties.children}
{props.children}
</AriaMenu>
</Popover>
)
);
}
type ButtonProperties = Parameters<typeof AriaButton>[0] & {
type ButtonProps = Parameters<typeof AriaButton>[0] & {
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
}
};
function ItemButton(properties: ButtonProperties) {
function ItemButton(props: ButtonProps) {
return (
<MenuItem className='outline-none'>
<MenuItem className="outline-none">
<AriaButton
{...properties}
{...props}
className={cn(
'px-4 py-2 w-full outline-none text-left',
'hover:bg-ui-200 dark:hover:bg-ui-700',
properties.className
props.className,
)}
aria-label='Menu Dialog'
aria-label="Menu Dialog"
// If control is passed, set the state value
onPress={event => {
properties.onPress?.(event)
properties.control?.[1](true)
onPress={(event) => {
props.onPress?.(event);
props.control?.[1](true);
}}
/>
</MenuItem>
)
);
}
function Item(properties: Parameters<typeof MenuItem>[0]) {
function Item(props: Parameters<typeof MenuItem>[0]) {
return (
<MenuItem
{...properties}
{...props}
className={cn(
'px-4 py-2 w-full outline-none',
'hover:bg-ui-200 dark:hover:bg-ui-700',
properties.className
props.className,
)}
/>
)
);
}
function Menu({ children }: { readonly children: ReactNode }) {
return (
<MenuTrigger>
{children}
</MenuTrigger>
)
function Menu({ children }: { children: ReactNode }) {
return <MenuTrigger>{children}</MenuTrigger>;
}
export default Object.assign(Menu, { Button, Item, ItemButton, Items })
export default Object.assign(Menu, { Button, Item, ItemButton, Items });
+12 -12
View File
@@ -1,23 +1,23 @@
import { InfoIcon } from '@primer/octicons-react'
import type { ReactNode } from 'react'
import { cn } from '~/utils/cn'
import { InfoIcon } from '@primer/octicons-react';
import type { ReactNode } from 'react';
import { cn } from '~/utils/cn';
interface Props {
className?: string
children: ReactNode
className?: string;
children: ReactNode;
}
export default function Notice({ children, className }: Props) {
return (
<div className={cn(
'p-4 rounded-md w-full flex items-center gap-3',
'bg-ui-200 dark:bg-ui-800',
className,
)}
<div
className={cn(
'p-4 rounded-md w-full flex items-center gap-3',
'bg-ui-200 dark:bg-ui-800',
className,
)}
>
<InfoIcon className="h-6 w-6 text-ui-700 dark:text-ui-200" />
{children}
</div>
)
);
}
+19 -20
View File
@@ -1,18 +1,17 @@
import { PlusIcon, DashIcon } from '@primer/octicons-react'
import { Dispatch, SetStateAction } from 'react'
import { PlusIcon, DashIcon } from '@primer/octicons-react';
import { Dispatch, SetStateAction } from 'react';
import {
Button,
Group,
Input,
NumberField as AriaNumberField
} from 'react-aria-components'
import { cn } from '~/utils/cn'
NumberField as AriaNumberField,
} from 'react-aria-components';
import { cn } from '~/utils/cn';
type NumberFieldProps = Parameters<typeof AriaNumberField>[0] & {
label: string;
state?: [number, Dispatch<SetStateAction<number>>];
}
};
export default function NumberField(props: NumberFieldProps) {
return (
@@ -21,20 +20,20 @@ export default function NumberField(props: NumberFieldProps) {
aria-label={props.label}
className="w-full"
value={props.state?.[0]}
onChange={value => {
props.state?.[1](value)
onChange={(value) => {
props.state?.[1](value);
}}
>
<Group className={cn(
'flex px-2.5 py-1.5 w-full rounded-lg my-1',
'border border-ui-200 dark:border-ui-600',
'dark:bg-ui-800 dark:text-ui-300 gap-2',
'focus-within:ring-2 focus-within:ring-blue-600',
props.className
)}>
<Input
className="w-full bg-transparent focus:outline-none"
/>
<Group
className={cn(
'flex px-2.5 py-1.5 w-full rounded-lg my-1',
'border border-ui-200 dark:border-ui-600',
'dark:bg-ui-800 dark:text-ui-300 gap-2',
'focus-within:ring-2 focus-within:ring-blue-600',
props.className,
)}
>
<Input className="w-full bg-transparent focus:outline-none" />
<Button slot="decrement">
<DashIcon className="w-4 h-4" />
</Button>
@@ -43,5 +42,5 @@ export default function NumberField(props: NumberFieldProps) {
</Button>
</Group>
</AriaNumberField>
)
);
}
+19 -21
View File
@@ -1,5 +1,5 @@
import { ChevronDownIcon } from '@primer/octicons-react'
import { Dispatch, ReactNode, SetStateAction } from 'react'
import { ChevronDownIcon } from '@primer/octicons-react';
import { Dispatch, ReactNode, SetStateAction } from 'react';
import {
Button,
ListBox,
@@ -7,15 +7,14 @@ import {
Popover,
Select as AriaSelect,
SelectValue,
} from 'react-aria-components'
import { cn } from '~/utils/cn'
} from 'react-aria-components';
import { cn } from '~/utils/cn';
type SelectProps = Parameters<typeof AriaSelect>[0] & {
readonly label: string
readonly state?: [string, Dispatch<SetStateAction<string>>]
readonly children: ReactNode
}
readonly label: string;
readonly state?: [string, Dispatch<SetStateAction<string>>];
readonly children: ReactNode;
};
function Select(props: SelectProps) {
return (
@@ -24,7 +23,7 @@ function Select(props: SelectProps) {
aria-label={props.label}
selectedKey={props.state?.[0]}
onSelectionChange={(key) => {
props.state?.[1](key.toString())
props.state?.[1](key.toString());
}}
className={cn(
'block w-full rounded-lg my-1',
@@ -34,10 +33,11 @@ function Select(props: SelectProps) {
props.className,
)}
>
<Button className={cn(
'w-full flex items-center justify-between',
'px-2.5 py-1.5 rounded-lg',
)}
<Button
className={cn(
'w-full flex items-center justify-between',
'px-2.5 py-1.5 rounded-lg',
)}
>
<SelectValue />
<ChevronDownIcon className="w-4 h-4" aria-hidden="true" />
@@ -54,15 +54,13 @@ function Select(props: SelectProps) {
'fill-mode-forwards origin-left-right',
)}
>
<ListBox orientation="vertical">
{props.children}
</ListBox>
<ListBox orientation="vertical">{props.children}</ListBox>
</Popover>
</AriaSelect>
)
);
}
type ItemProps = Parameters<typeof ListBoxItem>[0]
type ItemProps = Parameters<typeof ListBoxItem>[0];
function Item(props: ItemProps) {
return (
@@ -76,7 +74,7 @@ function Item(props: ItemProps) {
>
{props.children}
</ListBoxItem>
)
);
}
export default Object.assign(Select, { Item })
export default Object.assign(Select, { Item });
+8 -9
View File
@@ -1,23 +1,22 @@
import clsx from 'clsx'
import clsx from 'clsx';
type Properties = {
// eslint-disable-next-line unicorn/no-keyword-prefix
interface Props {
className?: string;
}
export default function Spinner(properties: Properties) {
export default function Spinner({ className }: Props) {
return (
<div className={clsx('mr-1.5 inline-block align-middle mb-0.5', properties.className)}>
<div className={clsx('mr-1.5 inline-block align-middle mb-0.5', className)}>
<div
className={clsx(
'animate-spin rounded-full w-full h-full',
'border-2 border-current border-t-transparent',
properties.className
className,
)}
role='status'
role="status"
>
<span className='sr-only'>Loading...</span>
<span className="sr-only">Loading...</span>
</div>
</div>
)
);
}
+10 -10
View File
@@ -1,24 +1,24 @@
import clsx from 'clsx'
import { type HTMLProps } from 'react'
import clsx from 'clsx';
import { HTMLProps } from 'react';
type Properties = HTMLProps<SVGElement> & {
type Props = HTMLProps<SVGElement> & {
readonly isOnline: boolean;
}
};
// eslint-disable-next-line unicorn/no-keyword-prefix
export default function StatusCircle({ isOnline, className }: Properties) {
export default function StatusCircle({ isOnline, className }: Props) {
return (
<svg
className={clsx(
className,
isOnline
? 'text-green-700 dark:text-green-400'
: 'text-gray-300 dark:text-gray-500'
: 'text-gray-300 dark:text-gray-500',
)}
viewBox='0 0 24 24'
fill='currentColor'
viewBox="0 0 24 24"
fill="currentColor"
>
<circle cx='12' cy='12' r='8'/>
<circle cx="12" cy="12" r="8" />
</svg>
)
);
}
+17 -17
View File
@@ -1,34 +1,34 @@
import { Switch as AriaSwitch } from 'react-aria-components'
import { Switch as AriaSwitch } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
type SwitchProperties = Parameters<typeof AriaSwitch>[0] & {
type SwitchProps = Parameters<typeof AriaSwitch>[0] & {
readonly label: string;
}
};
export default function Switch(properties: SwitchProperties) {
export default function Switch(props: SwitchProps) {
return (
<AriaSwitch
{...properties}
aria-label={properties.label}
className='group flex gap-2 items-center'
{...props}
aria-label={props.label}
className="group flex gap-2 items-center"
>
<div
className={cn(
'flex h-[26px] w-[44px] p-[4px] shrink-0',
'rounded-full outline-none group-focus-visible:ring-2',
'bg-main-600/50 dark:bg-main-600/20 group-selected:bg-main-700',
properties.isDisabled && 'opacity-50 cursor-not-allowed',
properties.className
props.isDisabled && 'opacity-50 cursor-not-allowed',
props.className,
)}
>
<span className={cn(
'h-[18px] w-[18px] transform rounded-full',
'bg-white transition duration-100 ease-in-out',
'translate-x-0 group-selected:translate-x-[100%]'
)}
<span
className={cn(
'h-[18px] w-[18px] transform rounded-full',
'bg-white transition duration-100 ease-in-out',
'translate-x-0 group-selected:translate-x-[100%]',
)}
/>
</div>
</AriaSwitch>
)
);
}
+17 -16
View File
@@ -1,32 +1,33 @@
import { NavLink } from '@remix-run/react'
import type { ReactNode } from 'react'
import { NavLink } from 'react-router';
import type { ReactNode } from 'react';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
type Properties = {
readonly name: string;
readonly to: string;
readonly icon: ReactNode;
interface Props {
name: string;
to: string;
icon: ReactNode;
}
export default function TabLink({ name, to, icon }: Properties) {
export default function TabLink({ name, to, icon }: Props) {
return (
<NavLink
to={to}
prefetch='intent'
className={({ isActive }) => cn(
'border-b-2 py-1.5',
isActive ? 'border-white' : 'border-transparent'
)}
prefetch="intent"
className={({ isActive }) =>
cn(
'border-b-2 py-1.5',
isActive ? 'border-white' : 'border-transparent',
)
}
>
<div
className={cn(
'flex items-center gap-x-2 px-2.5 py-1.5 text-md text-nowrap',
'hover:bg-ui-100/5 dark:hover:bg-ui-900/40 rounded-md'
'hover:bg-ui-100/5 dark:hover:bg-ui-900/40 rounded-md',
)}
>
{icon} {name}
</div>
</NavLink>
)
);
}
+13 -14
View File
@@ -1,37 +1,36 @@
import clsx from 'clsx'
import { type HTMLProps } from 'react'
import clsx from 'clsx';
import { HTMLProps } from 'react';
function TableList(properties: HTMLProps<HTMLDivElement>) {
function TableList(props: HTMLProps<HTMLDivElement>) {
return (
<div
{...properties}
{...props}
className={clsx(
'border border-gray-300 rounded-lg overflow-clip',
'dark:border-zinc-700 dark:text-gray-300',
// 'dark:bg-zinc-800',
properties.className
props.className,
)}
>
{properties.children}
{props.children}
</div>
)
);
}
function Item(properties: HTMLProps<HTMLDivElement>) {
function Item(props: HTMLProps<HTMLDivElement>) {
return (
<div
{...properties}
{...props}
className={clsx(
'flex items-center justify-between px-3 py-2',
'border-b border-gray-200 last:border-b-0',
'dark:border-zinc-800',
properties.className
props.className,
)}
>
{properties.children}
{props.children}
</div>
)
);
}
export default Object.assign(TableList, { Item })
export default Object.assign(TableList, { Item });
+14 -22
View File
@@ -1,38 +1,30 @@
import { type Dispatch, type SetStateAction } from 'react'
import {
Input,
TextField as AriaTextField
} from 'react-aria-components'
import { Dispatch, SetStateAction } from 'react';
import { Input, TextField as AriaTextField } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
type TextFieldProperties = Parameters<typeof AriaTextField>[0] & {
type TextFieldProps = Parameters<typeof AriaTextField>[0] & {
readonly label: string;
readonly placeholder: string;
readonly state?: [string, Dispatch<SetStateAction<string>>];
}
};
export default function TextField(properties: TextFieldProperties) {
export default function TextField(props: TextFieldProps) {
return (
<AriaTextField
{...properties}
aria-label={properties.label}
className='w-full'
>
<AriaTextField {...props} aria-label={props.label} className="w-full">
<Input
placeholder={properties.placeholder}
value={properties.state?.[0]}
name={properties.name}
placeholder={props.placeholder}
value={props.state?.[0]}
name={props.name}
className={cn(
'block px-2.5 py-1.5 w-full rounded-lg my-1',
'border border-ui-200 dark:border-ui-600',
'dark:bg-ui-800 dark:text-ui-300',
properties.className
props.className,
)}
onChange={event => {
properties.state?.[1](event.target.value)
onChange={(event) => {
props.state?.[1](event.target.value);
}}
/>
</AriaTextField>
)
);
}
+44 -43
View File
@@ -1,22 +1,25 @@
import { XIcon } from '@primer/octicons-react'
import { AriaToastProps, useToast, useToastRegion } from '@react-aria/toast'
import { ToastQueue, ToastState, useToastQueue } from '@react-stately/toast'
import { ReactNode, useRef } from 'react'
import { Button } from 'react-aria-components'
import { createPortal } from 'react-dom'
import { ClientOnly } from 'remix-utils/client-only'
import { XIcon } from '@primer/octicons-react';
import { AriaToastProps, useToast, useToastRegion } from '@react-aria/toast';
import { ToastQueue, ToastState, useToastQueue } from '@react-stately/toast';
import { ReactNode, useRef } from 'react';
import { Button } from 'react-aria-components';
import { createPortal } from 'react-dom';
import { ClientOnly } from 'remix-utils/client-only';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
type ToastProperties = AriaToastProps<ReactNode> & {
type ToastProps = AriaToastProps<ReactNode> & {
readonly state: ToastState<ReactNode>;
}
};
function Toast({ state, ...properties }: ToastProperties) {
const reference = useRef(null)
function Toast({ state, ...properties }: ToastProps) {
const reference = useRef(null);
// @ts-expect-error: RefObject doesn't map to FocusableElement?
const { toastProps, titleProps, closeButtonProps } = useToast(properties, state, reference)
const { toastProps, titleProps, closeButtonProps } = useToast(
properties,
state,
reference,
);
return (
<div
@@ -26,7 +29,7 @@ function Toast({ state, ...properties }: ToastProperties) {
'bg-main-700 dark:bg-main-800 rounded-lg',
'text-main-100 dark:text-main-200 z-50',
'border border-main-600 dark:border-main-700',
'flex items-center justify-between p-3 pl-4 w-80'
'flex items-center justify-between p-3 pl-4 w-80',
)}
>
<div {...titleProps}>{properties.toast.content}</div>
@@ -34,52 +37,50 @@ function Toast({ state, ...properties }: ToastProperties) {
{...closeButtonProps}
className={cn(
'outline-none rounded-full p-1',
'hover:bg-main-600 dark:hover:bg-main-700'
'hover:bg-main-600 dark:hover:bg-main-700',
)}
>
<XIcon className='w-4 h-4'/>
<XIcon className="w-4 h-4" />
</Button>
</div>
)
);
}
const toasts = new ToastQueue<ReactNode>({
maxVisibleToasts: 5
})
maxVisibleToasts: 5,
});
export function toast(text: string) {
return toasts.add(text, { timeout: 5000 })
return toasts.add(text, { timeout: 5000 });
}
export function Toaster() {
const reference = useRef(null)
const state = useToastQueue(toasts)
const reference = useRef(null);
const state = useToastQueue(toasts);
// @ts-expect-error: React 19 has weird types for Portal vs Node
const { regionProps } = useToastRegion({}, state, reference)
const { regionProps } = useToastRegion({}, state, reference);
return (
<ClientOnly>
{
// @ts-expect-error: Portal doesn't match Node in React 19 yet
() => createPortal(
state.visibleToasts.length >= 0 ? (
<div
className={cn(
'fixed bottom-4 right-4',
'flex flex-col gap-4'
)}
{...regionProps}
ref={reference}
>
{state.visibleToasts.map(toast => (
<Toast key={toast.key} toast={toast} state={state}/>
))}
</div>
) : undefined,
document.body
)}
() =>
createPortal(
state.visibleToasts.length >= 0 ? (
<div
className={cn('fixed bottom-4 right-4', 'flex flex-col gap-4')}
{...regionProps}
ref={reference}
>
{state.visibleToasts.map((toast) => (
<Toast key={toast.key} toast={toast} state={state} />
))}
</div>
) : undefined,
document.body,
)
}
</ClientOnly>
)
);
}
+16 -22
View File
@@ -1,43 +1,37 @@
import { ReactNode } from 'react'
import { ReactNode } from 'react';
import {
Button as AriaButton,
Tooltip as AriaTooltip,
TooltipTrigger,
} from 'react-aria-components'
import { cn } from '~/utils/cn'
} from 'react-aria-components';
import { cn } from '~/utils/cn';
interface Props {
children: ReactNode
className?: string
children: ReactNode;
className?: string;
}
function Tooltip({ children }: Props) {
return (
<TooltipTrigger delay={0}>
{children}
</TooltipTrigger>
)
return <TooltipTrigger delay={0}>{children}</TooltipTrigger>;
}
function Button(props: Parameters<typeof AriaButton>[0]) {
return (
<AriaButton {...props} />
)
return <AriaButton {...props} />;
}
function Body({ children, className }: Props) {
return (
<AriaTooltip className={cn(
'text-sm max-w-xs p-2 rounded-lg mb-2',
'bg-white dark:bg-ui-900 drop-shadow-sm',
'border border-gray-200 dark:border-zinc-700',
className,
)}
<AriaTooltip
className={cn(
'text-sm max-w-xs p-2 rounded-lg mb-2',
'bg-white dark:bg-ui-900 drop-shadow-sm',
'border border-gray-200 dark:border-zinc-700',
className,
)}
>
{children}
</AriaTooltip>
)
);
}
export default Object.assign(Tooltip, { Button, Body })
export default Object.assign(Tooltip, { Button, Body });