refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/

A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform
layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each
page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in
features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/
(ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows).

Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the
contract tests that scan sources now scan the whole app source, not only components/ — each glob
was counted before and after so none went green over fewer files. The locale-format rule matched
`/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton
check; it now covers every folder that holds components, with a test per folder.
This commit is contained in:
2026-10-22 23:12:45 +02:00
parent fdf8076013
commit 63a0fecc8b
356 changed files with 2433 additions and 2332 deletions
@@ -0,0 +1,229 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Trash2 } from "lucide-react";
import { api, type AuditQuery, type AuditRow } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors";
import { sinceOf } from "../../lib/activityUrlState";
import { auditActionLabel, auditText } from "../../lib/auditText";
import { formatDateTimeSec } from "../../lib/format";
import { notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
import { useHistoryList } from "../../lib/useHistoryList";
import type { Shared } from "./ActivityRuns";
import { useConfirm } from "../../components/ui/confirmContext";
import { StateMessage } from "../../components/ui/QueryState";
import HistoryList from "./HistoryList";
import HistoryRow from "../../features/diagnostics/HistoryRow";
import { RelativeTime } from "../../components/ui/RelativeTime";
import { Button } from "../../components/ui/Button";
import FilterSelect from "../../components/ui/FilterSelect";
// Activity → Admin actions: the append-only who/what/when trail, newest first, filtered on the
// server. A row opens to its before/after and to the lines its request logged. Clear stays here,
// leaving one row that says who cleared it.
const GRID =
"grid-cols-[minmax(0,1fr)_auto] sm:grid-cols-[minmax(0,12rem)_minmax(0,12rem)_8rem_minmax(10rem,1fr)]";
export default function ActivityAudit({
shared,
action,
onAction,
onRequest,
}: {
shared: Shared;
action: string | null;
onAction: (action: string | null) => void;
/** Open the server log at this request's lines. */
onRequest: (requestId: string) => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient();
const confirm = useConfirm();
const [open, setOpen] = useState<number | null>(null);
const query: AuditQuery = {
user: shared.user ?? undefined,
q: shared.q.trim() || undefined,
action: action ?? undefined,
};
const key = { ...query, window: shared.window };
const state = useHistoryList(qk.adminAudit(key), (before) =>
api.adminAudit({ ...query, since: sinceOf(shared.window) }, before),
);
const facets = useQuery({
queryKey: qk.adminAuditFacets(key),
queryFn: () => api.adminAuditFacets({ ...query, since: sinceOf(shared.window) }),
placeholderData: keepPreviousData,
});
const actions = [
...(facets.data?.actions ?? []),
...(action && !facets.data?.actions.some((f) => f.value === action)
? [{ value: action, count: 0 }]
: []),
];
const clear = useMutation({
mutationFn: () => api.clearAudit(),
onSuccess: (r) => {
void qc.invalidateQueries({ queryKey: qk.adminAudit() });
void qc.invalidateQueries({ queryKey: qk.adminAuditFacets() });
notify({ level: "success", message: t("audit.cleared", { count: r.cleared }) });
},
onError: (e) => notifyActionError(e, 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 actor = (row: AuditRow) =>
row.actor_id === null ? null : (state.accounts[String(row.actor_id)] ?? `#${row.actor_id}`);
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<FilterSelect
label={t("activity.audit.action")}
options={[
{ value: "", label: t("activity.any") },
...actions.map((f) => ({
value: f.value,
label: auditActionLabel(t, f.value),
count: f.count,
})),
]}
selected={[action ?? ""]}
onChange={([value]) => onAction(value || null)}
testId="audit-action"
/>
<Button
variant="outline-danger"
size="xs"
className="ml-auto"
onClick={() => void onClear()}
disabled={clear.isPending}
icon={<Trash2 className="h-3.5 w-3.5" />}
>
{t("audit.clear")}
</Button>
</div>
{/* The action menu above is built from these counts: without them it offers only "Any". */}
{facets.isError && (
<StateMessage tone="error" compact onRetry={() => void facets.refetch()}>
{t("activity.filtersFailed")}
</StateMessage>
)}
<HistoryList
state={state}
empty={t("audit.empty")}
count={t("activity.audit.count", { count: state.rows.length })}
>
{state.rows.map((row, index) => {
const { head, sub } = auditText(t, row, lang);
const who = actor(row);
return (
<HistoryRow
key={row.id}
navIndex={index}
navText={`${auditActionLabel(t, row.action)} ${head ?? ""}`}
grid={GRID}
open={open === row.id}
onToggle={() => setOpen(open === row.id ? null : row.id)}
title={formatDateTimeSec(row.created_at, lang)}
cells={
<>
<span className="truncate font-medium">{auditActionLabel(t, row.action)}</span>
<RelativeTime
at={row.created_at}
seconds
className="tabular-nums text-muted sm:hidden"
/>
<span
className={`hidden truncate sm:block ${who ? "text-muted" : "italic text-muted"}`}
>
{who ?? t("audit.system")}
</span>
<RelativeTime
at={row.created_at}
seconds
className="hidden tabular-nums text-muted sm:block"
/>
<span className="col-span-2 truncate text-muted sm:col-span-1">
{[head, sub].filter(Boolean).join(" · ")}
</span>
</>
}
>
<AuditDetail row={row} who={who} onRequest={onRequest} />
</HistoryRow>
);
})}
</HistoryList>
</div>
);
}
function AuditDetail({
row,
who,
onRequest,
}: {
row: AuditRow;
who: string | null;
onRequest: (requestId: string) => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const { head, sub } = auditText(t, row, lang);
return (
<div className="flex flex-col gap-2">
<dl className="grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-0.5">
<dt>{t("audit.cols.actor")}</dt>
<dd>{who ?? t("audit.system")}</dd>
<dt>{t("audit.cols.when")}</dt>
<dd className="tabular-nums">{formatDateTimeSec(row.created_at, lang)}</dd>
{(head || sub) && (
<>
<dt>{t("audit.cols.details")}</dt>
<dd className="wrap-break-word">{[head, sub].filter(Boolean).join(" · ")}</dd>
</>
)}
{row.target_id && (
<>
<dt>{t("activity.audit.target")}</dt>
<dd className="font-mono">{row.target_id}</dd>
</>
)}
</dl>
{(row.before || row.after) && (
<pre className="overflow-x-auto whitespace-pre font-mono">
{JSON.stringify({ before: row.before, after: row.after }, null, 2)}
</pre>
)}
{/* A request's lines are kept days, its row months: a link to none would open an empty list. */}
{row.request_id &&
(row.log_lines > 0 ? (
<div>
<Button
variant="link"
className="underline decoration-dotted underline-offset-2 hover:decoration-solid"
onClick={() => onRequest(row.request_id!)}
>
{t("activity.audit.requestLines")}
</Button>
</div>
) : (
<p>{t("activity.audit.noRequestLines")}</p>
))}
</div>
);
}