feat: experimental support for draggable domains
This commit is contained in:
parent
1019c1ebf0
commit
358629a93b
213
app/routes/_data.dns._index/domains.tsx
Normal file
213
app/routes/_data.dns._index/domains.tsx
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
/* eslint-disable unicorn/no-keyword-prefix */
|
||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
DragOverlay,
|
||||||
|
PointerSensor,
|
||||||
|
TouchSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors
|
||||||
|
} from '@dnd-kit/core'
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy
|
||||||
|
} from '@dnd-kit/sortable'
|
||||||
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
|
import { Bars3Icon } from '@heroicons/react/24/outline'
|
||||||
|
import { useFetcher, useRevalidator } from '@remix-run/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
type Properties = {
|
||||||
|
readonly baseDomain?: string;
|
||||||
|
readonly searchDomains: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Domains({ baseDomain, searchDomains }: Properties) {
|
||||||
|
// eslint-disable-next-line unicorn/no-null, @typescript-eslint/ban-types
|
||||||
|
const [activeId, setActiveId] = useState<number | string | null>(null)
|
||||||
|
const [localDomains, setLocalDomains] = useState(searchDomains)
|
||||||
|
const [newDomain, setNewDomain] = useState('')
|
||||||
|
const fetcher = useFetcher({ key: 'search-domains' })
|
||||||
|
const revalidator = useRevalidator()
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor),
|
||||||
|
useSensor(TouchSensor)
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col w-2/3'>
|
||||||
|
<h1 className='text-2xl font-medium mb-4'>Search Domains</h1>
|
||||||
|
<p className='text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
|
Set custom DNS search domains for your Tailnet.
|
||||||
|
When using Magic DNS, your tailnet domain is used as the first search domain.
|
||||||
|
</p>
|
||||||
|
<div className='border border-gray-200 rounded-lg bg-gray-50 overflow-clip'>
|
||||||
|
{baseDomain ? (
|
||||||
|
<div
|
||||||
|
key='magic-dns-sd'
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center justify-between px-3 py-2',
|
||||||
|
'border-b border-gray-200 last:border-b-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className='font-mono text-sm'>{baseDomain}</p>
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={event => {
|
||||||
|
setActiveId(event.active.id)
|
||||||
|
}}
|
||||||
|
onDragEnd={event => {
|
||||||
|
// eslint-disable-next-line unicorn/no-null
|
||||||
|
setActiveId(null)
|
||||||
|
const { active, over } = event
|
||||||
|
if (!over) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeItem = localDomains[active.id as number - 1]
|
||||||
|
const overItem = localDomains[over.id as number - 1]
|
||||||
|
|
||||||
|
if (!activeItem || !overItem) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldIndex = localDomains.indexOf(activeItem)
|
||||||
|
const newIndex = localDomains.indexOf(overItem)
|
||||||
|
|
||||||
|
if (oldIndex !== newIndex) {
|
||||||
|
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={localDomains}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
{localDomains.map((sd, index) => (
|
||||||
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
|
<Domain key={index} domain={sd} id={index + 1} localDomains={localDomains}/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
<DragOverlay adjustScale>
|
||||||
|
{activeId ? <Domain
|
||||||
|
isDrag
|
||||||
|
domain={localDomains[activeId as number - 1]}
|
||||||
|
localDomains={localDomains}
|
||||||
|
id={activeId as number - 1}
|
||||||
|
/> : undefined}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
<div
|
||||||
|
key='add-sd'
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center justify-between px-3 py-2',
|
||||||
|
'border-b border-gray-200 last:border-b-0',
|
||||||
|
'bg-white dark:bg-gray-800'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
className='w-full focus:ring-none focus:outline-none font-mono text-sm'
|
||||||
|
placeholder='Search Domain'
|
||||||
|
value={newDomain}
|
||||||
|
onChange={event => {
|
||||||
|
setNewDomain(event.target.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='text-sm text-blue-700'
|
||||||
|
onClick={() => {
|
||||||
|
fetcher.submit({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
'dns_config.domains': [...localDomains, newDomain]
|
||||||
|
}, {
|
||||||
|
method: 'PATCH',
|
||||||
|
encType: 'application/json'
|
||||||
|
})
|
||||||
|
|
||||||
|
setNewDomain('')
|
||||||
|
if (revalidator.state === 'idle') {
|
||||||
|
revalidator.revalidate()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type DomainProperties = {
|
||||||
|
readonly domain: string;
|
||||||
|
readonly id: number;
|
||||||
|
readonly isDrag?: boolean;
|
||||||
|
readonly localDomains: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function Domain({ domain, id, localDomains, isDrag }: DomainProperties) {
|
||||||
|
const fetcher = useFetcher({ key: 'individual-domain' })
|
||||||
|
const revalidator = useRevalidator()
|
||||||
|
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging
|
||||||
|
} = useSortable({ id })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center justify-between px-3 py-2',
|
||||||
|
'border-b border-gray-200 last:border-b-0',
|
||||||
|
isDragging ? 'text-gray-400' : 'bg-gray-50',
|
||||||
|
isDrag ? 'outline outline-1 outline-gray-500 rounded-md' : undefined
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition
|
||||||
|
}}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<p className='font-mono text-sm flex items-center gap-4'>
|
||||||
|
<Bars3Icon className='h-4 w-4 text-gray-400'/>
|
||||||
|
{domain}
|
||||||
|
</p>
|
||||||
|
{isDrag ? undefined : (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='text-sm text-red-700'
|
||||||
|
onClick={() => {
|
||||||
|
fetcher.submit({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
'dns_config.domains': localDomains.filter((_, index) => index !== id - 1)
|
||||||
|
}, {
|
||||||
|
method: 'PATCH',
|
||||||
|
encType: 'application/json'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (revalidator.state === 'idle') {
|
||||||
|
revalidator.revalidate()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import { useState } from 'react'
|
|||||||
|
|
||||||
import { getConfig, patchConfig } from '~/utils/config'
|
import { getConfig, patchConfig } from '~/utils/config'
|
||||||
|
|
||||||
|
import Domains from './domains'
|
||||||
import MagicModal from './magic'
|
import MagicModal from './magic'
|
||||||
import RenameModal from './rename'
|
import RenameModal from './rename'
|
||||||
|
|
||||||
@ -28,6 +29,7 @@ export async function loader() {
|
|||||||
|
|
||||||
export async function action({ request }: ActionFunctionArgs) {
|
export async function action({ request }: ActionFunctionArgs) {
|
||||||
const data = await request.json() as Record<string, unknown>
|
const data = await request.json() as Record<string, unknown>
|
||||||
|
console.log(data)
|
||||||
await patchConfig(data)
|
await patchConfig(data)
|
||||||
return json({ success: true })
|
return json({ success: true })
|
||||||
}
|
}
|
||||||
@ -163,44 +165,11 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex flex-col w-2/3'>
|
<Domains
|
||||||
<h1 className='text-2xl font-medium mb-4'>Search Domains</h1>
|
baseDomain={data.magicDns ? data.baseDomain : undefined}
|
||||||
<p className='text-gray-700 dark:text-gray-300'>
|
searchDomains={data.searchDomains}
|
||||||
Set custom DNS search domains for your Tailnet.
|
/>
|
||||||
When using Magic DNS, your tailnet domain is used as the first search domain.
|
|
||||||
</p>
|
|
||||||
<div className='border border-gray-200 rounded-lg bg-gray-50'>
|
|
||||||
{data.magicDns ? (
|
|
||||||
<div
|
|
||||||
key='magic-dns-sd'
|
|
||||||
className={clsx(
|
|
||||||
'flex items-center justify-between px-3 py-2',
|
|
||||||
'border-b border-gray-200 last:border-b-0'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p className='font-mono text-sm'>{data.baseDomain}</p>
|
|
||||||
</div>
|
|
||||||
) : undefined}
|
|
||||||
{data.searchDomains.map((sd, index) => (
|
|
||||||
<div
|
|
||||||
// eslint-disable-next-line react/no-array-index-key
|
|
||||||
key={index}
|
|
||||||
className={clsx(
|
|
||||||
'flex items-center justify-between px-3 py-2',
|
|
||||||
'border-b border-gray-200 last:border-b-0'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p className='font-mono text-sm'>{sd}</p>
|
|
||||||
<button
|
|
||||||
type='button'
|
|
||||||
className='text-sm text-red-700'
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='flex flex-col w-2/3'>
|
<div className='flex flex-col w-2/3'>
|
||||||
<h1 className='text-2xl font-medium mb-4'>Magic DNS</h1>
|
<h1 className='text-2xl font-medium mb-4'>Magic DNS</h1>
|
||||||
<p className='text-gray-700 dark:text-gray-300 mb-4'>
|
<p className='text-gray-700 dark:text-gray-300 mb-4'>
|
||||||
|
|||||||
@ -11,6 +11,9 @@
|
|||||||
"typecheck": "tsc"
|
"typecheck": "tsc"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.1.0",
|
||||||
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@headlessui/react": "^1.7.18",
|
"@headlessui/react": "^1.7.18",
|
||||||
"@heroicons/react": "^2.1.3",
|
"@heroicons/react": "^2.1.3",
|
||||||
"@remix-run/node": "^2.8.1",
|
"@remix-run/node": "^2.8.1",
|
||||||
|
|||||||
56
pnpm-lock.yaml
generated
56
pnpm-lock.yaml
generated
@ -5,6 +5,15 @@ settings:
|
|||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@dnd-kit/core':
|
||||||
|
specifier: ^6.1.0
|
||||||
|
version: 6.1.0(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
'@dnd-kit/sortable':
|
||||||
|
specifier: ^8.0.0
|
||||||
|
version: 8.0.0(@dnd-kit/core@6.1.0)(react@18.2.0)
|
||||||
|
'@dnd-kit/utilities':
|
||||||
|
specifier: ^3.2.2
|
||||||
|
version: 3.2.2(react@18.2.0)
|
||||||
'@headlessui/react':
|
'@headlessui/react':
|
||||||
specifier: ^1.7.18
|
specifier: ^1.7.18
|
||||||
version: 1.7.18(react-dom@18.2.0)(react@18.2.0)
|
version: 1.7.18(react-dom@18.2.0)(react@18.2.0)
|
||||||
@ -431,6 +440,49 @@ packages:
|
|||||||
to-fast-properties: 2.0.0
|
to-fast-properties: 2.0.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@dnd-kit/accessibility@3.1.0(react@18.2.0):
|
||||||
|
resolution: {integrity: sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
dependencies:
|
||||||
|
react: 18.2.0
|
||||||
|
tslib: 2.6.2
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@dnd-kit/core@6.1.0(react-dom@18.2.0)(react@18.2.0):
|
||||||
|
resolution: {integrity: sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
react-dom: '>=16.8.0'
|
||||||
|
dependencies:
|
||||||
|
'@dnd-kit/accessibility': 3.1.0(react@18.2.0)
|
||||||
|
'@dnd-kit/utilities': 3.2.2(react@18.2.0)
|
||||||
|
react: 18.2.0
|
||||||
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
|
tslib: 2.6.2
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@dnd-kit/sortable@8.0.0(@dnd-kit/core@6.1.0)(react@18.2.0):
|
||||||
|
resolution: {integrity: sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==}
|
||||||
|
peerDependencies:
|
||||||
|
'@dnd-kit/core': ^6.1.0
|
||||||
|
react: '>=16.8.0'
|
||||||
|
dependencies:
|
||||||
|
'@dnd-kit/core': 6.1.0(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
'@dnd-kit/utilities': 3.2.2(react@18.2.0)
|
||||||
|
react: 18.2.0
|
||||||
|
tslib: 2.6.2
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@dnd-kit/utilities@3.2.2(react@18.2.0):
|
||||||
|
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
dependencies:
|
||||||
|
react: 18.2.0
|
||||||
|
tslib: 2.6.2
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@emotion/hash@0.9.1:
|
/@emotion/hash@0.9.1:
|
||||||
resolution: {integrity: sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==}
|
resolution: {integrity: sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==}
|
||||||
dev: true
|
dev: true
|
||||||
@ -6054,6 +6106,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/tslib@2.6.2:
|
||||||
|
resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/tsutils@3.21.0(typescript@5.4.3):
|
/tsutils@3.21.0(typescript@5.4.3):
|
||||||
resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==}
|
resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user