import { useState, useEffect, useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { getChannels, getChannelFeed, unfollowChannel, indexChannel, syncAllChannels, downloadChannel, downloadFollowing, setChannelAutoDownload, markChannelsSeen, muteChannel, unmuteChannel, getChannelGroups, createChannelGroup, deleteChannelGroup, renameChannelGroup, addChannelToGroup, removeChannelFromGroup, getSettings, bulkChannelAction, followBulk, updateChannelNotes, } from "../api"; import VideoCard from "../components/VideoCard"; import SortPicker from "../components/SortPicker"; function parseChannelList(text) { const lines = text.split(/\r?\n/); const handles = new Set(); // Detect YouTube Takeout CSV (header: Channel Id,Channel Url,Channel Title) const isTakeout = lines[0]?.trim().toLowerCase().startsWith("channel id"); for (let i = isTakeout ? 1 : 0; i < lines.length; i++) { const line = lines[i].trim(); if (!line) continue; // CSV row: extract first column (Channel Id = UC...) if (isTakeout) { const id = line.split(",")[0].trim().replace(/^"(.*)"$/, "$1"); if (id.startsWith("UC")) handles.add(id); continue; } // UC... ID anywhere on the line const ucMatch = line.match(/UC[\w-]{22}/); if (ucMatch) { handles.add(ucMatch[0]); continue; } // @handle from URL or bare const handleMatch = line.match(/@([\w.-]+)/); if (handleMatch) { handles.add("@" + handleMatch[1]); continue; } // youtube.com/c/name or /user/name const pathMatch = line.match(/youtube\.com\/(?:c|user)\/([\w.-]+)/); if (pathMatch) { handles.add(pathMatch[1]); continue; } } return [...handles]; } const CHANNEL_SORTS = [ { value: "last_upload", label: "Last upload" }, { value: "name", label: "Name A–Z" }, { value: "unwatched", label: "Most unwatched" }, { value: "downloaded", label: "Most downloaded" }, { value: "upload_frequency", label: "Upload frequency" }, ]; const FEED_SORTS = [ { value: "newest", label: "Newest" }, { value: "oldest", label: "Oldest" }, { value: "channel", label: "Channel A–Z" }, ]; function frequencyLabel(days) { if (days == null) return null; if (days <= 1.5) return "Daily"; if (days <= 8) return "Weekly"; if (days <= 18) return "Biweekly"; if (days <= 40) return "Monthly"; if (days <= 100) return "Quarterly"; return "Rarely"; } function sortChannels(channels, sort) { const arr = [...channels]; if (sort === "name") return arr.sort((a, b) => a.name.localeCompare(b.name)); if (sort === "unwatched") return arr.sort((a, b) => (b.unwatched_count ?? 0) - (a.unwatched_count ?? 0)); if (sort === "downloaded") return arr.sort((a, b) => (b.downloaded_count ?? 0) - (a.downloaded_count ?? 0)); if (sort === "upload_frequency") return arr.sort((a, b) => { const fa = a.upload_frequency_days ?? Infinity; const fb = b.upload_frequency_days ?? Infinity; return fa - fb; }); return arr.sort((a, b) => new Date(b.last_published_at ?? 0) - new Date(a.last_published_at ?? 0)); } function sortFeed(items, sort) { const arr = [...items]; if (sort === "oldest") return arr.sort((a, b) => new Date(a.published_at ?? 0) - new Date(b.published_at ?? 0)); if (sort === "channel") return arr.sort((a, b) => (a.channel_name ?? "").localeCompare(b.channel_name ?? "")); return arr.sort((a, b) => new Date(b.published_at ?? 0) - new Date(a.published_at ?? 0)); } function formatSubs(n) { if (!n) return null; if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`; if (n >= 1_000) return `${Math.round(n / 1_000)}K`; return String(n); } function avatarColor(name) { if (!name) return "#52525b"; let h = 0; for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % 360; return `hsl(${h}, 55%, 42%)`; } function timeAgo(dateStr) { if (!dateStr) return null; const diff = Date.now() - new Date(dateStr).getTime(); const days = Math.floor(diff / 86400000); if (days === 0) return "today"; if (days === 1) return "yesterday"; if (days < 7) return `${days}d ago`; if (days < 30) return `${Math.floor(days / 7)}w ago`; if (days < 365) return `${Math.floor(days / 30)}mo ago`; return `${Math.floor(days / 365)}y ago`; } function muteLabel(muteUntil) { if (!muteUntil) return null; const until = new Date(muteUntil); if (until <= new Date()) return null; return `muted until ${until.toLocaleDateString("en-US", { month: "short", day: "numeric" })}`; } function AutoDownloadToggle({ channelId, value }) { const qc = useQueryClient(); const mut = useMutation({ mutationFn: (next) => setChannelAutoDownload(channelId, next), onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }), }); const states = [ { value: null, label: "Auto", title: "Use global setting" }, { value: true, label: "On", title: "Always auto-download" }, { value: false, label: "Off", title: "Never auto-download" }, ]; const current = states.find((s) => s.value === value) ?? states[0]; const next = states[(states.indexOf(current) + 1) % states.length]; return ( ); } function ChannelNotes({ channel }) { const qc = useQueryClient(); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(channel.notes || ""); const mut = useMutation({ mutationFn: (notes) => updateChannelNotes(channel.id, notes), onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }), }); const save = () => { mut.mutate(draft); setEditing(false); }; if (editing) { return ( setDraft(e.target.value)} onBlur={save} onKeyDown={e => { if (e.key === "Enter") save(); if (e.key === "Escape") setEditing(false); }} placeholder="Add a note…" className="mt-1 w-full text-xs bg-zinc-800 text-zinc-200 rounded px-2 py-1 focus:outline-none border border-zinc-600" /> ); } return (

{ e.stopPropagation(); setDraft(channel.notes || ""); setEditing(true); }} className="mt-1 text-xs text-zinc-600 italic cursor-pointer hover:text-zinc-400 transition-colors truncate" > {channel.notes || "Add a note…"}

); } function ChannelRow({ channel, groups, onGroupToggle, hideSubCount = false, selectable = false, selected = false, onSelect }) { const navigate = useNavigate(); const qc = useQueryClient(); const [dlResult, setDlResult] = useState(null); const [showGroups, setShowGroups] = useState(false); const isMuted = channel.muted_until && new Date(channel.muted_until) > new Date(); const muteInfo = muteLabel(channel.muted_until); const unfollowMut = useMutation({ mutationFn: () => unfollowChannel(channel.id), onSuccess: () => { qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: ["channel-feed"] }); }, }); const indexMut = useMutation({ mutationFn: () => indexChannel(channel.id), onSuccess: () => { setTimeout(() => { qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: ["channel-feed"] }); }, 3000); }, }); const dlMut = useMutation({ mutationFn: () => downloadChannel(channel.id), onSuccess: (res) => { setDlResult(res.data.queued); qc.invalidateQueries({ queryKey: ["downloads"] }); }, }); const muteMut = useMutation({ mutationFn: () => isMuted ? unmuteChannel(channel.id) : muteChannel(channel.id), onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }), }); const letter = channel.name?.[0]?.toUpperCase() ?? "?"; const bg = avatarColor(channel.name); const unwatched = channel.unwatched_count ?? 0; const downloaded = channel.downloaded_count ?? 0; const total = channel.video_count ?? 0; const watched = channel.watched_count ?? 0; const isNew = (channel.new_count ?? 0) > 0; const subs = formatSubs(channel.subscriber_count); const freqLabel = frequencyLabel(channel.upload_frequency_days); const daysSinceUpload = channel.last_published_at ? (Date.now() - new Date(channel.last_published_at)) / (1000 * 60 * 60 * 24) : null; const isDormant = daysSinceUpload !== null && daysSinceUpload > 180; return (
{/* Checkbox (selectable mode) */} {selectable && ( onSelect?.(channel.id)} onClick={(e) => e.stopPropagation()} className="mt-2 shrink-0 accent-accent w-3.5 h-3.5" /> )} {/* Avatar */}
!selectable && navigate(`/channels/${channel.id}`)}> {channel.thumbnail_url ? ( {channel.name} ) : (
{letter}
)} {isNew && !isMuted && ( )}
{/* Name + stats */}
selectable ? onSelect?.(channel.id) : navigate(`/channels/${channel.id}`)}>

{channel.name}

{isNew && !isMuted && {channel.new_count} new} {isMuted && {muteInfo}} {isDormant && !isMuted && dormant}

{[ subs && !hideSubCount && `${subs} subscribers`, unwatched > 0 && `${unwatched} unwatched`, downloaded > 0 && `${downloaded} downloaded`, channel.last_published_at && timeAgo(channel.last_published_at), freqLabel && freqLabel, ].filter(Boolean).join(" · ")}

{channel.latest_video_id && !isMuted && (

{channel.latest_video_title}

)} {total > 0 && watched > 0 && !isMuted && (
)} {/* Group tags */} {groups.filter(g => g.channel_ids.includes(channel.id)).length > 0 && (
{groups.filter(g => g.channel_ids.includes(channel.id)).map(g => ( {g.name} ))}
)} {!selectable && }
{/* Actions */}
{dlResult != null && ( {dlResult === 0 ? "✓" : `+${dlResult}`} )} {/* Group assign button */} {groups.length > 0 && (
{showGroups && (
{groups.map(g => { const inGroup = g.channel_ids.includes(channel.id); return ( ); })}
)}
)} {/* Mute / unmute */}
); } function GroupsPanel({ groups, channels }) { const qc = useQueryClient(); const [newName, setNewName] = useState(""); const [editingId, setEditingId] = useState(null); const [editName, setEditName] = useState(""); const createMut = useMutation({ mutationFn: () => createChannelGroup(newName.trim()), onSuccess: () => { setNewName(""); qc.invalidateQueries({ queryKey: ["channel-groups"] }); }, }); const deleteMut = useMutation({ mutationFn: (id) => deleteChannelGroup(id), onSuccess: () => qc.invalidateQueries({ queryKey: ["channel-groups"] }), }); const renameMut = useMutation({ mutationFn: ({ id, name }) => renameChannelGroup(id, name), onSuccess: () => { setEditingId(null); qc.invalidateQueries({ queryKey: ["channel-groups"] }); }, }); return (
{groups.map(g => { const members = channels.filter(c => g.channel_ids.includes(c.id)); return (
{editingId === g.id ? ( setEditName(e.target.value)} onKeyDown={e => { if (e.key === "Enter") renameMut.mutate({ id: g.id, name: editName }); if (e.key === "Escape") setEditingId(null); }} className="flex-1 bg-zinc-800 text-zinc-100 text-sm rounded-lg px-3 py-1.5 focus:outline-none border border-zinc-600" /> ) : (

{g.name}

)}
{editingId === g.id ? ( ) : ( )}
{members.length > 0 ? (
{members.map(c => ( {c.thumbnail_url ? :
} {c.name} ))}
) : (

No channels yet — assign from the channel list.

)}
); })}
{ e.preventDefault(); if (newName.trim()) createMut.mutate(); }} className="flex gap-2" > setNewName(e.target.value)} placeholder="New group name…" className="flex-1 bg-zinc-900 border border-zinc-800 rounded-xl px-3 py-2 text-sm text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-zinc-600" />
); } const PAGE_SIZE = 15; const FEED_PAGE = 24; export default function Following() { const qc = useQueryClient(); const [tab, setTab] = useState("channels"); const [syncDone, setSyncDone] = useState(false); const [dlResult, setDlResult] = useState(null); const [search, setSearch] = useState(""); const [page, setPage] = useState(0); const [channelSort, setChannelSort] = useState("last_upload"); const [feedSort, setFeedSort] = useState("newest"); const [feedOffset, setFeedOffset] = useState(0); const [feedItems, setFeedItems] = useState([]); const [activeGroup, setActiveGroup] = useState(null); // null = all const [selectMode, setSelectMode] = useState(false); const [selectedIds, setSelectedIds] = useState(new Set()); const [showImport, setShowImport] = useState(false); const [importResult, setImportResult] = useState(null); const { data: channels = [], isLoading: loadingChannels } = useQuery({ queryKey: ["channels"], queryFn: () => getChannels().then((r) => r.data), }); const { data: appSettings } = useQuery({ queryKey: ["settings"], queryFn: () => getSettings().then(r => r.data), staleTime: 5 * 60_000, }); const hideSubCount = appSettings?.hide_subscriber_counts ?? false; const { data: groups = [] } = useQuery({ queryKey: ["channel-groups"], queryFn: () => getChannelGroups().then((r) => r.data), }); const { data: feedPage = [], isLoading: loadingFeed, isFetching: fetchingFeed } = useQuery({ queryKey: ["channel-feed", feedOffset], queryFn: () => getChannelFeed(feedOffset).then((r) => r.data), enabled: channels.length > 0, }); useEffect(() => { if (feedOffset === 0) setFeedItems(feedPage); else setFeedItems((prev) => [...prev, ...feedPage]); }, [feedPage]); // eslint-disable-line react-hooks/exhaustive-deps const hasMoreFeed = feedPage.length === FEED_PAGE; const sortedFeed = useMemo(() => sortFeed(feedItems, feedSort), [feedItems, feedSort]); useEffect(() => { if (channels.length > 0) { markChannelsSeen().then(() => { qc.invalidateQueries({ queryKey: ["channels"] }); }); } }, []); // eslint-disable-line react-hooks/exhaustive-deps const syncMut = useMutation({ mutationFn: syncAllChannels, onSuccess: () => { setSyncDone(true); setTimeout(() => { qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: ["channel-feed"] }); setSyncDone(false); }, 4000); }, }); const dlAllMut = useMutation({ mutationFn: downloadFollowing, onSuccess: (res) => { setDlResult(res.data.queued); qc.invalidateQueries({ queryKey: ["downloads"] }); }, }); const bulkMut = useMutation({ mutationFn: ({ action }) => bulkChannelAction([...selectedIds], action), onSuccess: () => { setSelectedIds(new Set()); setSelectMode(false); qc.invalidateQueries({ queryKey: ["channels"] }); }, }); const importMut = useMutation({ mutationFn: (handles) => followBulk(handles), onSuccess: (res) => { setImportResult(res.data); qc.invalidateQueries({ queryKey: ["channels"] }); }, }); const handleImportFile = (file) => { const reader = new FileReader(); reader.onload = (e) => { const text = e.target.result; const handles = parseChannelList(text); if (handles.length === 0) return; importMut.mutate(handles); }; reader.readAsText(file); }; const handleSelectToggle = (id) => { setSelectedIds(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }; const handleSelectAll = () => { if (selectedIds.size === pageChannels.length) { setSelectedIds(new Set()); } else { setSelectedIds(new Set(pageChannels.map(c => c.id))); } }; const handleGroupToggle = (groupId, channelId, inGroup) => { const fn = inGroup ? removeChannelFromGroup(groupId, channelId) : addChannelToGroup(groupId, channelId); fn.then(() => qc.invalidateQueries({ queryKey: ["channel-groups"] })); }; if (loadingChannels) { return (
); } if (!channels.length) { return (

Not following anyone yet

Hit Follow on a channel while watching a video or searching.

); } const totalVideos = channels.reduce((n, c) => n + (c.video_count ?? 0), 0); const totalUnwatched = channels.reduce((n, c) => n + (c.unwatched_count ?? 0), 0); const filtered = useMemo(() => { const q = search.trim().toLowerCase(); let list = q ? channels.filter((c) => c.name.toLowerCase().includes(q)) : channels; if (activeGroup !== null) { const g = groups.find(g => g.id === activeGroup); if (g) list = list.filter(c => g.channel_ids.includes(c.id)); } return sortChannels(list, channelSort); }, [channels, groups, search, channelSort, activeGroup]); const totalPages = Math.ceil(filtered.length / PAGE_SIZE); const safePage = Math.min(page, Math.max(0, totalPages - 1)); const pageChannels = filtered.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE); const Spinner = () => ( ); return (
{/* Header */}

Following

{channels.length} channel{channels.length !== 1 ? "s" : ""} {" · "}{totalVideos} indexed {totalUnwatched > 0 && <> · {totalUnwatched} unwatched}

{dlResult != null && ( {dlResult === 0 ? "Up to date" : `${dlResult} queued`} )}
{/* Tabs */}
{[["channels", "Channels"], ["feed", "Latest uploads"], ["groups", "Groups"]].map(([key, label]) => ( ))}
{/* ── Channels tab ── */} {tab === "channels" && (
{/* Toolbar: import + select */}
{/* Import panel */} {showImport && (

Import subscriptions

Upload a YouTube Takeout CSV (subscriptions.csv) or any plain-text file with one channel URL, handle, or UC… ID per line.

{importResult && (

Done — followed {importResult.followed} new {importResult.already_following > 0 && `, ${importResult.already_following} already following`} {importResult.new_channels > 0 && `, ${importResult.new_channels} new channel stubs created`}. Run Sync all to fetch their videos.

)} {importMut.isError && (

Import failed — check the file format.

)}
)} {/* Group filter pills */} {groups.length > 0 && (
{groups.map(g => ( ))}
)} {/* Search + sort row */}
{channels.length > 4 && (
{ setSearch(e.target.value); setPage(0); }} placeholder="Filter channels…" className="w-full bg-zinc-900 border border-zinc-800 rounded-xl pl-9 pr-8 py-2 text-sm text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-zinc-600" /> {search && ( )}
)} { setChannelSort(v); setPage(0); }} options={CHANNEL_SORTS} />
{filtered.length === 0 ? (

{activeGroup !== null ? "No channels in this group." : `No channels match "${search}"`}

) : ( <> {selectMode && pageChannels.length > 0 && (
0} onChange={handleSelectAll} className="accent-accent w-3.5 h-3.5" /> {selectedIds.size === pageChannels.length ? "Deselect all" : "Select all"} {selectedIds.size > 0 && ( {selectedIds.size} selected )}
)}
{pageChannels.map((ch) => ( ))}
{totalPages > 1 && (
{safePage + 1} / {totalPages}
)} )} {/* Sticky bulk action bar */} {selectMode && selectedIds.size > 0 && (
{selectedIds.size} selected
)}
)} {/* ── Feed tab ── */} {tab === "feed" && (
{loadingFeed && feedOffset === 0 ? (
) : !sortedFeed.length ? (

No videos indexed yet — hit Sync all to pull the latest from YouTube.

) : ( <>
{sortedFeed.map((v) => )}
{hasMoreFeed && (
)} )}
)} {/* ── Groups tab ── */} {tab === "groups" && ( )}
); }