feat: swap button and decompose dialog components

This commit is contained in:
Aarnav Tale
2026-03-14 19:22:18 -04:00
parent eda5713700
commit 27f8fa0b42
40 changed files with 604 additions and 872 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ import { GripVertical, Lock } from "lucide-react";
import { useEffect, useState } from "react";
import { Form } from "react-router";
import Button from "~/components/Button";
import Button from "~/components/button";
import Input from "~/components/Input";
import TableList from "~/components/TableList";
import cn from "~/utils/cn";
+1 -1
View File
@@ -1,7 +1,7 @@
import { Info } from "lucide-react";
import { Form, useSubmit } from "react-router";
import Button from "~/components/Button";
import Button from "~/components/button";
import Link from "~/components/link";
import Switch from "~/components/Switch";
import TableList from "~/components/TableList";
+1 -1
View File
@@ -1,6 +1,6 @@
import { Form } from "react-router";
import Button from "~/components/Button";
import Button from "~/components/button";
import Code from "~/components/Code";
import Link from "~/components/link";
import TableList from "~/components/TableList";
+45 -42
View File
@@ -1,48 +1,51 @@
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
import Button from "~/components/Button";
import Code from "~/components/Code";
import Dialog, { DialogPanel } from "~/components/Dialog";
import Input from "~/components/Input";
import Text from "~/components/Text";
import Title from "~/components/Title";
interface Props {
name: string;
isDisabled: boolean;
name: string;
isDisabled: boolean;
}
export default function RenameTailnet({ name, isDisabled }: Props) {
return (
<div className="flex flex-col w-full sm:w-2/3 gap-y-4">
<h1 className="text-2xl font-medium mb-2">Tailnet Name</h1>
<p>
This is the base domain name of your Tailnet. Devices are accessible at{' '}
<Code>[device].{name}</Code> when Magic DNS is enabled.
</p>
<Input
className="w-3/5 font-medium text-sm"
isReadOnly
label="Tailnet name"
labelHidden
onFocus={(event) => {
event.target.select();
}}
value={name}
/>
<Dialog>
<Dialog.Button isDisabled={isDisabled}>Rename Tailnet</Dialog.Button>
<Dialog.Panel isDisabled={isDisabled}>
<Dialog.Title>Rename Tailnet</Dialog.Title>
<Dialog.Text className="mb-8">
Keep in mind that changing this can lead to all sorts of unexpected
behavior and may break existing devices in your tailnet.
</Dialog.Text>
<input name="action_id" type="hidden" value="rename_tailnet" />
<Input
defaultValue={name}
isRequired
label="Tailnet name"
name="new_name"
placeholder="ts.net"
/>
</Dialog.Panel>
</Dialog>
</div>
);
return (
<div className="flex w-full flex-col gap-y-4 sm:w-2/3">
<h1 className="mb-2 text-2xl font-medium">Tailnet Name</h1>
<p>
This is the base domain name of your Tailnet. Devices are accessible at{" "}
<Code>[device].{name}</Code> when Magic DNS is enabled.
</p>
<Input
className="w-3/5 text-sm font-medium"
isReadOnly
label="Tailnet name"
labelHidden
onFocus={(event) => {
event.target.select();
}}
value={name}
/>
<Dialog>
<Button isDisabled={isDisabled}>Rename Tailnet</Button>
<DialogPanel isDisabled={isDisabled}>
<Title>Rename Tailnet</Title>
<Text className="mb-8">
Keep in mind that changing this can lead to all sorts of unexpected behavior and may
break existing devices in your tailnet.
</Text>
<input name="action_id" type="hidden" value="rename_tailnet" />
<Input
defaultValue={name}
isRequired
label="Tailnet name"
name="new_name"
placeholder="ts.net"
/>
</DialogPanel>
</Dialog>
</div>
);
}
+20 -25
View File
@@ -1,31 +1,26 @@
import Dialog from '~/components/Dialog';
import Button from "~/components/Button";
import Dialog, { DialogPanel } from "~/components/Dialog";
import Text from "~/components/Text";
import Title from "~/components/Title";
interface Props {
isEnabled: boolean;
isDisabled: boolean;
isEnabled: boolean;
isDisabled: boolean;
}
export default function Modal({ isEnabled, isDisabled }: Props) {
return (
<Dialog>
<Dialog.Button isDisabled={isDisabled}>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Button>
<Dialog.Panel isDisabled={isDisabled}>
<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>
<input type="hidden" name="action_id" value="toggle_magic" />
<input
type="hidden"
name="new_state"
value={isEnabled ? 'disabled' : 'enabled'}
/>
</Dialog.Panel>
</Dialog>
);
return (
<Dialog>
<Button isDisabled={isDisabled}>{isEnabled ? "Disable" : "Enable"} Magic DNS</Button>
<DialogPanel isDisabled={isDisabled}>
<Title>{isEnabled ? "Disable" : "Enable"} Magic DNS</Title>
<Text>
Devices will no longer be accessible via your tailnet domain. The search domain will also
be disabled.
</Text>
<input type="hidden" name="action_id" value="toggle_magic" />
<input type="hidden" name="new_state" value={isEnabled ? "disabled" : "enabled"} />
</DialogPanel>
</Dialog>
);
}
+83 -84
View File
@@ -1,94 +1,93 @@
import { Split } from 'lucide-react';
import { useMemo, useState } from 'react';
import Chip from '~/components/Chip';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
import Switch from '~/components/Switch';
import Tooltip from '~/components/Tooltip';
import cn from '~/utils/cn';
import { Split } from "lucide-react";
import { useMemo, useState } from "react";
import Button from "~/components/Button";
import Chip from "~/components/Chip";
import Dialog, { DialogPanel } from "~/components/Dialog";
import Input from "~/components/Input";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import Title from "~/components/Title";
import Tooltip from "~/components/Tooltip";
import cn from "~/utils/cn";
interface Props {
nameservers: Record<string, string[]>;
nameservers: Record<string, string[]>;
}
export default function AddNameserver({ nameservers }: Props) {
const [split, setSplit] = useState(false);
const [ns, setNs] = useState('');
const [domain, setDomain] = useState('');
const [split, setSplit] = useState(false);
const [ns, setNs] = useState("");
const [domain, setDomain] = useState("");
const isInvalid = useMemo(() => {
if (ns === '') return false;
// Test if it's a valid IPv4 or IPv6 address
const ipv4 = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/;
const ipv6 = /^([0-9a-fA-F:]+:+)+[0-9a-fA-F]+$/;
if (!ipv4.test(ns) && !ipv6.test(ns)) return true;
const isInvalid = useMemo(() => {
if (ns === "") return false;
// Test if it's a valid IPv4 or IPv6 address
const ipv4 = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/;
const ipv6 = /^([0-9a-fA-F:]+:+)+[0-9a-fA-F]+$/;
if (!ipv4.test(ns) && !ipv6.test(ns)) return true;
if (split) {
return nameservers[domain]?.includes(ns);
}
if (split) {
return nameservers[domain]?.includes(ns);
}
return Object.values(nameservers).some((nsList) => nsList.includes(ns));
}, [nameservers, ns]);
return Object.values(nameservers).some((nsList) => nsList.includes(ns));
}, [nameservers, ns]);
return (
<Dialog>
<Dialog.Button>Add nameserver</Dialog.Button>
<Dialog.Panel>
<Dialog.Title className="mb-4">Add nameserver</Dialog.Title>
<input name="action_id" type="hidden" value="add_ns" />
<Input
description="Use this IPv4 or IPv6 address to resolve names."
isInvalid={isInvalid}
isRequired
label="Nameserver"
name="ns"
onChange={setNs}
placeholder="1.2.3.4"
/>
<div className="flex items-center justify-between mt-8">
<div className="block">
<div className="inline-flex items-center gap-2">
<Dialog.Text className="font-semibold">
Restrict to domain
</Dialog.Text>
<Tooltip>
<Chip
className={cn('inline-flex items-center')}
leftIcon={<Split className="w-3 h-3 mr-0.5" />}
text="Split DNS"
/>
<Tooltip.Body>
Only clients that support split DNS (Tailscale v1.8 or later
for most platforms) will use this nameserver. Older clients
will ignore it.
</Tooltip.Body>
</Tooltip>
</div>
<Dialog.Text className="text-sm">
This nameserver will only be used for some domains.
</Dialog.Text>
</div>
<Switch label="Split DNS" onChange={setSplit} />
</div>
{split ? (
<>
<Dialog.Text className="font-semibold mt-8">Domain</Dialog.Text>
<Input
isRequired={split === true}
label="Domain"
name="split_name"
onChange={setDomain}
placeholder="example.com"
/>
<Dialog.Text className="text-sm">
Only single-label or fully-qualified queries matching this suffix
should use the nameserver.
</Dialog.Text>
</>
) : (
<input name="split_name" type="hidden" value="global" />
)}
</Dialog.Panel>
</Dialog>
);
return (
<Dialog>
<Button>Add nameserver</Button>
<DialogPanel>
<Title className="mb-4">Add nameserver</Title>
<input name="action_id" type="hidden" value="add_ns" />
<Input
description="Use this IPv4 or IPv6 address to resolve names."
isInvalid={isInvalid}
isRequired
label="Nameserver"
name="ns"
onChange={setNs}
placeholder="1.2.3.4"
/>
<div className="mt-8 flex items-center justify-between">
<div className="block">
<div className="inline-flex items-center gap-2">
<Text className="font-semibold">Restrict to domain</Text>
<Tooltip>
<Chip
className={cn("inline-flex items-center")}
leftIcon={<Split className="mr-0.5 h-3 w-3" />}
text="Split DNS"
/>
<Tooltip.Body>
Only clients that support split DNS (Tailscale v1.8 or later for most platforms)
will use this nameserver. Older clients will ignore it.
</Tooltip.Body>
</Tooltip>
</div>
<Text className="text-sm">This nameserver will only be used for some domains.</Text>
</div>
<Switch label="Split DNS" onChange={setSplit} />
</div>
{split ? (
<>
<Text className="mt-8 font-semibold">Domain</Text>
<Input
isRequired={split === true}
label="Domain"
name="split_name"
onChange={setDomain}
placeholder="example.com"
/>
<Text className="text-sm">
Only single-label or fully-qualified queries matching this suffix should use the
nameserver.
</Text>
</>
) : (
<input name="split_name" type="hidden" value="global" />
)}
</DialogPanel>
</Dialog>
);
}
+70 -70
View File
@@ -1,79 +1,79 @@
import { useMemo, useState } from 'react';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
import Select from '~/components/Select';
import { useMemo, useState } from "react";
import Button from "~/components/Button";
import Code from "~/components/Code";
import Dialog, { DialogPanel } from "~/components/Dialog";
import Input from "~/components/Input";
import Select from "~/components/Select";
import Text from "~/components/Text";
import Title from "~/components/Title";
interface Props {
records: { name: string; type: 'A' | 'AAAA' | string; value: string }[];
records: { name: string; type: "A" | "AAAA" | string; value: string }[];
}
export default function AddRecord({ records }: Props) {
const [type, setType] = useState<'A' | 'AAAA' | string>('A');
const [name, setName] = useState('');
const [ip, setIp] = useState('');
const [type, setType] = useState<"A" | "AAAA" | string>("A");
const [name, setName] = useState("");
const [ip, setIp] = useState("");
const isDuplicate = useMemo(() => {
if (name.length === 0 || ip.length === 0) return false;
const lookup = records.find((record) => record.name === name);
if (!lookup) return false;
const isDuplicate = useMemo(() => {
if (name.length === 0 || ip.length === 0) return false;
const lookup = records.find((record) => record.name === name);
if (!lookup) return false;
return lookup.value === ip;
}, [records, name, ip]);
return lookup.value === ip;
}, [records, name, ip]);
return (
<Dialog>
<Dialog.Button>Add DNS record</Dialog.Button>
<Dialog.Panel
onSubmit={() => {
setName('');
setIp('');
}}
>
<Dialog.Title>Add DNS record</Dialog.Title>
<Dialog.Text>
Enter the domain and IP address for the new DNS record.
</Dialog.Text>
<div className="flex flex-col gap-2 mt-4">
<input type="hidden" name="action_id" value="add_record" />
<Select
isRequired
label="Record Type"
name="record_type"
defaultInputValue={type}
onSelectionChange={(v) => {
if (v) setType(v.toString() as 'A' | 'AAAA');
}}
>
<Select.Item key="A">A</Select.Item>
<Select.Item key="AAAA">AAAA</Select.Item>
</Select>
<Input
isRequired
label="Domain"
placeholder="test.example.com"
name="record_name"
onChange={setName}
isInvalid={isDuplicate}
/>
<Input
isRequired
label="IP Address"
placeholder={
type === 'AAAA' ? '2001:db8::ff00:42:8329' : '101.101.101.101'
}
name="record_value"
onChange={setIp}
isInvalid={isDuplicate}
/>
{isDuplicate ? (
<p className="text-sm opacity-50">
A record with the domain name <Code>{name}</Code> and IP address{' '}
<Code>{ip}</Code> already exists.
</p>
) : undefined}
</div>
</Dialog.Panel>
</Dialog>
);
return (
<Dialog>
<Button>Add DNS record</Button>
<DialogPanel
onSubmit={() => {
setName("");
setIp("");
}}
>
<Title>Add DNS record</Title>
<Text>Enter the domain and IP address for the new DNS record.</Text>
<div className="mt-4 flex flex-col gap-2">
<input type="hidden" name="action_id" value="add_record" />
<Select
isRequired
label="Record Type"
name="record_type"
defaultInputValue={type}
onSelectionChange={(v) => {
if (v) setType(v.toString() as "A" | "AAAA");
}}
>
<Select.Item key="A">A</Select.Item>
<Select.Item key="AAAA">AAAA</Select.Item>
</Select>
<Input
isRequired
label="Domain"
placeholder="test.example.com"
name="record_name"
onChange={setName}
isInvalid={isDuplicate}
/>
<Input
isRequired
label="IP Address"
placeholder={type === "AAAA" ? "2001:db8::ff00:42:8329" : "101.101.101.101"}
name="record_value"
onChange={setIp}
isInvalid={isDuplicate}
/>
{isDuplicate ? (
<p className="text-sm opacity-50">
A record with the domain name <Code>{name}</Code> and IP address <Code>{ip}</Code>{" "}
already exists.
</p>
) : undefined}
</div>
</DialogPanel>
</Dialog>
);
}