mirror of
https://github.com/tale/headplane.git
synced 2026-09-04 11:15:42 +00:00
feat: replace the old modal completely
This commit is contained in:
@@ -1,160 +0,0 @@
|
|||||||
import { Dialog, Transition } from '@headlessui/react'
|
|
||||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
import { Fragment, type ReactNode, type SetStateAction, useState } from 'react'
|
|
||||||
|
|
||||||
import Button from './Button'
|
|
||||||
|
|
||||||
type HookParameters = {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
buttonText?: string;
|
|
||||||
variant?: 'danger' | 'confirm';
|
|
||||||
children?: ReactNode;
|
|
||||||
|
|
||||||
// Optional because the button submits
|
|
||||||
onConfirm?: () => void | Promise<void>;
|
|
||||||
onOpen?: () => void | Promise<void>;
|
|
||||||
onClose?: () => void | Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Overrides = Omit<HookParameters, 'onOpen' | 'onClose'>
|
|
||||||
|
|
||||||
type Properties = {
|
|
||||||
readonly isOpen: boolean;
|
|
||||||
readonly setIsOpen: (value: SetStateAction<boolean>) => void;
|
|
||||||
readonly parameters?: HookParameters;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type OpenFunction = (overrides?: Overrides) => void
|
|
||||||
|
|
||||||
export default function useModal(properties?: HookParameters) {
|
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
|
||||||
const [liveProperties, setLiveProperties] = useState(properties)
|
|
||||||
|
|
||||||
return {
|
|
||||||
Modal: (
|
|
||||||
<Modal
|
|
||||||
isOpen={isOpen}
|
|
||||||
setIsOpen={setIsOpen}
|
|
||||||
parameters={liveProperties}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
|
|
||||||
open: (overrides?: Overrides) => {
|
|
||||||
if (!overrides && !properties) {
|
|
||||||
throw new Error('No properties provided')
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsOpen(true)
|
|
||||||
if (properties?.onOpen) {
|
|
||||||
void properties.onOpen()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (overrides) {
|
|
||||||
setLiveProperties(overrides)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
close: () => {
|
|
||||||
setIsOpen(false)
|
|
||||||
if (properties?.onClose) {
|
|
||||||
void properties.onClose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Modal({ parameters, isOpen, setIsOpen }: Properties) {
|
|
||||||
if (!parameters) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Transition
|
|
||||||
show={isOpen}
|
|
||||||
as={Fragment}
|
|
||||||
>
|
|
||||||
<Dialog
|
|
||||||
as='div'
|
|
||||||
className='relative z-50'
|
|
||||||
onClose={() => {
|
|
||||||
setIsOpen(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Transition.Child
|
|
||||||
enter='ease-out duration-100'
|
|
||||||
enterFrom='opacity-0'
|
|
||||||
enterTo='opacity-100'
|
|
||||||
leave='ease-in duration-75'
|
|
||||||
leaveFrom='opacity-100'
|
|
||||||
leaveTo='opacity-0'
|
|
||||||
as={Fragment}
|
|
||||||
>
|
|
||||||
<div className='fixed inset-0 bg-black/30' aria-hidden='true'/>
|
|
||||||
</Transition.Child>
|
|
||||||
<div className='fixed inset-0 flex w-screen items-center justify-center'>
|
|
||||||
<Transition.Child
|
|
||||||
enter='transition ease-out duration-100'
|
|
||||||
enterFrom='transform opacity-0 scale-95'
|
|
||||||
enterTo='transform opacity-100 scale-100'
|
|
||||||
leave='transition ease-in duration-75'
|
|
||||||
leaveFrom='transform opacity-100 scale-100'
|
|
||||||
leaveTo='transform opacity-0 scale-95'
|
|
||||||
as={Fragment}
|
|
||||||
>
|
|
||||||
<Dialog.Panel className={clsx(
|
|
||||||
'rounded-lg p-4 w-full max-w-md',
|
|
||||||
'bg-white dark:bg-black relative',
|
|
||||||
'border border-gray-200 dark:border-zinc-800'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<XMarkIcon
|
|
||||||
className={clsx(
|
|
||||||
'absolute top-3 right-3 rounded-lg p-1.5',
|
|
||||||
'w-8 h-8 text-gray-500 dark:text-gray-400',
|
|
||||||
'hover:bg-gray-100 dark:hover:bg-zinc-800'
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
setIsOpen(false)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Dialog.Title className='text-xl font-bold'>
|
|
||||||
{parameters.title}
|
|
||||||
</Dialog.Title>
|
|
||||||
{parameters.description ? (
|
|
||||||
<Dialog.Description className='text-gray-500 dark:text-gray-400 mt-1'>
|
|
||||||
{parameters.description}
|
|
||||||
</Dialog.Description>
|
|
||||||
) : undefined}
|
|
||||||
{parameters.children ? (
|
|
||||||
<div className='w-full mt-4'>
|
|
||||||
{parameters.children}
|
|
||||||
</div>
|
|
||||||
) : undefined}
|
|
||||||
<Button
|
|
||||||
variant='emphasized'
|
|
||||||
type='submit'
|
|
||||||
className={clsx(
|
|
||||||
'w-full mt-12',
|
|
||||||
parameters.variant === 'danger'
|
|
||||||
? 'bg-red-800 dark:bg-red-500 focus:ring-red-500 dark:focus:ring-red-500'
|
|
||||||
: ''
|
|
||||||
)}
|
|
||||||
onClick={async () => {
|
|
||||||
if (parameters.onConfirm) {
|
|
||||||
await parameters.onConfirm()
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsOpen(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{parameters.buttonText ?? 'Confirm'}
|
|
||||||
</Button>
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Transition.Child>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</Transition>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useFetcher } from '@remix-run/react'
|
import { useFetcher } from '@remix-run/react'
|
||||||
|
|
||||||
import Button from '~/components/Button'
|
import Dialog from '~/components/Dialog'
|
||||||
// TODO: Remove useModal and replace with Dialog
|
|
||||||
import useModal from '~/components/Modal'
|
|
||||||
import Spinner from '~/components/Spinner'
|
import Spinner from '~/components/Spinner'
|
||||||
|
import { cn } from '~/utils/cn'
|
||||||
|
|
||||||
type Properties = {
|
type Properties = {
|
||||||
readonly isEnabled: boolean;
|
readonly isEnabled: boolean;
|
||||||
@@ -13,38 +12,59 @@ type Properties = {
|
|||||||
|
|
||||||
export default function Modal({ isEnabled, disabled }: Properties) {
|
export default function Modal({ isEnabled, disabled }: Properties) {
|
||||||
const fetcher = useFetcher()
|
const fetcher = useFetcher()
|
||||||
const { Modal, open } = useModal({
|
|
||||||
title: `${isEnabled ? 'Disable' : 'Enable'} Magic DNS`,
|
|
||||||
variant: isEnabled ? 'danger' : 'confirm',
|
|
||||||
buttonText: `${isEnabled ? 'Disable' : 'Enable'} Magic DNS`,
|
|
||||||
description: 'Devices will no longer be accessible via your tailnet domain. The search domain will also be disabled.',
|
|
||||||
onConfirm: () => {
|
|
||||||
fetcher.submit({
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
'dns_config.magic_dns': !isEnabled
|
|
||||||
}, {
|
|
||||||
method: 'PATCH',
|
|
||||||
encType: 'application/json'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Dialog>
|
||||||
<Button
|
<Dialog.Button
|
||||||
variant='emphasized'
|
isDisabled={disabled}
|
||||||
className='w-fit text-sm'
|
className={cn(
|
||||||
disabled={disabled}
|
'w-fit text-sm rounded-lg px-4 py-2',
|
||||||
onClick={() => {
|
'bg-gray-700 dark:bg-gray-800 text-white',
|
||||||
open()
|
disabled && 'opacity-50 cursor-not-allowed'
|
||||||
}}
|
)}
|
||||||
>
|
>
|
||||||
{fetcher.state === 'idle' ? undefined : (
|
{fetcher.state === 'idle' ? undefined : (
|
||||||
<Spinner className='w-3 h-3'/>
|
<Spinner className='w-3 h-3'/>
|
||||||
)}
|
)}
|
||||||
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
|
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
|
||||||
</Button>
|
</Dialog.Button>
|
||||||
{Modal}
|
<Dialog.Panel>
|
||||||
</>
|
{close => (
|
||||||
|
<>
|
||||||
|
<Dialog.Title>
|
||||||
|
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Text>
|
||||||
|
Devices will no longer be accessible via your tailnet domain.
|
||||||
|
The search domain will also be disabled.
|
||||||
|
</Dialog.Text>
|
||||||
|
<div className='mt-6 flex justify-end gap-2 mt-6'>
|
||||||
|
<Dialog.Action
|
||||||
|
variant='cancel'
|
||||||
|
onPress={close}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Dialog.Action>
|
||||||
|
<Dialog.Action
|
||||||
|
variant='confirm'
|
||||||
|
onPress={() => {
|
||||||
|
fetcher.submit({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
'dns_config.magic_dns': !isEnabled
|
||||||
|
}, {
|
||||||
|
method: 'PATCH',
|
||||||
|
encType: 'application/json'
|
||||||
|
})
|
||||||
|
|
||||||
|
close()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
|
||||||
|
</Dialog.Action>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,12 @@
|
|||||||
import { useFetcher } from '@remix-run/react'
|
import { useFetcher } from '@remix-run/react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
import Button from '~/components/Button'
|
|
||||||
import Code from '~/components/Code'
|
import Code from '~/components/Code'
|
||||||
|
import Dialog from '~/components/Dialog'
|
||||||
import Input from '~/components/Input'
|
import Input from '~/components/Input'
|
||||||
import useModal from '~/components/Modal'
|
|
||||||
import Spinner from '~/components/Spinner'
|
import Spinner from '~/components/Spinner'
|
||||||
|
import TextField from '~/components/TextField'
|
||||||
|
import { cn } from '~/utils/cn'
|
||||||
|
|
||||||
type Properties = {
|
type Properties = {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
@@ -18,29 +19,6 @@ type Properties = {
|
|||||||
export default function Modal({ name, disabled }: Properties) {
|
export default function Modal({ name, disabled }: Properties) {
|
||||||
const [newName, setNewName] = useState(name)
|
const [newName, setNewName] = useState(name)
|
||||||
const fetcher = useFetcher()
|
const fetcher = useFetcher()
|
||||||
const { Modal, open } = useModal({
|
|
||||||
title: 'Rename Tailnet',
|
|
||||||
description: 'Keep in mind that changing this can lead to all sorts of unexpected behavior and may break existing devices in your tailnet.',
|
|
||||||
buttonText: 'Rename',
|
|
||||||
children: (
|
|
||||||
<Input
|
|
||||||
type='text'
|
|
||||||
className='font-mono mt-4'
|
|
||||||
value={newName}
|
|
||||||
onChange={event => {
|
|
||||||
setNewName(event.target.value)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
onConfirm: () => {
|
|
||||||
fetcher.submit({
|
|
||||||
'dns_config.base_domain': newName
|
|
||||||
}, {
|
|
||||||
method: 'PATCH',
|
|
||||||
encType: 'application/json'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col w-2/3'>
|
<div className='flex flex-col w-2/3'>
|
||||||
@@ -64,20 +42,62 @@ export default function Modal({ name, disabled }: Properties) {
|
|||||||
event.target.select()
|
event.target.select()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Dialog>
|
||||||
variant='emphasized'
|
<Dialog.Button
|
||||||
className='text-sm w-fit'
|
isDisabled={disabled}
|
||||||
disabled={disabled}
|
className={cn(
|
||||||
onClick={() => {
|
'w-fit text-sm rounded-lg px-4 py-2',
|
||||||
open()
|
'bg-gray-700 dark:bg-gray-800 text-white',
|
||||||
}}
|
disabled && 'opacity-50 cursor-not-allowed'
|
||||||
>
|
)}
|
||||||
{fetcher.state === 'idle' ? undefined : (
|
>
|
||||||
<Spinner className='w-3 h-3'/>
|
{fetcher.state === 'idle' ? undefined : (
|
||||||
)}
|
<Spinner className='w-3 h-3'/>
|
||||||
Rename Tailnet...
|
)}
|
||||||
</Button>
|
Rename Tailnet
|
||||||
{Modal}
|
</Dialog.Button>
|
||||||
|
<Dialog.Panel>
|
||||||
|
{close => (
|
||||||
|
<>
|
||||||
|
<Dialog.Title>
|
||||||
|
Rename Tailnet
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Text>
|
||||||
|
Keep in mind that changing this can lead to all sorts of unexpected behavior and may break existing devices in your tailnet.
|
||||||
|
</Dialog.Text>
|
||||||
|
<TextField
|
||||||
|
label='Tailnet name'
|
||||||
|
placeholder='ts.net'
|
||||||
|
state={[newName, setNewName]}
|
||||||
|
className='my-2'
|
||||||
|
/>
|
||||||
|
<div className='mt-6 flex justify-end gap-2 mt-6'>
|
||||||
|
<Dialog.Action
|
||||||
|
variant='cancel'
|
||||||
|
onPress={close}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Dialog.Action>
|
||||||
|
<Dialog.Action
|
||||||
|
variant='confirm'
|
||||||
|
onPress={() => {
|
||||||
|
fetcher.submit({
|
||||||
|
'dns_config.base_domain': newName
|
||||||
|
}, {
|
||||||
|
method: 'PATCH',
|
||||||
|
encType: 'application/json'
|
||||||
|
})
|
||||||
|
|
||||||
|
close()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</Dialog.Action>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user