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);