import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { UserPlus } from "lucide-react"; import { api, HttpError, type DiscoveredChannel } from "../lib/api"; import { accountKey } from "../lib/storage"; import { formatViews } from "../lib/format"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; import ChannelLink from "./ChannelLink"; import DataTable, { type Column } from "./DataTable"; import { useConfirm } from "./ConfirmProvider"; // 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, onOpenWizard, }: { canWrite: boolean; onOpenWizard: () => void; }) { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); const query = useQuery({ queryKey: ["discovered-channels"], 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: ["discovered-channels"] }); qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: ["my-status"] }); 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) => { // A 403 means the user hasn't granted the write scope — offer to connect instead of // a vague failure (mirrors the subscriptions tab). if (err instanceof HttpError && err.status === 403) { notify({ level: "error", message: t("channels.notify.needYouTube"), action: { label: t("channels.notify.connect"), onClick: onOpenWizard }, }); } else { notify({ level: "error", message: t("channels.discovery.subscribeFailed") }); } }, }); // 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 rows = query.data ?? []; const columns: Column[] = [ { 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, render: (c) => ( ), }, { key: "subs", header: t("channels.cols.subs"), align: "right", nowrap: true, sortable: true, sortValue: (c) => c.subscriber_count ?? -1, render: (c) => ( {c.subscriber_count != null ? formatViews(c.subscriber_count) : "—"} ), }, { key: "videos", header: t("channels.discovery.cols.totalVideos"), align: "right", nowrap: true, sortable: true, sortValue: (c) => c.video_count ?? -1, render: (c) => ( {c.video_count != null ? formatViews(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} ), }, { key: "actions", header: t("channels.cols.actions"), align: "right", nowrap: true, width: "120px", cardLabel: false, render: (c) => ( ), }, ]; return (

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

{query.isLoading ? (
{t("channels.discovery.loading")}
) : ( c.id} persistKey={accountKey("siftlode.channelDiscoveryTable") ?? undefined} controlsPosition="top" emptyText={t("channels.discovery.empty")} /> )}
); }