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.`}
(
)}
/>
);
}
// ---------------------------------------------------------------------------
// 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();
})()}
onCreateRedirect(item.path)}
className="text-kumo-subtle hover:text-kumo-default"
title={t`Create redirect for this path`}
aria-label={t`Create redirect for ${item.path}`}
>
onMarkGone(item.path)}
className="text-kumo-subtle hover:text-kumo-danger text-xs font-semibold tabular-nums"
title={t`Mark as Gone (410) — tells search engines it was permanently deleted`}
aria-label={t`Mark ${item.path} as Gone (410)`}
>
410
))}
);
}
// ---------------------------------------------------------------------------
// 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.`}
} onClick={() => setShowCreate(true)}>
{t`New Redirect`}
{/* Tabs */}
setTab("redirects")}
className={cn(
"px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
tab === "redirects"
? "border-kumo-brand text-kumo-brand"
: "border-transparent text-kumo-subtle hover:text-kumo-default",
)}
>
{t`Redirects`}
{redirectsQuery.data && (
{redirectsQuery.data.items.length}
{redirectsQuery.data.nextCursor ? "+" : ""}
)}
setTab("404s")}
className={cn(
"px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
tab === "404s"
? "border-kumo-brand text-kumo-brand"
: "border-transparent text-kumo-subtle hover:text-kumo-default",
)}
>
{t`404 Errors`}
{/* Tab content */}
{tab === "redirects" && (
<>
{/* Filters */}
{/* Loop warning banner */}
{loopRedirectIds.size > 0 && (
{t`Redirect loop detected`}
{plural(loopRedirectIds.size, {
one: "# redirect is part of a loop.",
other: "# redirects are part of a loop.",
})}{" "}
{t`Visitors hitting these paths will see an error.`}
)}
{/* 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`}
)}
setEditRedirect(r)}
className="p-1 text-kumo-subtle hover:text-kumo-default"
title={t`Edit redirect`}
aria-label={t`Edit redirect ${r.source}`}
>
setDeleteId(r.id)}
className="p-1 text-kumo-subtle hover:text-kumo-danger"
title={t`Delete redirect`}
aria-label={t`Delete redirect ${r.source}`}
>
))}
)}
>
)}
{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)}
/>
);
}