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:
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user