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
+63 -77
View File
@@ -46,6 +46,10 @@ import { PageToolbar } from "./ui/PageShell";
import { useMe } from "../lib/useMe";
import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors";
import { Pressable } from "./ui/Pressable";
import { useContextMenu } from "./ui/useContextMenu";
import { ContextMenu } from "./ui/ContextMenu";
import { MoreButton } from "./ui/MoreButton";
import type { ActionMenuItem } from "./ui/ActionMenu";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
@@ -206,10 +210,58 @@ function UsersRoles({ me }: { me: Me }) {
if (ok) del.mutate({ id: u.id, email: u.email });
};
// The row's actions are ONE menu (DESIGN § Action placement, E31): the role, the suspension and
// the delete — rare and two of them destructive — open from the row's "…", a right-click on the row,
// a long-press or Shift+F10. The row keeps what the admin reads: the name, the role and state
// badges, the last sign-in. A locked action (the demo, yourself) stays listed with its reason.
const rowMenu = useContextMenu<AdminUserRow>({ keyOf: (u) => u.id });
const menuItems = (u: AdminUserRow): ActionMenuItem[] => {
const self = u.id === me.id;
const pending = pendingIds.has(u.id);
const locked = (demoNote: string, selfNote: string) =>
u.is_demo ? demoNote : self ? selfNote : undefined;
const roleNote = locked(t("users.roles.demoLocked"), t("users.roles.selfLocked"));
const suspendNote = locked(t("users.suspend.demoLocked"), t("users.suspend.selfLocked"));
const deleteNote = u.is_demo || self ? t("users.delete.locked") : undefined;
return [
{
id: "role",
label: u.role === "admin" ? t("users.roles.makeUser") : t("users.roles.makeAdmin"),
icon: <Shield />,
disabled: pending || !!roleNote,
note: roleNote,
onSelect: () => onToggle(u),
},
{
id: "suspend",
label: u.is_suspended ? t("users.suspend.undoAction") : t("users.suspend.action"),
icon: u.is_suspended ? <RotateCcw /> : <Ban />,
disabled: pending || !!suspendNote,
note: suspendNote,
onSelect: () => onSuspend(u),
},
{
id: "delete",
label: t("users.delete.action"),
icon: <Trash2 />,
danger: true,
separatorBefore: true,
disabled: pending || !!deleteNote,
note: deleteNote,
onSelect: () => onDelete(u),
},
];
};
const rows = q.data ?? [];
return (
<Section card title={t("users.roles.title")}>
<ContextMenu
menu={rowMenu}
ariaLabel={t("users.rowMenu")}
items={rowMenu.target ? menuItems(rowMenu.target) : []}
/>
{panelFor && <AccountPanel user={panelFor} onClose={() => setPanelFor(null)} />}
<p className="text-xs text-muted leading-relaxed mb-3">{t("users.roles.intro")}</p>
{q.isError && !q.data ? (
@@ -232,6 +284,7 @@ function UsersRoles({ me }: { me: Me }) {
// stretched layer. The hover used to be the name button's own and ended at the first badge.
<div
key={u.id}
{...rowMenu.triggerProps(u)}
className={`${SURFACE.card} ${ROW_LAYER} flex items-center gap-2.5 p-2.5`}
>
<Pressable
@@ -256,83 +309,9 @@ function UsersRoles({ me }: { me: Me }) {
{u.is_active && !u.email_verified && !u.has_google && (
<Badge tone="warn">{t("users.roles.unverified")}</Badge>
)}
<Tooltip
always={u.is_demo || self}
hint={
u.is_demo
? t("users.roles.demoLocked")
: self
? t("users.roles.selfLocked")
: u.role === "admin"
? t("users.roles.makeUser")
: t("users.roles.makeAdmin")
}
>
<Button
variant="glass"
size="xs"
className="relative shrink-0"
onClick={() => onToggle(u)}
// Locked (demo / self) is unavailable WITH a reason, the tooltip's: aria-disabled keeps it
// focusable so a keyboard user reaches the reason. A pending write is transient.
aria-disabled={u.is_demo || self}
disabled={pendingIds.has(u.id)}
icon={<Shield className="w-3.5 h-3.5" />}
>
{u.role === "admin" ? t("users.roles.makeUser") : t("users.roles.makeAdmin")}
</Button>
</Tooltip>
<Tooltip
always={u.is_demo || self}
hint={
u.is_demo
? t("users.suspend.demoLocked")
: self
? t("users.suspend.selfLocked")
: u.is_suspended
? t("users.suspend.undoAction")
: t("users.suspend.action")
}
>
<IconButton
noTitle
label={
u.is_suspended ? t("users.suspend.undoAction") : t("users.suspend.action")
}
size="sm"
variant={u.is_suspended ? "outline-warn" : "outline"}
className="relative"
onClick={() => onSuspend(u)}
aria-disabled={u.is_demo || self}
disabled={pendingIds.has(u.id)}
>
{u.is_suspended ? (
<RotateCcw className="w-4 h-4" />
) : (
<Ban className="w-4 h-4" />
)}
</IconButton>
</Tooltip>
<Tooltip
always={u.is_demo || self}
hint={u.is_demo || self ? t("users.delete.locked") : t("users.delete.action")}
>
<IconButton
noTitle
label={t("users.delete.action")}
size="sm"
variant="outline-danger"
className="relative"
onClick={() => onDelete(u)}
aria-disabled={u.is_demo || self}
disabled={pendingIds.has(u.id)}
>
<Trash2 className="w-4 h-4" />
</IconButton>
</Tooltip>
{/* How long ago this account last signed in. LAST in the row on purpose: the badges and
the role button before it vary in width row to row, so only the row's own right
edge lines the values up into one column. The exact moment is the hint here and a
{/* How long ago this account last signed in. At the row's right end on purpose (only the
fixed-width "…" after it): the badges before it vary in width row to row, so only
the right edge lines the values up into one column. The exact moment is the hint here and a
line in the account panel; below `sm` the row has no room, and the panel still says it. */}
<Tooltip
hint={
@@ -347,6 +326,13 @@ function UsersRoles({ me }: { me: Me }) {
{u.last_login_at ? relativeTime(u.last_login_at, lang) : "—"}
</span>
</Tooltip>
<MoreButton
label={t("users.rowMenu")}
size="md"
expanded={rowMenu.open && rowMenu.target?.id === u.id}
menuProps={rowMenu.buttonProps(u)}
className="relative"
/>
</div>
);
})}