milestone 9: react spa admin ui, frontend ci and embedded dist
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
import { useId, useState, type FormEvent } from "react";
|
||||
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
localRecordCreateMutation,
|
||||
localRecordDeleteMutation,
|
||||
localRecordUpdateMutation,
|
||||
localRecordsQuery,
|
||||
} from "@/lib/queries";
|
||||
import type { LocalRecord, LocalRecordInput, LocalRecordType } from "@/lib/types";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
|
||||
const RTYPES: readonly LocalRecordType[] = ["A", "AAAA", "CNAME"];
|
||||
|
||||
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"; record: LocalRecord };
|
||||
|
||||
function RecordForm({
|
||||
initial,
|
||||
busy,
|
||||
error,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
initial?: LocalRecord;
|
||||
busy: boolean;
|
||||
error: unknown;
|
||||
onSubmit: (input: LocalRecordInput) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [name, setName] = useState(initial?.name ?? "");
|
||||
const [rtype, setRtype] = useState<LocalRecordType>(initial?.rtype ?? "A");
|
||||
const [value, setValue] = useState(initial?.value ?? "");
|
||||
const [ttl, setTtl] = useState(initial === undefined ? "" : String(initial.ttl));
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const input: LocalRecordInput = { name: name.trim(), rtype, value: value.trim() };
|
||||
if (ttl.trim() !== "") input.ttl = Number(ttl);
|
||||
onSubmit(input);
|
||||
}
|
||||
|
||||
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 record" : `Edit ${initial.name}`}</h3>
|
||||
<div>
|
||||
<label htmlFor={`${id}-name`} className="block text-sm font-medium">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id={`${id}-name`}
|
||||
required
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="nas.lan.home"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={`${id}-rtype`} className="block text-sm font-medium">
|
||||
Type
|
||||
</label>
|
||||
<select
|
||||
id={`${id}-rtype`}
|
||||
value={rtype}
|
||||
onChange={(event) => setRtype(event.target.value as LocalRecordType)}
|
||||
className={inputClass}
|
||||
>
|
||||
{RTYPES.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={`${id}-value`} className="block text-sm font-medium">
|
||||
Value
|
||||
</label>
|
||||
<input
|
||||
id={`${id}-value`}
|
||||
required
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
placeholder={
|
||||
rtype === "CNAME" ? "target.example.com" : rtype === "AAAA" ? "fd00::10" : "192.168.1.10"
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={`${id}-ttl`} className="block text-sm font-medium">
|
||||
TTL (seconds)
|
||||
</label>
|
||||
<input
|
||||
id={`${id}-ttl`}
|
||||
type="number"
|
||||
min={0}
|
||||
value={ttl}
|
||||
onChange={(event) => setTtl(event.target.value)}
|
||||
placeholder="300"
|
||||
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 RecordsTab() {
|
||||
const records = useSuspenseQuery(localRecordsQuery()).data;
|
||||
const queryClient = useQueryClient();
|
||||
const create = useMutation(localRecordCreateMutation(queryClient));
|
||||
const update = useMutation(localRecordUpdateMutation(queryClient));
|
||||
const remove = useMutation(localRecordDeleteMutation(queryClient));
|
||||
const [form, setForm] = useState<FormState | null>(null);
|
||||
|
||||
function openForm(next: FormState) {
|
||||
create.reset();
|
||||
update.reset();
|
||||
setForm(next);
|
||||
}
|
||||
|
||||
function onSubmit(input: LocalRecordInput) {
|
||||
if (form === null) return;
|
||||
if (form.mode === "create") {
|
||||
create.mutate(input, { onSuccess: () => setForm(null) });
|
||||
} else {
|
||||
update.mutate({ id: form.record.id, input }, { onSuccess: () => setForm(null) });
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete(record: LocalRecord) {
|
||||
if (!window.confirm(`Delete record "${record.name}"?`)) return;
|
||||
remove.mutate(record.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<p className="text-sm text-zinc-500">Answers served directly for LAN names. Changes apply live.</p>
|
||||
<button type="button" onClick={() => openForm({ mode: "create" })} className={primaryButtonClass}>
|
||||
Add record
|
||||
</button>
|
||||
</div>
|
||||
<InlineError error={remove.error} />
|
||||
{form?.mode === "create" && (
|
||||
<RecordForm
|
||||
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">
|
||||
Name
|
||||
</th>
|
||||
<th scope="col" className="py-2 pr-4 font-medium">
|
||||
Type
|
||||
</th>
|
||||
<th scope="col" className="py-2 pr-4 font-medium">
|
||||
Value
|
||||
</th>
|
||||
<th scope="col" className="py-2 pr-4 font-medium">
|
||||
TTL
|
||||
</th>
|
||||
<th scope="col" className="py-2">
|
||||
<span className="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-4 text-zinc-500">
|
||||
No local records yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{records.map((record) => (
|
||||
<tr key={record.id} className="border-b border-zinc-100 dark:border-zinc-900">
|
||||
{form?.mode === "edit" && form.record.id === record.id ? (
|
||||
<td colSpan={5}>
|
||||
<RecordForm
|
||||
initial={record}
|
||||
busy={update.isPending}
|
||||
error={update.error}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={() => setForm(null)}
|
||||
/>
|
||||
</td>
|
||||
) : (
|
||||
<>
|
||||
<td className="py-2 pr-4 font-mono">{record.name}</td>
|
||||
<td className="py-2 pr-4">{record.rtype}</td>
|
||||
<td className="py-2 pr-4 font-mono">{record.value}</td>
|
||||
<td className="py-2 pr-4">{record.ttl}</td>
|
||||
<td className="py-2 text-right whitespace-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openForm({ mode: "edit", record })}
|
||||
className={rowButtonClass}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(record)}
|
||||
disabled={remove.isPending}
|
||||
className={`${rowButtonClass} text-red-600 dark:text-red-400`}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user