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:
@@ -690,3 +690,13 @@ of a full-width panel and nothing on the right.
|
|||||||
| 2026-09-27 | Missing picture, bare link | A glyph of the kind for no picture; a download with no name is titled by its short link, once | An empty box; the URL as title, channel and link | The owner's picks 4A and 3A: an empty box read as loading, the URL showed three times |
|
| 2026-09-27 | Missing picture, bare link | A glyph of the kind for no picture; a download with no name is titled by its short link, once | An empty box; the URL as title, channel and link | The owner's picks 4A and 3A: an empty box read as loading, the URL showed three times |
|
||||||
| 2026-09-27 | Copy on a selection | Right-click on selected words keeps the browser's menu; no app Copy row | An app menu with Copy first | A real browser's menu has copy, search and translate; only the desktop pane's is thin |
|
| 2026-09-27 | Copy on a selection | Right-click on selected words keeps the browser's menu; no app Copy row | An app menu with Copy first | A real browser's menu has copy, search and translate; only the desktop pane's is thin |
|
||||||
| 2026-10-01 | Zoom target 125–133% | Screens are held at 125% and 133% browser zoom (1536 × 729, 1444 × 685 on a 1080p window) and the largest text size | 200% browser zoom | The owner: 200% was a guess; 125–133% is what they read Siftlode and most sites at, so 200% would be an upper bound nobody uses |
|
| 2026-10-01 | Zoom target 125–133% | Screens are held at 125% and 133% browser zoom (1536 × 729, 1444 × 685 on a 1080p window) and the largest text size | 200% browser zoom | The owner: 200% was a guess; 125–133% is what they read Siftlode and most sites at, so 200% would be an upper bound nobody uses |
|
||||||
|
| 2026-10-01 | E31 Nav rail at short heights | One "Admin" rail item opening a panel of the admin pages | A denser rail when short; a visible "+4" hint | The rail fits at 133–150% without a hidden scroll; the admin pages are rare and admin-only |
|
||||||
|
| 2026-10-01 | E31 Configuration sections | A vertical, grouped section list, as Settings has | Group tabs + sub-tabs; one long page with a contents list | Sixteen tabs wrapped to three rows at 133%; one pattern for both settings pages |
|
||||||
|
| 2026-10-01 | E31 Statistics overview | Number tiles, and the per-operation counts as horizontal bars | Three narrow cards; a 30-day chart | The label sits by its value; what spends the quota is seen at a glance |
|
||||||
|
| 2026-10-01 | E31 Channel page header | The full banner, folding to one row once the grid scrolls | Always compact; no banner | The header is fixed and took 40% of a 685px window; the channel keeps its face at first sight |
|
||||||
|
| 2026-10-01 | E31 Channel page watch state | A fresh channel page shows the unwatched videos; a picked state is remembered | Show all, forgotten on reload | The owner: open a channel to see what is new; a choice made there should stick |
|
||||||
|
| 2026-10-01 | E31 Audit log runs | Back-to-back successful runs of one job fold into one row that opens; a failure is never folded and reads in the danger colour | A filter defaulting to admin actions; both | Nothing hidden, the noise collapsed, the failure seen |
|
||||||
|
| 2026-10-01 | E31 Users row | Role and state as badges, the actions in the row's one menu | An inline role toggle; icon-only buttons | Action placement: the row shows state, rare and destructive actions live in the menu |
|
||||||
|
| 2026-10-01 | E31 YouTube player | Video beside an info panel at 1280px and wider (the video as large as the height allows); stacked below | Today's stack with a one-line bar; full-window like Plex | The owner's eyesight: about 1460px of video on 1080p instead of 1008, the actions still visible |
|
||||||
|
| 2026-10-01 | E31 Plex info and show pages | A second column for the facts, keywords and crew, as the person page; a collection's hero sized to its content | Full-width text; a larger poster and fanart | The right ~40% was empty; one composition for every media page |
|
||||||
|
| 2026-10-01 | E31 Nav rail rows are links | Each page row is a real link: a middle-click or Ctrl-click opens the page in a new tab | A button | The owner: open a page in a tab of its own, as on any site |
|
||||||
|
|||||||
@@ -116,13 +116,63 @@ describe("a user row", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** The open menu's rows (a closed popover's are hidden from the accessibility tree in jsdom). */
|
||||||
|
const menuRows = () => screen.queryAllByRole("menuitem", { hidden: true });
|
||||||
|
|
||||||
|
/** The row's actions live in its "…" menu (E31): open it, then pick the row. */
|
||||||
|
async function chooseInRowMenu(label: string) {
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "users.rowMenu" }));
|
||||||
|
const item = await waitFor(() => {
|
||||||
|
const found = menuRows().find((m) => m.textContent?.startsWith(label));
|
||||||
|
if (!found) throw new Error(`no menu row "${label}"`);
|
||||||
|
return found;
|
||||||
|
});
|
||||||
|
fireEvent.click(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// E31 (DESIGN § Action placement): the role, the suspension and the delete moved off the row into
|
||||||
|
// ONE menu — the row's "…", which a right-click on the row opens too. A locked one stays listed with
|
||||||
|
// its reason.
|
||||||
|
describe("a user row's menu", () => {
|
||||||
|
it("holds the role, the suspension and the delete; the row shows no action button", async () => {
|
||||||
|
mount();
|
||||||
|
const open = await screen.findByTitle("users.account.open");
|
||||||
|
const card = open.parentElement!;
|
||||||
|
const buttons = [...card.querySelectorAll("button")].filter((b) => b !== open);
|
||||||
|
expect(buttons.map((b) => b.getAttribute("aria-label"))).toEqual(["users.rowMenu"]);
|
||||||
|
fireEvent.contextMenu(card, { clientX: 5, clientY: 5 });
|
||||||
|
await waitFor(() => expect(menuRows().length).toBe(3));
|
||||||
|
expect(menuRows().map((i) => i.textContent)).toEqual([
|
||||||
|
"users.roles.makeAdmin",
|
||||||
|
"users.suspend.action",
|
||||||
|
"users.delete.action",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists your own row's actions as locked, with the reason", async () => {
|
||||||
|
vi.spyOn(api, "adminUsers").mockResolvedValue([{ ...row, id: 1, email: "admin@x" }] as never);
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<AdminUsers />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "users.rowMenu" }));
|
||||||
|
await waitFor(() => expect(menuRows().length).toBe(3));
|
||||||
|
const [role, suspend, del] = menuRows();
|
||||||
|
expect(role!.hasAttribute("disabled")).toBe(true);
|
||||||
|
expect(role!.textContent).toBe("users.roles.makeAdminusers.roles.selfLocked");
|
||||||
|
expect(suspend!.textContent).toBe("users.suspend.actionusers.suspend.selfLocked");
|
||||||
|
expect(del!.textContent).toBe("users.delete.actionusers.delete.locked");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("a user action that fails", () => {
|
describe("a user action that fails", () => {
|
||||||
it("says a user who is gone, and refetches the list", async () => {
|
it("says a user who is gone, and refetches the list", async () => {
|
||||||
const users = mount();
|
const users = mount();
|
||||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
answer(404, { code: "not_found", reason: "user" }),
|
answer(404, { code: "not_found", reason: "user" }),
|
||||||
);
|
);
|
||||||
fireEvent.click(await screen.findByText("users.roles.makeAdmin"));
|
await chooseInRowMenu("users.roles.makeAdmin");
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(notify).toHaveBeenCalledWith({
|
expect(notify).toHaveBeenCalledWith({
|
||||||
level: "error",
|
level: "error",
|
||||||
@@ -136,7 +186,7 @@ describe("a user action that fails", () => {
|
|||||||
it("is not toasted on top of the error dialog the request already opened", async () => {
|
it("is not toasted on top of the error dialog the request already opened", async () => {
|
||||||
mount();
|
mount();
|
||||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(answer(409, "Cannot demote the last admin"));
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(answer(409, "Cannot demote the last admin"));
|
||||||
fireEvent.click(await screen.findByText("users.roles.makeAdmin"));
|
await chooseInRowMenu("users.roles.makeAdmin");
|
||||||
const { reportError } = await import("../lib/errorDialog");
|
const { reportError } = await import("../lib/errorDialog");
|
||||||
await waitFor(() => expect(reportError).toHaveBeenCalled());
|
await waitFor(() => expect(reportError).toHaveBeenCalled());
|
||||||
expect(notify.mock.calls.filter(([n]) => n.level === "error")).toEqual([]);
|
expect(notify.mock.calls.filter(([n]) => n.level === "error")).toEqual([]);
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ import { PageToolbar } from "./ui/PageShell";
|
|||||||
import { useMe } from "../lib/useMe";
|
import { useMe } from "../lib/useMe";
|
||||||
import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors";
|
import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors";
|
||||||
import { Pressable } from "./ui/Pressable";
|
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 { Input } from "./ui/Input";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "./ui/surfaces";
|
||||||
|
|
||||||
@@ -206,10 +210,58 @@ function UsersRoles({ me }: { me: Me }) {
|
|||||||
if (ok) del.mutate({ id: u.id, email: u.email });
|
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 ?? [];
|
const rows = q.data ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section card title={t("users.roles.title")}>
|
<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)} />}
|
{panelFor && <AccountPanel user={panelFor} onClose={() => setPanelFor(null)} />}
|
||||||
<p className="text-xs text-muted leading-relaxed mb-3">{t("users.roles.intro")}</p>
|
<p className="text-xs text-muted leading-relaxed mb-3">{t("users.roles.intro")}</p>
|
||||||
{q.isError && !q.data ? (
|
{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.
|
// stretched layer. The hover used to be the name button's own and ended at the first badge.
|
||||||
<div
|
<div
|
||||||
key={u.id}
|
key={u.id}
|
||||||
|
{...rowMenu.triggerProps(u)}
|
||||||
className={`${SURFACE.card} ${ROW_LAYER} flex items-center gap-2.5 p-2.5`}
|
className={`${SURFACE.card} ${ROW_LAYER} flex items-center gap-2.5 p-2.5`}
|
||||||
>
|
>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -256,83 +309,9 @@ function UsersRoles({ me }: { me: Me }) {
|
|||||||
{u.is_active && !u.email_verified && !u.has_google && (
|
{u.is_active && !u.email_verified && !u.has_google && (
|
||||||
<Badge tone="warn">{t("users.roles.unverified")}</Badge>
|
<Badge tone="warn">{t("users.roles.unverified")}</Badge>
|
||||||
)}
|
)}
|
||||||
<Tooltip
|
{/* How long ago this account last signed in. At the row's right end on purpose (only the
|
||||||
always={u.is_demo || self}
|
fixed-width "…" after it): the badges before it vary in width row to row, so only
|
||||||
hint={
|
the right edge lines the values up into one column. The exact moment is the hint here and a
|
||||||
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
|
|
||||||
line in the account panel; below `sm` the row has no room, and the panel still says it. */}
|
line in the account panel; below `sm` the row has no room, and the panel still says it. */}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
hint={
|
hint={
|
||||||
@@ -347,6 +326,13 @@ function UsersRoles({ me }: { me: Me }) {
|
|||||||
{u.last_login_at ? relativeTime(u.last_login_at, lang) : "—"}
|
{u.last_login_at ? relativeTime(u.last_login_at, lang) : "—"}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<MoreButton
|
||||||
|
label={t("users.rowMenu")}
|
||||||
|
size="md"
|
||||||
|
expanded={rowMenu.open && rowMenu.target?.id === u.id}
|
||||||
|
menuProps={rowMenu.buttonProps(u)}
|
||||||
|
className="relative"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import type { AuditRow } from "../lib/api";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
|
import { RelativeTime } from "./ui/RelativeTime";
|
||||||
|
|
||||||
|
/** A folded run of one job's successful runs (lib/auditGroups, E31): the row shows the newest, this
|
||||||
|
* opens the earlier ones, each with its moment and its result (`detailOf`, the row's own wording). */
|
||||||
|
export default function AuditEarlierRuns({
|
||||||
|
runs,
|
||||||
|
detailOf,
|
||||||
|
}: {
|
||||||
|
runs: AuditRow[];
|
||||||
|
detailOf: (run: AuditRow) => string | null;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const earlier = runs.slice(1);
|
||||||
|
return (
|
||||||
|
<div className="mt-1">
|
||||||
|
<Button
|
||||||
|
variant="link-muted"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
icon={<ChevronRight className={clsx("transition-transform", open && "rotate-90")} />}
|
||||||
|
>
|
||||||
|
{t("audit.earlierRuns", { count: earlier.length })}
|
||||||
|
</Button>
|
||||||
|
{open && (
|
||||||
|
<ul className="mt-1 space-y-0.5 text-2xs text-muted">
|
||||||
|
{earlier.map((run) => (
|
||||||
|
<li key={run.id} className="flex gap-2">
|
||||||
|
<RelativeTime
|
||||||
|
at={run.created_at}
|
||||||
|
seconds
|
||||||
|
empty="—"
|
||||||
|
className="shrink-0 tabular-nums"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 wrap-break-word">{detailOf(run)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
|
|||||||
import { auditColumns } from "./auditColumns";
|
import { auditColumns } from "./auditColumns";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { compareText } from "../lib/collate";
|
import { compareText } from "../lib/collate";
|
||||||
|
import { foldJobRuns } from "../lib/auditGroups";
|
||||||
|
|
||||||
// Admin-only audit trail: an append-only who/what/when log of admin actions + scheduler run
|
// 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
|
// summaries (never per-row). Read-only apart from Clear. Client-side sort/filter/paginate via
|
||||||
@@ -25,7 +26,8 @@ export default function AuditLog() {
|
|||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const q = useQuery({ queryKey: qk.adminAudit(), queryFn: () => api.adminAudit() });
|
const q = useQuery({ queryKey: qk.adminAudit(), queryFn: () => api.adminAudit() });
|
||||||
const data = q.data;
|
const data = q.data;
|
||||||
const rows = useMemo(() => data?.items ?? [], [data]);
|
// Back-to-back successful runs of one job fold into one row (lib/auditGroups, E31).
|
||||||
|
const rows = useMemo(() => foldJobRuns(data?.items ?? []), [data]);
|
||||||
|
|
||||||
const actionOptions = useMemo(() => {
|
const actionOptions = useMemo(() => {
|
||||||
// By what the reader sees — the named action, in their alphabet — not by the code.
|
// By what the reader sees — the named action, in their alphabet — not by the code.
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ import ChannelAboutContent from "./ChannelAbout";
|
|||||||
import { PageToolbar } from "./ui/PageShell";
|
import { PageToolbar } from "./ui/PageShell";
|
||||||
import { PageToolbarSlot } from "./ui/pageToolbarSlot";
|
import { PageToolbarSlot } from "./ui/pageToolbarSlot";
|
||||||
import { useFeedFilters } from "./feedFiltersContext";
|
import { useFeedFilters } from "./feedFiltersContext";
|
||||||
|
import { SHOW_IDS } from "./feedFiltersInit";
|
||||||
|
import { usePref } from "../lib/usePref";
|
||||||
|
import { LS } from "../lib/storage";
|
||||||
import { useConfirm } from "./confirmContext";
|
import { useConfirm } from "./confirmContext";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
@@ -260,7 +263,13 @@ export default function ChannelPage({
|
|||||||
|
|
||||||
// Channel-scoped feed: the whole catalog filtered to this channel (scope=all + source=all so
|
// Channel-scoped feed: the whole catalog filtered to this channel (scope=all + source=all so
|
||||||
// the explored, per-user-private videos show — the backend gates them to this explorer).
|
// the explored, per-user-private videos show — the backend gates them to this explorer).
|
||||||
const [filters, setFilters] = useState<FeedFilters>(() => ({
|
// The watch state is the one filter a channel page remembers (the owner, E31): a fresh page shows
|
||||||
|
// what is still unwatched, and a change sticks — across channels, reloads and devices.
|
||||||
|
const [show, setShow] = usePref<string>("channelShow", "unwatched", {
|
||||||
|
localKey: LS.channelShow,
|
||||||
|
parse: (v) => (typeof v === "string" && SHOW_IDS.includes(v) ? v : "unwatched"),
|
||||||
|
});
|
||||||
|
const [ownFilters, setOwnFilters] = useState<FeedFilters>(() => ({
|
||||||
tags: [],
|
tags: [],
|
||||||
tagModes: DEFAULT_TAG_MODES,
|
tagModes: DEFAULT_TAG_MODES,
|
||||||
q: "",
|
q: "",
|
||||||
@@ -271,9 +280,14 @@ export default function ChannelPage({
|
|||||||
includeNormal: globalFilters.includeNormal,
|
includeNormal: globalFilters.includeNormal,
|
||||||
includeShorts: globalFilters.includeShorts,
|
includeShorts: globalFilters.includeShorts,
|
||||||
includeLive: globalFilters.includeLive,
|
includeLive: globalFilters.includeLive,
|
||||||
show: "all",
|
show: "unwatched",
|
||||||
channelIds: [channelId],
|
channelIds: [channelId],
|
||||||
}));
|
}));
|
||||||
|
const filters: FeedFilters = { ...ownFilters, show };
|
||||||
|
const setFilters = (next: FeedFilters) => {
|
||||||
|
if (next.show !== show) setShow(next.show);
|
||||||
|
setOwnFilters(next);
|
||||||
|
};
|
||||||
|
|
||||||
const name = ch?.title ?? initialName ?? channelId;
|
const name = ch?.title ?? initialName ?? channelId;
|
||||||
const ytUrl = channelYouTubeUrl(channelId, ch?.handle);
|
const ytUrl = channelYouTubeUrl(channelId, ch?.handle);
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// E31 (the owner): a freshly opened channel page shows what is still unwatched, and a watch state the
|
||||||
|
// viewer picks there sticks — a reload (a fresh mount, seeded from the stored preference) keeps it.
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||||
|
import { render, cleanup, act } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { FeedFilters } from "../lib/api";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
|
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||||
|
}));
|
||||||
|
vi.mock("./navigationContext", () => ({
|
||||||
|
useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }),
|
||||||
|
}));
|
||||||
|
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||||
|
vi.mock("./feedFiltersContext", () => ({
|
||||||
|
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
||||||
|
}));
|
||||||
|
const feed = vi.hoisted(() => ({
|
||||||
|
last: null as null | { filters: FeedFilters; setFilters: (f: FeedFilters) => void },
|
||||||
|
}));
|
||||||
|
vi.mock("./Feed", () => ({
|
||||||
|
default: (p: { filters: FeedFilters; setFilters: (f: FeedFilters) => void }) => {
|
||||||
|
feed.last = p;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("./ChannelAbout", () => ({ default: () => null }));
|
||||||
|
vi.mock("./ui/PageShell", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("./ui/PageShell")>()),
|
||||||
|
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import ChannelPage from "./ChannelPage";
|
||||||
|
import { api, type ChannelDetail, type Me } from "../lib/api";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
class Stub {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
takeRecords() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||||
|
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
feed.last = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const CH = "UCqoAEDirJPjEUFcF2FklnBA";
|
||||||
|
|
||||||
|
function mount(preferences: Record<string, unknown> = {}) {
|
||||||
|
vi.spyOn(api, "channelDetail").mockResolvedValue({
|
||||||
|
id: CH,
|
||||||
|
title: "StarTalk",
|
||||||
|
subscribed: true,
|
||||||
|
stored_videos: 10,
|
||||||
|
} as unknown as ChannelDetail);
|
||||||
|
vi.spyOn(api, "tags").mockResolvedValue([] as never);
|
||||||
|
const save = vi.spyOn(api, "savePrefs").mockResolvedValue({} as never);
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
qc.setQueryData(qk.me(), { id: 1, preferences } as unknown as Me);
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<ChannelPage
|
||||||
|
channelId={CH}
|
||||||
|
initialName="StarTalk"
|
||||||
|
me={{ id: 1, can_write: true, can_read: true } as unknown as Me}
|
||||||
|
view="cards"
|
||||||
|
setView={() => {}}
|
||||||
|
onShowInFeed={() => {}}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
return save;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ChannelPage — the watch state", () => {
|
||||||
|
it("shows only the unwatched videos on a freshly opened page", () => {
|
||||||
|
mount();
|
||||||
|
expect(feed.last?.filters.show).toBe("unwatched");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores a picked watch state", () => {
|
||||||
|
const save = mount();
|
||||||
|
act(() => feed.last!.setFilters({ ...feed.last!.filters, show: "all" }));
|
||||||
|
expect(feed.last?.filters.show).toBe("all");
|
||||||
|
expect(save).toHaveBeenCalledWith({ channelShow: "all" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the stored watch state after a reload", () => {
|
||||||
|
mount({ channelShow: "watched" });
|
||||||
|
expect(feed.last?.filters.show).toBe("watched");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to unwatched for a stored value it does not know", () => {
|
||||||
|
mount({ channelShow: "bogus" });
|
||||||
|
expect(feed.last?.filters.show).toBe("unwatched");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
} from "../lib/feedSort";
|
} from "../lib/feedSort";
|
||||||
import { type SortOption, type SortState } from "../lib/columnSort";
|
import { type SortOption, type SortState } from "../lib/columnSort";
|
||||||
import { usePref } from "../lib/usePref";
|
import { usePref } from "../lib/usePref";
|
||||||
|
import { SHOW_IDS } from "./feedFiltersInit";
|
||||||
import { FEED_VIEWS, type FeedView } from "../lib/feedView";
|
import { FEED_VIEWS, type FeedView } from "../lib/feedView";
|
||||||
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
||||||
import ActiveFilterChips from "./ActiveFilterChips";
|
import ActiveFilterChips from "./ActiveFilterChips";
|
||||||
@@ -64,7 +65,6 @@ const PAGE = 60;
|
|||||||
|
|
||||||
// The "Show" view filter, content-type filter and ordering live in the feed toolbar
|
// The "Show" view filter, content-type filter and ordering live in the feed toolbar
|
||||||
// (above the cards), not the filter sidebar.
|
// (above the cards), not the filter sidebar.
|
||||||
const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "hidden"];
|
|
||||||
const CONTENT = [
|
const CONTENT = [
|
||||||
{ key: "includeNormal", label: "sidebar.content.normal" },
|
{ key: "includeNormal", label: "sidebar.content.normal" },
|
||||||
{ key: "includeShorts", label: "sidebar.content.shorts" },
|
{ key: "includeShorts", label: "sidebar.content.shorts" },
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
RAIL_ROW,
|
RAIL_ROW,
|
||||||
} from "./ui/NavRail";
|
} from "./ui/NavRail";
|
||||||
import { usePrefs } from "./prefsContext";
|
import { usePrefs } from "./prefsContext";
|
||||||
|
import { pageHref } from "../lib/nav";
|
||||||
|
|
||||||
// Primary app navigation: a thin icon-only left rail. The modules used to hide under the avatar
|
// Primary app navigation: a thin icon-only left rail. The modules used to hide under the avatar
|
||||||
// dropdown; they now live here, each revealing its name in a hover/focus flyout label. Account
|
// dropdown; they now live here, each revealing its name in a hover/focus flyout label. Account
|
||||||
@@ -185,6 +186,7 @@ export default function NavSidebar({
|
|||||||
showLabel={railLabels}
|
showLabel={railLabels}
|
||||||
active={page === p}
|
active={page === p}
|
||||||
badge={badge}
|
badge={badge}
|
||||||
|
href={pageHref(p)}
|
||||||
onClick={() => setPage(p)}
|
onClick={() => setPage(p)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -214,6 +216,7 @@ export default function NavSidebar({
|
|||||||
shortLabel={t(moduleRailKey.settings)}
|
shortLabel={t(moduleRailKey.settings)}
|
||||||
showLabel={railLabels}
|
showLabel={railLabels}
|
||||||
active={page === "settings"}
|
active={page === "settings"}
|
||||||
|
href={pageHref("settings")}
|
||||||
onClick={() => setPage("settings")}
|
onClick={() => setPage("settings")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// "log_level = DEBUG", "Background sync paused") reads in the reader's language, rebuilt from the row's
|
// "log_level = DEBUG", "Background sync paused") reads in the reader's language, rebuilt from the row's
|
||||||
// data through the REAL catalog. A row whose summary is data (an e-mail, a URL) keeps it.
|
// data through the REAL catalog. A row whose summary is data (an e-mail, a URL) keeps it.
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { render } from "@testing-library/react";
|
import { fireEvent, render } from "@testing-library/react";
|
||||||
import i18n, { setLanguage } from "../i18n";
|
import i18n, { setLanguage } from "../i18n";
|
||||||
import type { AuditRow } from "../lib/api";
|
import type { AuditRow } from "../lib/api";
|
||||||
import { auditColumns } from "./auditColumns";
|
import { auditColumns } from "./auditColumns";
|
||||||
@@ -152,3 +152,49 @@ describe("an audit row's details", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// E31: a folded run (lib/auditGroups) shows its newest run and opens to the earlier ones; a failed run
|
||||||
|
// is the line to see in a page of routine ones.
|
||||||
|
describe("a folded run of one job", () => {
|
||||||
|
const ok = (id: number, enriched: number) =>
|
||||||
|
row({
|
||||||
|
id,
|
||||||
|
target_type: "job",
|
||||||
|
target_id: "enrich",
|
||||||
|
after: { status: "ok", result: { enriched } },
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names how many earlier runs it holds, and opens to each", () => {
|
||||||
|
setLanguage("hu");
|
||||||
|
const t = i18n.getFixedT("hu");
|
||||||
|
const col = auditColumns(t, "hu", []).find((c) => c.key === "details")!;
|
||||||
|
const runs = [ok(3, 50), ok(2, 40), ok(1, 30)];
|
||||||
|
const { getByRole, container } = render(<>{col.render!({ ...runs[0]!, runs })}</>);
|
||||||
|
const toggle = getByRole("button", { name: "2 korábbi futás" });
|
||||||
|
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
expect(container.querySelector("li")).toBeNull();
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
expect(toggle.getAttribute("aria-expanded")).toBe("true");
|
||||||
|
expect([...container.querySelectorAll("li")].map((li) => li.textContent)).toEqual([
|
||||||
|
"—dúsítva: 40",
|
||||||
|
"—dúsítva: 30",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no toggle for a lone run", () => {
|
||||||
|
const t = i18n.getFixedT("hu");
|
||||||
|
const col = auditColumns(t, "hu", []).find((c) => c.key === "details")!;
|
||||||
|
const { container } = render(<>{col.render!(ok(1, 5))}</>);
|
||||||
|
expect(container.querySelector("button")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("paints a failed run's line in the danger colour", () => {
|
||||||
|
const t = i18n.getFixedT("hu");
|
||||||
|
const col = auditColumns(t, "hu", []).find((c) => c.key === "details")!;
|
||||||
|
const failed = row({ target_type: "job", target_id: "enrich", after: { status: "error" } });
|
||||||
|
const { container } = render(<>{col.render!(failed)}</>);
|
||||||
|
expect(container.querySelector(".wrap-break-word")!.className).toContain("text-danger");
|
||||||
|
const { container: fine } = render(<>{col.render!(ok(2, 5))}</>);
|
||||||
|
expect(fine.querySelector(".wrap-break-word")!.className).not.toContain("text-danger");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
|
import clsx from "clsx";
|
||||||
import type { AuditRow } from "../lib/api";
|
import type { AuditRow } from "../lib/api";
|
||||||
|
import type { AuditListRow } from "../lib/auditGroups";
|
||||||
|
import AuditEarlierRuns from "./AuditEarlierRuns";
|
||||||
import { formatNumber } from "../lib/format";
|
import { formatNumber } from "../lib/format";
|
||||||
import type { JobResult } from "../lib/api";
|
import type { JobResult } from "../lib/api";
|
||||||
import { jobResultText, jobRunText } from "../lib/jobResult";
|
import { jobResultText, jobRunText } from "../lib/jobResult";
|
||||||
@@ -105,7 +108,7 @@ export function auditColumns(
|
|||||||
t: TFunction,
|
t: TFunction,
|
||||||
lang: string,
|
lang: string,
|
||||||
actionOptions: { value: string; label: string }[],
|
actionOptions: { value: string; label: string }[],
|
||||||
): Column<AuditRow>[] {
|
): Column<AuditListRow>[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: "when",
|
key: "when",
|
||||||
@@ -147,13 +150,23 @@ export function auditColumns(
|
|||||||
const local = localDetails(t, r, lang);
|
const local = localDetails(t, r, lang);
|
||||||
const summary = local ? local.head : r.summary;
|
const summary = local ? local.head : r.summary;
|
||||||
const change = local ? local.sub : changeText(r);
|
const change = local ? local.sub : changeText(r);
|
||||||
|
// A failed run is the row to see in a page of routine ones (E31): its line in the danger colour.
|
||||||
|
const failed = r.action === "job_run" && r.after?.status === "error";
|
||||||
return (
|
return (
|
||||||
// wrap-break-word (wrap), NOT truncate: in the auto-layout table a `truncate` (nowrap) cell
|
// wrap-break-word (wrap), NOT truncate: in the auto-layout table a `truncate` (nowrap) cell
|
||||||
// sizes to its longest row, so one long summary/change stretched the whole table past the
|
// sizes to its longest row, so one long summary/change stretched the whole table past the
|
||||||
// card (the row rules bled into the page). Wrapping keeps every row inside the container.
|
// card (the row rules bled into the page). Wrapping keeps every row inside the container.
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
{summary && <div className="wrap-break-word">{summary}</div>}
|
{summary && (
|
||||||
|
<div className={clsx("wrap-break-word", failed && "text-danger")}>{summary}</div>
|
||||||
|
)}
|
||||||
{change && <div className="text-2xs text-muted wrap-break-word">{change}</div>}
|
{change && <div className="text-2xs text-muted wrap-break-word">{change}</div>}
|
||||||
|
{r.runs && r.runs.length > 1 && (
|
||||||
|
<AuditEarlierRuns
|
||||||
|
runs={r.runs}
|
||||||
|
detailOf={(run) => localDetails(t, run, lang)?.sub ?? run.summary}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ export const DEFAULT_FILTERS: FeedFilters = {
|
|||||||
show: "unwatched",
|
show: "unwatched",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** The watch-state choices, in the order the filter offers them. */
|
||||||
|
export const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "hidden"];
|
||||||
|
|
||||||
export const FILTERS_KEY = LS.filters;
|
export const FILTERS_KEY = LS.filters;
|
||||||
|
|
||||||
// This account's own persisted filters (per-account keys — never another account's). No account
|
// This account's own persisted filters (per-account keys — never another account's). No account
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { Bell } from "lucide-react";
|
import { Bell } from "lucide-react";
|
||||||
import { NavRailAction, NavRailItem } from "./NavRail";
|
import { NavRailAction, NavRailItem } from "./NavRail";
|
||||||
|
|
||||||
@@ -24,10 +24,11 @@ describe("NavRailItem", () => {
|
|||||||
showLabel={false}
|
showLabel={false}
|
||||||
active
|
active
|
||||||
badge={3}
|
badge={3}
|
||||||
|
href="/?page=notifications"
|
||||||
onClick={() => {}}
|
onClick={() => {}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
const row = screen.getByRole("button", { name: "Notifications (3)" });
|
const row = screen.getByRole("link", { name: "Notifications (3)" });
|
||||||
expect(row.getAttribute("aria-current")).toBe("page");
|
expect(row.getAttribute("aria-current")).toBe("page");
|
||||||
expect(row.className).toContain("bg-accent/15");
|
expect(row.className).toContain("bg-accent/15");
|
||||||
expect(row.textContent).not.toContain("Alerts");
|
expect(row.textContent).not.toContain("Alerts");
|
||||||
@@ -40,10 +41,11 @@ describe("NavRailItem", () => {
|
|||||||
label="Notifications"
|
label="Notifications"
|
||||||
shortLabel="Alerts"
|
shortLabel="Alerts"
|
||||||
showLabel
|
showLabel
|
||||||
|
href="/?page=notifications"
|
||||||
onClick={() => {}}
|
onClick={() => {}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
const row = screen.getByRole("button", { name: "Alerts" });
|
const row = screen.getByRole("link", { name: "Alerts" });
|
||||||
expect(row.getAttribute("aria-current")).toBeNull();
|
expect(row.getAttribute("aria-current")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,10 +59,51 @@ describe("NavRailItem", () => {
|
|||||||
shortLabel="Alerts"
|
shortLabel="Alerts"
|
||||||
showLabel
|
showLabel
|
||||||
badge={20}
|
badge={20}
|
||||||
|
href="/?page=notifications"
|
||||||
onClick={() => {}}
|
onClick={() => {}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(screen.getByRole("button", { name: "Alerts (20)" })).toBeTruthy();
|
expect(screen.getByRole("link", { name: "Alerts (20)" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// E31 (the owner): a middle-click on a rail row opens the page in a new tab. The row is a real link
|
||||||
|
// to the page, so the browser's own gestures work; only a plain left click navigates in-app.
|
||||||
|
it("is a link to its page, navigating in-app only on a plain left click", () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<NavRailItem
|
||||||
|
icon={Bell}
|
||||||
|
label="Notifications"
|
||||||
|
shortLabel="Alerts"
|
||||||
|
showLabel={false}
|
||||||
|
href="/?page=notifications"
|
||||||
|
onClick={onClick}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const row = screen.getByRole("link", { name: "Notifications" });
|
||||||
|
expect(row.getAttribute("href")).toBe("/?page=notifications");
|
||||||
|
// Whether the row left the click to the browser, read after the app's own handler ran; then the
|
||||||
|
// click is stopped here, as jsdom cannot follow a link.
|
||||||
|
const leftToBrowser: boolean[] = [];
|
||||||
|
const record = (e: Event) => {
|
||||||
|
leftToBrowser.push(!e.defaultPrevented);
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
window.addEventListener("click", record);
|
||||||
|
try {
|
||||||
|
fireEvent.click(row, { button: 0 });
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
|
for (const init of [
|
||||||
|
{ button: 1 },
|
||||||
|
{ button: 0, ctrlKey: true },
|
||||||
|
{ button: 0, metaKey: true },
|
||||||
|
])
|
||||||
|
fireEvent.click(row, init);
|
||||||
|
} finally {
|
||||||
|
window.removeEventListener("click", record);
|
||||||
|
}
|
||||||
|
expect(leftToBrowser).toEqual([false, true, true, true]);
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { LucideIcon } from "lucide-react";
|
|||||||
import { Pressable } from "./Pressable";
|
import { Pressable } from "./Pressable";
|
||||||
import { SURFACE } from "./surfaces";
|
import { SURFACE } from "./surfaces";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
|
import { isPlainLeftClick } from "../../lib/nav";
|
||||||
|
|
||||||
// B7 (docs/DESIGN.md): the app's primary navigation rail. Slim — 3.75rem wide whether or not the
|
// B7 (docs/DESIGN.md): the app's primary navigation rail. Slim — 3.75rem wide whether or not the
|
||||||
// labels are on (they add height, never width) — with "you are here" as a soft accent tint plus a 3px
|
// labels are on (they add height, never width) — with "you are here" as a soft accent tint plus a 3px
|
||||||
@@ -76,6 +77,7 @@ export function NavRailItem({
|
|||||||
showLabel,
|
showLabel,
|
||||||
active = false,
|
active = false,
|
||||||
badge,
|
badge,
|
||||||
|
href,
|
||||||
onClick,
|
onClick,
|
||||||
testId,
|
testId,
|
||||||
}: {
|
}: {
|
||||||
@@ -88,15 +90,24 @@ export function NavRailItem({
|
|||||||
active?: boolean;
|
active?: boolean;
|
||||||
/** An unread / in-progress count on the icon's corner (0 hides it). */
|
/** An unread / in-progress count on the icon's corner (0 hides it). */
|
||||||
badge?: number;
|
badge?: number;
|
||||||
|
/** The page's own address. A page row is a real link (the owner, E31): a middle-click, a
|
||||||
|
* Ctrl/Cmd-click or the browser's "Open in new tab" opens the page in a tab of its own. */
|
||||||
|
href: string;
|
||||||
|
/** The in-app navigation, run only for a plain left click. */
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
testId?: string;
|
testId?: string;
|
||||||
}) {
|
}) {
|
||||||
const shown = !!badge && badge > 0;
|
const shown = !!badge && badge > 0;
|
||||||
return (
|
return (
|
||||||
<Tooltip hint={label} side="right" always openDelay={350}>
|
<Tooltip hint={label} side="right" always openDelay={350}>
|
||||||
<Pressable
|
<a
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
onClick={onClick}
|
href={href}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (!isPlainLeftClick(e)) return; // the browser opens its tab or window
|
||||||
|
e.preventDefault();
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
// With the label visible the row's name starts with that text (WCAG 2.5.3: the spoken name
|
// With the label visible the row's name starts with that text (WCAG 2.5.3: the spoken name
|
||||||
// contains the visible one); the full name stays in the tooltip. Icon-only, the full name is the
|
// contains the visible one); the full name stays in the tooltip. Icon-only, the full name is the
|
||||||
// name. The exact count rides in the name either way, so "Messages (3)" is heard and not only
|
// name. The exact count rides in the name either way, so "Messages (3)" is heard and not only
|
||||||
@@ -131,7 +142,7 @@ export function NavRailItem({
|
|||||||
{shortLabel}
|
{shortLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</a>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const PLAIN: Record<string, [number, string]> = {
|
|||||||
"ui/ActionMenu.tsx": [1, "a menu row"],
|
"ui/ActionMenu.tsx": [1, "a menu row"],
|
||||||
"ui/BackToTop.tsx": [1, "a control"],
|
"ui/BackToTop.tsx": [1, "a control"],
|
||||||
"ui/CommandPalette.tsx": [1, "a command row"],
|
"ui/CommandPalette.tsx": [1, "a command row"],
|
||||||
"ui/NavRail.tsx": [2, "nav items"],
|
"ui/NavRail.tsx": [1, "the action rows (About, Sign out); a page row is a link (E31)"],
|
||||||
"ui/PanelGroup.tsx": [2, "a group's collapse toggle and its label"],
|
"ui/PanelGroup.tsx": [2, "a group's collapse toggle and its label"],
|
||||||
"ui/SidePanel.tsx": [2, "panel controls"],
|
"ui/SidePanel.tsx": [2, "panel controls"],
|
||||||
"ui/tableHeader.tsx": [1, "a column's sort header"],
|
"ui/tableHeader.tsx": [1, "a column's sort header"],
|
||||||
|
|||||||
@@ -19,5 +19,7 @@
|
|||||||
"actor": "Who",
|
"actor": "Who",
|
||||||
"action": "Action",
|
"action": "Action",
|
||||||
"details": "Details"
|
"details": "Details"
|
||||||
}
|
},
|
||||||
|
"earlierRuns_one": "{{count}} earlier run",
|
||||||
|
"earlierRuns_other": "{{count}} earlier runs"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,5 +101,6 @@
|
|||||||
"setPassword": "Create a set-password link",
|
"setPassword": "Create a set-password link",
|
||||||
"setPasswordAgain": "Create another link",
|
"setPasswordAgain": "Create another link",
|
||||||
"linkFailed": "Couldn't create the link. Please try again."
|
"linkFailed": "Couldn't create the link. Please try again."
|
||||||
}
|
},
|
||||||
|
"rowMenu": "User actions"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,5 +19,7 @@
|
|||||||
"actor": "Ki",
|
"actor": "Ki",
|
||||||
"action": "Művelet",
|
"action": "Művelet",
|
||||||
"details": "Részletek"
|
"details": "Részletek"
|
||||||
}
|
},
|
||||||
|
"earlierRuns_one": "{{count}} korábbi futás",
|
||||||
|
"earlierRuns_other": "{{count}} korábbi futás"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,5 +101,6 @@
|
|||||||
"setPassword": "Jelszóbeállító link készítése",
|
"setPassword": "Jelszóbeállító link készítése",
|
||||||
"setPasswordAgain": "Új link készítése",
|
"setPasswordAgain": "Új link készítése",
|
||||||
"linkFailed": "Nem sikerült létrehozni a linket. Próbáld újra."
|
"linkFailed": "Nem sikerült létrehozni a linket. Próbáld újra."
|
||||||
}
|
},
|
||||||
|
"rowMenu": "Felhasználó műveletei"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// E31: the audit log's first screen was one job's "done" ten times over. Back-to-back successful runs
|
||||||
|
// of one job fold into one row; a failure, a skip, another job or an admin's own action breaks the run.
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { AuditRow } from "./api";
|
||||||
|
import { foldJobRuns } from "./auditGroups";
|
||||||
|
|
||||||
|
let nextId = 1;
|
||||||
|
const run = (job: string, status = "ok"): AuditRow => ({
|
||||||
|
id: nextId++,
|
||||||
|
created_at: null,
|
||||||
|
actor: null,
|
||||||
|
action: "job_run",
|
||||||
|
target_type: "job",
|
||||||
|
target_id: job,
|
||||||
|
summary: null,
|
||||||
|
before: null,
|
||||||
|
after: { status },
|
||||||
|
});
|
||||||
|
const admin = (): AuditRow => ({ ...run("x"), action: "config_set", target_type: "config" });
|
||||||
|
|
||||||
|
const shape = (rows: ReturnType<typeof foldJobRuns>) =>
|
||||||
|
rows.map((r) => `${r.target_id}:${String(r.after?.status)}×${r.runs?.length ?? 1}`);
|
||||||
|
|
||||||
|
describe("folding the job runs", () => {
|
||||||
|
it("folds back-to-back successful runs of one job, newest first", () => {
|
||||||
|
const rows = [run("enrich"), run("enrich"), run("enrich"), run("people")];
|
||||||
|
const out = foldJobRuns(rows);
|
||||||
|
expect(shape(out)).toEqual(["enrich:ok×3", "people:ok×1"]);
|
||||||
|
expect(out[0]!.id).toBe(rows[0]!.id);
|
||||||
|
expect(out[0]!.runs!.map((r) => r.id)).toEqual(rows.slice(0, 3).map((r) => r.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never folds a failed or skipped run, and lets it break the run", () => {
|
||||||
|
const rows = [run("rss"), run("rss", "error"), run("rss", "error"), run("rss"), run("rss")];
|
||||||
|
expect(shape(foldJobRuns(rows))).toEqual([
|
||||||
|
"rss:ok×1",
|
||||||
|
"rss:error×1",
|
||||||
|
"rss:error×1",
|
||||||
|
"rss:ok×2",
|
||||||
|
]);
|
||||||
|
expect(shape(foldJobRuns([run("rss", "skipped"), run("rss", "skipped")]))).toEqual([
|
||||||
|
"rss:skipped×1",
|
||||||
|
"rss:skipped×1",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an admin's own action between two runs, and the runs apart", () => {
|
||||||
|
expect(shape(foldJobRuns([run("enrich"), admin(), run("enrich")]))).toEqual([
|
||||||
|
"enrich:ok×1",
|
||||||
|
"x:ok×1",
|
||||||
|
"enrich:ok×1",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the rows it was given as they were", () => {
|
||||||
|
const rows = [run("enrich"), run("enrich")];
|
||||||
|
foldJobRuns(rows);
|
||||||
|
expect("runs" in rows[0]!).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -17,6 +17,13 @@ export function navigateTo(p: Page): void {
|
|||||||
_navigate?.(p);
|
_navigate?.(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A page's own address, for a link to it (the nav rail's rows). Only the page: the rest of a page's
|
||||||
|
* state — the feed's filters, Plex's library — a fresh tab restores from the account's own storage,
|
||||||
|
* as a bookmark would; the CURRENT page's params would be the wrong page's state. */
|
||||||
|
export function pageHref(p: Page): string {
|
||||||
|
return `/?page=${p}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Should this click be handled IN-APP, or left to the browser?
|
/** Should this click be handled IN-APP, or left to the browser?
|
||||||
*
|
*
|
||||||
* An anchor that ALSO navigates in-app has to keep the browser's own gestures working — Ctrl/Cmd-click
|
* An anchor that ALSO navigates in-app has to keep the browser's own gestures working — Ctrl/Cmd-click
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export const LS = {
|
|||||||
channelLayout: "siftlode.channelLayout",
|
channelLayout: "siftlode.channelLayout",
|
||||||
// The manager's sort, lifted out of the table so the card layout shares it (per-account, per-tab).
|
// The manager's sort, lifted out of the table so the card layout shares it (per-account, per-tab).
|
||||||
channelSort: "siftlode.channelSort",
|
channelSort: "siftlode.channelSort",
|
||||||
|
channelShow: "siftlode.channelShow",
|
||||||
channelDiscoverySort: "siftlode.channelDiscoverySort",
|
channelDiscoverySort: "siftlode.channelDiscoverySort",
|
||||||
// Cards-per-page for the card layout (the table keeps its own rows-per-page in its persist blob).
|
// Cards-per-page for the card layout (the table keeps its own rows-per-page in its persist blob).
|
||||||
channelCardSize: "siftlode.channelCardSize",
|
channelCardSize: "siftlode.channelCardSize",
|
||||||
|
|||||||
Reference in New Issue
Block a user