import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { Ban, Eye, EyeOff, History, LayoutGrid, Pencil, RefreshCw, Table, X } from "lucide-react"; import ExcludedChannelList from "./ExcludedChannelList"; import { api, type ManagedChannel } from "../lib/api"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { accountKey, LS } from "../lib/storage"; import { formatEta } from "../lib/format"; import { buildChannelColumns } from "./channelColumns"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; import DataTable from "./DataTable"; import ChannelLayoutGrid from "./ChannelLayoutGrid"; import { LoadingState, StateMessage } from "./QueryState"; import { useChannelTagActions } from "../lib/useChannelTagActions"; import { useChannelPriority } from "../lib/useChannelPriority"; import ViewSwitcher, { type ViewOption } from "./ViewSwitcher"; import ColumnSortControl from "./ColumnSortControl"; import Pager from "./Pager"; import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout"; import { sortRows } from "../lib/columnSort"; import { filterRows } from "./tableHeader"; import { useCardPager } from "../lib/useCardPager"; import { PageToolbar } from "./PageShell"; import ChannelDiscovery from "./ChannelDiscovery"; import TagManager from "./TagManager"; import { useConfirm } from "./ConfirmProvider"; import { useChannelLayout, useChannels, useChannelsActions } from "./ChannelsProvider"; import { useNavigationActions } from "./NavigationProvider"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; import { useWizard } from "./WizardProvider"; import { useMe } from "../lib/useMe"; export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked"; // 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, }; export default function Channels() { const me = useMe(); const canWrite = me.can_write; const isAdmin = me.role === "admin"; // "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, } = useChannelsActions(); const { openChannel: onViewChannel, setPage } = useNavigationActions(); const feedFilters = useFeedFilters(); const { setFilters: setFeedFilters } = useFeedFiltersActions(); const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); // 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. const onFilterByTag = (tagId: number, name: string) => { setFeedFilters({ ...feedFilters, tags: [tagId], channelIds: [] }); setPage("feed"); notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) }); }; const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels }); const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags }); 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), 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.feedCount() }); }; const userTags = (tagsQuery.data ?? []).filter((t) => !t.system); const patch = useMutation({ mutationFn: (v: { id: string; body: { priority?: number; hidden?: boolean; deep_requested?: boolean }; }) => api.updateChannel(v.id, v.body), // Requesting full history may have just pulled in a channel's recent uploads, so // refresh the per-user status and feed too — not only the channel list. onSuccess: () => { qc.invalidateQueries({ queryKey: qk.channels() }); qc.invalidateQueries({ queryKey: qk.myStatus() }); qc.invalidateQueries({ queryKey: qk.feed() }); qc.invalidateQueries({ queryKey: qk.feedCount() }); }, }); // Priority is updated optimistically in place and NOT re-sorted/refetched, so the list doesn't // jump (the server list is priority-sorted; refetching would reorder rows and lose your scroll // position). The new order takes effect next time the page loads. Press-and-hold is debounced + // 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() }), }); // 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 { toggleTag, createAndAttachTag } = useChannelTagActions({ 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. onSuccess: (id) => qc.invalidateQueries({ queryKey: qk.channel(id) }), onError: () => qc.invalidateQueries({ queryKey: qk.channels() }), }); 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 unsubscribe = useMutation({ mutationFn: (v: { id: string; name: string }) => api.unsubscribeChannel(v.id), onSuccess: (_d, v) => { qc.invalidateQueries({ queryKey: qk.channels() }); qc.invalidateQueries({ queryKey: qk.myStatus() }); notify({ level: "success", message: t("channels.notify.unsubscribed", { name: v.name }) }); }, onError: (e) => notifyYouTubeActionError(e, t("channels.notify.unsubscribeFailed"), onOpenWizard), }); const resetBackfill = useMutation({ mutationFn: (v: { id: string; name: string }) => api.resetChannelBackfill(v.id), onSuccess: (_d, v) => { qc.invalidateQueries({ queryKey: qk.channels() }); qc.invalidateQueries({ queryKey: qk.myStatus() }); notify({ level: "success", message: t("channels.notify.resetDone", { name: v.name }) }); }, onError: (e) => notifyYouTubeActionError(e, t("channels.notify.resetFailed"), 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 : []; const toggleTagFilter = (tagId: number) => { const id = String(tagId); const next = tagFilter.includes(id) ? tagFilter.filter((x) => x !== id) : [...tagFilter, id]; setFilters({ ...filters, tags: next }); }; // A focus-channel intent (header "without full history" / tag manager) seeds the search box. useEffect(() => { if (focusChannelName) setSearch(focusChannelName); // eslint-disable-next-line react-hooks/exhaustive-deps }, [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]); // The sync-status AND tag filters are now controlled column filters (`filters.sync` / `filters.tags`) // shared by the top chips, the column funnels, and the card layout — no separate pre-filter. Hidden // channels have their own tab, so they're excluded from the subscribed table (no more dimmed rows). // Only the name search filters here; the column filters (sync/tags/priority/text funnels) are applied // by DataTable in table mode and by filterRows below in card mode, over the same `columns`. const hiddenChannels = (channelsQuery.data ?? []).filter((c) => c.hidden); const channels = (channelsQuery.data ?? []).filter((c) => !c.hidden); const q = search.trim().toLowerCase(); const visibleChannels = channels.filter( (c) => !q || `${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q), ); const s = statusQuery.data; const onView = (c: ManagedChannel) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel")); const onUnsub = async (c: ManagedChannel) => { const ok = await confirm({ title: t("channels.row.unsubscribeOnYoutube"), message: t("channels.confirmUnsubscribe", { name: c.title ?? c.id }), confirmLabel: t("channels.row.unsubscribeOnYoutube"), danger: true, }); if (ok) unsubscribe.mutate({ id: c.id, name: c.title ?? c.id }); }; const onReset = async (c: ManagedChannel) => { const ok = await confirm({ title: t("channels.row.backfillThis"), message: t("channels.confirmReset", { name: c.title ?? c.id }), confirmLabel: t("channels.row.backfillThis"), }); if (ok) resetBackfill.mutate({ id: c.id, name: c.title ?? c.id }); }; // Distinct priority levels present in the catalog → the priority column's filter options. const prioOptions = [...new Set((channelsQuery.data ?? []).map((c) => c.priority))] .sort((a, b) => b - a) .map((p) => ({ value: String(p), label: String(p) })); const columns = buildChannelColumns({ t, isAdmin, canWrite, prioOptions, userTags, bumpPriority, setPriority, onHide: (c) => patch.mutate({ id: c.id, body: { hidden: !c.hidden } }), onDeep: (c) => patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } }), onReset, onUnsub, onView, toggleTag, createAndAttachTag, onFilterByTag, }); // 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 = (
{SYNC_CHIPS.map((c) => ( ))}
); // The catalog-wide stats ride on the right of the tab row (subscribed only), leaving room for // future tabs on the left. const statsBlock = (
{s && ( <> {s.deep_pending_count > 0 && ( )} )}
); // Sync + backfill — now share the tags row (right side). 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(visibleChannels, columns, filters), columns, sort) : visibleChannels; const { paged: pagedCards, pagerProps, resetPage, } = useCardPager(sortedChannels, LS.channelCardSize, isCards); // 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 tabs = (
{(["subscribed", "discovery", "hidden", "blocked"] as ChannelsView[]).map((v) => ( ))}
{view === "subscribed" && statsBlock} {view === "subscribed" && layout === "cards" && sortControl} {hasLayout && layoutSwitcher}
); return ( <> {/* 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" && (
{/* Your tags (left) share one row with the two catalog-wide actions (right); the channel-name search lives in the shared top SearchBar, tag create/delete in the manager. */}
{t("channels.tags.yourTags")} {userTags.map((tg) => ( ))} {tagFilter.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" && (
{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={(id) => patch.mutate({ id, body: { hidden: false } })} 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} controlsPosition="both" controlsLeading={statusChips} controlsInBand controlsBandClassName="px-4 max-w-384 mx-auto" sort={sort} onSortChange={setSort} filters={filters} onFiltersChange={setFilters} emptyText={t("channels.empty")} /> ) : ( c.id} leadKey="priority" aboutKey="about" actionsKey="actions" emptyText={t("channels.empty")} /> )}
)} ); } function Stat({ label, value, hint }: { label: string; value: string | number; hint?: string }) { return ( {value} {label} ); }