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.
901 lines
41 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|