import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { UserPlus } from "lucide-react"; import { api, type DiscoveredChannel } from "../lib/api"; import { accountKey, LS, readAccount, writeAccount } from "../lib/storage"; import { formatCountOrDash } from "../lib/format"; import { parseSortState, sortRows, type SortState } from "../lib/columnSort"; import { useCardPager } from "../lib/useCardPager"; import { subsColumn } from "./channelColumns"; import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import Tooltip from "./Tooltip"; import ChannelLink from "./ChannelLink"; import AboutCell from "./AboutCell"; import DataTable, { type Column } from "./DataTable"; import ChannelLayoutGrid from "./ChannelLayoutGrid"; import { LoadingState, StateMessage } from "./QueryState"; import ColumnSortControl from "./ColumnSortControl"; import Pager from "./Pager"; import { PageToolbar } from "./PageShell"; import { useConfirm } from "./ConfirmProvider"; import { useChannelLayout } from "./ChannelsProvider"; // The Channel manager's "Discovery" tab: channels that turn up in the user's playlists but // that they don't subscribe to. Subscribing here only creates the subscription + enriches // the channel's metadata — the scheduler pulls its videos on its next run (see backend). export default function ChannelDiscovery({ canWrite, search, onOpenWizard, onViewChannel, }: { canWrite: boolean; // Client-side name filter from the shared top SearchBar (matches title + handle). search: string; onOpenWizard: () => void; onViewChannel: (id: string, name: string) => void; }) { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); // The table/cards density is shared with the Subscriptions tab (ChannelsProvider). The sort is // this tab's own (its columns differ), lifted out of the table so the card layout shares it. const layout = useChannelLayout(); const [sort, setSortState] = useState(() => parseSortState(readAccount(LS.channelDiscoverySort, null)), ); const setSort = (s: SortState) => { setSortState(s); writeAccount(LS.channelDiscoverySort, s); }; const query = useQuery({ queryKey: qk.discoveredChannels(), queryFn: api.discoveredChannels, }); const subscribe = useMutation({ mutationFn: (c: DiscoveredChannel) => api.subscribeChannel(c.id), onSuccess: (_data, c) => { // The channel moves from "discovery" to "subscribed", and its videos will start // arriving — refresh both lists plus the per-user status. qc.invalidateQueries({ queryKey: qk.discoveredChannels() }); qc.invalidateQueries({ queryKey: qk.channels() }); qc.invalidateQueries({ queryKey: qk.myStatus() }); const name = c.title ?? c.id; // Name the channel and ship a typed payload so the inbox can offer "open in the // Channel manager" / "open on YouTube" links — kept after reload, when live // callbacks are gone (see ChannelSubscribedMeta). notify({ level: "success", title: t("channels.discovery.subscribedTitle"), message: t("channels.discovery.subscribedBody", { name }), meta: { kind: "channel-subscribed", channelId: c.id, channelName: name }, }); }, onError: (err: unknown) => notifyYouTubeActionError(err, t("channels.discovery.subscribeFailed"), onOpenWizard), }); // Subscribing changes the user's real YouTube account and spends a little quota — confirm // first (mirrors the unsubscribe guard in the Subscriptions tab). const onSubscribe = async (c: DiscoveredChannel) => { const ok = await confirm({ title: t("channels.discovery.subscribe"), message: t("channels.discovery.confirmSubscribe", { name: c.title ?? c.id }), confirmLabel: t("channels.discovery.subscribe"), }); if (ok) subscribe.mutate(c); }; const q = search.trim().toLowerCase(); const rows = (query.data ?? []).filter( (c) => !q || `${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q), ); const columns: Column[] = [ // Actions first, mirroring the Subscriptions table (where it sits right after Prio) so the two // tabs' action clusters line up in the same place. { key: "actions", header: t("channels.cols.actions"), align: "left", nowrap: true, width: "120px", cardLabel: false, render: (c) => ( ), }, { key: "channel", header: t("channels.cols.channel"), sortable: true, sortValue: (c) => (c.title ?? c.id).toLowerCase(), filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` }, cardPrimary: true, // Cap the width (a max-w element inside the cell; table-layout:auto ignores a cell max-width) so // a long-titled channel can't stretch the column and overflow the centered table. render: (c) => (
onViewChannel(c.id, c.title ?? c.id)} />
), }, { key: "about", header: t("channels.cols.about"), hideInCard: true, render: (c) => , }, subsColumn(t), { key: "videos", header: t("channels.discovery.cols.totalVideos"), align: "right", nowrap: true, sortable: true, sortValue: (c) => c.video_count ?? -1, render: (c) => ( {formatCountOrDash(c.video_count)} ), }, { key: "inPlaylists", header: t("channels.discovery.cols.inPlaylists"), align: "right", nowrap: true, sortable: true, sortValue: (c) => c.playlist_video_count, render: (c) => ( {c.playlist_video_count} / {c.playlist_count} ), }, ]; // Card layout paginates like the table (slice the sorted rows; the Pager reports changes). Only // computed for cards — DataTable sorts and slices internally, so doing it in table mode too would // copy+sort the whole list every render for a result nothing reads. const isCards = layout === "cards"; const sortedRows = isCards ? sortRows(rows, columns, sort) : rows; const { paged: pagedRows, pagerProps, resetPage, } = useCardPager(sortedRows, LS.channelDiscoveryCardSize, isCards); // Changing what you're looking at starts over at the first page. NOT keyed on the row count: // subscribing drops a row from this tab, and being thrown to page 1 for it would punish the very // action you just took — a list that shrank past your page is handled by the hook's clamp. useEffect(() => { resetPage(); }, [search, sort, resetPage]); return ( <> {/* Intro + the table controls are fixed chrome; only the table rows scroll (sticky header). */}

{t("channels.discovery.intro")}

{/* Cards has no column headers, so a dropdown drives the sort + a pager (table uses its own). */} {layout === "cards" && (
)}
{query.isError && !query.data ? ( query.refetch()}> {t("common.loadError")} ) : query.isLoading ? ( ) : layout === "table" ? ( c.id} persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined} controlsPosition="both" controlsInBand controlsBandClassName="px-4 max-w-[96rem] mx-auto" sort={sort} onSortChange={setSort} emptyText={t("channels.discovery.empty")} /> ) : ( c.id} aboutKey="about" actionsKey="actions" emptyText={t("channels.discovery.empty")} /> )}
); }