feat: completely nuke headlessui and consolidate to aria

This commit is contained in:
Aarnav Tale 2024-05-01 02:21:54 -04:00
parent 8205f2b99b
commit a57e777a6b
No known key found for this signature in database
16 changed files with 336 additions and 302 deletions

View File

@ -1,5 +1,6 @@
import { ClipboardIcon } from '@heroicons/react/24/outline' import { ClipboardIcon } from '@heroicons/react/24/outline'
import toast from 'react-hot-toast/headless'
import { toast } from './Toaster'
type Properties = { type Properties = {
readonly name: string; readonly name: string;

View File

@ -1,3 +1,4 @@
/* eslint-disable unicorn/no-keyword-prefix */
import { type Dispatch, type ReactNode, type SetStateAction } from 'react' import { type Dispatch, type ReactNode, type SetStateAction } from 'react'
import { import {
Button as AriaButton, Button as AriaButton,
@ -20,7 +21,9 @@ function Button(properties: ButtonProperties) {
{...properties} {...properties}
aria-label='Dialog' aria-label='Dialog'
className={cn( className={cn(
'outline-none', 'w-fit text-sm rounded-lg px-4 py-2',
'bg-main-700 dark:bg-main-800 text-white',
properties.isDisabled && 'opacity-50 cursor-not-allowed',
properties.className properties.className
)} )}
// If control is passed, set the state value // If control is passed, set the state value
@ -42,11 +45,12 @@ function Action(properties: ActionProperties) {
type={properties.variant === 'confirm' ? 'submit' : 'button'} type={properties.variant === 'confirm' ? 'submit' : 'button'}
className={cn( className={cn(
'px-4 py-2 rounded-lg', 'px-4 py-2 rounded-lg',
properties.isDisabled && 'opacity-50 cursor-not-allowed',
properties.variant === 'cancel' properties.variant === 'cancel'
? 'text-ui-700 dark:text-ui-300' ? 'text-ui-700 dark:text-ui-300'
: 'text-ui-950 dark:text-ui-50', : 'text-ui-300 dark:text-ui-300',
properties.variant === 'confirm' properties.variant === 'confirm'
? 'bg-blue-500 hover:bg-blue-600 pressed:bg-blue-700 text-white' ? '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', : 'bg-ui-200 dark:bg-ui-800 pressed:bg-ui-300 dark:pressed:bg-ui-700',
properties.className properties.className
)} )}
@ -82,9 +86,10 @@ function Text(properties: React.HTMLProps<HTMLParagraphElement>) {
type PanelProperties = { type PanelProperties = {
readonly children: (close: () => void) => ReactNode; readonly children: (close: () => void) => ReactNode;
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>]; readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
readonly className?: string;
} }
function Panel({ children, control }: PanelProperties) { function Panel({ children, control, className }: PanelProperties) {
return ( return (
<ModalOverlay <ModalOverlay
aria-hidden='true' aria-hidden='true'
@ -92,8 +97,9 @@ function Panel({ children, control }: PanelProperties) {
'fixed inset-0 h-screen w-screen z-50 bg-black/30', 'fixed inset-0 h-screen w-screen z-50 bg-black/30',
'flex items-center justify-center dark:bg-black/70', 'flex items-center justify-center dark:bg-black/70',
'entering:animate-in exiting:animate-out', 'entering:animate-in exiting:animate-out',
'entering:fade-in entering:duration-300 entering:ease-out', 'entering:fade-in entering:duration-200 entering:ease-out',
'exiting:fade-out exiting:duration-200 exiting:ease-in' 'exiting:fade-out exiting:duration-100 exiting:ease-in',
className
)} )}
isOpen={control ? control[0] : undefined} isOpen={control ? control[0] : undefined}
onOpenChange={control ? control[1] : undefined} onOpenChange={control ? control[1] : undefined}
@ -104,8 +110,8 @@ function Panel({ children, control }: PanelProperties) {
'bg-ui-50 dark:bg-ui-900 shadow-lg', 'bg-ui-50 dark:bg-ui-900 shadow-lg',
'entering:animate-in exiting:animate-out', 'entering:animate-in exiting:animate-out',
'dark:border dark:border-ui-700', 'dark:border dark:border-ui-700',
'entering:zoom-in-95 entering:ease-out entering:duration-300', 'entering:zoom-in-95 entering:ease-out entering:duration-200',
'exiting:zoom-out-95 exiting:ease-in exiting:duration-200' 'exiting:zoom-out-95 exiting:ease-in exiting:duration-100'
)} )}
> >
<AriaDialog role='alertdialog' className='outline-none relative'> <AriaDialog role='alertdialog' className='outline-none relative'>

View File

@ -1,66 +1,52 @@
import { Transition } from '@headlessui/react'
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline' import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
import { isRouteErrorResponse, useRouteError } from '@remix-run/react' import { isRouteErrorResponse, useRouteError } from '@remix-run/react'
import clsx from 'clsx' import { useState } from 'react'
import { Fragment, useEffect, useState } from 'react'
import { cn } from '~/utils/cn'
import Code from './Code' import Code from './Code'
import Dialog from './Dialog'
type Properties = { type Properties = {
readonly type?: 'full' | 'embedded'; readonly type?: 'full' | 'embedded';
} }
export function ErrorPopup({ type = 'full' }: Properties) { export function ErrorPopup({ type = 'full' }: Properties) {
const [isOpen, setIsOpen] = useState(false) // eslint-disable-next-line react/hook-use-state
const open = useState(true)
const error = useRouteError() const error = useRouteError()
const routing = isRouteErrorResponse(error) const routing = isRouteErrorResponse(error)
const message = (error instanceof Error ? error.message : 'An unexpected error occurred') const message = (error instanceof Error ? error.message : 'An unexpected error occurred')
console.error(error)
// Debounce the error modal so it doesn't show up for a split second
// when the user navigates to a new page.
useEffect(() => {
setTimeout(() => {
setIsOpen(true)
}, 150)
}, [])
return ( return (
<Transition as={Fragment} show={isOpen}> <Dialog>
<Transition.Child <Dialog.Panel
as={Fragment} className={cn(
enter='ease-out duration-150' type === 'embedded' ? 'pointer-events-none bg-transparent dark:bg-transparent' : '',
enterFrom='opacity-0 scale-95'
enterTo='opacity-100 scale-100'
>
<div className={clsx(
'flex items-center justify-center overflow-clip',
type === 'full' ? 'min-h-screen' : 'mt-24'
)} )}
> control={open}
<div className={clsx( >
'flex flex-col items-center justify-center space-y-2 w-full sm:w-1/2 xl:w-1/3', {() => (
'bg-white dark:bg-zinc-800 rounded-lg py-8 px-4 md:px-16', <>
'border border-gray-200 dark:border-zinc-700 text-center' <div className='flex items-center justify-between'>
)} <Dialog.Title className='text-3xl mb-0'>
> {routing ? error.status : 'Error'}
<ExclamationTriangleIcon className='w-12 h-12 text-red-500'/> </Dialog.Title>
<h1 className='text-2xl font-semibold text-gray-800 dark:text-gray-100'> <ExclamationTriangleIcon className='w-12 h-12 text-red-500'/>
{routing ? error.status : 'Error'} </div>
</h1> <Dialog.Text className='mt-4 text-lg'>
{routing ? ( {routing ? (
<p className='text-gray-500 dark:text-gray-400'> error.statusText
{error.statusText} ) : (
</p> <Code>
) : ( {message}
<Code className='text-sm'> </Code>
{message} )}
</Code> </Dialog.Text>
)} </>
</div> )}
</div> </Dialog.Panel>
</Transition.Child> </Dialog>
</Transition>
) )
} }

View File

@ -1,4 +1,4 @@
import { type ReactNode } from 'react' import { type Dispatch, type ReactNode, type SetStateAction } from 'react'
import { import {
Button as AriaButton, Button as AriaButton,
Menu as AriaMenu, Menu as AriaMenu,
@ -49,6 +49,31 @@ function Items(properties: Parameters<typeof AriaMenu>[0]) {
) )
} }
type ButtonProperties = Parameters<typeof AriaButton>[0] & {
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
}
function ItemButton(properties: ButtonProperties) {
return (
<MenuItem className='outline-none'>
<AriaButton
{...properties}
className={cn(
'px-4 py-2 w-full outline-none text-left',
'hover:bg-ui-200 dark:hover:bg-ui-700',
properties.className
)}
aria-label='Menu Dialog'
// If control is passed, set the state value
onPress={event => {
properties.onPress?.(event)
properties.control?.[1](true)
}}
/>
</MenuItem>
)
}
function Item(properties: Parameters<typeof MenuItem>[0]) { function Item(properties: Parameters<typeof MenuItem>[0]) {
return ( return (
<MenuItem <MenuItem
@ -70,4 +95,4 @@ function Menu({ children }: { readonly children: ReactNode }) {
) )
} }
export default Object.assign(Menu, { Button, Item, Items }) export default Object.assign(Menu, { Button, Item, ItemButton, Items })

40
app/components/Switch.tsx Normal file
View File

@ -0,0 +1,40 @@
import { Switch as AriaSwitch } from 'react-aria-components'
import { cn } from '~/utils/cn'
type SwitchProperties = Parameters<typeof AriaSwitch>[0] & {
readonly label: string;
}
export default function Switch(properties: SwitchProperties) {
return (
<AriaSwitch
{...properties}
aria-label={properties.label}
className='group flex gap-2 items-center'
>
<div
className={cn(
'flex h-[26px] w-[44px] shrink-0 cursor-default',
'rounded-full shadow-inner bg-clip-padding',
'border border-solid border-white/30 p-[3px]',
'box-border transition duration-100 ease-in-out',
'outline-none group-focus-visible:ring-2 ring-black',
'bg-main-700 dark:bg-main-800',
'group-pressed:bg-main-800 dark:group-pressed:bg-main-900',
'group-selected:bg-main-900 group-selected:group-pressed:bg-main-900',
properties.isDisabled && 'opacity-50 cursor-not-allowed',
properties.className
)}
>
<span className={cn(
'h-[18px] w-[18px] transform rounded-full',
'bg-white shadow transition duration-100',
'ease-in-out translate-x-0 group-selected:translate-x-[100%]'
)}
/>
</div>
</AriaSwitch>
)
}

View File

@ -26,6 +26,7 @@ export default function TextField(properties: TextFieldProperties) {
className={cn( className={cn(
'block px-2.5 py-1.5 w-full rounded-lg my-1', 'block px-2.5 py-1.5 w-full rounded-lg my-1',
'border border-ui-200 dark:border-ui-600', 'border border-ui-200 dark:border-ui-600',
'dark:bg-ui-800 dark:text-ui-300',
properties.className properties.className
)} )}
onChange={event => { onChange={event => {

View File

@ -1,47 +1,79 @@
import { useToaster } from 'react-hot-toast/headless' import { XMarkIcon } from '@heroicons/react/24/outline'
import { type AriaToastProps, useToast, useToastRegion } from '@react-aria/toast'
import { ToastQueue, type ToastState, useToastQueue } from '@react-stately/toast'
import { type ReactNode, useRef } from 'react'
import { Button } from 'react-aria-components'
import { createPortal } from 'react-dom'
import { ClientOnly } from 'remix-utils/client-only'
export default function Toaster() { import { cn } from '~/utils/cn'
const { toasts, handlers } = useToaster()
const { startPause, endPause, calculateOffset, updateHeight } = handlers type ToastProperties = AriaToastProps<ReactNode> & {
readonly state: ToastState<ReactNode>;
}
function Toast({ state, ...properties }: ToastProperties) {
const reference = useRef(null)
const { toastProps, titleProps, closeButtonProps } = useToast(properties, state, reference)
return ( return (
<div <div
className='fixed bottom-0 right-0 p-4 w-80 h-1/2 overflow-hidden pointer-events-none' {...toastProps}
onMouseEnter={startPause} ref={reference}
onMouseLeave={endPause} className={cn(
'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'
)}
> >
{toasts.slice(0, 6).map(toast => { <div {...titleProps}>{properties.toast.content}</div>
const offset = calculateOffset(toast, { <Button
reverseOrder: false, {...closeButtonProps}
gutter: -8 className={cn(
}) 'outline-none rounded-full p-1',
'hover:bg-main-600 dark:hover:bg-main-700'
// eslint-disable-next-line @typescript-eslint/ban-types )}
const reference = (element: HTMLDivElement | null) => { >
if (element && typeof toast.height !== 'number') { <XMarkIcon className='w-4 h-4'/>
const { height } = element.getBoundingClientRect() </Button>
updateHeight(toast.id, -height)
}
}
return (
<div
key={toast.id}
ref={reference}
className='fixed bottom-4 right-4 p-4 bg-gray-800 rounded-lg text-white transition-all duration-300'
{...toast.ariaProps}
style={{
transform: `translateY(${offset}px) translateX(${toast.visible ? 0 : 200}%)`
}}
>
{typeof toast.message === 'function' ? (
toast.message(toast)
) : (
toast.message
)}
</div>
)
})}
</div> </div>
) )
} }
const toasts = new ToastQueue<ReactNode>({
maxVisibleToasts: 5
})
export function toast(text: string) {
return toasts.add(text, { timeout: 5000 })
}
export function Toaster() {
const reference = useRef(null)
const state = useToastQueue(toasts)
const { regionProps } = useToastRegion({}, state, reference)
return (
<ClientOnly>
{() => 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>
)
}

View File

@ -8,7 +8,7 @@ import {
} from '@remix-run/react' } from '@remix-run/react'
import { ErrorPopup } from '~/components/Error' import { ErrorPopup } from '~/components/Error'
import Toaster from '~/components/Toaster' import { Toaster } from '~/components/Toaster'
import stylesheet from '~/tailwind.css?url' import stylesheet from '~/tailwind.css?url'
import { getContext, registerConfigWatcher } from '~/utils/config' import { getContext, registerConfigWatcher } from '~/utils/config'

View File

@ -6,10 +6,10 @@ import CodeMirror from '@uiw/react-codemirror'
import clsx from 'clsx' import clsx from 'clsx'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import CodeMirrorMerge from 'react-codemirror-merge' import CodeMirrorMerge from 'react-codemirror-merge'
import { toast } from 'react-hot-toast/headless'
import Button from '~/components/Button' import Button from '~/components/Button'
import Spinner from '~/components/Spinner' import Spinner from '~/components/Spinner'
import { toast } from '~/components/Toaster'
import Fallback from './fallback' import Fallback from './fallback'
@ -48,28 +48,24 @@ export default function Editor({ data, acl, setAcl, mode }: EditorProperties) {
<> <>
<div className={clsx( <div className={clsx(
'border border-gray-200 dark:border-gray-700', 'border border-gray-200 dark:border-gray-700',
'rounded-b-lg rounded-tr-lg mb-2 overflow-hidden' 'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden'
)} )}
> >
{loading ? ( <div className='overflow-y-scroll h-editor text-sm'>
<Fallback acl={acl} where='client'/> {loading ? (
) : ( <Fallback acl={acl} where='client'/>
mode === 'edit' ? (
<CodeMirror
value={acl}
className='h-editor text-sm'
theme={light ? githubLight : githubDark}
extensions={[aclType]}
readOnly={!data.hasAclWrite}
onChange={value => {
setAcl(value)
}}
/>
) : ( ) : (
<div mode === 'edit' ? (
className='overflow-y-scroll' <CodeMirror
style={{ height: 'calc(100vh - 20rem)' }} value={acl}
> theme={light ? githubLight : githubDark}
extensions={[aclType]}
readOnly={!data.hasAclWrite}
onChange={value => {
setAcl(value)
}}
/>
) : (
<CodeMirrorMerge <CodeMirrorMerge
theme={light ? githubLight : githubDark} theme={light ? githubLight : githubDark}
orientation='a-b' orientation='a-b'
@ -85,9 +81,9 @@ export default function Editor({ data, acl, setAcl, mode }: EditorProperties) {
extensions={[aclType]} extensions={[aclType]}
/> />
</CodeMirrorMerge> </CodeMirrorMerge>
</div> )
) )}
)} </div>
</div> </div>
<Button <Button

View File

@ -1,13 +1,12 @@
import { Tab } from '@headlessui/react'
import { BeakerIcon, CubeTransparentIcon, EyeIcon, PencilSquareIcon } from '@heroicons/react/24/outline' import { BeakerIcon, CubeTransparentIcon, EyeIcon, PencilSquareIcon } from '@heroicons/react/24/outline'
import { type ActionFunctionArgs, json } from '@remix-run/node' import { type ActionFunctionArgs, json } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react' import { useLoaderData } from '@remix-run/react'
import clsx from 'clsx'
import { useState } from 'react' import { useState } from 'react'
import { Fragment } from 'react/jsx-runtime' import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'
import { ClientOnly } from 'remix-utils/client-only' import { ClientOnly } from 'remix-utils/client-only'
import Notice from '~/components/Notice' import Notice from '~/components/Notice'
import { cn } from '~/utils/cn'
import { getAcl, getContext, patchAcl } from '~/utils/config' import { getAcl, getContext, patchAcl } from '~/utils/config'
import { sighupHeadscale } from '~/utils/docker' import { sighupHeadscale } from '~/utils/docker'
import { getSession } from '~/utils/sessions' import { getSession } from '~/utils/sessions'
@ -87,100 +86,80 @@ export default function Page() {
</a> </a>
</p> </p>
<Tab.Group> <Tabs>
<Tab.List className={clsx( <TabList className={cn(
'flex border-t border-gray-200 dark:border-gray-700', 'flex border-t border-gray-200 dark:border-gray-700',
'w-fit rounded-t-lg overflow-hidden', 'w-fit rounded-t-lg overflow-hidden',
'text-gray-400 dark:text-gray-500' 'text-gray-400 dark:text-gray-500'
)} )}
> >
<Tab as={Fragment}> <Tab
{({ selected }) => ( id='edit'
<button className={({ isSelected }) => cn(
type='button' 'px-4 py-2 rounded-tl-lg',
className={clsx( 'focus:outline-none flex items-center gap-2',
'px-4 py-2 rounded-tl-lg', 'border-x border-gray-200 dark:border-gray-700',
'focus:outline-none flex items-center gap-2', isSelected ? 'text-gray-900 dark:text-gray-100' : ''
'border-l border-gray-200 dark:border-gray-700',
selected ? 'text-gray-900 dark:text-gray-100' : ''
)}
>
<PencilSquareIcon className='w-5 h-5'/>
<p>
Edit file
</p>
</button>
)} )}
>
<PencilSquareIcon className='w-5 h-5'/>
<p>Edit file</p>
</Tab> </Tab>
<Tab as={Fragment}> <Tab
{({ selected }) => ( id='diff'
<button className={({ isSelected }) => cn(
type='button' 'px-4 py-2',
className={clsx( 'focus:outline-none flex items-center gap-2',
'px-4 py-2', 'border-x border-gray-200 dark:border-gray-700',
'focus:outline-none flex items-center gap-2', isSelected ? 'text-gray-900 dark:text-gray-100' : ''
'border-x border-gray-200 dark:border-gray-700',
selected ? 'text-gray-900 dark:text-gray-100' : ''
)}
>
<EyeIcon className='w-5 h-5'/>
<p>
Preview changes
</p>
</button>
)} )}
>
<EyeIcon className='w-5 h-5'/>
<p>Preview changes</p>
</Tab> </Tab>
<Tab as={Fragment}> <Tab
{({ selected }) => ( id='preview'
<button className={({ isSelected }) => cn(
type='button' 'px-4 py-2 rounded-tr-lg',
className={clsx( 'focus:outline-none flex items-center gap-2',
'px-4 py-2 rounded-tr-lg', 'border-x border-gray-200 dark:border-gray-700',
'focus:outline-none flex items-center gap-2', isSelected ? 'text-gray-900 dark:text-gray-100' : ''
'border-r border-gray-200 dark:border-gray-700',
selected ? 'text-gray-900 dark:text-gray-100' : ''
)}
>
<BeakerIcon className='w-5 h-5'/>
<p>
Preview rules
</p>
</button>
)} )}
>
<BeakerIcon className='w-5 h-5'/>
<p>Preview rules</p>
</Tab> </Tab>
</Tab.List> </TabList>
<Tab.Panels> <TabPanel id='edit'>
<Tab.Panel> <ClientOnly fallback={<Fallback acl={acl} where='server'/>}>
<ClientOnly fallback={<Fallback acl={acl} where='server'/>}> {() => (
{() => ( <Editor data={data} acl={acl} setAcl={setAcl} mode='edit'/>
<Editor data={data} acl={acl} setAcl={setAcl} mode='edit'/> )}
)} </ClientOnly>
</ClientOnly> </TabPanel>
</Tab.Panel> <TabPanel id='diff'>
<Tab.Panel> <ClientOnly fallback={<Fallback acl={acl} where='server'/>}>
<ClientOnly fallback={<Fallback acl={acl} where='server'/>}> {() => (
{() => ( <Editor data={data} acl={acl} setAcl={setAcl} mode='diff'/>
<Editor data={data} acl={acl} setAcl={setAcl} mode='diff'/> )}
)} </ClientOnly>
</ClientOnly> </TabPanel>
</Tab.Panel> <TabPanel id='preview'>
<Tab.Panel> <div
<div className={cn(
className={clsx( 'border border-gray-200 dark:border-gray-700',
'border border-gray-200 dark:border-gray-700', 'rounded-b-lg rounded-tr-lg mb-4 overflow-hidden',
'rounded-b-lg rounded-tr-lg mb-4 overflow-hidden', 'p-16 flex flex-col items-center justify-center'
'p-16 flex flex-col items-center justify-center' )}
)} >
> <CubeTransparentIcon className='w-24 h-24 text-gray-300 dark:text-gray-500'/>
<CubeTransparentIcon className='w-24 h-24 text-gray-300 dark:text-gray-500'/> <p className='w-1/2 text-center mt-4'>
<p className='w-1/2 text-center mt-4'> The Preview rules is very much still a work in progress.
The Preview rules is very much still a work in progress. It is a bit complicated to implement right now but hopefully it will be available soon.
It is a bit complicated to implement right now but hopefully it will be available soon. </p>
</p> </div>
</div> </TabPanel>
</Tab.Panel> </Tabs>
</Tab.Panels>
</Tab.Group>
</div> </div>
) )
} }

View File

@ -19,7 +19,7 @@ export default function Modal({ isEnabled, disabled }: Properties) {
isDisabled={disabled} isDisabled={disabled}
className={cn( className={cn(
'w-fit text-sm rounded-lg px-4 py-2', 'w-fit text-sm rounded-lg px-4 py-2',
'bg-gray-700 dark:bg-gray-800 text-white', 'bg-main-700 dark:bg-main-800 text-white',
disabled && 'opacity-50 cursor-not-allowed' disabled && 'opacity-50 cursor-not-allowed'
)} )}
> >

View File

@ -47,7 +47,7 @@ export default function Modal({ name, disabled }: Properties) {
isDisabled={disabled} isDisabled={disabled}
className={cn( className={cn(
'w-fit text-sm rounded-lg px-4 py-2', 'w-fit text-sm rounded-lg px-4 py-2',
'bg-gray-700 dark:bg-gray-800 text-white', 'bg-main-700 dark:bg-main-800 text-white',
disabled && 'opacity-50 cursor-not-allowed' disabled && 'opacity-50 cursor-not-allowed'
)} )}
> >

View File

@ -1,7 +1,5 @@
import { Switch } from '@headlessui/react'
import { type ActionFunctionArgs } from '@remix-run/node' import { type ActionFunctionArgs } from '@remix-run/node'
import { json, useFetcher, useLoaderData } from '@remix-run/react' import { json, useFetcher, useLoaderData } from '@remix-run/react'
import clsx from 'clsx'
import { useState } from 'react' import { useState } from 'react'
import Button from '~/components/Button' import Button from '~/components/Button'
@ -9,6 +7,7 @@ import Code from '~/components/Code'
import Input from '~/components/Input' import Input from '~/components/Input'
import Notice from '~/components/Notice' import Notice from '~/components/Notice'
import Spinner from '~/components/Spinner' import Spinner from '~/components/Spinner'
import Switch from '~/components/Switch'
import TableList from '~/components/TableList' import TableList from '~/components/TableList'
import { getConfig, getContext, patchConfig } from '~/utils/config' import { getConfig, getContext, patchConfig } from '~/utils/config'
import { restartHeadscale } from '~/utils/docker' import { restartHeadscale } from '~/utils/docker'
@ -97,12 +96,9 @@ export default function Page() {
Override local DNS Override local DNS
</span> </span>
<Switch <Switch
checked={localOverride} label='Override local DNS'
disabled={!data.hasConfigWrite} defaultSelected={localOverride}
className={clsx( isDisabled={!data.hasConfigWrite}
localOverride ? 'bg-gray-800 dark:bg-gray-600' : 'bg-gray-200 dark:bg-gray-400',
'relative inline-flex h-4 w-9 items-center rounded-full'
)}
onChange={() => { onChange={() => {
fetcher.submit({ fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
@ -114,15 +110,7 @@ export default function Page() {
setLocalOverride(!localOverride) setLocalOverride(!localOverride)
}} }}
> />
<span className='sr-only'>Override local DNS</span>
<span
className={clsx(
localOverride ? 'translate-x-6' : 'translate-x-1',
'inline-block h-2 w-2 transform rounded-full bg-white transition'
)}
/>
</Switch>
</div> </div>
</div> </div>
<TableList> <TableList>

View File

@ -1,6 +1,6 @@
import { Cog8ToothIcon, CpuChipIcon, GlobeAltIcon, LockClosedIcon, ServerStackIcon, UserCircleIcon, UsersIcon } from '@heroicons/react/24/outline' import { Cog8ToothIcon, CpuChipIcon, GlobeAltIcon, LockClosedIcon, ServerStackIcon, UserCircleIcon, UsersIcon } from '@heroicons/react/24/outline'
import { type LoaderFunctionArgs, redirect } from '@remix-run/node' import { type LoaderFunctionArgs, redirect } from '@remix-run/node'
import { Form, Outlet, useLoaderData, useRouteError } from '@remix-run/react' import { Form, Outlet, useLoaderData } from '@remix-run/react'
import { ErrorPopup } from '~/components/Error' import { ErrorPopup } from '~/components/Error'
import Menu from '~/components/Menu' import Menu from '~/components/Menu'
@ -43,9 +43,10 @@ export async function loader({ request }: LoaderFunctionArgs) {
export default function Layout() { export default function Layout() {
const data = useLoaderData<typeof loader>() const data = useLoaderData<typeof loader>()
return ( return (
<> <>
<header className='mb-6 bg-gray-800 text-white dark:bg-gray-700'> <header className='mb-6 bg-main-700 dark:bg-main-800 text-white'>
<nav className='container mx-auto'> <nav className='container mx-auto'>
<div className='flex items-center justify-between mb-8 pt-4'> <div className='flex items-center justify-between mb-8 pt-4'>
<div className='flex items-center gap-x-2'> <div className='flex items-center gap-x-2'>
@ -71,7 +72,7 @@ export default function Layout() {
<p className='font-bold'>{data.user?.name}</p> <p className='font-bold'>{data.user?.name}</p>
<p>{data.user?.email}</p> <p>{data.user?.email}</p>
</Menu.Item> </Menu.Item>
<Menu.Item className='text-red-700 cursor-pointer'> <Menu.Item className='text-red-500 dark:text-red-400'>
<Form method='POST' action='/logout'> <Form method='POST' action='/logout'>
<button type='submit' className='w-full text-right'> <button type='submit' className='w-full text-right'>
Logout Logout
@ -104,15 +105,9 @@ export default function Layout() {
} }
export function ErrorBoundary() { export function ErrorBoundary() {
const data = useLoaderData<typeof loader>()
const error = useRouteError()
if (!data) {
throw error
}
return ( return (
<> <>
<header className='mb-16 bg-gray-800 text-white dark:bg-gray-700'> <header className='mb-16 bg-main-700 dark:bg-main-800 text-white'>
<nav className='container mx-auto'> <nav className='container mx-auto'>
<div className='flex items-center gap-x-2 mb-8 pt-4'> <div className='flex items-center gap-x-2 mb-8 pt-4'>
<CpuChipIcon className='w-8 h-8'/> <CpuChipIcon className='w-8 h-8'/>
@ -121,13 +116,9 @@ export function ErrorBoundary() {
<div className='flex items-center gap-x-4'> <div className='flex items-center gap-x-4'>
<TabLink to='/machines' name='Machines' icon={<ServerStackIcon className='w-5 h-5'/>}/> <TabLink to='/machines' name='Machines' icon={<ServerStackIcon className='w-5 h-5'/>}/>
<TabLink to='/users' name='Users' icon={<UsersIcon className='w-5 h-5'/>}/> <TabLink to='/users' name='Users' icon={<UsersIcon className='w-5 h-5'/>}/>
{data.hasAcl ? <TabLink to='/acls' name='Access Control' icon={<LockClosedIcon className='w-5 h-5'/>}/> : undefined} <TabLink to='/acls' name='Access Control' icon={<LockClosedIcon className='w-5 h-5'/>}/>
{data.hasConfig ? ( <TabLink to='/dns' name='DNS' icon={<GlobeAltIcon className='w-5 h-5'/>}/>
<> <TabLink to='/settings' name='Settings' icon={<Cog8ToothIcon className='w-5 h-5'/>}/>
<TabLink to='/dns' name='DNS' icon={<GlobeAltIcon className='w-5 h-5'/>}/>
<TabLink to='/settings' name='Settings' icon={<Cog8ToothIcon className='w-5 h-5'/>}/>
</>
) : undefined}
</div> </div>
</nav> </nav>
</header> </header>

View File

@ -17,8 +17,9 @@
"@dnd-kit/modifiers": "^7.0.0", "@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0", "@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@headlessui/react": "^1.7.18",
"@heroicons/react": "^2.1.3", "@heroicons/react": "^2.1.3",
"@react-aria/toast": "3.0.0-beta.10",
"@react-stately/toast": "3.0.0-beta.2",
"@remix-run/node": "^2.8.1", "@remix-run/node": "^2.8.1",
"@remix-run/react": "^2.8.1", "@remix-run/react": "^2.8.1",
"@remix-run/serve": "^2.8.1", "@remix-run/serve": "^2.8.1",
@ -31,7 +32,6 @@
"react-aria-components": "^1.1.1", "react-aria-components": "^1.1.1",
"react-codemirror-merge": "^4.21.25", "react-codemirror-merge": "^4.21.25",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1",
"remix-utils": "^7.6.0", "remix-utils": "^7.6.0",
"tailwind-merge": "^2.3.0", "tailwind-merge": "^2.3.0",
"tailwindcss-react-aria-components": "^1.1.1", "tailwindcss-react-aria-components": "^1.1.1",

View File

@ -23,12 +23,15 @@ dependencies:
'@dnd-kit/utilities': '@dnd-kit/utilities':
specifier: ^3.2.2 specifier: ^3.2.2
version: 3.2.2(react@18.2.0) version: 3.2.2(react@18.2.0)
'@headlessui/react':
specifier: ^1.7.18
version: 1.7.18(react-dom@18.2.0)(react@18.2.0)
'@heroicons/react': '@heroicons/react':
specifier: ^2.1.3 specifier: ^2.1.3
version: 2.1.3(react@18.2.0) version: 2.1.3(react@18.2.0)
'@react-aria/toast':
specifier: 3.0.0-beta.10
version: 3.0.0-beta.10(react@18.2.0)
'@react-stately/toast':
specifier: 3.0.0-beta.2
version: 3.0.0-beta.2(react@18.2.0)
'@remix-run/node': '@remix-run/node':
specifier: ^2.8.1 specifier: ^2.8.1
version: 2.8.1(typescript@5.4.3) version: 2.8.1(typescript@5.4.3)
@ -65,9 +68,6 @@ dependencies:
react-dom: react-dom:
specifier: ^18.2.0 specifier: ^18.2.0
version: 18.2.0(react@18.2.0) version: 18.2.0(react@18.2.0)
react-hot-toast:
specifier: ^2.4.1
version: 2.4.1(csstype@3.1.3)(react-dom@18.2.0)(react@18.2.0)
remix-utils: remix-utils:
specifier: ^7.6.0 specifier: ^7.6.0
version: 7.6.0(@remix-run/node@2.8.1)(@remix-run/react@2.8.1)(react@18.2.0) version: 7.6.0(@remix-run/node@2.8.1)(@remix-run/react@2.8.1)(react@18.2.0)
@ -1110,19 +1110,6 @@ packages:
tslib: 2.6.2 tslib: 2.6.2
dev: false dev: false
/@headlessui/react@1.7.18(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-4i5DOrzwN4qSgNsL4Si61VMkUcWbcSKueUV7sFhpHzQcSShdlHENE5+QBntMSRvHt8NyoFO2AGG8si9lq+w4zQ==}
engines: {node: '>=10'}
peerDependencies:
react: ^16 || ^17 || ^18
react-dom: ^16 || ^17 || ^18
dependencies:
'@tanstack/react-virtual': 3.2.0(react-dom@18.2.0)(react@18.2.0)
client-only: 0.0.1
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@heroicons/react@2.1.3(react@18.2.0): /@heroicons/react@2.1.3(react@18.2.0):
resolution: {integrity: sha512-fEcPfo4oN345SoqdlCDdSa4ivjaKbk0jTd+oubcgNxnNgAfzysfwWfQUr+51wigiWHQQRiZNd1Ao0M5Y3M2EGg==} resolution: {integrity: sha512-fEcPfo4oN345SoqdlCDdSa4ivjaKbk0jTd+oubcgNxnNgAfzysfwWfQUr+51wigiWHQQRiZNd1Ao0M5Y3M2EGg==}
peerDependencies: peerDependencies:
@ -1606,6 +1593,18 @@ packages:
react: 18.2.0 react: 18.2.0
dev: false dev: false
/@react-aria/landmark@3.0.0-beta.10(react@18.2.0):
resolution: {integrity: sha512-qFfAVgCUP/d+sFXCYVJHOMA8fD9VGBWcbJIbfz14X0sdyJ1gj5SL/m4o7cX3OsD9tgPz2A33c1FfmO2gN/qOVg==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
dependencies:
'@react-aria/utils': 3.23.2(react@18.2.0)
'@react-types/shared': 3.22.1(react@18.2.0)
'@swc/helpers': 0.5.10
react: 18.2.0
use-sync-external-store: 1.2.0(react@18.2.0)
dev: false
/@react-aria/link@3.6.5(react@18.2.0): /@react-aria/link@3.6.5(react@18.2.0):
resolution: {integrity: sha512-kg8CxKqkciQFzODvLAfxEs8gbqNXFZCW/ISOE2LHYKbh9pA144LVo71qO3SPeYVVzIjmZeW4vEMdZwqkNozecw==} resolution: {integrity: sha512-kg8CxKqkciQFzODvLAfxEs8gbqNXFZCW/ISOE2LHYKbh9pA144LVo71qO3SPeYVVzIjmZeW4vEMdZwqkNozecw==}
peerDependencies: peerDependencies:
@ -1958,6 +1957,22 @@ packages:
react: 18.2.0 react: 18.2.0
dev: false dev: false
/@react-aria/toast@3.0.0-beta.10(react@18.2.0):
resolution: {integrity: sha512-b4PZaCZBc8oiiZ4xxWA0pusPj0VBiNbGJsmyQSe9L0OS7/wL+Z+70xpUMzL+RzhtRuP/lNx7G9a92HwcxFfrog==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
dependencies:
'@react-aria/i18n': 3.10.2(react@18.2.0)
'@react-aria/interactions': 3.21.1(react@18.2.0)
'@react-aria/landmark': 3.0.0-beta.10(react@18.2.0)
'@react-aria/utils': 3.23.2(react@18.2.0)
'@react-stately/toast': 3.0.0-beta.2(react@18.2.0)
'@react-types/button': 3.9.2(react@18.2.0)
'@react-types/shared': 3.22.1(react@18.2.0)
'@swc/helpers': 0.5.10
react: 18.2.0
dev: false
/@react-aria/toggle@3.10.2(react@18.2.0): /@react-aria/toggle@3.10.2(react@18.2.0):
resolution: {integrity: sha512-DgitscHWgI6IFgnvp2HcMpLGX/cAn+XX9kF5RJQbRQ9NqUgruU5cEEGSOLMrEJ6zXDa2xmOiQ+kINcyNhA+JLg==} resolution: {integrity: sha512-DgitscHWgI6IFgnvp2HcMpLGX/cAn+XX9kF5RJQbRQ9NqUgruU5cEEGSOLMrEJ6zXDa2xmOiQ+kINcyNhA+JLg==}
peerDependencies: peerDependencies:
@ -2284,6 +2299,16 @@ packages:
react: 18.2.0 react: 18.2.0
dev: false dev: false
/@react-stately/toast@3.0.0-beta.2(react@18.2.0):
resolution: {integrity: sha512-LJT3VJ01VeQHTDt+6NwmRn4ma0plZWcWByAeJ24dLYJ0gUpHhHyCCIBB/C1BwZ3/eaNKn7/Crp5FONA6bBzDaA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0
dependencies:
'@swc/helpers': 0.5.10
react: 18.2.0
use-sync-external-store: 1.2.0(react@18.2.0)
dev: false
/@react-stately/toggle@3.7.2(react@18.2.0): /@react-stately/toggle@3.7.2(react@18.2.0):
resolution: {integrity: sha512-SHCF2btcoK57c4lyhucRbyPBAFpp0Pdp0vcPdn3hUgqbu6e5gE0CwG/mgFmZRAQoc7PRc7XifL0uNw8diJJI0Q==} resolution: {integrity: sha512-SHCF2btcoK57c4lyhucRbyPBAFpp0Pdp0vcPdn3hUgqbu6e5gE0CwG/mgFmZRAQoc7PRc7XifL0uNw8diJJI0Q==}
peerDependencies: peerDependencies:
@ -2925,21 +2950,6 @@ packages:
tslib: 2.6.2 tslib: 2.6.2
dev: false dev: false
/@tanstack/react-virtual@3.2.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-OEdMByf2hEfDa6XDbGlZN8qO6bTjlNKqjM3im9JG+u3mCL8jALy0T/67oDI001raUUPh1Bdmfn4ZvPOV5knpcg==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
'@tanstack/virtual-core': 3.2.0
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@tanstack/virtual-core@3.2.0:
resolution: {integrity: sha512-P5XgYoAw/vfW65byBbJQCw+cagdXDT/qH6wmABiLt4v4YBT2q2vqCOhihe+D1Nt325F/S/0Tkv6C5z0Lv+VBQQ==}
dev: false
/@types/acorn@4.0.6: /@types/acorn@4.0.6:
resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==} resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==}
dependencies: dependencies:
@ -3884,6 +3894,7 @@ packages:
/csstype@3.1.3: /csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
dev: true
/data-uri-to-buffer@3.0.1: /data-uri-to-buffer@3.0.1:
resolution: {integrity: sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og==} resolution: {integrity: sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og==}
@ -4926,14 +4937,6 @@ packages:
resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==}
dev: true dev: true
/goober@2.1.14(csstype@3.1.3):
resolution: {integrity: sha512-4UpC0NdGyAFqLNPnhCT2iHpza2q+RAY3GV85a/mRPdzyPQMsj0KmMMuetdIkzWRbJ+Hgau1EZztq8ImmiMGhsg==}
peerDependencies:
csstype: ^3.0.10
dependencies:
csstype: 3.1.3
dev: false
/gopd@1.0.1: /gopd@1.0.1:
resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==}
dependencies: dependencies:
@ -6935,20 +6938,6 @@ packages:
scheduler: 0.23.0 scheduler: 0.23.0
dev: false dev: false
/react-hot-toast@2.4.1(csstype@3.1.3)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==}
engines: {node: '>=10'}
peerDependencies:
react: '>=16'
react-dom: '>=16'
dependencies:
goober: 2.1.14(csstype@3.1.3)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
transitivePeerDependencies:
- csstype
dev: false
/react-is@16.13.1: /react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
dev: true dev: true