milestone 9: react spa admin ui, frontend ci and embedded dist
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { groupsQuery, ruleCreateMutation, ruleDeleteMutation, rulesQuery } from "@/lib/queries";
|
||||
import type { Rule, RuleAction, RuleKind } from "@/lib/types";
|
||||
|
||||
const TH_CLASS = "border-b border-zinc-300 px-3 py-2 text-left font-medium dark:border-zinc-700";
|
||||
const TD_CLASS = "border-b border-zinc-200 px-3 py-2 dark:border-zinc-800";
|
||||
const INPUT_CLASS =
|
||||
"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";
|
||||
|
||||
export default function RulesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: rules } = useSuspenseQuery(rulesQuery());
|
||||
const { data: groups } = useSuspenseQuery(groupsQuery());
|
||||
|
||||
const create = useMutation(ruleCreateMutation(queryClient));
|
||||
const remove = useMutation(ruleDeleteMutation(queryClient));
|
||||
|
||||
const [pattern, setPattern] = useState("");
|
||||
const [kind, setKind] = useState<RuleKind>("exact");
|
||||
const [action, setAction] = useState<RuleAction>("block");
|
||||
const [groupId, setGroupId] = useState(groups[0]?.id ?? 1);
|
||||
|
||||
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
create.mutate(
|
||||
{ group_id: groupId, pattern: pattern.trim(), kind, action },
|
||||
{ onSuccess: () => setPattern("") },
|
||||
);
|
||||
}
|
||||
|
||||
function deleteRule(rule: Rule) {
|
||||
if (window.confirm(`Delete the ${rule.action} rule for "${rule.pattern}"?`)) {
|
||||
remove.mutate(rule.id);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h1 className="text-2xl font-semibold">Rules</h1>
|
||||
|
||||
{rules.length === 0 ? (
|
||||
<p className="mt-4 text-zinc-500">No allow or block rules yet. Create one below.</p>
|
||||
) : (
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="w-full min-w-max border-collapse text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={TH_CLASS}>Pattern</th>
|
||||
<th className={TH_CLASS}>Kind</th>
|
||||
<th className={TH_CLASS}>Action</th>
|
||||
<th className={TH_CLASS}>Group</th>
|
||||
<th className={TH_CLASS}>Created</th>
|
||||
<th className={TH_CLASS}>
|
||||
<span className="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rules.map((rule) => (
|
||||
<tr key={rule.id}>
|
||||
<td className={`${TD_CLASS} font-medium`}>{rule.pattern}</td>
|
||||
<td className={TD_CLASS}>{rule.kind}</td>
|
||||
<td className={TD_CLASS}>
|
||||
<span
|
||||
className={
|
||||
rule.action === "allow"
|
||||
? "text-green-700 dark:text-green-400"
|
||||
: "text-red-600 dark:text-red-400"
|
||||
}
|
||||
>
|
||||
{rule.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className={TD_CLASS}>{rule.group}</td>
|
||||
<td className={TD_CLASS}>{formatTime(rule.created_at)}</td>
|
||||
<td className={TD_CLASS}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteRule(rule)}
|
||||
disabled={remove.isPending}
|
||||
className="text-sm font-medium text-red-600 disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-red-400"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<InlineError error={remove.error} />
|
||||
|
||||
<form onSubmit={onSubmit} className="mt-6 max-w-xl space-y-3">
|
||||
<h2 className="text-lg font-medium">Create rule</h2>
|
||||
<div>
|
||||
<label htmlFor="rule-pattern" className="block text-sm font-medium">
|
||||
Pattern
|
||||
</label>
|
||||
<input
|
||||
id="rule-pattern"
|
||||
type="text"
|
||||
required
|
||||
value={pattern}
|
||||
onChange={(event) => setPattern(event.target.value)}
|
||||
placeholder="ads.example.com or *.example.com"
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<label htmlFor="rule-kind" className="block text-sm font-medium">
|
||||
Kind
|
||||
</label>
|
||||
<select
|
||||
id="rule-kind"
|
||||
value={kind}
|
||||
onChange={(event) => setKind(event.target.value as RuleKind)}
|
||||
className={INPUT_CLASS}
|
||||
>
|
||||
<option value="exact">exact</option>
|
||||
<option value="wildcard">wildcard</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="rule-action" className="block text-sm font-medium">
|
||||
Action
|
||||
</label>
|
||||
<select
|
||||
id="rule-action"
|
||||
value={action}
|
||||
onChange={(event) => setAction(event.target.value as RuleAction)}
|
||||
className={INPUT_CLASS}
|
||||
>
|
||||
<option value="allow">allow</option>
|
||||
<option value="block">block</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="rule-group" className="block text-sm font-medium">
|
||||
Group
|
||||
</label>
|
||||
<select
|
||||
id="rule-group"
|
||||
value={groupId}
|
||||
onChange={(event) => setGroupId(Number(event.target.value))}
|
||||
className={INPUT_CLASS}
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={create.isPending}
|
||||
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
>
|
||||
{create.isPending ? "Creating…" : "Create rule"}
|
||||
</button>
|
||||
<InlineError error={create.error} />
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user