feat(design): the channel menu's Tags and Priority submenus (C1, E28 S3e3b)

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.
This commit is contained in:
2026-09-26 17:34:47 +02:00
parent 2b3094ffc5
commit 5e5c8a42a9
15 changed files with 467 additions and 28 deletions
+70 -10
View File
@@ -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 <body>, 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<string, HTMLButtonElement>());
const listRef = useRef<HTMLDivElement>(null);
const pickPriority = (c: ManagedChannel, next: number) => {
setPriority(c.id, next);
qc.setQueryData<ManagedChannel[]>(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<ManagedChannel>();
// 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<ManagedChannel>({ 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 (
<MoreButton
ref={(el) => {
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). */
<div className="px-4 pb-4 max-w-384 mx-auto">
<div ref={listRef} className="px-4 pb-4 max-w-384 mx-auto">
{/* 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}
/>
) : (
<ChannelLayoutGrid