Files
siftlode/frontend/src/modules/channels/Channels.tsx
T
peter b5f9cf14db feat(channels): a "Discover from your history" tab for the channels watched but not followed
The Channel manager lists the channels the Takeout history shows watched
often but not followed, the most watched first: twenty at a time, the
channel's About on hover, its name to its page, Follow, and Not interested
with an Undo. A row's count opens the videos it counts, ready to play one
or all. Before a history is uploaded it points to the upload; while the
import still asks YouTube it says more channels may join.
2026-10-24 00:00:50 +02:00

901 lines
41 KiB
TypeScript

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 { LS, ownKey } from "../../lib/storage";
import { buildChannelColumns, applySyncFilter, tagFilterChips } from "./channelColumns";
import { notify } from "../../lib/notifications";
import { Badge } from "../../components/ui/Badge";
import { Button } from "../../components/ui/Button";
import Tooltip from "../../components/ui/Tooltip";
import DataTable from "../../components/ui/DataTable";
import Tabs, { type TabDef } from "../../components/ui/Tabs";
import { useTabPanel } from "../../components/ui/tabPanel";
import ChannelLayoutGrid from "./ChannelLayoutGrid";
import { pinChannel, useComfortPin } from "../../features/comfort/comfortPin";
import { ContextMenu } from "../../components/ui/ContextMenu";
import { MoreButton } from "../../components/ui/MoreButton";
import { useChannelActions } from "./useChannelActions";
import { useContextMenu } from "../../components/ui/useContextMenu";
import { LoadingState, StateMessage } from "../../components/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 "../../components/ui/ViewSwitcher";
import ColumnSortControl from "../../components/ui/ColumnSortControl";
import FilterChipGroup from "../../components/ui/FilterChipGroup";
import TagFilterChips from "./TagFilterChips";
import ActionMenu, { type ActionMenuItem } from "../../components/ui/ActionMenu";
import ChannelStatsPopover from "./ChannelStatsPopover";
import Pager from "../../components/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 "../../components/ui/FilterFold";
import { HiddenFilterCue } from "../../components/ui/HiddenFilterCue";
import { useCardPager } from "../../lib/useCardPager";
import { PageToolbar } from "../../components/ui/PageShell";
import { PageMenu } from "../../components/ui/PageMenu";
import { menuGroups } from "../../components/ui/menuGroups";
import { choiceSubmenu, sortSubmenu } from "../../components/ui/pageMenuItems";
import { optionsFromColumns } from "../../lib/sortOptions";
import ChannelDiscovery from "./ChannelDiscovery";
import WatchedChannels from "./WatchedChannels";
import TagManager from "../../features/channel/TagManager";
import {
useChannelLayout,
useChannels,
useChannelsActions,
type ChannelsView,
} from "./channelsContext";
import { useNavigationActions } from "../../features/navigation/navigationContext";
import { useFeedFilters, useFeedFiltersActions } from "../feed";
import { useWizard } from "../../features/wizard/wizardContext";
import { useMe } from "../../lib/useMe";
import { useTags } from "../../lib/useTags";
import ChannelsHeader from "./ChannelsHeader";
// 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<ChannelLayout, typeof Table> = {
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<string | null>;
function RowMoreButton({
id,
label,
menuProps,
register,
openRow,
}: {
id: string;
label: string;
menuProps: Record<string, unknown>;
register: (id: string, el: HTMLButtonElement | null) => void;
openRow: OpenRowStore;
}) {
const mine = useSyncExternalStore(openRow.subscribe, () => openRow.get() === id);
return (
<MoreButton
ref={(el) => 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() });
// A blocked channel is off the watched-channels list; unblocked, it may be back on it.
qc.invalidateQueries({ queryKey: qk.watchedChannels() });
},
});
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<ManagedChannel[]>(qk.channels())?.find((c) => c.id === id)?.priority,
optimistic: (id, next) =>
qc.setQueryData<ManagedChannel[]>(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 (the owner's call): 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, 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<Parameters<typeof useChannelTagActions>[0]>(
() => ({
optimisticToggle: (id, tagId, attach) =>
qc.setQueryData<ManagedChannel[]>(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.
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: 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: 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 — 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<ManagedChannel>({ 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<string | null>(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 — the retired Sync column became 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) => (
<RowMoreButton
id={c.id}
label={t("channels.row.menu")}
menuProps={rowButtonProps(c)}
register={registerMore}
openRow={openRow}
/>
),
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 = (
<FilterChipGroup
ariaLabel={t("channels.filters.syncLabel")}
className="flex flex-wrap items-center gap-1.5"
options={SYNC_CHIPS.map((c) => ({ 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 ? <ChannelStatsPopover status={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: <RefreshCw className={`h-4 w-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />,
onSelect: () => syncSubs.mutate(),
disabled: syncSubs.isPending,
},
{
id: "backfill",
label: t("channels.backfillEverything"),
title: t("channels.backfillEverythingHint"),
icon: (
<History className={`h-4 w-4 ${deepAll.isPending ? "motion-safe:animate-pulse" : ""}`} />
),
onSelect: () => deepAll.mutate(),
disabled: deepAll.isPending,
},
];
const actionButtons = (
<ActionMenu ariaLabel={t("channels.actionsLabel")} items={catalogActions} />
);
// Table / cards density — shared by the subscribed + both discovery tables (blocked is a chip list, no
// layout to switch). Rides the same generic ViewSwitcher the feed uses.
const layoutOptions: ViewOption<ChannelLayout>[] = CHANNEL_LAYOUTS.map((id) => ({
id,
label: t("channels.layout." + id),
icon: LAYOUT_ICON[id],
}));
const layoutSwitcher = (
<ViewSwitcher
value={layout}
options={layoutOptions}
onChange={setLayout}
label={t("channels.layoutLabel")}
labelClassName="hidden lg:inline"
/>
);
const hasLayout = view === "subscribed" || view === "discovery" || view === "watched";
// 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 = (
<ColumnSortControl
columns={columns}
sort={sort}
onChange={setSort}
label={t("channels.sort.label")}
defaultLabel={t("channels.sort.default")}
ascLabel={t("channels.sort.asc")}
descLabel={t("channels.sort.desc")}
/>
);
// 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 = <Pager {...pagerProps} />;
const viewTabs: TabDef[] = useMemo(
() =>
(["subscribed", "discovery", "watched", "hidden", "blocked"] as ChannelsView[]).map((v) => ({
id: v,
label: t(`channels.tabs.${v}`),
})),
[t],
);
const tabs = (
<div className="px-4 pt-2 max-w-384 mx-auto flex items-center justify-between gap-4 flex-wrap">
<Tabs
idBase={panel.idBase}
tabs={viewTabs}
active={view}
onChange={(id) => setView(id as ChannelsView)}
className="flex flex-wrap items-center gap-1"
/>
<div className="flex items-center gap-3">
{view === "subscribed" && statsBlock}
{view === "subscribed" && layout === "cards" && sortControl}
{hasLayout && layoutSwitcher}
</div>
</div>
);
// 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 (
<>
<ChannelsHeader />
{pageMenuItems.length > 0 && (
<PageMenu ariaLabel={t("common.pageMenu")} items={pageMenuItems} />
)}
{/* 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). */}
<PageToolbar>
{tabs}
{view === "subscribed" && (
<div className="@container/tagrow px-4 pt-2 pb-2 max-w-384 mx-auto">
{/* 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 (the owner's pick) — 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. */}
<div className="flex flex-wrap items-center gap-2 min-w-0">
{/* 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. */}
<Tooltip hint={t("channels.tags.yourTagsHint")}>
<Button
variant="outline"
size="xs"
className="shrink-0 uppercase tracking-wide"
onClick={() => setTagManagerOpen(true)}
icon={<Pencil className="h-3 w-3" />}
>
{t("channels.tags.label")}
</Button>
</Tooltip>
<FilterFoldButton fold={fold} label={t("sidebar.filters")} count={foldCount} />
{!fold.folded && <TagFilterChips chips={tagChips} className="flex-1" />}
{!fold.folded && tagFilter.length > 0 && (
<Button
variant="link-muted"
onClick={() => setFilters({ ...filters, tags: [] })}
className="text-xs px-2"
>
{t("datatable.clear")}
</Button>
)}
{/* Suggested-tags affordance: 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 && (
<div
data-tag-suggestions
className="shrink-0 flex items-center gap-1.5 @max-[40rem]/tagrow:order-last @max-[40rem]/tagrow:basis-full"
>
<Tooltip hint={t("channels.tags.suggestionsHint")}>
<Badge tone="accent">
<Sparkles />
{t("channels.tags.suggestionsCount", { count: visibleSuggestions.length })}
</Badge>
</Tooltip>
{highConfidence.length > 0 && (
<Button variant="outline" size="xs" onClick={acceptAllHighConfidence}>
{t("channels.tags.acceptAllHigh", { count: highConfidence.length })}
</Button>
)}
</div>
)}
<div className="shrink-0">{actionButtons}</div>
</div>
</div>
)}
{/* 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 && (
<div className="px-4 pb-2 max-w-384 mx-auto flex items-center justify-between gap-3 flex-wrap">
{statusChips}
{cardPager}
</div>
)}
</PageToolbar>
{tagManagerOpen && (
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
)}
<div {...panel.panelProps(view)}>
{view === "discovery" ? (
<ChannelDiscovery
canWrite={canWrite}
search={search}
onOpenWizard={onOpenWizard}
onViewChannel={onViewChannel}
/>
) : view === "watched" ? (
<WatchedChannels
canWrite={canWrite}
search={search}
onOpenWizard={onOpenWizard}
onViewChannel={onViewChannel}
/>
) : view === "blocked" ? (
/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
<ExcludedChannelList
channels={blockedQuery.data ?? []}
icon={<Ban className="w-4 h-4 text-muted" />}
title={t("channels.blocked.title")}
hint={t("channels.blocked.hint")}
emptyText={t("channels.blocked.empty")}
actionLabel={t("channel.unblock")}
actionIcon={<X className="w-3.5 h-3.5" />}
onAction={(id) => unblock.mutate(id)}
onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
/>
</div>
) : 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). */
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
<ExcludedChannelList
channels={hiddenChannels}
icon={<EyeOff className="w-4 h-4 text-muted" />}
title={t("channels.hidden.title")}
hint={t("channels.hidden.hint")}
emptyText={t("channels.hidden.empty")}
actionLabel={t("channels.hidden.unhide")}
actionIcon={<Eye className="w-3.5 h-3.5" />}
onAction={channelActions.unhide}
onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
/>
</div>
) : (
/* 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 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
status chips ride in the fixed band above instead). */}
{channelsQuery.isError && !channelsQuery.data ? (
<StateMessage tone="error" onRetry={() => channelsQuery.refetch()}>
{t("common.loadError")}
</StateMessage>
) : channelsQuery.isLoading ? (
<LoadingState shape="table" label={t("channels.loading")} />
) : layout === "table" ? (
<DataTable
rows={syncedChannels}
columns={columns}
rowKey={(c) => c.id}
persistKey={ownKey(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. */}
<HiddenFilterCue
columns={columns}
filters={filters}
by="cards"
surfaced={SURFACED_FILTERS}
onFilter={(key, value) => setFilters({ ...filters, [key]: value })}
/>
<ChannelLayoutGrid
rows={pagedCards}
columns={columns}
rowKey={(c) => 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 (the cards had once lost their pager). */}
{fold.folded && (
<div className="pt-3 flex items-center justify-between gap-3 flex-wrap">
{statusChips}
{cardPager}
</div>
)}
</>
)}
<ContextMenu menu={rowMenu} ariaLabel={t("channels.row.menu")} items={menuItems} />
</div>
)}
</div>
</>
);
}