import { useState, type FormEvent } from "react"; import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import * as stylex from "@stylexjs/stylex"; 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"; import { defaultGroupId } from "@/lib/defaultGroup"; import ConfirmDialog from "@/ui/ConfirmDialog"; import Select from "@/ui/Select"; import { styles as shared } from "@/ui/styles"; import { colors } from "@/ui/tokens.stylex"; import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority"; const KIND_OPTIONS = [ { value: "exact", label: "exact" }, { value: "wildcard", label: "wildcard" }, { value: "regex", label: "regex" }, ]; const ACTION_OPTIONS = [ { value: "allow", label: "allow" }, { value: "block", label: "block" }, ]; const styles = stylex.create({ heading: { fontSize: "1.5rem", lineHeight: "2rem", fontWeight: 600, }, empty: { marginTop: "1rem", color: colors.textMuted, }, table: { width: "100%", minWidth: "max-content", borderCollapse: "collapse", fontSize: "0.875rem", lineHeight: "1.25rem", }, pattern: { fontWeight: 500, }, allow: { color: colors.primaryOnSurface, }, block: { color: colors.danger, }, form: { display: "flex", flexDirection: "column", gap: "0.75rem", marginTop: "1.5rem", maxWidth: "36rem", }, formHeading: { fontSize: "1.125rem", lineHeight: "1.75rem", fontWeight: 500, }, fieldLabel: { display: "block", fontSize: "0.875rem", lineHeight: "1.25rem", fontWeight: 500, }, /** One column on a phone, three from the `sm` breakpoint, as before. */ fieldGrid: { display: "grid", gap: "0.75rem", gridTemplateColumns: { default: "repeat(1, minmax(0, 1fr))", "@media (min-width: 640px)": "repeat(3, minmax(0, 1fr))", }, }, submitRow: { display: "flex", }, }); 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("exact"); const [action, setAction] = useState("block"); const [groupId, setGroupId] = useState(() => defaultGroupId(groups)); const [pendingDelete, setPendingDelete] = useState(null); const readOnly = useReadOnlyConfig(); function onSubmit(event: FormEvent) { event.preventDefault(); // A regex pattern is stored and matched byte for byte, so the UI must not // edit it: trimming here would make a UI-created rule differ from the same // bytes posted to /api/rules. Name-shaped kinds are normalized server-side, // so trimming them only spares a pasted space a 400. const sent = kind === "regex" ? pattern : pattern.trim(); create.mutate({ group_id: groupId, pattern: sent, kind, action }, { onSuccess: () => setPattern("") }); } function confirmDelete() { if (pendingDelete === null) return; remove.mutate(pendingDelete.id); setPendingDelete(null); } return (

Rules

{rules.length === 0 ? (

No allow or block rules yet. Create one below.

) : (
{rules.map((rule) => ( ))}
Pattern Kind Action Group Created Actions
{rule.pattern} {rule.kind} {rule.action} {rule.group} {formatTime(rule.created_at)}
)}

Create rule

setPattern(event.target.value)} placeholder="ads.example.com, *.example.com or ^ad[0-9]+-" // A phone keyboard capitalizing the first letter is silent for // exact and wildcard (normalized server-side) but fatal for a // regex, which matches the lowercase query name byte for byte. autoCapitalize="none" autoCorrect="off" spellCheck={false} {...stylex.props(shared.input, shared.focusRing)} />
setAction(value as RuleAction)} options={ACTION_OPTIONS} />