import { Badge, Button, Dialog, Input, Label, Select, Switch } from "@cloudflare/kumo"; import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import { MagnifyingGlass, Plus, ArrowsLeftRight, Trash, PencilSimple, WarningCircle, X, } from "@phosphor-icons/react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useState } from "react"; import { createRedirect, deleteRedirect, fetch404Summary, fetchRedirects, updateRedirect, } from "../lib/api/redirects.js"; import type { CreateRedirectInput, NotFoundSummary, Redirect, UpdateRedirectInput, } from "../lib/api/redirects.js"; import { cn } from "../lib/utils.js"; import { ArrowNext } from "./ArrowIcons.js"; import { ConfirmDialog } from "./ConfirmDialog.js"; import { DialogError, getMutationError } from "./DialogError.js"; // --------------------------------------------------------------------------- // Redirect form dialog (create + edit) // --------------------------------------------------------------------------- function RedirectFormDialog({ open, onClose, redirect, defaultSource, }: { open: boolean; onClose: () => void; /** Pass for edit mode */ redirect?: Redirect; /** Pre-fill source for create mode (e.g. from 404 list) */ defaultSource?: string; }) { const { t } = useLingui(); const queryClient = useQueryClient(); const isEdit = !!redirect; const [source, setSource] = useState(redirect?.source ?? defaultSource ?? ""); const [destination, setDestination] = useState(redirect?.destination ?? ""); const [type, setType] = useState(String(redirect?.type ?? 301)); const [enabled, setEnabled] = useState(redirect?.enabled ?? true); const [groupName, setGroupName] = useState(redirect?.groupName ?? ""); // Terminal statuses (410 Gone / 451) serve a status directly — no destination. const isTerminal = type === "410" || type === "451"; const createMutation = useMutation({ mutationFn: (input: CreateRedirectInput) => createRedirect(input), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["redirects"] }); onClose(); }, }); const updateMutation = useMutation({ mutationFn: (input: UpdateRedirectInput) => updateRedirect(redirect!.id, input), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["redirects"] }); onClose(); }, }); const mutation = isEdit ? updateMutation : createMutation; function handleSubmit(e: React.FormEvent) { e.preventDefault(); const input = { source: source.trim(), destination: isTerminal ? "" : destination.trim(), type: Number(type), enabled, groupName: groupName.trim() || null, }; if (isEdit) { updateMutation.mutate(input); } else { createMutation.mutate(input); } } return ( !o && onClose()}>
{isEdit ? t`Edit Redirect` : t`New Redirect`}

{isEdit ? t`Update this redirect rule.` : t`Use [param] or [...rest] in paths for pattern matching.`}

( )} />
) => setSource(e.target.value)} required /> {!isTerminal && ( ) => setDestination(e.target.value)} required /> )}
) => setGroupName(e.target.value)} />
); } // --------------------------------------------------------------------------- // 404 Summary panel // --------------------------------------------------------------------------- function NotFoundPanel({ items, onCreateRedirect, onMarkGone, }: { items: NotFoundSummary[]; onCreateRedirect: (path: string) => void; onMarkGone: (path: string) => void; }) { const { t } = useLingui(); if (items.length === 0) { return (

{t`No 404 errors recorded yet.`}

); } return (
{t`Path`}
{t`Hits`}
{t`Last seen`}
{items.map((item) => (
{item.path}
{item.count}
{(() => { const d = new Date(item.lastSeen); return Number.isNaN(d.getTime()) ? item.lastSeen : d.toLocaleDateString(); })()}
))}
); } // --------------------------------------------------------------------------- // Main Redirects page // --------------------------------------------------------------------------- type TabKey = "redirects" | "404s"; export function Redirects() { const { t } = useLingui(); const queryClient = useQueryClient(); const [tab, setTab] = useState("redirects"); const [search, setSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); const [filterEnabled, setFilterEnabled] = useState("all"); const [filterAuto, setFilterAuto] = useState("all"); // Debounce search input useEffect(() => { const timer = setTimeout(setDebouncedSearch, 300, search); return () => clearTimeout(timer); }, [search]); // Dialog state const [showCreate, setShowCreate] = useState(false); const [editRedirect, setEditRedirect] = useState(null); const [deleteId, setDeleteId] = useState(null); const [prefillSource, setPrefillSource] = useState(""); // Queries const enabledFilter = filterEnabled === "all" ? undefined : filterEnabled === "true"; const autoFilter = filterAuto === "all" ? undefined : filterAuto === "true"; const redirectsQuery = useQuery({ queryKey: ["redirects", debouncedSearch, enabledFilter, autoFilter], queryFn: () => fetchRedirects({ search: debouncedSearch || undefined, enabled: enabledFilter, auto: autoFilter, limit: 100, }), }); const notFoundQuery = useQuery({ queryKey: ["redirects", "404-summary"], queryFn: () => fetch404Summary(50), enabled: tab === "404s", }); // Delete mutation const deleteMutation = useMutation({ mutationFn: (id: string) => deleteRedirect(id), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["redirects"] }); setDeleteId(null); }, }); // Toggle enabled mutation const toggleMutation = useMutation({ mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => updateRedirect(id, { enabled }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["redirects"] }); }, onError: () => { void queryClient.invalidateQueries({ queryKey: ["redirects"] }); }, }); // One-click "Gone": create a 410 rule (no destination) for a 404 path. const markGoneMutation = useMutation({ mutationFn: (path: string) => createRedirect({ source: path, destination: "", type: 410, enabled: true }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["redirects"] }); }, }); function handleCreateFrom404(path: string) { setPrefillSource(path); setShowCreate(true); setTab("redirects"); } const redirects = redirectsQuery.data?.items ?? []; const loopRedirectIds = new Set(redirectsQuery.data?.loopRedirectIds ?? []); return (
{/* Header */}

{t`Redirects`}

{t`Manage URL redirects and view 404 errors.`}

{/* Tabs */}
{/* Tab content */} {tab === "redirects" && ( <> {/* Filters */}
) => setSearch(e.target.value)} />
setFilterAuto(v ?? "all")} items={{ all: t`All types`, false: t`Manual`, true: t`Auto (slug change)` }} aria-label={t`Filter by type`} />
{/* Loop warning banner */} {loopRedirectIds.size > 0 && (
)} {/* Redirect list */} {redirectsQuery.isLoading ? (
{t`Loading redirects...`}
) : redirects.length === 0 ? (

{t`No redirects yet`}

{t`Create redirect rules to manage URL changes.`}

) : (
{t`Source`}
{t`Destination`}
{t`Code`}
{t`Hits`}
{t`Status`}
{redirects.map((r) => (
{r.source}
{r.destination}
{r.type}
{r.hits}
toggleMutation.mutate({ id: r.id, enabled: checked, }) } aria-label={r.enabled ? t`Disable redirect` : t`Enable redirect`} />
{loopRedirectIds.has(r.id) && ( )} {r.auto && ( {t`auto`} )}
))}
)} )} {tab === "404s" && ( markGoneMutation.mutate(path)} /> )} {/* Create dialog */} {showCreate && ( { setShowCreate(false); setPrefillSource(""); }} defaultSource={prefillSource || undefined} /> )} {/* Edit dialog */} {editRedirect && ( setEditRedirect(null)} redirect={editRedirect} /> )} {/* Delete confirmation */} { setDeleteId(null); deleteMutation.reset(); }} title={t`Delete Redirect?`} description={t`This redirect rule will be permanently removed.`} confirmLabel={t`Delete`} pendingLabel={t`Deleting...`} isPending={deleteMutation.isPending} error={deleteMutation.error} onConfirm={() => deleteId && deleteMutation.mutate(deleteId)} />
); }