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 (
mut.mutate(next.value)}
disabled={mut.isPending}
title={`Auto-download: ${current.title}. Click to set to "${next.label}"`}
className={[
"text-xs px-2.5 py-1 rounded-lg font-medium transition-colors",
value === true && "bg-accent/20 text-accent",
value === false && "bg-zinc-700 text-zinc-500",
value === null && "bg-zinc-800 text-zinc-400",
].filter(Boolean).join(" ")}
>
DL: {current.label}
);
}
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 ? (
) : (
{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 && (
setShowGroups(v => !v)}
title="Assign to group"
className="p-1.5 rounded-md bg-zinc-800 text-zinc-400 hover:text-zinc-100 hover:bg-zinc-700 transition-colors"
>
{showGroups && (
{groups.map(g => {
const inGroup = g.channel_ids.includes(channel.id);
return (
{ onGroupToggle(g.id, channel.id, inGroup); setShowGroups(false); }}
className="w-full flex items-center gap-2 px-3 py-2 text-xs hover:bg-zinc-700 transition-colors text-left"
>
{g.name}
);
})}
)}
)}
{/* Mute / unmute */}
muteMut.mutate()}
disabled={muteMut.isPending}
title={isMuted ? "Unmute channel" : "Mute for 30 days"}
className={`p-1.5 rounded-md transition-colors disabled:opacity-50 ${
isMuted
? "bg-zinc-700 text-zinc-400 hover:text-zinc-100 hover:bg-zinc-600"
: "bg-zinc-800 text-zinc-400 hover:text-amber-400 hover:bg-amber-900/30"
}`}
>
{isMuted ? (
) : (
)}
dlMut.mutate()}
disabled={dlMut.isPending}
title="Download new videos"
className="p-1.5 rounded-md bg-zinc-800 text-zinc-400 hover:text-zinc-100 hover:bg-zinc-700 transition-colors disabled:opacity-50"
>
{dlMut.isPending ? (
) : (
)}
indexMut.mutate()}
disabled={indexMut.isPending || indexMut.isSuccess}
title={indexMut.isSuccess ? "Done" : "Re-index channel"}
className="p-1.5 rounded-md bg-zinc-800 text-zinc-400 hover:text-zinc-100 hover:bg-zinc-700 transition-colors disabled:opacity-50"
>
{indexMut.isPending ? (
) : indexMut.isSuccess ? (
) : (
)}
unfollowMut.mutate()}
disabled={unfollowMut.isPending}
title="Unfollow"
className="p-1.5 rounded-md bg-zinc-800 text-zinc-400 hover:text-red-400 hover:bg-red-900/30 transition-colors disabled:opacity-50"
>
);
}
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 ? (
renameMut.mutate({ id: g.id, name: editName })}
className="text-xs px-2 py-1 bg-accent text-black rounded-lg font-medium"
>Save
) : (
{ setEditingId(g.id); setEditName(g.name); }}
className="p-1.5 rounded-md text-zinc-600 hover:text-zinc-300 hover:bg-zinc-800 transition-colors"
title="Rename group"
>
)}
deleteMut.mutate(g.id)}
disabled={deleteMut.isPending}
className="p-1.5 rounded-md text-zinc-600 hover:text-red-400 hover:bg-red-900/20 transition-colors"
title="Delete group"
>
{members.length > 0 ? (
{members.map(c => (
{c.thumbnail_url
?
:
}
{c.name}
))}
) : (
No channels yet — assign from the channel list.
)}
);
})}
);
}
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`}
)}
dlAllMut.mutate()}
disabled={dlAllMut.isPending}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-accent text-black text-sm font-semibold hover:bg-yellow-300 transition-colors disabled:opacity-60"
>
{dlAllMut.isPending ? <> Queuing…> : "Download all new"}
syncMut.mutate()}
disabled={syncMut.isPending || syncDone}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-zinc-800 text-zinc-200 text-sm font-medium hover:bg-zinc-700 transition-colors disabled:opacity-60"
>
{syncMut.isPending ? <> Syncing…> : syncDone ? "Syncing ✓" : "Sync all"}
{/* Tabs */}
{[["channels", "Channels"], ["feed", "Latest uploads"], ["groups", "Groups"]].map(([key, label]) => (
setTab(key)}
className={[
"px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px",
tab === key
? "border-accent text-zinc-100"
: "border-transparent text-zinc-500 hover:text-zinc-300",
].join(" ")}
>
{label}
{key === "groups" && groups.length > 0 && (
{groups.length}
)}
))}
{/* ── Channels tab ── */}
{tab === "channels" && (
{/* Toolbar: import + select */}
{ setShowImport(v => !v); setImportResult(null); }}
className="flex items-center gap-1.5 text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
>
Import
{ setSelectMode(v => !v); setSelectedIds(new Set()); }}
className={[
"text-xs px-3 py-1.5 rounded-lg font-medium transition-colors",
selectMode ? "bg-accent text-black" : "bg-zinc-800 text-zinc-400 hover:text-zinc-200",
].join(" ")}
>
{selectMode ? "Cancel" : "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.
{importMut.isPending ? "Importing…" : "Choose file or drop here"}
{ if (e.target.files[0]) handleImportFile(e.target.files[0]); }}
/>
{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 && (
setActiveGroup(null)}
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors ${
activeGroup === null ? "bg-zinc-700 text-zinc-100" : "bg-zinc-900 text-zinc-500 hover:text-zinc-300"
}`}
>
All
{groups.map(g => (
setActiveGroup(activeGroup === g.id ? null : g.id)}
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors ${
activeGroup === g.id ? "bg-accent text-black" : "bg-zinc-900 text-zinc-500 hover:text-zinc-300"
}`}
>
{g.name}
{g.channel_ids.length}
))}
)}
{/* Search + sort row */}
{channels.length > 4 && (
)}
{ 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 && (
setPage((p) => Math.max(0, p - 1))} disabled={safePage === 0}
className="flex items-center gap-1 text-sm text-zinc-400 hover:text-zinc-100 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
Prev
{safePage + 1} / {totalPages}
setPage((p) => Math.min(totalPages - 1, p + 1))} disabled={safePage >= totalPages - 1}
className="flex items-center gap-1 text-sm text-zinc-400 hover:text-zinc-100 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
Next
)}
>
)}
{/* Sticky bulk action bar */}
{selectMode && selectedIds.size > 0 && (
{selectedIds.size} selected
bulkMut.mutate({ action: "mute" })}
disabled={bulkMut.isPending}
className="px-3 py-1.5 rounded-lg bg-amber-900/40 text-amber-400 text-sm font-medium hover:bg-amber-900/60 transition-colors disabled:opacity-50"
>
Mute
bulkMut.mutate({ action: "unfollow" })}
disabled={bulkMut.isPending}
className="px-3 py-1.5 rounded-lg bg-red-900/30 text-red-400 text-sm font-medium hover:bg-red-900/50 transition-colors disabled:opacity-50"
>
Unfollow
)}
)}
{/* ── 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 && (
setFeedOffset((o) => o + FEED_PAGE)} disabled={fetchingFeed}
className="flex items-center gap-2 px-5 py-2 rounded-xl bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 transition-colors disabled:opacity-50">
{fetchingFeed ? <> Loading…> : "Load more"}
)}
>
)}
)}
{/* ── Groups tab ── */}
{tab === "groups" && (
)}
);
}