Counting the un-migrated call sites showed the primitive and the app had drifted into two different looks: the majority of hand-rolled buttons hover to accent (outline) and fill with card (ghost), while Button hovered to fg and filled with surface. That split is the inconsistency this epic exists to remove, so the primitive follows the majority rather than the other way round -- 16 existing call sites shift slightly, ~90 hand-rolled ones stop being special. Adds the variants the call sites prove are missing (outline-accent, outline-danger, outline-warn, link), an icon-sm size for the p-1.5 icon family, and a size=none so a link owns no button padding; renames danger-outline to danger-soft so it cannot be confused with outline-danger (red at rest vs red on hover). Two new tests lock the link-size default and the rule that a variant never adds a second font-weight utility -- without tailwind-merge, two font-* utilities are decided by stylesheet order, not class order, so that is a real hazard. Migrates AdminUsers, AuditLog, Channels, TagManager, Playlists, PlaylistsRail and SettingsPanel (Button call sites 37 -> 52). Left alone deliberately: conditional-paint sites, video-overlay white buttons, drag handles, chip removers and card-click targets -- the families Button's own docstring excludes. 3x tsc, eslint, knip, prettier and 689 vitest green; visual UAT on Users, Access requests and Playlists at 1280x900, no console errors.
99 lines
3.5 KiB
TypeScript
99 lines
3.5 KiB
TypeScript
import { useMemo } from "react";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Trash2 } from "lucide-react";
|
|
import { api } from "../lib/api";
|
|
import { notify } from "../lib/notifications";
|
|
import { LS } from "../lib/storage";
|
|
import { Button } from "./ui/Button";
|
|
import { Section } from "./ui/form";
|
|
import { useConfirm } from "./ConfirmProvider";
|
|
import DataTable from "./ui/DataTable";
|
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
|
import { auditColumns } from "./auditColumns";
|
|
|
|
// Admin-only audit trail: an append-only who/what/when log of admin actions + scheduler run
|
|
// summaries (never per-row). Read-only apart from Clear. Client-side sort/filter/paginate via
|
|
// DataTable; the API returns the most recent window (capped) — the log stays small by design.
|
|
export default function AuditLog() {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const confirm = useConfirm();
|
|
const q = useQuery({ queryKey: qk.adminAudit(), queryFn: () => api.adminAudit() });
|
|
const data = q.data;
|
|
const rows = useMemo(() => data?.items ?? [], [data]);
|
|
|
|
const actionOptions = useMemo(() => {
|
|
const present = Array.from(new Set(rows.map((r) => r.action))).sort();
|
|
return present.map((a) => ({ value: a, label: t(`auditActions.${a}`, a) }));
|
|
}, [rows, t]);
|
|
const columns = useMemo(() => auditColumns(t, actionOptions), [t, actionOptions]);
|
|
|
|
const clear = useMutation({
|
|
mutationFn: () => api.clearAudit(),
|
|
onSuccess: (r) => {
|
|
qc.invalidateQueries({ queryKey: qk.adminAudit() });
|
|
notify({ level: "success", message: t("audit.cleared", { count: r.cleared }) });
|
|
},
|
|
onError: () => notify({ level: "error", message: t("audit.clearFailed") }),
|
|
});
|
|
const onClear = async () => {
|
|
if (
|
|
await confirm({
|
|
title: t("audit.clearTitle"),
|
|
message: t("audit.clearConfirm"),
|
|
confirmLabel: t("audit.clear"),
|
|
danger: true,
|
|
})
|
|
)
|
|
clear.mutate();
|
|
};
|
|
|
|
const truncated = !!data && data.total > data.returned;
|
|
|
|
return (
|
|
<div className="p-4 max-w-5xl w-full mx-auto">
|
|
<Section card title={t("audit.title")}>
|
|
<p className="text-xs text-muted leading-relaxed mb-3">{t("audit.intro")}</p>
|
|
{q.isError && !q.data ? (
|
|
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
|
{t("common.loadError")}
|
|
</StateMessage>
|
|
) : q.isLoading ? (
|
|
<LoadingState />
|
|
) : rows.length === 0 ? (
|
|
<p className="text-sm text-muted">{t("audit.empty")}</p>
|
|
) : (
|
|
<>
|
|
<DataTable
|
|
rows={rows}
|
|
columns={columns}
|
|
rowKey={(r) => String(r.id)}
|
|
persistKey={LS.auditTable}
|
|
controlsPosition="top"
|
|
controlsLeading={
|
|
<Button
|
|
variant="outline-danger"
|
|
size="xs"
|
|
onClick={onClear}
|
|
disabled={clear.isPending}
|
|
icon={<Trash2 className="w-3.5 h-3.5" />}
|
|
>
|
|
{t("audit.clear")}
|
|
</Button>
|
|
}
|
|
emptyText={t("audit.empty")}
|
|
/>
|
|
{truncated && (
|
|
<p className="text-2xs text-muted mt-2">
|
|
{t("audit.truncated", { returned: data!.returned, total: data!.total })}
|
|
</p>
|
|
)}
|
|
</>
|
|
)}
|
|
</Section>
|
|
</div>
|
|
);
|
|
}
|