import { useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowDown, ArrowUp, Eye, EyeOff, History, Plus, RefreshCw, Search, UserMinus, X, } from "lucide-react"; import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api"; import { formatEta } from "../lib/format"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; import Avatar from "./Avatar"; export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden"; const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [ { id: "all", labelKey: "channels.filters.all" }, { id: "needs_full", labelKey: "channels.filters.needsFull" }, { id: "fully_synced", labelKey: "channels.filters.fullySynced" }, { id: "hidden", labelKey: "channels.filters.hidden" }, ]; export default function Channels({ canWrite, onViewChannel, statusFilter, setStatusFilter, onOpenWizard, }: { canWrite: boolean; onViewChannel: (id: string, name: string) => void; statusFilter: ChannelStatusFilter; setStatusFilter: (f: ChannelStatusFilter) => void; onOpenWizard: () => void; }) { const { t } = useTranslation(); const qc = useQueryClient(); // A YouTube-gated action (sync, backfill, unsubscribe) that 403s means the user hasn't // granted the needed scope — surface that with a "Connect" action instead of a vague fail. const notifyActionError = (err: unknown, fallbackKey: string) => { 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(fallbackKey) }); } }; const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels }); const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus }); const [q, setQ] = useState(""); const [newTag, setNewTag] = useState(""); const invalidate = () => { qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: ["tags"] }); qc.invalidateQueries({ queryKey: ["my-status"] }); }; 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: ["channels"] }); qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: ["feed-count"] }); }, }); // 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. const bumpPriority = (id: string, current: number, delta: number) => { const next = current + delta; qc.setQueryData(["channels"], (old) => (old ?? []).map((ch) => (ch.id === id ? { ...ch, priority: next } : ch)) ); api .updateChannel(id, { priority: next }) .catch(() => qc.invalidateQueries({ queryKey: ["channels"] })); }; const attach = useMutation({ mutationFn: (v: { id: string; tagId: number }) => api.attachChannelTag(v.id, v.tagId), onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }), }); const detach = useMutation({ mutationFn: (v: { id: string; tagId: number }) => api.detachChannelTag(v.id, v.tagId), onSuccess: () => qc.invalidateQueries({ queryKey: ["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) => notifyActionError(e, "channels.notify.syncFailed"), }); const createTag = useMutation({ mutationFn: (name: string) => api.createTag({ name, category: "other" }), onSuccess: () => { setNewTag(""); qc.invalidateQueries({ queryKey: ["tags"] }); }, }); const deleteTag = useMutation({ mutationFn: (id: number) => api.deleteTag(id), onSuccess: () => invalidate(), }); const unsubscribe = useMutation({ mutationFn: (id: string) => api.unsubscribeChannel(id), onSuccess: () => { qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: ["my-status"] }); notify({ level: "success", message: t("channels.notify.unsubscribed") }); }, onError: (e) => notifyActionError(e, "channels.notify.unsubscribeFailed"), }); const deepAll = useMutation({ mutationFn: () => api.deepAll(true), onSuccess: (r: { updated?: number }) => { qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: ["my-status"] }); notify({ level: "success", message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }), }); }, onError: (e) => notifyActionError(e, "channels.notify.fullHistoryFailed"), }); const channels = (channelsQuery.data ?? []) .filter((c) => !q || (c.title ?? "").toLowerCase().includes(q.toLowerCase())) .filter((c) => statusFilter === "needs_full" ? !c.backfill_done : statusFilter === "fully_synced" ? c.backfill_done : statusFilter === "hidden" ? c.hidden : true ); const s = statusQuery.data; return (
{/* Per-user sync status */} {s && (
{s.deep_pending_count > 0 && ( )}
)} {/* Toolbar */}
setQ(e.target.value)} placeholder={t("channels.filterPlaceholder")} className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent" />
{/* Status filter */}
{STATUS_FILTERS.map((f) => ( ))}

, , , ]} />

{/* Your tags */}
{t("channels.tags.yourTags")} {userTags.map((t) => ( {t.name} ))}
{ e.preventDefault(); if (newTag.trim()) createTag.mutate(newTag.trim()); }} className="inline-flex items-center gap-1" > setNewTag(e.target.value)} placeholder={t("channels.tags.newTag")} className="w-24 bg-card border border-border rounded-full px-2.5 py-1 text-xs outline-none focus:border-accent" />
{/* Channel list */} {channelsQuery.isLoading ? (
{t("channels.loading")}
) : channels.length === 0 ? (
{t("channels.empty")}
) : (
{channels.map((c) => ( { if ( window.confirm( t("channels.confirmUnsubscribe", { name: c.title ?? c.id }) ) ) unsubscribe.mutate(c.id); }} onView={() => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))} onPriority={(d) => bumpPriority(c.id, c.priority, d)} onHide={() => patch.mutate({ id: c.id, body: { hidden: !c.hidden } })} onDeep={() => patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } }) } onToggleTag={(tagId) => c.tag_ids.includes(tagId) ? detach.mutate({ id: c.id, tagId }) : attach.mutate({ id: c.id, tagId }) } /> ))}
)}
); } function Stat({ label, value, hint, }: { label: string; value: string | number; hint?: string; }) { return ( {value} {label} ); } function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: string }) { return ( {label} ); } function ChannelRow({ c, userTags, canWrite, onUnsubscribe, onView, onPriority, onHide, onDeep, onToggleTag, }: { c: ManagedChannel; userTags: Tag[]; canWrite: boolean; onUnsubscribe: () => void; onView: () => void; onPriority: (delta: number) => void; onHide: () => void; onDeep: () => void; onToggleTag: (tagId: number) => void; }) { const { t } = useTranslation(); return (
{c.priority}
{t("channels.row.stored", { count: c.stored_videos, formatted: c.stored_videos.toLocaleString() })} {c.subscriber_count != null && · {t("channels.row.subs", { count: c.subscriber_count, formatted: c.subscriber_count.toLocaleString() })}}
{c.backfill_done ? ( ) : c.deep_requested ? ( ) : c.deep_in_queue ? ( {t("channels.row.fullHistoryQueued")} ) : ( )} {userTags.map((t) => { const on = c.tag_ids.includes(t.id); return ( ); })}
{canWrite && ( )}
); }