From 5e5c8a42a93af80cb52969b21f7704f9031f6830 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 26 Sep 2026 17:34:47 +0200 Subject: [PATCH] feat(design): the channel menu's Tags and Priority submenus (C1, E28 S3e3b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tags A-Z as in-place checks (the menu stays open for the next); Priority as the fixed steps 10 5 3 2 1 0, an off-scale current value ticked in its place. A pick re-sorts the list at once, moving only that row (placeByPriority, the server's order), and the table / card pager and the focus follow it (DataTable and useCardPager take a reveal seq, listNav.focusNavItem lands on the row's own "…", a virtualized card revealed first). ActionMenu rows take check: many | one. The menu reads the row fresh from the cache and keys its target by id. --- frontend/src/components/Channels.tsx | 80 ++++++++++++++++--- .../src/components/ui/ActionMenu.dom.test.tsx | 31 +++++++ frontend/src/components/ui/ActionMenu.tsx | 23 +++++- .../ui/DataTable.keyboard.dom.test.tsx | 24 ++++++ frontend/src/components/ui/DataTable.tsx | 14 ++++ .../components/useChannelActions.dom.test.tsx | 67 ++++++++++++++-- frontend/src/components/useChannelActions.tsx | 60 +++++++++++++- frontend/src/i18n/locales/en/channels.json | 4 +- frontend/src/i18n/locales/hu/channels.json | 4 +- frontend/src/lib/channelOrder.test.ts | 53 ++++++++++++ frontend/src/lib/channelOrder.ts | 41 ++++++++++ frontend/src/lib/listNav.dom.test.tsx | 23 +++++- frontend/src/lib/listNav.ts | 30 ++++++- frontend/src/lib/useCardPager.dom.test.tsx | 24 ++++++ frontend/src/lib/useCardPager.ts | 17 +++- 15 files changed, 467 insertions(+), 28 deletions(-) create mode 100644 frontend/src/lib/channelOrder.test.ts create mode 100644 frontend/src/lib/channelOrder.ts diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 3d84ee93..d6983e06 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; @@ -25,6 +25,8 @@ import { LoadingState, StateMessage } from "./ui/QueryState"; import { useChannelTagActions } from "../lib/useChannelTagActions"; import { useTagSuggestions } from "./useTagSuggestions"; import { useChannelPriority } from "../lib/useChannelPriority"; +import { placeByPriority } from "../lib/channelOrder"; +import { focusNavItem } from "../lib/listNav"; import ViewSwitcher, { type ViewOption } from "./ViewSwitcher"; import ColumnSortControl from "./ui/ColumnSortControl"; import FilterChipGroup from "./ui/FilterChipGroup"; @@ -129,9 +131,10 @@ export default function Channels() { const userTags = (tagsQuery.data ?? []).filter((t) => !t.system); - // Priority is updated optimistically in place and NOT re-sorted/refetched, so the list doesn't - // jump (the server list is priority-sorted; refetching would reorder rows and lose your scroll - // position). The new order takes effect next time the page loads. Press-and-hold is debounced + + // The row's stepper updates priority optimistically in place and does NOT re-sort/refetch, so the + // list doesn't jump under a press-and-hold (the server list is priority-sorted; refetching would + // reorder rows and lose your scroll position) — its new order shows next time the page loads. A + // pick from the row's menu DOES re-sort (`pickPriority` below). Press-and-hold is debounced + // flushed on unmount by the shared hook (same flow the channel detail page uses). const { setPriority, bumpPriority } = useChannelPriority({ read: (id) => @@ -143,6 +146,21 @@ export default function Channels() { onError: () => qc.invalidateQueries({ queryKey: qk.channels() }), }); + // A priority step picked from the row's menu (E28 S3e3b, owner): the one thing a pick is FOR is + // to move the channel up (or down) the list, so the list re-sorts at once — only that row moves + // (`placeByPriority`, the server's order) — and the page and the focus follow it (`follow`): the + // table/card pager turns to its page, and the effect below hands the focus to its "…" (its node + // may have been re-inserted, which drops the focus to , or be a virtualized card not yet + // mounted). Picked again from there, the next step is one Enter away. + const [follow, setFollow] = useState<{ key: string; seq: number } | null>(null); + const moreRefs = useRef(new Map()); + const listRef = useRef(null); + const pickPriority = (c: ManagedChannel, next: number) => { + setPriority(c.id, next); + qc.setQueryData(qk.channels(), (old) => old && placeByPriority(old, c.id)); + setFollow((f) => ({ key: c.id, seq: (f?.seq ?? 0) + 1 })); + }; + // Tagging is updated optimistically in place (no refetch of the whole channel list, which // made the toggle feel ~2s slow); only revert by refetching if the server call fails. The // attach/detach + create-and-attach flow is shared with the channel detail page. @@ -297,10 +315,22 @@ export default function Channels() { // layout: they are two paints of the same rows, so a right-click, a long-press and the row's "…" // must mean the same thing in both. Only mounted under the SUBSCRIBED branch below: a discovery // result is not a `Channel` row yet, so pinning or hiding one could only 404. - const rowMenu = useContextMenu(); + // Keyed by id: a tag toggled in the menu, a priority picked, a poll — each rebuilds the row object + // under the open menu, and its "…" must still know the menu is its own. + const rowMenu = useContextMenu({ keyOf: (c) => c.id }); const rowPin = useComfortPin(rowMenu.open); - const channelActions = useChannelActions({ onView, onOpenWizard }); - const menuRow = rowMenu.target; + const channelActions = useChannelActions({ + onView, + onOpenWizard, + tags: userTags, + onToggleTag: (c, tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId)), + onPickPriority: pickPriority, + }); + // The row as the cache holds it NOW, not as it was when the menu opened: the Tags submenu stays + // open across toggles, and each tick (and the next toggle's attach-or-detach) reads this. + const menuRow = + rowMenu.target && + ((channelsQuery.data ?? []).find((c) => c.id === rowMenu.target!.id) ?? rowMenu.target); const menuItems = menuRow ? channelActions.items(menuRow, { pin: rowPin.item(pinChannel(menuRow.id), menuRow.title ?? t("channels.row.thisChannel")), @@ -318,9 +348,13 @@ export default function Channels() { bumpPriority, setPriority, more: (c) => { - const mine = rowMenu.open && rowMenu.target === c; + const mine = rowMenu.open && rowMenu.target?.id === c.id; return ( { + if (el) moreRefs.current.set(c.id, el); + else moreRefs.current.delete(c.id); + }} label={t("channels.row.menu")} expanded={mine} menuProps={rowMenu.buttonProps(c)} @@ -422,7 +456,32 @@ export default function Channels() { paged: pagedCards, pagerProps, resetPage, - } = useCardPager(sortedChannels, LS.channelCardSize, isCards); + } = useCardPager( + sortedChannels, + LS.channelCardSize, + isCards, + follow && { seq: follow.seq, match: (c) => c.id === follow.key }, + ); + // The focus half of `follow` (the pagers took the page half during render). A mounted row names + // its own place; a card the virtualized grid has not mounted yet is found by its place on the page. + const focusFollowed = useEffectEvent((key: string) => { + const wrap = listRef.current; + if (!wrap) return; + const button = () => moreRefs.current.get(key) ?? null; + const item = button()?.closest("[data-nav-index]"); + const at = item + ? Number(item.getAttribute("data-nav-index")) + : isCards + ? sortedChannels.findIndex((c) => c.id === key) - + pagerProps.page * Math.max(0, pagerProps.size) + : -1; + // Filtered out by its new priority (a Prio column filter): nothing to follow — honest, not a + // jump to some other row. + if (at >= 0) focusNavItem(wrap, at, button); + }); + useEffect(() => { + if (follow) focusFollowed(follow.key); + }, [follow]); // Changing what you're looking at starts over at the first page, the way DataTable resets its own // page on a filter/sort change. Deliberately NOT keyed on the row count: the qk.channels() query // refetches on window focus and after every sync, so a catalogue that merely gained a channel would @@ -570,7 +629,7 @@ export default function Channels() { /* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when `position: sticky` engages on the first scroll (the band's controls row above gives the visual gap). */ -
+
{/* Channel table — only the rows scroll; the header row is sticky, and the controls row (status chips + pager) rides in the fixed band right above it (controlsInBand). The card layout swaps the table for a virtualized grid over the same rows (its pager and @@ -601,6 +660,7 @@ export default function Channels() { // `group`: the row's "…" reveals on its hover or focus (`.reveal`), and stays on touch. rowClassName={() => "group"} keyboardNav + reveal={follow} /> ) : ( { await vi.waitFor(() => expect(onSelect).toHaveBeenCalledTimes(1)); }); + it("a 'many' check row toggles in place: the menu stays open and the focus stays on it", async () => { + const onSelect = vi.fn(); + renderMenu([ + { id: "rust", label: "rust", check: { kind: "many", checked: true }, onSelect }, + { id: "go", label: "go", check: { kind: "many", checked: false }, onSelect: vi.fn() }, + ]); + fireEvent.click(screen.getByRole("button", { name: "Catalog actions" })); + const rows = screen.getAllByRole("menuitemcheckbox", { hidden: true }); + expect(rows.map((r) => r.getAttribute("aria-checked"))).toEqual(["true", "false"]); + fireEvent.click(rows[0]!); + // Synchronously — no close, no deferred act — and nothing closed around it. + expect(onSelect).toHaveBeenCalledTimes(1); + expect(screen.getAllByRole("menuitemcheckbox", { hidden: true })).toHaveLength(2); + await new Promise((r) => setTimeout(r, 0)); + expect(screen.getAllByRole("menuitemcheckbox", { hidden: true })).toHaveLength(2); + }); + + it("a 'one' check row is a radio that picks and closes like any row", async () => { + const onSelect = vi.fn(); + renderMenu([ + { id: "10", label: "10", check: { kind: "one", checked: false }, onSelect }, + { id: "5", label: "5", check: { kind: "one", checked: true }, onSelect: vi.fn() }, + ]); + fireEvent.click(screen.getByRole("button", { name: "Catalog actions" })); + const rows = screen.getAllByRole("menuitemradio", { hidden: true }); + expect(rows.map((r) => r.getAttribute("aria-checked"))).toEqual(["false", "true"]); + fireEvent.click(rows[0]!); + expect(screen.queryByRole("menuitemradio", { hidden: true })).toBeNull(); + await vi.waitFor(() => expect(onSelect).toHaveBeenCalledTimes(1)); + }); + it("a disabled item is inert — rendered disabled and never fires onSelect", () => { const onSelect = vi.fn(); renderMenu([{ id: "sync", label: "Sync", onSelect, disabled: true }]); diff --git a/frontend/src/components/ui/ActionMenu.tsx b/frontend/src/components/ui/ActionMenu.tsx index c2b54aa4..6f1817ad 100644 --- a/frontend/src/components/ui/ActionMenu.tsx +++ b/frontend/src/components/ui/ActionMenu.tsx @@ -3,13 +3,15 @@ import { ChevronRight, MoreHorizontal } from "lucide-react"; import { PopoverItem, PopoverSurface } from "./AnchoredPopover"; import { closeThenAct, useAnchoredPopover } from "./anchoredPopoverHooks"; import { IconButton } from "./IconButton"; +import { MenuCheck } from "./MenuCheck"; import { Pressable } from "./Pressable"; import { SURFACE } from "./surfaces"; // A reusable "⋯ more actions" overflow menu: a compact trigger that opens a list of one-shot actions. // Built on the R8 AnchoredPopover platform (arrows/typeahead/Escape/anchor-flip), same as ViewSwitcher // and LanguageSwitcher. Use it to keep occasional actions out of a crowded toolbar. The items are -// fire-and-close (`onSelect`); a `disabled` item (e.g. a mutation already in flight) is inert. +// fire-and-close (`onSelect`) — all but a `check: { kind: "many" }` row, which toggles in place; a +// `disabled` item (e.g. a mutation already in flight) is inert. // // E10 S2 split the ITEM LIST out of the trigger (`MenuItemList`) so the ⋯ menu and the right-click // context menu (`ContextMenu`) render the same rows from the same item type. That is the whole point @@ -62,6 +64,11 @@ interface ActionItem extends MenuItemBase { * user's gesture (a task later, as `closeThenAct` runs every other row, it is refused). Only for * an action that opens nothing and moves no focus: that is what the deferral exists for. */ inGesture?: boolean; + /** A checkable row, its state shown in the icon slot. `many`: one of a set that can all be on (a + * channel's tags) — a menuitemcheckbox that toggles IN PLACE: the menu stays open, so the next + * tag is one click away and the check just set is seen. `one`: one choice of several (a priority + * step) — a menuitemradio that picks and closes, like any other row. */ + check?: { kind: "many" | "one"; checked: boolean }; items?: never; } @@ -99,19 +106,29 @@ export function MenuItemList({ ) : ( { + if (it.check?.kind === "many") return it.onSelect(); if (!it.inGesture) return closeThenAct(onClose, it.onSelect); it.onSelect(); onClose(); }} className={`${ROW}${it.danger ? " text-danger" : ""}`} > - {it.icon && ( + {it.check && } + {!it.check && it.icon && ( diff --git a/frontend/src/components/ui/DataTable.keyboard.dom.test.tsx b/frontend/src/components/ui/DataTable.keyboard.dom.test.tsx index e515ed9f..7adb0eed 100644 --- a/frontend/src/components/ui/DataTable.keyboard.dom.test.tsx +++ b/frontend/src/components/ui/DataTable.keyboard.dom.test.tsx @@ -136,3 +136,27 @@ describe("DataTable · keyboardNav", () => { expect(document.activeElement).toBe(document.body); }); }); + +describe("DataTable · reveal", () => { + const many: Row[] = Array.from({ length: 25 }, (_, i) => ({ id: `r${i}` })); + const cols: Column[] = [ + { key: "name", header: "Name", cardPrimary: true, render: (r) => r.id }, + ]; + const shown = () => [...document.querySelectorAll("tbody tr")].map((tr) => tr.textContent); + + it("turns to the page that now holds a moved row on each new seq", () => { + viewport(true); + const view = (list: Row[], reveal: { key: string; seq: number } | null) => ( + r.id} reveal={reveal} /> + ); + const { rerender } = render(view(many, { key: "r22", seq: 0 })); + // The mount's seq is not a request: the first page stays. + expect(shown()[0]).toBe("r0"); + rerender(view(many, { key: "r22", seq: 1 })); + expect(shown()).toContain("r22"); + expect(shown()[0]).toBe("r20"); + // r22 bubbled up to the top: the next seq follows it back to page one. + rerender(view([many[22]!, ...many.filter((r) => r.id !== "r22")], { key: "r22", seq: 2 })); + expect(shown()[0]).toBe("r22"); + }); +}); diff --git a/frontend/src/components/ui/DataTable.tsx b/frontend/src/components/ui/DataTable.tsx index 3702b513..c2c027de 100644 --- a/frontend/src/components/ui/DataTable.tsx +++ b/frontend/src/components/ui/DataTable.tsx @@ -69,6 +69,7 @@ export default function DataTable({ onFiltersChange, fixedLayout = false, keyboardNav = false, + reveal, }: { rows: T[]; columns: Column[]; @@ -121,6 +122,10 @@ export default function DataTable({ // Enter clicks the element a column's render marks `data-nav-open` (the channel name). Both row // shapes register; listNav walks only the one on screen, so the hidden copy is never a stop. keyboardNav?: boolean; + // Follow a row the caller just MOVED (a channel re-sorted by the priority its menu set): each new + // `seq` turns to the page that now holds the row `key`. Focus is the caller's (it knows which of + // the row's controls it came from); the page is the table's, so it is decided here. + reveal?: { key: string; seq: number } | null; }) { const { t } = useTranslation(); const initial = loadPersist(persistKey); @@ -208,6 +213,15 @@ export default function DataTable({ const allRows = size <= 0; const totalPages = allRows ? 1 : Math.max(1, Math.ceil(sorted.length / size)); const safePage = Math.min(page, totalPages - 1); + // Adjusted DURING render against the last seq seen (the house's key-change reset), so the frame + // that re-sorted the row already shows its new page. Seeded with the mount's seq: arriving with a + // stale reveal must not move a page nobody asked to move. + const [revealSeen, setRevealSeen] = useState(reveal?.seq); + if (reveal && reveal.seq !== revealSeen) { + setRevealSeen(reveal.seq); + const at = sorted.findIndex((row) => rowKey(row) === reveal.key); + if (at >= 0 && !allRows) setPage(Math.floor(at / size)); + } const paged = allRows ? sorted : sorted.slice(safePage * size, safePage * size + size); function toggleSort(key: string) { diff --git a/frontend/src/components/useChannelActions.dom.test.tsx b/frontend/src/components/useChannelActions.dom.test.tsx index 09f220da..cc076cc7 100644 --- a/frontend/src/components/useChannelActions.dom.test.tsx +++ b/frontend/src/components/useChannelActions.dom.test.tsx @@ -2,7 +2,8 @@ // // A subscribed channel's ONE action list (E28 C1): what the manager row's "…" (and its right-click, // long-press and Shift+F10) offers — and the row buttons it replaced, now rows here: full history, -// hide (an undo toast instead of a confirm), unsubscribe (danger, confirmed). +// hide (an undo toast instead of a confirm), unsubscribe (danger, confirmed) — and the Tags and +// Priority submenus (E28 S3e3b). import { describe, it, expect, vi, afterEach } from "vitest"; import { renderHook, act, cleanup, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -27,7 +28,8 @@ vi.mock("./confirmContext", () => ({ useConfirm: () => h.confirm })); vi.mock("../lib/useMe", () => ({ useMe: () => ({ role: h.role, can_write: h.canWrite }) })); import { useChannelActions } from "./useChannelActions"; -import { api, type ManagedChannel } from "../lib/api"; +import { api, type ManagedChannel, type Tag } from "../lib/api"; +import type { ActionMenuItem } from "./ui/ActionMenu"; afterEach(() => { cleanup(); @@ -37,6 +39,9 @@ afterEach(() => { h.confirm.mockImplementation(async () => true); h.role = "user"; h.canWrite = true; + tags = []; + onToggleTag.mockReset(); + onPickPriority.mockReset(); }); const ch = (over: Partial = {}): ManagedChannel => ({ @@ -62,12 +67,19 @@ const ch = (over: Partial = {}): ManagedChannel => ({ }); const onView = vi.fn(); +const onToggleTag = vi.fn(); +const onPickPriority = vi.fn(); +let tags: Tag[] = []; +const tag = (id: number, name: string) => ({ id, name }) as Tag; function mount() { const qc = new QueryClient({ defaultOptions: { mutations: { retry: false } } }); const wrapper = ({ children }: { children: ReactNode }) => ( {children} ); - return renderHook(() => useChannelActions({ onView, onOpenWizard: () => {} }), { wrapper }); + return renderHook( + () => useChannelActions({ onView, onOpenWizard: () => {}, tags, onToggleTag, onPickPriority }), + { wrapper }, + ); } const pin = [{ id: "pin", label: "pin", onSelect: () => {} }]; const ids = (items: { id: string }[]) => items.map((i) => i.id); @@ -75,11 +87,13 @@ const rowsFor = (c: ManagedChannel) => mount().result.current.items(c, { pin }); const row = (c: ManagedChannel, id: string) => rowsFor(c).find((i) => i.id === id)!; describe("useChannelActions — the menu", () => { - it("lists open, pin, the YouTube + copy group, full history, hide and unsubscribe", () => { + it("lists open, pin, tags + priority, the YouTube + copy group, full history, hide and unsubscribe", () => { const items = rowsFor(ch()); expect(ids(items)).toEqual([ "open", "pin", + "tags", + "priority", "open-youtube", "copy-text", "copy-link", @@ -87,8 +101,9 @@ describe("useChannelActions — the menu", () => { "hide", "unsubscribe", ]); - // Groups: YouTube/copy, the channel's own settings, then the destructive row alone. + // Groups: tuning, YouTube/copy, the channel's own settings, then the destructive row alone. expect(items.filter((i) => i.separatorBefore).map((i) => i.id)).toEqual([ + "tags", "open-youtube", "backfill", "unsubscribe", @@ -188,3 +203,45 @@ describe("useChannelActions — hide and unsubscribe", () => { await waitFor(() => expect(unsub).toHaveBeenCalledWith("UC1")); }); }); + +const sub = (it: ActionMenuItem) => (it.items ?? []) as ActionMenuItem[]; + +describe("useChannelActions — the Tags submenu", () => { + it("lists the reader's tags A–Z, the channel's own ticked, each a toggle that stays open", () => { + tags = [tag(3, "rust"), tag(1, "Go"), tag(2, "music")]; + const items = sub(row(ch({ tag_ids: [2] }), "tags")); + expect(items.map((i) => i.label)).toEqual(["Go", "music", "rust"]); + expect(items.map((i) => ("check" in i ? i.check : null))).toEqual([ + { kind: "many", checked: false }, + { kind: "many", checked: true }, + { kind: "many", checked: false }, + ]); + items[1]!.onSelect!(); + expect(onToggleTag).toHaveBeenCalledWith(expect.objectContaining({ id: "UC1" }), 2); + }); + + it("stays in place, off, saying where tags are made, with no tag yet", () => { + expect(row(ch(), "tags")).toMatchObject({ disabled: true, note: "channels.row.noTagsYet" }); + expect(row(ch(), "tags").items).toBeUndefined(); + }); +}); + +describe("useChannelActions — the Priority submenu", () => { + it("offers the fixed steps with the current one ticked, and picks a new one", () => { + const items = sub(row(ch({ priority: 3 }), "priority")); + expect(items.map((i) => i.label)).toEqual(["10", "5", "3", "2", "1", "0"]); + expect(items.filter((i) => "check" in i && i.check?.checked).map((i) => i.label)).toEqual([ + "3", + ]); + expect(items.every((i) => "check" in i && i.check?.kind === "one")).toBe(true); + items[0]!.onSelect!(); + expect(onPickPriority).toHaveBeenCalledWith(expect.objectContaining({ id: "UC1" }), 10); + }); + + it("shows an off-scale priority ticked in its place, and re-picking the current one is a no-op", () => { + const items = sub(row(ch({ priority: 7 }), "priority")); + expect(items.map((i) => i.label)).toEqual(["10", "7", "5", "3", "2", "1", "0"]); + items[1]!.onSelect!(); + expect(onPickPriority).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/useChannelActions.tsx b/frontend/src/components/useChannelActions.tsx index c32d3085..2c465289 100644 --- a/frontend/src/components/useChannelActions.tsx +++ b/frontend/src/components/useChannelActions.tsx @@ -1,8 +1,9 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; -import { EyeOff, ExternalLink, History, Tv, UserMinus } from "lucide-react"; +import { ArrowUpDown, EyeOff, ExternalLink, History, Tags, Tv, UserMinus } from "lucide-react"; import { qk } from "../lib/queryKeys"; -import { api, type ManagedChannel } from "../lib/api"; +import { api, type ManagedChannel, type Tag } from "../lib/api"; +import { priorityChoices } from "../lib/channelOrder"; import { channelYouTubeUrl } from "../lib/format"; import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; @@ -14,14 +15,24 @@ import { type ActionMenuItem } from "./ui/ActionMenu"; // A subscribed channel's ONE action list (E28 C1): the manager row's "…", its right-click, long-press // and Shift+F10, in the table and in the card layout alike. The row keeps what the reader looks at // or tunes all the time (priority, tags, the stored count's ✓); everything that was a button on the -// row — full history, hide, unsubscribe — is here, beside open, the pin and the copies. +// row — full history, hide, unsubscribe — is here, beside open, the pin and the copies. Tags and +// priority are here TOO, as submenus (owner, 2026-09-26): tagging a run of channels, or bubbling one +// up the list, from the menu the hand is already in. export function useChannelActions({ onView, onOpenWizard, + tags, + onToggleTag, + onPickPriority, }: { onView: (c: ManagedChannel) => void; onOpenWizard: () => void; + /** The reader's own tags (no system tags) — the Tags submenu lists them A–Z. */ + tags: readonly Tag[]; + onToggleTag: (c: ManagedChannel, tagId: number) => void; + /** A priority step picked: the caller re-sorts its list and follows the row. */ + onPickPriority: (c: ManagedChannel, priority: number) => void; }) { const qc = useQueryClient(); const { t } = useTranslation(); @@ -130,6 +141,47 @@ export function useChannelActions({ }; } + /** Tags A–Z, one check each, toggled in place (the menu stays open for the next one). With no tag + * yet the row stays, off, and says where tags are made. */ + function tagsItem(c: ManagedChannel): ActionMenuItem { + const base = { + id: "tags", + label: t("channels.tags.label"), + icon: , + }; + if (tags.length === 0) + return { ...base, disabled: true, note: t("channels.row.noTagsYet"), onSelect: () => {} }; + const on = new Set(c.tag_ids); + return { + ...base, + items: [...tags] + .sort((a, b) => a.name.localeCompare(b.name)) + .map((tg) => ({ + id: `tag-${tg.id}`, + label: tg.name, + check: { kind: "many" as const, checked: on.has(tg.id) }, + onSelect: () => onToggleTag(c, tg.id), + })), + }; + } + + /** The fixed priority steps, the current one ticked; a pick re-sorts the list (the caller). */ + function priorityItem(c: ManagedChannel): ActionMenuItem { + return { + id: "priority", + label: t("channels.row.priority"), + icon: , + items: priorityChoices(c.priority).map((p) => ({ + id: `priority-${p}`, + label: String(p), + check: { kind: "one" as const, checked: p === c.priority }, + onSelect: () => { + if (p !== c.priority) onPickPriority(c, p); + }, + })), + }; + } + /** The channel's menu rows; `pin` is the caller's comfort-shelf row (its "is it pinned?" read * follows the caller's own menu). */ function items(c: ManagedChannel, { pin }: { pin: ActionMenuItem[] }): ActionMenuItem[] { @@ -142,6 +194,8 @@ export function useChannelActions({ onSelect: () => onView(c), }, ...pin, + { ...tagsItem(c), separatorBefore: true }, + priorityItem(c), ...[ { id: "open-youtube", diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index 739303cf..e3c35874 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -118,7 +118,9 @@ "unsubscribeHint": "Unsubscribe from this channel on YouTube (changes your real account). Read-only mode hides this — use Hide instead.", "unsubscribeOnYoutube": "Unsubscribe on YouTube", "thisChannel": "This channel", - "menu": "Channel actions" + "menu": "Channel actions", + "priority": "Priority", + "noTagsYet": "No tags yet — make one with a row’s tag “+”" }, "notify": { "synced": "Synced {{count}} subscriptions", diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index 32393364..ca68c44c 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -118,7 +118,9 @@ "unsubscribeHint": "Leiratkozás erről a csatornáról a YouTube-on (megváltoztatja a valódi fiókodat). Csak olvasható módban ez rejtve van — használd helyette az Elrejtést.", "unsubscribeOnYoutube": "Leiratkozás a YouTube-on", "thisChannel": "Ez a csatorna", - "menu": "Csatorna műveletei" + "menu": "Csatorna műveletei", + "priority": "Prioritás", + "noTagsYet": "Még nincs címkéd — egy sor címke-„+” gombjával hozhatsz létre" }, "notify": { "synced": "{{count}} feliratkozás szinkronizálva", diff --git a/frontend/src/lib/channelOrder.test.ts b/frontend/src/lib/channelOrder.test.ts new file mode 100644 index 00000000..3805f51c --- /dev/null +++ b/frontend/src/lib/channelOrder.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { placeByPriority, priorityChoices } from "./channelOrder"; + +const row = (id: string, priority: number, title: string | null = id) => ({ id, priority, title }); +const ids = (rows: { id: string }[]) => rows.map((r) => r.id); + +describe("placeByPriority", () => { + const list = [row("a", 10), row("b", 5), row("c", 5), row("d", 0), row("e", 0)]; + + it("bubbles a raised channel up to the first row it now outranks", () => { + expect(ids(placeByPriority([...list.slice(0, 4), { ...list[4]!, priority: 5 }], "e"))).toEqual([ + "a", + "b", + "c", + "e", + "d", + ]); + }); + + it("sinks a lowered channel below every row it no longer outranks", () => { + expect(ids(placeByPriority([{ ...list[0]!, priority: 0 }, ...list.slice(1)], "a"))).toEqual([ + "b", + "c", + "a", + "d", + "e", + ]); + }); + + it("orders equal priorities by title, case-folded, a missing title last", () => { + const rows = [row("x", 3, "beta"), row("y", 3, null), row("z", 3, "Alpha")]; + expect(ids(placeByPriority(rows, "z"))).toEqual(["z", "x", "y"]); + expect(ids(placeByPriority([row("y", 3, null), row("x", 3, "beta")], "y"))).toEqual(["x", "y"]); + }); + + it("leaves every other row where it was, even ones out of the server's order", () => { + const rows = [row("p", 0), row("q", 9), row("r", 2)]; + // r lands before the first row it outranks; p and q keep their (unsorted) order. + expect(ids(placeByPriority(rows, "r"))).toEqual(["r", "p", "q"]); + }); + + it("returns an unknown id's list unchanged", () => { + expect(ids(placeByPriority(list, "nope"))).toEqual(ids(list)); + }); +}); + +describe("priorityChoices", () => { + it("offers the fixed steps, and an off-scale current value in its place", () => { + expect(priorityChoices(3)).toEqual([10, 5, 3, 2, 1, 0]); + expect(priorityChoices(7)).toEqual([10, 7, 5, 3, 2, 1, 0]); + expect(priorityChoices(-2)).toEqual([10, 5, 3, 2, 1, 0, -2]); + }); +}); diff --git a/frontend/src/lib/channelOrder.ts b/frontend/src/lib/channelOrder.ts new file mode 100644 index 00000000..1a2f1192 --- /dev/null +++ b/frontend/src/lib/channelOrder.ts @@ -0,0 +1,41 @@ +// The channel manager's list order, and the priority steps its menu offers (E28 S3e3b). + +/** The steps a channel's menu offers (owner, 2026-09-26): fixed levels, so bubbling a channel up + * the list is one pick, not a run of +1 presses, and not straight to the top either. The row's + * stepper still sets any value, negatives included. */ +const PRIORITY_STEPS: readonly number[] = [10, 5, 3, 2, 1, 0]; + +/** The steps to offer for a channel at `current`: the fixed ones, plus `current` itself in its + * place when it is off the scale (a 7, a -2) — the menu always shows, ticked, where it is now. */ +export function priorityChoices(current: number): number[] { + return PRIORITY_STEPS.includes(current) + ? [...PRIORITY_STEPS] + : [...PRIORITY_STEPS, current].sort((a, b) => b - a); +} + +interface Ordered { + id: string; + priority: number; + title?: string | null; +} + +/** The server's order (`routes/channels.py`: priority desc, then the title case-folded, a missing + * title last). */ +function before(a: Ordered, b: Ordered): boolean { + if (a.priority !== b.priority) return a.priority > b.priority; + if (a.title == null) return false; + if (b.title == null) return true; + return a.title.toLowerCase() < b.title.toLowerCase(); +} + +/** `rows` with the channel `id` taken out and put back where its priority now places it, every + * other row left exactly where it was — the server's order, without a refetch that would reorder + * anything the reader did not touch. */ +export function placeByPriority(rows: readonly T[], id: string): T[] { + const moved = rows.find((r) => r.id === id); + if (!moved) return [...rows]; + const rest = rows.filter((r) => r !== moved); + const at = rest.findIndex((r) => before(moved, r)); + rest.splice(at < 0 ? rest.length : at, 0, moved); + return rest; +} diff --git a/frontend/src/lib/listNav.dom.test.tsx b/frontend/src/lib/listNav.dom.test.tsx index 9a4470c0..2f1d0956 100644 --- a/frontend/src/lib/listNav.dom.test.tsx +++ b/frontend/src/lib/listNav.dom.test.tsx @@ -6,7 +6,7 @@ import { useRef, useState } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { navItemAttrs, useListNav } from "./listNav"; +import { focusNavItem, navItemAttrs, useListNav } from "./listNav"; import { pushLayer, removeLayer } from "./layerStack"; afterEach(cleanup); @@ -615,3 +615,24 @@ describe("useListNav · Delete (E28 S3e2: a playlist row's X became the Delete k expect(gone("d0")).toBe(false); }); }); + +describe("focusNavItem", () => { + it("hands the focus to the control `pick` finds in the item, else to the item", () => { + Element.prototype.scrollIntoView ??= () => {}; + const { container } = render(); + act(() => focusNavItem(container, 2, (item) => item.querySelector("button"))); + expect(focused()).toBe("a2-action"); + act(() => focusNavItem(container, 1)); + expect(focused()).toBe("a1"); + }); + + it("brings a virtualized item in first, then focuses its control", async () => { + Element.prototype.scrollIntoView ??= () => {}; + const reveal = vi.fn(); + const { container, rerender } = render(); + act(() => focusNavItem(container, 4, (item) => item.querySelector("button"))); + expect(reveal).toHaveBeenCalledWith(4); + rerender(); + await waitFor(() => expect(focused()).toBe("a4-action")); + }); +}); diff --git a/frontend/src/lib/listNav.ts b/frontend/src/lib/listNav.ts index 2d0eb4fe..32f00ca2 100644 --- a/frontend/src/lib/listNav.ts +++ b/frontend/src/lib/listNav.ts @@ -213,20 +213,44 @@ function focusItem(el: HTMLElement, reveal = true): void { if (reveal) el.scrollIntoView?.({ block: "nearest" }); } -function focusIndex(list: NavList, index: number): void { +function focusIndex( + list: NavList, + index: number, + land: (el: HTMLElement) => void = (el) => focusItem(el), +): void { const el = itemAt(list, index); - if (el) return focusItem(el); + if (el) return land(el); // Virtualized and not mounted: have the list scroll it in, then take it as soon as it renders. list.reveal(index); let frames = 0; const wait = () => { const got = itemAt(list, index); - if (got) focusItem(got); + if (got) land(got); else if (++frames < REVEAL_FRAMES) requestAnimationFrame(wait); }; requestAnimationFrame(wait); } +/** Hand the focus to item `index` of the list registered inside `within` — or, with `pick`, to the + * control `pick` finds in it (the row's own "…") — bringing a virtualized item into the DOM first + * and scrolling it just far enough to be seen. For a caller that MOVED an item and follows it: a + * channel re-sorted by the priority its menu set, whose node may have been re-inserted (the focus + * then fell to ) or scrolled off. */ +export function focusNavItem( + within: HTMLElement, + index: number, + pick?: (item: HTMLElement) => HTMLElement | null, +): void { + const list = [...lists.values()].find((l) => within.contains(l.el)); + if (!list) return; + focusIndex(list, index, (item) => { + const to = pick?.(item); + if (!to) return focusItem(item); + to.focus({ preventScroll: true }); + item.scrollIntoView?.({ block: "nearest" }); + }); +} + function step(delta: 1 | -1): void { const cur = current(); if (!cur) { diff --git a/frontend/src/lib/useCardPager.dom.test.tsx b/frontend/src/lib/useCardPager.dom.test.tsx index 0a1095fa..9ec05b69 100644 --- a/frontend/src/lib/useCardPager.dom.test.tsx +++ b/frontend/src/lib/useCardPager.dom.test.tsx @@ -84,3 +84,27 @@ describe("useCardPager — an out-of-range page", () => { expect(result.current.paged).toEqual(rows(30)); // and the rows are never sliced in table mode }); }); + +describe("useCardPager — following a moved row", () => { + it("turns to the page that holds the row on each new seq, and not on mount", () => { + const { result, rerender } = renderHook( + ({ list, seq }) => + useCardPager(list, KEY, true, seq ? { seq, match: (r: number) => r === 3 } : null), + { initialProps: { list: rows(30), seq: 0 } }, + ); + act(() => result.current.pagerProps.onSizeChange(10)); + act(() => result.current.pagerProps.onPageChange(2)); + // The row 3 sank to place 25: seq 1 follows it to the third page… + const sunk = [...rows(30).filter((r) => r !== 3)]; + sunk.splice(25, 0, 3); + rerender({ list: sunk, seq: 1 }); + expect(result.current.pagerProps.page).toBe(2); + // …and bubbled up to place 4: seq 2 follows it back to the first. + rerender({ list: rows(30), seq: 2 }); + expect(result.current.pagerProps.page).toBe(0); + // A re-render with the same seq leaves the reader's own paging alone. + act(() => result.current.pagerProps.onPageChange(1)); + rerender({ list: rows(30), seq: 2 }); + expect(result.current.pagerProps.page).toBe(1); + }); +}); diff --git a/frontend/src/lib/useCardPager.ts b/frontend/src/lib/useCardPager.ts index 531e000c..ea78cf74 100644 --- a/frontend/src/lib/useCardPager.ts +++ b/frontend/src/lib/useCardPager.ts @@ -10,11 +10,26 @@ import { readAccount, writeAccount } from "./storage"; // // The page size persists per account; the page itself is transient. `enabled` is the card layout // flag — in table mode DataTable does its own paging, so this does no work and holds still. -export function useCardPager(rows: T[], sizeKey: string, enabled: boolean) { +export function useCardPager( + rows: T[], + sizeKey: string, + enabled: boolean, + /** Follow a row the caller just moved: each new `seq` turns to the page holding the row `match` + * finds — the same contract as `DataTable`'s `reveal`, for the card paint of the same list. */ + reveal?: { seq: number; match: (row: T) => boolean } | null, +) { const { t } = useTranslation(); const [size, setSize] = useState(() => parseCardSize(readAccount(sizeKey, null))); const [page, setPage] = useState(0); + // During render against the last seq seen, as in DataTable (seeded so a mount never jumps). + const [revealSeen, setRevealSeen] = useState(reveal?.seq); + if (reveal && reveal.seq !== revealSeen) { + setRevealSeen(reveal.seq); + const at = rows.findIndex(reveal.match); + if (enabled && at >= 0 && size > 0) setPage(Math.floor(at / size)); + } + const totalPages = size <= 0 ? 1 : Math.max(1, Math.ceil(rows.length / size)); const safePage = Math.min(page, totalPages - 1);