import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState, useSyncExternalStore, } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createStore, type Store } from "../lib/store"; import { qk } from "../lib/queryKeys"; import { Ban, Eye, EyeOff, History, LayoutGrid, Pencil, RefreshCw, Table, X } from "lucide-react"; import ExcludedChannelList from "./ExcludedChannelList"; import { api, NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api"; import { Sparkles } from "lucide-react"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { accountKey, LS } from "../lib/storage"; import { buildChannelColumns, applySyncFilter, tagFilterChips } from "./channelColumns"; import { notify } from "../lib/notifications"; import { Badge } from "./ui/Badge"; import { Button } from "./ui/Button"; import Tooltip from "./ui/Tooltip"; import DataTable from "./ui/DataTable"; import Tabs, { type TabDef } from "./ui/Tabs"; import { useTabPanel } from "./ui/tabPanel"; import ChannelLayoutGrid from "./ChannelLayoutGrid"; import { pinChannel, useComfortPin } from "./comfortPin"; import { ContextMenu } from "./ui/ContextMenu"; import { MoreButton } from "./ui/MoreButton"; import { useChannelActions } from "./useChannelActions"; import { useContextMenu } from "./ui/useContextMenu"; 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"; import TagFilterChips from "./TagFilterChips"; import ActionMenu, { type ActionMenuItem } from "./ui/ActionMenu"; import ChannelStatsPopover from "./ChannelStatsPopover"; import Pager from "./ui/Pager"; import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout"; import { sortRows } from "../lib/columnSort"; import { filterRows, isActive } from "../lib/tableColumns"; import { useFilterFold } from "../lib/useFilterFold"; import { FilterFoldButton } from "./ui/FilterFold"; import { HiddenFilterCue } from "./ui/HiddenFilterCue"; import { useCardPager } from "../lib/useCardPager"; import { PageToolbar } from "./ui/PageShell"; import { PageMenu } from "./ui/PageMenu"; import { menuGroups } from "./ui/menuGroups"; import { choiceSubmenu, sortSubmenu } from "./ui/pageMenuItems"; import { optionsFromColumns } from "../lib/sortOptions"; import ChannelDiscovery from "./ChannelDiscovery"; import TagManager from "./TagManager"; import { useChannelLayout, useChannels, useChannelsActions } from "./channelsContext"; import { useNavigationActions } from "./navigationContext"; import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; import { useWizard } from "./wizardContext"; import { useMe } from "../lib/useMe"; import { useTags } from "../lib/useTags"; export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked"; // The tag filter has its own chip row above the list, so the hidden-filter cue does not name it again. const SURFACED_FILTERS = ["tags"]; // The top sync chips are a controlled view over the subscribed table's `sync` column filter (shared // via ChannelsProvider): each chip writes `filters.sync`, using the SAME vocab + predicate as the // Sync column funnel (channelColumns.tsx) so the two can't drift. "" = all (isActive treats it as // off). fullySynced = recent_synced && backfill_done (matches the cell's own "fully synced" badge). const SYNC_CHIPS: { value: string; labelKey: string }[] = [ { value: "", labelKey: "channels.filters.all" }, { value: "needsFull", labelKey: "channels.filters.needsFull" }, { value: "fully", labelKey: "channels.filters.fullySynced" }, ]; const LAYOUT_ICON: Record = { table: Table, cards: LayoutGrid, }; // The row menu's identity for a channel. Module scope, as useContextMenu asks: a fresh function per // render rebuilt its `buttonProps` on every render, and with them every column. const channelKey = (c: ManagedChannel) => c.id; // Which channel's row menu is open, for its "…" (`aria-expanded`, and staying shown while open). A // tiny store rather than a context: a context change walks the whole table to find its readers (~20 // ms on 400 rows, each open and close), while a store calls its subscribers and re-renders only the // two buttons whose answer changed (each reads a boolean, so a change elsewhere renders nothing). type OpenRowStore = Store; function RowMoreButton({ id, label, menuProps, register, openRow, }: { id: string; label: string; menuProps: Record; register: (id: string, el: HTMLButtonElement | null) => void; openRow: OpenRowStore; }) { const mine = useSyncExternalStore(openRow.subscribe, () => openRow.get() === id); return ( register(id, el)} label={label} expanded={mine} menuProps={menuProps} className={mine ? undefined : "reveal"} /> ); } export default function Channels() { const me = useMe(); const panel = useTabPanel(); const canWrite = me.can_write; // "Connect YouTube" onboarding lives in WizardProvider (a notify action / a mutation-error CTA). const { openWizard: onOpenWizard } = useWizard(); // The manager's name filter, status chip, active tab, reset token, and focus-channel intent live // in ChannelsProvider (shared with the header search box + the cross-module "focus this channel" // callers); this page is their primary editor. const { search, filters, view, sort, filtersResetToken, focusName: focusChannelName, focusToken: focusChannelToken, } = useChannels(); const layout = useChannelLayout(); const { setSearch, setFilters, setView, setLayout, setSort, focusChannel: onFocusChannel, consumeFocus, } = useChannelsActions(); const { openChannel: onViewChannel, setPage } = useNavigationActions(); const feedFilters = useFeedFilters(); const { setFilters: setFeedFilters } = useFeedFiltersActions(); const { t, i18n } = useTranslation(); const lang = i18n.language; const qc = useQueryClient(); // Clicking a tag chip filters the feed by that tag and jumps there. Internalised (was an App // callback) now that Channels reads the feed filters + navigation straight from their providers. // useCallback (like `onView`): the column memo below names it, and the hook lint cannot see the // compiler's memo of a plain closure. const onFilterByTag = useCallback( (tagId: number, name: string) => { setFeedFilters({ ...feedFilters, tags: [tagId], channelIds: [] }); setPage("feed"); notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) }); }, [setFeedFilters, feedFilters, setPage, t], ); const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels }); const tagsQuery = useTags(); const statusQuery = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus }); const blockedQuery = useQuery({ queryKey: qk.blockedChannels(), queryFn: api.blockedChannels }); const [tagManagerOpen, setTagManagerOpen] = useState(false); const unblock = useMutation({ mutationFn: (id: string) => api.unblockChannel(id), meta: { failTitle: "errors.writeFailed.unblockChannel" }, onSuccess: () => { qc.invalidateQueries({ queryKey: qk.blockedChannels() }); qc.invalidateQueries({ queryKey: qk.feed() }); }, }); const invalidate = () => { qc.invalidateQueries({ queryKey: qk.channels() }); qc.invalidateQueries({ queryKey: qk.tags() }); qc.invalidateQueries({ queryKey: qk.myStatus() }); // FB2: syncing subscriptions can change the feed immediately — newly-followed channels may // already have videos in the shared catalog, so refresh the feed too (not just the manager). qc.invalidateQueries({ queryKey: qk.feed() }); qc.invalidateQueries({ queryKey: qk.feedSummary() }); }; // A memo for the same reason as the tag actions below: the column memo names it. const allTags = tagsQuery.data; const userTags = useMemo(() => (allTags ?? []).filter((t) => !t.system), [allTags]); // 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) => qc.getQueryData(qk.channels())?.find((c) => c.id === id)?.priority, optimistic: (id, next) => qc.setQueryData(qk.channels(), (old) => (old ?? []).map((ch) => (ch.id === id ? { ...ch, priority: next } : ch)), ), 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, me.preferences), ); 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. const tagActionOpts = useMemo[0]>( () => ({ optimisticToggle: (id, tagId, attach) => qc.setQueryData(qk.channels(), (old) => (old ?? []).map((ch) => ch.id === id ? { ...ch, tag_ids: attach ? [...ch.tag_ids, tagId] : ch.tag_ids.filter((x) => x !== tagId), } : ch, ), ), // Keep the channel DETAIL cache in step with the manager list (the detail page reads its tags // from qk.channel(id)); mirrors the detail page invalidating the manager list on its side. Also // recompute suggestions: a channel that just gained/lost a user tag changes the untagged set and // the learned pattern (E8·S4). onSuccess: (id) => { qc.invalidateQueries({ queryKey: qk.channel(id) }); qc.invalidateQueries({ queryKey: qk.tagSuggestions() }); }, // A failed attach/detach reverts the optimistic change by refetching the list. Also refetch // suggestions: acceptSuggestion optimistically dropped the channel from that cache, and on // failure the tag never actually attached — so the server still suggests it and the ghost chips // must come back rather than leaving an empty cell (honest states). The hook reports the failure. onError: () => { qc.invalidateQueries({ queryKey: qk.channels() }); qc.invalidateQueries({ queryKey: qk.tagSuggestions() }); }, }), [qc], ); // The options are a memo so the hook's toggleTag/createAndAttachTag stay the same functions: the // column memo below names them (see `columns`). const { toggleTag, createAndAttachTag } = useChannelTagActions(tagActionOpts); // --- Suggested tags (E8·S4): learned from the user's own manual-tag pattern, shown as accept/dismiss // ghost chips on untagged channels. Dismissal is session-only (an accepted suggestion self-clears — // the channel becomes tagged and the server drops it; a dismissed one just hides until reload). The // /tags/suggestions query, the session dismiss set, and the optimistic accept/dismiss/bulk logic all // live in useTagSuggestions so they can be unit-tested apart from this component. const { suggestionsFor, dismissSuggestion, acceptSuggestion, visibleSuggestions, highConfidence, acceptAllHighConfidence, } = useTagSuggestions({ tags: tagsQuery.data ?? [], toggleTag }); const syncSubs = useMutation({ mutationFn: () => api.syncSubscriptions(), onSuccess: (r: { subscriptions?: number }) => { invalidate(); notify({ level: "success", message: t("channels.notify.synced", { count: r.subscriptions ?? 0 }), }); }, onError: (e) => notifyYouTubeActionError(e, t("channels.notify.syncFailed"), onOpenWizard), }); const deepAll = useMutation({ mutationFn: () => api.deepAll(true), onSuccess: (r: { updated?: number }) => { qc.invalidateQueries({ queryKey: qk.channels() }); qc.invalidateQueries({ queryKey: qk.myStatus() }); notify({ level: "success", message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }), }); }, onError: (e) => notifyYouTubeActionError(e, t("channels.notify.fullHistoryFailed"), onOpenWizard), }); // The tag-chip row is a controlled view over the Tags COLUMN filter (`filters.tags`, a string[] of // tag ids), the same way the sync chips drive `filters.sync` — so the top chips and the Tags column // funnel share ONE state and stay in sync (bidirectional). Toggling a chip rewrites `filters.tags`; // filterRows / DataTable then applies it. Channel-name search stays separate (the shared top box). const tagFilter = Array.isArray(filters.tags) ? filters.tags : []; // B4 (E28 review L7): on a short window the band's filters — the tag chip lane and the status / // pager row — fold behind one "Filters · n"; the tabs and the TAGS editor stay. n = each picked tag, // plus every other active column filter (the status chips are the Sync column's). const fold = useFilterFold(); const foldCount = tagFilter.length + Object.entries(filters).filter(([k, v]) => k !== "tags" && isActive(v)).length; // Picking a real tag drops the "No tag" sentinel (item B) — they're mutually exclusive, as in the feed. const toggleTagFilter = (tagId: number) => { const id = String(tagId); const base = tagFilter.filter((x) => x !== NO_TAG_SENTINEL); const next = base.includes(id) ? base.filter((x) => x !== id) : [...base, id]; setFilters({ ...filters, tags: next }); }; // The "No tag" chip: filter the table to channels you haven't personally tagged yet. Exclusive with // real tag chips (clicking it drops them); the Tags column's test reads the sentinel (channelColumns). const noTagOn = tagFilter.includes(NO_TAG_SENTINEL); const toggleNoTagFilter = () => setFilters({ ...filters, tags: noTagOn ? [] : [NO_TAG_SENTINEL] }); // The tag-filter chips, fed to TagFilterChips which folds any overflow into a "+N" pill so the row // stays one line and the trailing "…" actions can't wrap. const tagChips = tagFilterChips({ userTags, tags: tagsQuery, picked: tagFilter, noTagOn, toggle: toggleTagFilter, toggleNoTag: toggleNoTagFilter, t, }); // A focus-channel intent (header "without full history" / tag manager) seeds the search box. // The TOKEN is the intent (it advances even when the same channel is re-focused); the name is the // payload, so it is read through an effect event. Naming it would re-seed the box on any render that // changed it, overwriting whatever the user had since typed. // // `consumeFocus` is what stops this page's OWN remounts from re-applying it: this effect runs on // every mount, and this page unmounts on every navigation away — and on opening a channel page, // which does not even change `page`. The provider then reports `focusName` as null, so a later // visit leaves the box exactly as the user left it. const applyFocus = useEffectEvent(() => { if (!focusChannelName) return; setSearch(focusChannelName); consumeFocus(); }); useEffect(() => applyFocus(), [focusChannelToken]); // The header's reset intent clears the search box (skip the initial mount); the tag chips ride in // `filters.tags`, which goToFullHistory already clears by setting a fresh map. const resetRef = useRef(filtersResetToken); useEffect(() => { if (filtersResetToken === resetRef.current) return; resetRef.current = filtersResetToken; setSearch(""); }, [filtersResetToken, setSearch]); const onView = useCallback( (c: ManagedChannel) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel")), [onViewChannel, t], ); // The channel's ONE action list (E28 C1) — ONE menu for the list, shared by the table and the card // 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. // 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: channelKey }); const rowPin = useComfortPin(rowMenu.open); // The columns read only the menu's STABLE handles. Which row's menu is open reaches each "…" through // the open-row store instead: read in the columns, it rebuilt every column on each open, close and // reposition, and so re-rendered the whole table — ~0.8 s to open the menu on 400 rows. const { buttonProps: rowButtonProps, triggerProps: rowTriggerProps } = rowMenu; const openRowId = rowMenu.open ? (rowMenu.target?.id ?? null) : null; const [openRow] = useState(() => createStore(null)); useEffect(() => openRow.set(openRowId), [openRow, openRowId]); const channelActions = useChannelActions({ onView, onOpenWizard, tags: userTags, tagsState: tagsQuery.isPending ? "loading" : tagsQuery.isError ? "error" : "ready", 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")), }) : []; // Tag/priority/text filters stay controlled COLUMN filters (`filters.tags` etc.) shared by the top // chips, the column funnels and the cards. The sync-status filter (`filters.sync`) is no longer a // column — R14 retired the Sync column and folded its status into a ✓ charm on Stored — so it's // applied as a pre-filter (`applySyncFilter`) over the rows fed to BOTH the table and the cards, // still driven by the same top chips. Hidden channels have their own tab (excluded here). Only the // name search filters at this step; the remaining column funnels run in DataTable / filterRows below. // // The table's rows and columns are explicit memos, not inline. Both later reach calls the React // Compiler must assume may mutate them (`filterRows`, `sortRows`, `optionsFromColumns`) past this // page's hooks, and a compiler memo cannot span a hook call — so inline, the compiler dropped their // memo without a word, and every render (each open, close and reposition of the row menu) handed // DataTable new rows and columns: ~0.9 s to open the menu on 400 rows. A useMemo result is frozen. const hiddenChannels = (channelsQuery.data ?? []).filter((c) => c.hidden); const allChannels = channelsQuery.data; const q = search.trim().toLowerCase(); const syncFilterValue = filters.sync; const syncedChannels = useMemo( () => // Sync-status pre-filter (top chips → `filters.sync`), applied before the table/cards see the rows. applySyncFilter( (allChannels ?? []).filter( (c) => !c.hidden && (!q || `${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q)), ), syncFilterValue, ), [allChannels, q, syncFilterValue], ); const s = statusQuery.data; const columns = useMemo(() => { const registerMore = (id: string, el: HTMLButtonElement | null) => { if (el) moreRefs.current.set(id, el); else moreRefs.current.delete(id); }; return buildChannelColumns({ t, lang, // Distinct priority levels present in the catalog → the priority column's filter options. prioOptions: [...new Set((allChannels ?? []).map((c) => c.priority))] .sort((a, b) => b - a) .map((p) => ({ value: String(p), label: String(p) })), userTags, bumpPriority, setPriority, more: (c) => ( ), onView, toggleTag, createAndAttachTag, onFilterByTag, suggestionsFor, onAcceptSuggestion: acceptSuggestion, onDismissSuggestion: dismissSuggestion, }); }, [ t, lang, allChannels, userTags, bumpPriority, setPriority, rowButtonProps, openRow, onView, toggleTag, createAndAttachTag, onFilterByTag, suggestionsFor, acceptSuggestion, dismissSuggestion, ]); // Status chips sit in the table's controls row (next to paging) — compact and close to // where they act. const syncFilter = typeof filters.sync === "string" ? filters.sync : ""; const statusChips = ( ({ value: c.value, label: t(c.labelKey) }))} value={syncFilter} onChange={(sync) => setFilters({ ...filters, sync })} /> ); // The catalog-wide stats collapse into a compact "Stats" hover chip (right of the tab row, subscribed // only) instead of a crowded inline row. const statsBlock = s ? : null; // Catalog-wide, occasional actions — tucked into a shared ⋯ overflow menu so they stop crowding the // tags row. Pending mutations keep the item visible but disabled, with a spinning icon. const catalogActions: ActionMenuItem[] = [ { id: "sync", label: t("channels.syncSubscriptions"), title: t("channels.syncSubscriptionsHint"), icon: , onSelect: () => syncSubs.mutate(), disabled: syncSubs.isPending, }, { id: "backfill", label: t("channels.backfillEverything"), title: t("channels.backfillEverythingHint"), icon: , onSelect: () => deepAll.mutate(), disabled: deepAll.isPending, }, ]; const actionButtons = ( ); // Table / cards density — shared by the subscribed + discovery tables (blocked is a chip list, no // layout to switch). Rides the same generic ViewSwitcher the feed uses. const layoutOptions: ViewOption[] = CHANNEL_LAYOUTS.map((id) => ({ id, label: t("channels.layout." + id), icon: LAYOUT_ICON[id], })); const layoutSwitcher = ( ); const hasLayout = view === "subscribed" || view === "discovery"; // In cards there are no column headers to sort by, so a dropdown drives the SAME shared sort state // the table header uses (table ↔ cards stay in sync). Table mode keeps the header clicks. const sortControl = ( ); // The card layout paginates like the table (a page of cards + a pager), rather than one long // infinite scroll. Slice the sorted rows; the Pager reports page/size changes. // Only computed for cards: in table mode DataTable sorts and slices internally, so doing it here // too would copy+sort the whole ~300-row array on every render for a result nothing reads. const isCards = layout === "cards"; // Cards have no column headers, so apply the shared column filters (sync + any funnel set in table // mode) here via the exported predicate — the same filterRows DataTable runs internally — then sort. const sortedChannels = isCards ? sortRows(filterRows(syncedChannels, columns, filters), columns, sort, lang) : syncedChannels; const { paged: pagedCards, pagerProps, resetPage, } = 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 // otherwise yank you off page 7 for something you didn't do — a list that shrank past your page is // handled by the hook's clamp instead. useEffect(() => { resetPage(); }, [filters, search, sort, resetPage]); const cardPager = ; const viewTabs: TabDef[] = useMemo( () => (["subscribed", "discovery", "hidden", "blocked"] as ChannelsView[]).map((v) => ({ id: v, label: t(`channels.tabs.${v}`), })), [t], ); const tabs = (
setView(id as ChannelsView)} className="flex flex-wrap items-center gap-1" />
{view === "subscribed" && statsBlock} {view === "subscribed" && layout === "cards" && sortControl} {hasLayout && layoutSwitcher}
); // The page's background menu (DESIGN "Background menu"): the toolbar's own controls, no more — the // layout, the cards' sort, the status and tag filters, the tag editor and the catalog sync. Backfill // stays in the "…" only: a long quota-heavy run is not a thing to start from a stray right-click. const syncOptions = SYNC_CHIPS.map((c) => ({ value: c.value, label: t(c.labelKey) })); const pageMenuItems = menuGroups( hasLayout ? [ choiceSubmenu({ id: "layout", label: t("channels.layoutLabel"), options: layoutOptions.map((o) => ({ value: o.id, label: o.label })), selected: layout, onPick: setLayout, }), ] : [], view === "subscribed" && isCards ? [ sortSubmenu({ id: "sort", label: t("channels.sort.label"), options: optionsFromColumns(columns), sort, onChange: setSort, ascLabel: t("channels.sort.asc"), descLabel: t("channels.sort.desc"), defaultLabel: t("channels.sort.default"), }), ] : [], view === "subscribed" ? [ choiceSubmenu({ id: "status", label: t("channels.filters.syncLabel"), options: syncOptions, selected: syncFilter, onPick: (sync) => setFilters({ ...filters, sync }), }), { id: "tags", label: t("channels.tags.edit"), onSelect: () => setTagManagerOpen(true) }, ...(tagFilter.length > 0 ? [ { id: "clearTags", label: t("channels.tags.clearFilter"), onSelect: () => setFilters({ ...filters, tags: [] }), }, ] : []), ] : [], view === "subscribed" ? catalogActions.filter((a) => a.id === "sync") : [], ); return ( <> {pageMenuItems.length > 0 && ( )} {/* The tab bar is always fixed chrome (it portals into the shell's fixed band, so it never scrolls and doesn't jump between tabs). On Subscriptions the stats/description/tags ride with it, and the status-chip + pager row lands just below (DataTable's controlsInBand). */} {tabs} {view === "subscribed" && (
{/* One row: the TAGS editor trigger (left) + the tag-filter chip lane (folds overflow into "+N") + Clear, then the catalog "…" actions pinned right, so the "…" menu always stays on the first line. Only the suggestions block wraps: below 40rem it takes a line of its own under the row (E28 S3f3, the owner's pick A) — at 375 it had squeezed the chip lane to 0 px and pushed the "…" off the screen. Channel-name search lives in the shared top SearchBar. */}
{/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate "Manage tags" button; the tooltip explains what tags are + that it's clickable. */} {!fold.folded && } {!fold.folded && tagFilter.length > 0 && ( )} {/* Suggested-tags affordance (E8·S4): a sparkle count of the visible suggestions, with a one-click bulk-accept for the high-confidence ones. The per-row ghost chips handle the rest. Hidden when there's nothing to suggest. */} {visibleSuggestions.length > 0 && (
{t("channels.tags.suggestionsCount", { count: visibleSuggestions.length })} {highConfidence.length > 0 && ( )}
)}
{actionButtons}
)} {/* In table mode the status chips + pager ride in DataTable's own in-band controls row. The card layout has no such row, so surface the same status chips (left) and its pager (right) here. */} {view === "subscribed" && layout === "cards" && !fold.folded && (
{statusChips} {cardPager}
)}
{tagManagerOpen && ( setTagManagerOpen(false)} onFocusChannel={onFocusChannel} /> )}
{view === "discovery" ? ( ) : view === "blocked" ? ( /* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
} title={t("channels.blocked.title")} hint={t("channels.blocked.hint")} emptyText={t("channels.blocked.empty")} actionLabel={t("channel.unblock")} actionIcon={} onAction={(id) => unblock.mutate(id)} onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))} />
) : view === "hidden" ? ( /* Hidden channels — kept out of the Mine feed but still in the shared Library; managed here instead of dimmed in the subscribed table. Same card grid as Blocked (ExcludedChannelList). */
} title={t("channels.hidden.title")} hint={t("channels.hidden.hint")} emptyText={t("channels.hidden.empty")} actionLabel={t("channels.hidden.unhide")} actionIcon={} onAction={channelActions.unhide} onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))} />
) : ( /* 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 status chips ride in the fixed band above instead). */} {channelsQuery.isError && !channelsQuery.data ? ( channelsQuery.refetch()}> {t("common.loadError")} ) : channelsQuery.isLoading ? ( ) : layout === "table" ? ( c.id} persistKey={accountKey(LS.channelsTable) ?? undefined} // Folded, the status / pager row leaves the band; the list's own end keeps it. controlsPosition={fold.folded ? "bottom" : "both"} controlsLeading={statusChips} controlsInBand controlsBandClassName="px-4 max-w-384 mx-auto" sort={sort} onSortChange={setSort} filters={filters} onFiltersChange={setFilters} fixedLayout emptyText={t("channels.empty")} rowProps={rowTriggerProps} // `group`: the row's "…" reveals on its hover or focus (`.reveal`), and stays on touch. rowClassName={() => "group"} keyboardNav reveal={follow} surfacedFilters={SURFACED_FILTERS} narrowedBy={q} /> ) : ( <> {/* Cards have no funnels: a Priority or name filter set in the table would narrow them unseen. The tags have their own chip row. */} setFilters({ ...filters, [key]: value })} /> c.id} leadKey="priority" aboutKey="about" moreKey="actions" emptyText={t("channels.empty")} rowProps={rowTriggerProps} rowClassName={() => "group"} /> {/* Folded, the status / pager row leaves the band, as the table's does — and the list's own end keeps it here too (round 2, F2: the cards had lost their pager). */} {fold.folded && (
{statusChips} {cardPager}
)} )}
)}
); }