Files
nxdns/web/src/features/local/ZonesTab.tsx
T

203 lines
6.1 KiB
TypeScript

import { useId, useState, type FormEvent } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import {
forwardZoneCreateMutation,
forwardZoneDeleteMutation,
forwardZoneUpdateMutation,
forwardZonesQuery,
} from "@/lib/queries";
import type { ForwardZone, ForwardZoneInput } from "@/lib/types";
import InlineError from "@/lib/InlineError";
const inputClass =
"mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
const primaryButtonClass =
"rounded bg-blue-600 px-3 py-2 font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600";
const secondaryButtonClass =
"rounded border border-zinc-300 px-3 py-2 font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700";
const rowButtonClass =
"rounded px-2 py-1 text-sm text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-blue-400";
type FormState = { mode: "create" } | { mode: "edit"; zone: ForwardZone };
function ZoneForm({
initial,
busy,
error,
onSubmit,
onCancel,
}: {
initial?: ForwardZone;
busy: boolean;
error: unknown;
onSubmit: (input: ForwardZoneInput) => void;
onCancel: () => void;
}) {
const id = useId();
const [zone, setZone] = useState(initial?.zone ?? "");
const [resolver, setResolver] = useState(initial?.resolver ?? "");
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
onSubmit({ zone: zone.trim(), resolver: resolver.trim() });
}
return (
<form
onSubmit={submit}
className="mt-4 max-w-lg space-y-3 rounded border border-zinc-200 p-4 dark:border-zinc-800"
>
<h3 className="font-medium">{initial === undefined ? "New forward zone" : `Edit ${initial.zone}`}</h3>
<div>
<label htmlFor={`${id}-zone`} className="block text-sm font-medium">
Zone
</label>
<input
id={`${id}-zone`}
required
value={zone}
onChange={(event) => setZone(event.target.value)}
placeholder="lan.home"
className={inputClass}
/>
</div>
<div>
<label htmlFor={`${id}-resolver`} className="block text-sm font-medium">
Resolver
</label>
<input
id={`${id}-resolver`}
required
value={resolver}
onChange={(event) => setResolver(event.target.value)}
placeholder="udp://192.168.1.1:53"
className={inputClass}
/>
</div>
<div className="flex gap-2">
<button type="submit" disabled={busy} className={primaryButtonClass}>
{busy ? "Saving…" : "Save"}
</button>
<button type="button" onClick={onCancel} className={secondaryButtonClass}>
Cancel
</button>
</div>
<InlineError error={error} />
</form>
);
}
export default function ZonesTab() {
const zones = useSuspenseQuery(forwardZonesQuery()).data;
const queryClient = useQueryClient();
const create = useMutation(forwardZoneCreateMutation(queryClient));
const update = useMutation(forwardZoneUpdateMutation(queryClient));
const remove = useMutation(forwardZoneDeleteMutation(queryClient));
const [form, setForm] = useState<FormState | null>(null);
function openForm(next: FormState) {
create.reset();
update.reset();
setForm(next);
}
function onSubmit(input: ForwardZoneInput) {
if (form === null) return;
if (form.mode === "create") {
create.mutate(input, { onSuccess: () => setForm(null) });
} else {
update.mutate({ id: form.zone.id, input }, { onSuccess: () => setForm(null) });
}
}
function onDelete(zone: ForwardZone) {
if (!window.confirm(`Delete forward zone "${zone.zone}"?`)) return;
remove.mutate(zone.id);
}
return (
<div>
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-zinc-500">
Names under these zones go to their own resolver. Changes apply live.
</p>
<button type="button" onClick={() => openForm({ mode: "create" })} className={primaryButtonClass}>
Add zone
</button>
</div>
<InlineError error={remove.error} />
{form?.mode === "create" && (
<ZoneForm
busy={create.isPending}
error={create.error}
onSubmit={onSubmit}
onCancel={() => setForm(null)}
/>
)}
<div className="mt-4 overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800">
<th scope="col" className="py-2 pr-4 font-medium">
Zone
</th>
<th scope="col" className="py-2 pr-4 font-medium">
Resolver
</th>
<th scope="col" className="py-2">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
{zones.length === 0 && (
<tr>
<td colSpan={3} className="py-4 text-zinc-500">
No forward zones yet.
</td>
</tr>
)}
{zones.map((zone) => (
<tr key={zone.id} className="border-b border-zinc-100 dark:border-zinc-900">
{form?.mode === "edit" && form.zone.id === zone.id ? (
<td colSpan={3}>
<ZoneForm
initial={zone}
busy={update.isPending}
error={update.error}
onSubmit={onSubmit}
onCancel={() => setForm(null)}
/>
</td>
) : (
<>
<td className="py-2 pr-4 font-mono">{zone.zone}</td>
<td className="py-2 pr-4 font-mono">{zone.resolver}</td>
<td className="py-2 text-right whitespace-nowrap">
<button
type="button"
onClick={() => openForm({ mode: "edit", zone })}
className={rowButtonClass}
>
Edit
</button>
<button
type="button"
onClick={() => onDelete(zone)}
disabled={remove.isPending}
className={`${rowButtonClass} text-red-600 dark:text-red-400`}
>
Delete
</button>
</td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}