From 87d71e3762de4a8b5d5b6e7ab1095c7f0f0cff27 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 1 Oct 2026 22:51:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20E31=20S4a=20=E2=80=94=20rail=20rows?= =?UTF-8?q?=20are=20links,=20the=20channel=20page=20opens=20on=20unwatched?= =?UTF-8?q?,=20users=20and=20audit=20tidied?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nav rail page rows are real links (/?page=): 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. --- docs/DESIGN.md | 10 ++ .../src/components/AdminUsers.dom.test.tsx | 54 ++++++- frontend/src/components/AdminUsers.tsx | 140 ++++++++---------- frontend/src/components/AuditEarlierRuns.tsx | 48 ++++++ frontend/src/components/AuditLog.tsx | 4 +- frontend/src/components/ChannelPage.tsx | 18 ++- .../ChannelPage.watchState.dom.test.tsx | 109 ++++++++++++++ frontend/src/components/Feed.tsx | 2 +- frontend/src/components/NavSidebar.tsx | 3 + .../src/components/auditColumns.dom.test.tsx | 48 +++++- frontend/src/components/auditColumns.tsx | 17 ++- frontend/src/components/feedFiltersInit.ts | 3 + .../src/components/ui/NavRail.dom.test.tsx | 51 ++++++- frontend/src/components/ui/NavRail.tsx | 17 ++- .../ui/pressableSelectable.contract.test.ts | 2 +- frontend/src/i18n/locales/en/audit.json | 4 +- frontend/src/i18n/locales/en/users.json | 3 +- frontend/src/i18n/locales/hu/audit.json | 4 +- frontend/src/i18n/locales/hu/users.json | 3 +- frontend/src/lib/auditGroups.test.ts | 60 ++++++++ frontend/src/lib/auditGroups.ts | 32 ++++ frontend/src/lib/nav.ts | 7 + frontend/src/lib/storage.ts | 1 + 23 files changed, 542 insertions(+), 98 deletions(-) create mode 100644 frontend/src/components/AuditEarlierRuns.tsx create mode 100644 frontend/src/components/ChannelPage.watchState.dom.test.tsx create mode 100644 frontend/src/lib/auditGroups.test.ts create mode 100644 frontend/src/lib/auditGroups.ts diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 4a85a08a..d128c257 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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 | 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 | 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 | diff --git a/frontend/src/components/AdminUsers.dom.test.tsx b/frontend/src/components/AdminUsers.dom.test.tsx index 81c87530..c6eb94b3 100644 --- a/frontend/src/components/AdminUsers.dom.test.tsx +++ b/frontend/src/components/AdminUsers.dom.test.tsx @@ -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( + + + , + ); + 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", () => { it("says a user who is gone, and refetches the list", async () => { const users = mount(); vi.spyOn(globalThis, "fetch").mockResolvedValue( answer(404, { code: "not_found", reason: "user" }), ); - fireEvent.click(await screen.findByText("users.roles.makeAdmin")); + await chooseInRowMenu("users.roles.makeAdmin"); await waitFor(() => expect(notify).toHaveBeenCalledWith({ 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 () => { mount(); 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"); await waitFor(() => expect(reportError).toHaveBeenCalled()); expect(notify.mock.calls.filter(([n]) => n.level === "error")).toEqual([]); diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 13d9e541..1703aba9 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -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({ 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: , + 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 ? : , + disabled: pending || !!suspendNote, + note: suspendNote, + onSelect: () => onSuspend(u), + }, + { + id: "delete", + label: t("users.delete.action"), + icon: , + danger: true, + separatorBefore: true, + disabled: pending || !!deleteNote, + note: deleteNote, + onSelect: () => onDelete(u), + }, + ]; + }; + const rows = q.data ?? []; return (
+ {panelFor && setPanelFor(null)} />}

{t("users.roles.intro")}

{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.
{t("users.roles.unverified")} )} - - - - - onSuspend(u)} - aria-disabled={u.is_demo || self} - disabled={pendingIds.has(u.id)} - > - {u.is_suspended ? ( - - ) : ( - - )} - - - - onDelete(u)} - aria-disabled={u.is_demo || self} - disabled={pendingIds.has(u.id)} - > - - - - {/* 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. */} +
); })} diff --git a/frontend/src/components/AuditEarlierRuns.tsx b/frontend/src/components/AuditEarlierRuns.tsx new file mode 100644 index 00000000..62036b7e --- /dev/null +++ b/frontend/src/components/AuditEarlierRuns.tsx @@ -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 ( +
+ + {open && ( +
    + {earlier.map((run) => ( +
  • + + {detailOf(run)} +
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/AuditLog.tsx b/frontend/src/components/AuditLog.tsx index b034905b..b2a5c97c 100644 --- a/frontend/src/components/AuditLog.tsx +++ b/frontend/src/components/AuditLog.tsx @@ -14,6 +14,7 @@ import { LoadingState, StateMessage } from "./ui/QueryState"; import { auditColumns } from "./auditColumns"; import { notifyActionError } from "../lib/actionErrors"; 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 // 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 q = useQuery({ queryKey: qk.adminAudit(), queryFn: () => api.adminAudit() }); 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(() => { // By what the reader sees — the named action, in their alphabet — not by the code. diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 8df8bd86..8fea4170 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -29,6 +29,9 @@ import ChannelAboutContent from "./ChannelAbout"; import { PageToolbar } from "./ui/PageShell"; import { PageToolbarSlot } from "./ui/pageToolbarSlot"; import { useFeedFilters } from "./feedFiltersContext"; +import { SHOW_IDS } from "./feedFiltersInit"; +import { usePref } from "../lib/usePref"; +import { LS } from "../lib/storage"; import { useConfirm } from "./confirmContext"; import { notify } from "../lib/notifications"; 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 // the explored, per-user-private videos show — the backend gates them to this explorer). - const [filters, setFilters] = useState(() => ({ + // 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("channelShow", "unwatched", { + localKey: LS.channelShow, + parse: (v) => (typeof v === "string" && SHOW_IDS.includes(v) ? v : "unwatched"), + }); + const [ownFilters, setOwnFilters] = useState(() => ({ tags: [], tagModes: DEFAULT_TAG_MODES, q: "", @@ -271,9 +280,14 @@ export default function ChannelPage({ includeNormal: globalFilters.includeNormal, includeShorts: globalFilters.includeShorts, includeLive: globalFilters.includeLive, - show: "all", + show: "unwatched", 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 ytUrl = channelYouTubeUrl(channelId, ch?.handle); diff --git a/frontend/src/components/ChannelPage.watchState.dom.test.tsx b/frontend/src/components/ChannelPage.watchState.dom.test.tsx new file mode 100644 index 00000000..1533fa0b --- /dev/null +++ b/frontend/src/components/ChannelPage.watchState.dom.test.tsx @@ -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()), + 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()), + 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 = {}) { + 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( + + {}} + onShowInFeed={() => {}} + /> + , + ); + 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"); + }); +}); diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 37a82723..a6fabbf5 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -37,6 +37,7 @@ import { } from "../lib/feedSort"; import { type SortOption, type SortState } from "../lib/columnSort"; import { usePref } from "../lib/usePref"; +import { SHOW_IDS } from "./feedFiltersInit"; import { FEED_VIEWS, type FeedView } from "../lib/feedView"; import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters"; 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 // (above the cards), not the filter sidebar. -const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "hidden"]; const CONTENT = [ { key: "includeNormal", label: "sidebar.content.normal" }, { key: "includeShorts", label: "sidebar.content.shorts" }, diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index e0967a64..7f19c5ee 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -35,6 +35,7 @@ import { RAIL_ROW, } from "./ui/NavRail"; 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 // 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} active={page === p} badge={badge} + href={pageHref(p)} onClick={() => setPage(p)} /> ); @@ -214,6 +216,7 @@ export default function NavSidebar({ shortLabel={t(moduleRailKey.settings)} showLabel={railLabels} active={page === "settings"} + href={pageHref("settings")} onClick={() => setPage("settings")} /> diff --git a/frontend/src/components/auditColumns.dom.test.tsx b/frontend/src/components/auditColumns.dom.test.tsx index ebea3d16..f418518f 100644 --- a/frontend/src/components/auditColumns.dom.test.tsx +++ b/frontend/src/components/auditColumns.dom.test.tsx @@ -4,7 +4,7 @@ // "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. 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 type { AuditRow } from "../lib/api"; 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"); + }); +}); diff --git a/frontend/src/components/auditColumns.tsx b/frontend/src/components/auditColumns.tsx index d9181cb6..61b5ef4b 100644 --- a/frontend/src/components/auditColumns.tsx +++ b/frontend/src/components/auditColumns.tsx @@ -1,5 +1,8 @@ import type { TFunction } from "i18next"; +import clsx from "clsx"; import type { AuditRow } from "../lib/api"; +import type { AuditListRow } from "../lib/auditGroups"; +import AuditEarlierRuns from "./AuditEarlierRuns"; import { formatNumber } from "../lib/format"; import type { JobResult } from "../lib/api"; import { jobResultText, jobRunText } from "../lib/jobResult"; @@ -105,7 +108,7 @@ export function auditColumns( t: TFunction, lang: string, actionOptions: { value: string; label: string }[], -): Column[] { +): Column[] { return [ { key: "when", @@ -147,13 +150,23 @@ export function auditColumns( const local = localDetails(t, r, lang); const summary = local ? local.head : r.summary; 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 ( // 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 // card (the row rules bled into the page). Wrapping keeps every row inside the container.
- {summary &&
{summary}
} + {summary && ( +
{summary}
+ )} {change &&
{change}
} + {r.runs && r.runs.length > 1 && ( + localDetails(t, run, lang)?.sub ?? run.summary} + /> + )}
); }, diff --git a/frontend/src/components/feedFiltersInit.ts b/frontend/src/components/feedFiltersInit.ts index fe0f9594..d9eb0ccd 100644 --- a/frontend/src/components/feedFiltersInit.ts +++ b/frontend/src/components/feedFiltersInit.ts @@ -18,6 +18,9 @@ export const DEFAULT_FILTERS: FeedFilters = { 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; // This account's own persisted filters (per-account keys — never another account's). No account diff --git a/frontend/src/components/ui/NavRail.dom.test.tsx b/frontend/src/components/ui/NavRail.dom.test.tsx index cd0b9b70..0ed36d31 100644 --- a/frontend/src/components/ui/NavRail.dom.test.tsx +++ b/frontend/src/components/ui/NavRail.dom.test.tsx @@ -6,7 +6,7 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; 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 { NavRailAction, NavRailItem } from "./NavRail"; @@ -24,10 +24,11 @@ describe("NavRailItem", () => { showLabel={false} active badge={3} + href="/?page=notifications" 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.className).toContain("bg-accent/15"); expect(row.textContent).not.toContain("Alerts"); @@ -40,10 +41,11 @@ describe("NavRailItem", () => { label="Notifications" shortLabel="Alerts" showLabel + href="/?page=notifications" onClick={() => {}} />, ); - const row = screen.getByRole("button", { name: "Alerts" }); + const row = screen.getByRole("link", { name: "Alerts" }); expect(row.getAttribute("aria-current")).toBeNull(); }); @@ -57,10 +59,51 @@ describe("NavRailItem", () => { shortLabel="Alerts" showLabel badge={20} + href="/?page=notifications" 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( + , + ); + 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); }); }); diff --git a/frontend/src/components/ui/NavRail.tsx b/frontend/src/components/ui/NavRail.tsx index 0ce9e85d..04b65eb8 100644 --- a/frontend/src/components/ui/NavRail.tsx +++ b/frontend/src/components/ui/NavRail.tsx @@ -4,6 +4,7 @@ import type { LucideIcon } from "lucide-react"; import { Pressable } from "./Pressable"; import { SURFACE } from "./surfaces"; 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 // 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, active = false, badge, + href, onClick, testId, }: { @@ -88,15 +90,24 @@ export function NavRailItem({ active?: boolean; /** An unread / in-progress count on the icon's corner (0 hides it). */ 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; testId?: string; }) { const shown = !!badge && badge > 0; return ( - { + 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 // 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 @@ -131,7 +142,7 @@ export function NavRailItem({ {shortLabel} )} - + ); } diff --git a/frontend/src/components/ui/pressableSelectable.contract.test.ts b/frontend/src/components/ui/pressableSelectable.contract.test.ts index 1b1d270a..6164121b 100644 --- a/frontend/src/components/ui/pressableSelectable.contract.test.ts +++ b/frontend/src/components/ui/pressableSelectable.contract.test.ts @@ -46,7 +46,7 @@ const PLAIN: Record = { "ui/ActionMenu.tsx": [1, "a menu row"], "ui/BackToTop.tsx": [1, "a control"], "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/SidePanel.tsx": [2, "panel controls"], "ui/tableHeader.tsx": [1, "a column's sort header"], diff --git a/frontend/src/i18n/locales/en/audit.json b/frontend/src/i18n/locales/en/audit.json index 82df2382..f4513bd8 100644 --- a/frontend/src/i18n/locales/en/audit.json +++ b/frontend/src/i18n/locales/en/audit.json @@ -19,5 +19,7 @@ "actor": "Who", "action": "Action", "details": "Details" - } + }, + "earlierRuns_one": "{{count}} earlier run", + "earlierRuns_other": "{{count}} earlier runs" } diff --git a/frontend/src/i18n/locales/en/users.json b/frontend/src/i18n/locales/en/users.json index f07a73bc..70c4064b 100644 --- a/frontend/src/i18n/locales/en/users.json +++ b/frontend/src/i18n/locales/en/users.json @@ -101,5 +101,6 @@ "setPassword": "Create a set-password link", "setPasswordAgain": "Create another link", "linkFailed": "Couldn't create the link. Please try again." - } + }, + "rowMenu": "User actions" } diff --git a/frontend/src/i18n/locales/hu/audit.json b/frontend/src/i18n/locales/hu/audit.json index 907042c2..294ce05e 100644 --- a/frontend/src/i18n/locales/hu/audit.json +++ b/frontend/src/i18n/locales/hu/audit.json @@ -19,5 +19,7 @@ "actor": "Ki", "action": "Művelet", "details": "Részletek" - } + }, + "earlierRuns_one": "{{count}} korábbi futás", + "earlierRuns_other": "{{count}} korábbi futás" } diff --git a/frontend/src/i18n/locales/hu/users.json b/frontend/src/i18n/locales/hu/users.json index b9718050..b897a21c 100644 --- a/frontend/src/i18n/locales/hu/users.json +++ b/frontend/src/i18n/locales/hu/users.json @@ -101,5 +101,6 @@ "setPassword": "Jelszóbeállító link készítése", "setPasswordAgain": "Új link készítése", "linkFailed": "Nem sikerült létrehozni a linket. Próbáld újra." - } + }, + "rowMenu": "Felhasználó műveletei" } diff --git a/frontend/src/lib/auditGroups.test.ts b/frontend/src/lib/auditGroups.test.ts new file mode 100644 index 00000000..ff505de3 --- /dev/null +++ b/frontend/src/lib/auditGroups.test.ts @@ -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) => + 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); + }); +}); diff --git a/frontend/src/lib/auditGroups.ts b/frontend/src/lib/auditGroups.ts new file mode 100644 index 00000000..6fe39b81 --- /dev/null +++ b/frontend/src/lib/auditGroups.ts @@ -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; +} diff --git a/frontend/src/lib/nav.ts b/frontend/src/lib/nav.ts index 1bfcd6ff..53716af5 100644 --- a/frontend/src/lib/nav.ts +++ b/frontend/src/lib/nav.ts @@ -17,6 +17,13 @@ export function navigateTo(p: Page): void { _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? * * An anchor that ALSO navigates in-app has to keep the browser's own gestures working — Ctrl/Cmd-click diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index adc1b7b6..264cf7a5 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -35,6 +35,7 @@ export const LS = { channelLayout: "siftlode.channelLayout", // The manager's sort, lifted out of the table so the card layout shares it (per-account, per-tab). channelSort: "siftlode.channelSort", + channelShow: "siftlode.channelShow", channelDiscoverySort: "siftlode.channelDiscoverySort", // Cards-per-page for the card layout (the table keeps its own rows-per-page in its persist blob). channelCardSize: "siftlode.channelCardSize",