mirror of
https://github.com/tale/headplane.git
synced 2026-08-28 07:57:03 +00:00
feat: add loading states
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
type Properties = {
|
||||||
|
// eslint-disable-next-line unicorn/no-keyword-prefix
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Spinner(properties: Properties) {
|
||||||
|
return (
|
||||||
|
<div className={clsx('mr-1.5 inline-block align-middle mb-0.5', properties.className)}>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'animate-spin rounded-full w-full h-full',
|
||||||
|
'border-2 border-current border-t-transparent',
|
||||||
|
properties.className
|
||||||
|
)}
|
||||||
|
role='status'
|
||||||
|
>
|
||||||
|
<span className='sr-only'>Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -16,12 +16,13 @@ import {
|
|||||||
} from '@dnd-kit/sortable'
|
} from '@dnd-kit/sortable'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import { Bars3Icon, LockClosedIcon } from '@heroicons/react/24/outline'
|
import { Bars3Icon, LockClosedIcon } from '@heroicons/react/24/outline'
|
||||||
import { useFetcher } from '@remix-run/react'
|
import { type FetcherWithComponents, useFetcher } from '@remix-run/react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import Button from '~/components/Button'
|
import Button from '~/components/Button'
|
||||||
import Input from '~/components/Input'
|
import Input from '~/components/Input'
|
||||||
|
import Spinner from '~/components/Spinner'
|
||||||
import TableList from '~/components/TableList'
|
import TableList from '~/components/TableList'
|
||||||
|
|
||||||
type Properties = {
|
type Properties = {
|
||||||
@@ -97,6 +98,7 @@ export default function Domains({ baseDomain, searchDomains, disabled }: Propert
|
|||||||
id={index + 1}
|
id={index + 1}
|
||||||
localDomains={localDomains}
|
localDomains={localDomains}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
fetcher={fetcher}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<DragOverlay adjustScale>
|
<DragOverlay adjustScale>
|
||||||
@@ -106,6 +108,7 @@ export default function Domains({ baseDomain, searchDomains, disabled }: Propert
|
|||||||
localDomains={localDomains}
|
localDomains={localDomains}
|
||||||
id={activeId as number - 1}
|
id={activeId as number - 1}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
fetcher={fetcher}
|
||||||
/> : undefined}
|
/> : undefined}
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
@@ -121,23 +124,27 @@ export default function Domains({ baseDomain, searchDomains, disabled }: Propert
|
|||||||
setNewDomain(event.target.value)
|
setNewDomain(event.target.value)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
{fetcher.state === 'idle' ? (
|
||||||
className='text-sm'
|
<Button
|
||||||
disabled={newDomain.length === 0}
|
className='text-sm'
|
||||||
onClick={() => {
|
disabled={newDomain.length === 0}
|
||||||
fetcher.submit({
|
onClick={() => {
|
||||||
|
fetcher.submit({
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
'dns_config.domains': [...localDomains, newDomain]
|
'dns_config.domains': [...localDomains, newDomain]
|
||||||
}, {
|
}, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
encType: 'application/json'
|
encType: 'application/json'
|
||||||
})
|
})
|
||||||
|
|
||||||
setNewDomain('')
|
setNewDomain('')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Add
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Spinner className='w-3 h-3 mr-0'/>
|
||||||
|
)}
|
||||||
</TableList.Item>
|
</TableList.Item>
|
||||||
)}
|
)}
|
||||||
</TableList>
|
</TableList>
|
||||||
@@ -153,11 +160,10 @@ type DomainProperties = {
|
|||||||
readonly localDomains: string[];
|
readonly localDomains: string[];
|
||||||
// eslint-disable-next-line react/boolean-prop-naming
|
// eslint-disable-next-line react/boolean-prop-naming
|
||||||
readonly disabled?: boolean;
|
readonly disabled?: boolean;
|
||||||
|
readonly fetcher: FetcherWithComponents<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Domain({ domain, id, localDomains, isDrag, disabled }: DomainProperties) {
|
function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainProperties) {
|
||||||
const fetcher = useFetcher()
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
listeners,
|
listeners,
|
||||||
@@ -167,14 +173,15 @@ function Domain({ domain, id, localDomains, isDrag, disabled }: DomainProperties
|
|||||||
isDragging
|
isDragging
|
||||||
} = useSortable({ id })
|
} = useSortable({ id })
|
||||||
|
|
||||||
|
// TODO: Figure out why TableList.Item breaks dndkit
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex items-center justify-between px-3 py-2',
|
'flex items-center justify-between px-3 py-2',
|
||||||
'border-b border-gray-200 last:border-b-0',
|
'border-b border-gray-200 last:border-b-0 dark:border-zinc-800',
|
||||||
isDragging ? 'text-gray-400' : 'bg-gray-50',
|
isDragging ? 'text-gray-400' : '',
|
||||||
isDrag ? 'outline outline-1 outline-gray-500' : undefined
|
isDrag ? 'outline outline-1 outline-gray-500 bg-gray-200 dark:bg-zinc-800' : ''
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
@@ -198,7 +205,7 @@ function Domain({ domain, id, localDomains, isDrag, disabled }: DomainProperties
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
fetcher.submit({
|
fetcher.submit({
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
'dns_config.domains': localDomains.filter((_, index) => index !== id - 1)
|
'dns_config.domains': localDomains.filter((_, index) => index !== id - 1)
|
||||||
}, {
|
}, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import clsx from 'clsx'
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
import Button from '~/components/Button'
|
import Button from '~/components/Button'
|
||||||
|
import Spinner from '~/components/Spinner'
|
||||||
|
|
||||||
type Properties = {
|
type Properties = {
|
||||||
readonly isEnabled: boolean;
|
readonly isEnabled: boolean;
|
||||||
@@ -25,6 +26,9 @@ export default function Modal({ isEnabled, disabled }: Properties) {
|
|||||||
setIsOpen(true)
|
setIsOpen(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{fetcher.state === 'idle' ? undefined : (
|
||||||
|
<Spinner className='w-3 h-3'/>
|
||||||
|
)}
|
||||||
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
|
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
|
||||||
</Button>
|
</Button>
|
||||||
<Dialog
|
<Dialog
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useState } from 'react'
|
|||||||
import Button from '~/components/Button'
|
import Button from '~/components/Button'
|
||||||
import Code from '~/components/Code'
|
import Code from '~/components/Code'
|
||||||
import Input from '~/components/Input'
|
import Input from '~/components/Input'
|
||||||
|
import Spinner from '~/components/Spinner'
|
||||||
|
|
||||||
type Properties = {
|
type Properties = {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
@@ -49,6 +50,9 @@ export default function Modal({ name, disabled }: Properties) {
|
|||||||
setIsOpen(true)
|
setIsOpen(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{fetcher.state === 'idle' ? undefined : (
|
||||||
|
<Spinner className='w-3 h-3'/>
|
||||||
|
)}
|
||||||
Rename Tailnet...
|
Rename Tailnet...
|
||||||
</Button>
|
</Button>
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -88,7 +92,6 @@ export default function Modal({ name, disabled }: Properties) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
setNewName(name)
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Rename
|
Rename
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import Button from '~/components/Button'
|
|||||||
import Code from '~/components/Code'
|
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 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'
|
||||||
@@ -80,12 +81,14 @@ export default function Page() {
|
|||||||
Global Nameservers
|
Global Nameservers
|
||||||
</h2>
|
</h2>
|
||||||
<div className='flex gap-2 items-center'>
|
<div className='flex gap-2 items-center'>
|
||||||
<span className='text-sm opacity-50'>Override local DNS</span>
|
<span className='text-sm opacity-50'>
|
||||||
|
Override local DNS
|
||||||
|
</span>
|
||||||
<Switch
|
<Switch
|
||||||
checked={localOverride}
|
checked={localOverride}
|
||||||
disabled={!data.hasConfigWrite}
|
disabled={!data.hasConfigWrite}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
localOverride ? 'bg-gray-800' : 'bg-gray-200',
|
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'
|
'relative inline-flex h-4 w-9 items-center rounded-full'
|
||||||
)}
|
)}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
@@ -145,23 +148,27 @@ export default function Page() {
|
|||||||
setNs(event.target.value)
|
setNs(event.target.value)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
{fetcher.state === 'idle' ? (
|
||||||
className='text-sm'
|
<Button
|
||||||
disabled={ns.length === 0}
|
className='text-sm'
|
||||||
onClick={() => {
|
disabled={ns.length === 0}
|
||||||
fetcher.submit({
|
onClick={() => {
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
fetcher.submit({
|
||||||
'dns_config.nameservers': [...data.nameservers, ns]
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
}, {
|
'dns_config.nameservers': [...data.nameservers, ns]
|
||||||
method: 'PATCH',
|
}, {
|
||||||
encType: 'application/json'
|
method: 'PATCH',
|
||||||
})
|
encType: 'application/json'
|
||||||
|
})
|
||||||
|
|
||||||
setNs('')
|
setNs('')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Add
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Spinner className='w-3 h-3 mr-0'/>
|
||||||
|
)}
|
||||||
</TableList.Item>
|
</TableList.Item>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</TableList>
|
</TableList>
|
||||||
|
|||||||
Reference in New Issue
Block a user