feat(ui): E31 S4a — rail rows are links, the channel page opens on unwatched, users and audit tidied

- Nav rail page rows are real links (/?page=<id>): a middle-click or
  Ctrl-click opens the page in a new tab; a plain click stays in-app.
- A fresh channel page shows the unwatched videos; a picked watch state
  is a remembered preference (channelShow).
- Users: the role, suspend and delete move into the row's one menu
  (the "…", right-click, Shift+F10); locked ones keep their reason.
- Audit log: back-to-back successful runs of one job fold into one row
  that opens to the earlier ones; a failed run reads in the danger colour.
- DESIGN.md decision log: the E31 picks.
This commit is contained in:
2026-10-01 22:51:38 +02:00
parent ab146211fa
commit 87d71e3762
23 changed files with 542 additions and 98 deletions
+32
View File
@@ -0,0 +1,32 @@
import type { AuditRow } from "./api";
// The audit log's scheduler noise, folded (E31, the owner's pick): the background jobs write a row per
// run that changed something, so an hour of "enrichment done" filled the first screen and pushed the
// admins' own actions down. Back-to-back SUCCESSFUL runs of the same job become one row that names how
// many there were and opens to each; a failed or skipped run is never folded — it stays a row of its
// own, where it is seen. Rows arrive newest first; the group stands at its newest run's place.
/** An audit row as the table lists it: a lone row, or the newest of a run of folded ones (`runs`, the
* whole run newest first, the row itself included). */
export type AuditListRow = AuditRow & { runs?: AuditRow[] };
/** The job a row is a successful run of, or null for any other row. */
function okRunOf(row: AuditRow): string | null {
if (row.action !== "job_run" || row.target_type !== "job" || !row.target_id) return null;
return row.after?.status === "ok" ? row.target_id : null;
}
export function foldJobRuns(rows: readonly AuditRow[]): AuditListRow[] {
const out: AuditListRow[] = [];
let head: AuditListRow | null = null;
for (const row of rows) {
const job = okRunOf(row);
if (job !== null && head !== null && okRunOf(head) === job) {
head.runs = [...(head.runs ?? [head]), row];
continue;
}
head = { ...row };
out.push(head);
}
return out;
}