mirror of
https://github.com/tale/headplane.git
synced 2026-08-24 19:46:29 +00:00
chore: switch to react-router v7
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user