Files
youclonedl/frontend/src/pages/Following.jsx
inputnoise 1827dd6c4e Initial commit — YT Hub
Self-hosted personal YouTube management app.
FastAPI + SQLite backend, React + Vite + Tailwind frontend.
Dockerfiles and compose included for Portainer deployment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 20:09:04 +02:00

1028 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 AZ" },
{ 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 AZ" },
];
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 (
<button
onClick={() => 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}
</button>
);
}
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 (
<input
autoFocus
value={draft}
onChange={e => 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 (
<p
onClick={e => { 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…"}
</p>
);
}
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 (
<div className={`flex items-start gap-3 py-2.5 px-3 rounded-lg transition-colors group ${isMuted ? "opacity-50" : "hover:bg-zinc-900/60"} ${selected ? "bg-zinc-800/50" : ""}`}>
{/* Checkbox (selectable mode) */}
{selectable && (
<input
type="checkbox"
checked={selected}
onChange={() => onSelect?.(channel.id)}
onClick={(e) => e.stopPropagation()}
className="mt-2 shrink-0 accent-accent w-3.5 h-3.5"
/>
)}
{/* Avatar */}
<div className="shrink-0 cursor-pointer relative mt-0.5" onClick={() => !selectable && navigate(`/channels/${channel.id}`)}>
{channel.thumbnail_url ? (
<img src={channel.thumbnail_url} alt={channel.name} className="w-8 h-8 rounded-full object-cover" />
) : (
<div
className="w-8 h-8 rounded-full flex items-center justify-center font-bold text-white text-sm"
style={{ backgroundColor: bg }}
>
{letter}
</div>
)}
{isNew && !isMuted && (
<span className="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 bg-accent rounded-full border-2 border-zinc-950" />
)}
</div>
{/* Name + stats */}
<div className="flex-1 min-w-0 cursor-pointer" onClick={() => selectable ? onSelect?.(channel.id) : navigate(`/channels/${channel.id}`)}>
<div className="flex items-center gap-2 min-w-0">
<p className="text-sm font-medium text-zinc-100 truncate">{channel.name}</p>
{isNew && !isMuted && <span className="text-xs text-accent shrink-0">{channel.new_count} new</span>}
{isMuted && <span className="text-xs text-zinc-600 shrink-0">{muteInfo}</span>}
{isDormant && !isMuted && <span className="text-[10px] px-1.5 py-0.5 rounded-full bg-zinc-800 text-zinc-500 shrink-0">dormant</span>}
</div>
<p className="text-xs text-zinc-500 mt-0.5 truncate">
{[
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(" · ")}
</p>
{channel.latest_video_id && !isMuted && (
<div className="flex items-center gap-2 mt-1.5">
<img
src={`https://i.ytimg.com/vi/${channel.latest_video_id}/mqdefault.jpg`}
alt=""
className="w-20 h-[45px] object-cover rounded shrink-0"
loading="lazy"
/>
<p className="text-xs text-zinc-400 line-clamp-2 leading-snug">{channel.latest_video_title}</p>
</div>
)}
{total > 0 && watched > 0 && !isMuted && (
<div className="h-0.5 mt-1.5 bg-zinc-800 rounded-full overflow-hidden w-full">
<div className="h-full bg-accent/50 rounded-full" style={{ width: `${Math.round((watched / total) * 100)}%` }} />
</div>
)}
{/* Group tags */}
{groups.filter(g => g.channel_ids.includes(channel.id)).length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{groups.filter(g => g.channel_ids.includes(channel.id)).map(g => (
<span key={g.id} className="text-[10px] px-1.5 py-0.5 rounded-full bg-zinc-800 text-zinc-500">{g.name}</span>
))}
</div>
)}
{!selectable && <ChannelNotes channel={channel} />}
</div>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
{dlResult != null && (
<span className="text-xs text-accent font-mono mr-1">
{dlResult === 0 ? "✓" : `+${dlResult}`}
</span>
)}
<AutoDownloadToggle channelId={channel.id} value={channel.auto_download ?? null} />
{/* Group assign button */}
{groups.length > 0 && (
<div className="relative">
<button
onClick={() => 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"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a2 2 0 012-2z"/>
</svg>
</button>
{showGroups && (
<div className="absolute right-0 top-full mt-1 z-20 bg-zinc-800 border border-zinc-700 rounded-xl shadow-xl py-1 min-w-[120px]">
{groups.map(g => {
const inGroup = g.channel_ids.includes(channel.id);
return (
<button
key={g.id}
onClick={() => { 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"
>
<span className={`w-3 h-3 rounded-full border ${inGroup ? "bg-accent border-accent" : "border-zinc-600"}`} />
{g.name}
</button>
);
})}
</div>
)}
</div>
)}
{/* Mute / unmute */}
<button
onClick={() => 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 ? (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072M12 6v12m0 0l-3-3m3 3l3-3M5.636 5.636a9 9 0 000 12.728"/>
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"/>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2"/>
</svg>
)}
</button>
<button
onClick={() => 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 ? (
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
)}
</button>
<button
onClick={() => 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 ? (
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
) : indexMut.isSuccess ? (
<svg className="w-3.5 h-3.5 text-accent" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 00-1.414 0L8 12.586 4.707 9.293a1 1 0 00-1.414 1.414l4 4a1 1 0 001.414 0l8-8a1 1 0 000-1.414z" clipRule="evenodd"/>
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
)}
</button>
<button
onClick={() => 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"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
);
}
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 (
<div className="flex flex-col gap-4">
{groups.map(g => {
const members = channels.filter(c => g.channel_ids.includes(c.id));
return (
<div key={g.id} className="bg-zinc-900 rounded-xl p-4 flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
{editingId === g.id ? (
<input
autoFocus
value={editName}
onChange={e => 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"
/>
) : (
<h3 className="font-medium text-zinc-100 text-sm">{g.name}</h3>
)}
<div className="flex items-center gap-1">
{editingId === g.id ? (
<button
onClick={() => renameMut.mutate({ id: g.id, name: editName })}
className="text-xs px-2 py-1 bg-accent text-black rounded-lg font-medium"
>Save</button>
) : (
<button
onClick={() => { 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"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</button>
)}
<button
onClick={() => 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"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
</div>
{members.length > 0 ? (
<div className="flex flex-wrap gap-2">
{members.map(c => (
<span key={c.id} className="flex items-center gap-1.5 px-2 py-1 bg-zinc-800 rounded-full text-xs text-zinc-300">
{c.thumbnail_url
? <img src={c.thumbnail_url} alt="" className="w-4 h-4 rounded-full object-cover" />
: <div className="w-4 h-4 rounded-full" style={{ backgroundColor: avatarColor(c.name) }} />
}
{c.name}
</span>
))}
</div>
) : (
<p className="text-xs text-zinc-600">No channels yet assign from the channel list.</p>
)}
</div>
);
})}
<form
onSubmit={e => { e.preventDefault(); if (newName.trim()) createMut.mutate(); }}
className="flex gap-2"
>
<input
value={newName}
onChange={e => 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"
/>
<button
type="submit"
disabled={!newName.trim() || createMut.isPending}
className="px-4 py-2 rounded-xl bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 transition-colors disabled:opacity-40"
>
Create
</button>
</form>
</div>
);
}
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 (
<div className="flex items-center justify-center py-24">
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (!channels.length) {
return (
<div className="flex flex-col items-center gap-4 py-20 text-center">
<div className="w-16 h-16 rounded-full bg-zinc-800 flex items-center justify-center">
<svg className="w-8 h-8 text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M15 10l4.553-2.069A1 1 0 0121 8.82v6.36a1 1 0 01-1.447.893L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</div>
<div>
<p className="text-zinc-300 font-medium">Not following anyone yet</p>
<p className="text-zinc-500 text-sm mt-1">
Hit Follow on a channel while watching a video or searching.
</p>
</div>
</div>
);
}
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 = () => (
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
);
return (
<div className="flex flex-col gap-6 max-w-5xl mx-auto">
{/* Header */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="font-display font-bold text-2xl text-white">Following</h1>
<p className="text-sm text-zinc-500 mt-1">
{channels.length} channel{channels.length !== 1 ? "s" : ""}
{" · "}{totalVideos} indexed
{totalUnwatched > 0 && <> · <span className="text-zinc-400">{totalUnwatched} unwatched</span></>}
</p>
</div>
<div className="flex items-center gap-2 flex-wrap">
{dlResult != null && (
<span className="text-sm text-accent font-mono">
{dlResult === 0 ? "Up to date" : `${dlResult} queued`}
</span>
)}
<button
onClick={() => 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 ? <><Spinner /> Queuing</> : "Download all new"}
</button>
<button
onClick={() => 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 ? <><Spinner /> Syncing</> : syncDone ? "Syncing " : "Sync all"}
</button>
</div>
</div>
{/* Tabs */}
<div className="flex items-center gap-1 border-b border-zinc-800">
{[["channels", "Channels"], ["feed", "Latest uploads"], ["groups", "Groups"]].map(([key, label]) => (
<button
key={key}
onClick={() => 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 && (
<span className="ml-1.5 text-xs text-zinc-600">{groups.length}</span>
)}
</button>
))}
</div>
{/* ── Channels tab ── */}
{tab === "channels" && (
<div className="flex flex-col gap-4">
{/* Toolbar: import + select */}
<div className="flex items-center justify-between gap-3">
<button
onClick={() => { setShowImport(v => !v); setImportResult(null); }}
className="flex items-center gap-1.5 text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Import
</button>
<button
onClick={() => { 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"}
</button>
</div>
{/* Import panel */}
{showImport && (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4 flex flex-col gap-3">
<div>
<p className="text-sm font-medium text-zinc-200">Import subscriptions</p>
<p className="text-xs text-zinc-500 mt-0.5">
Upload a <span className="text-zinc-400">YouTube Takeout CSV</span> (subscriptions.csv) or any plain-text file with one channel URL, handle, or UC ID per line.
</p>
</div>
<label className={[
"flex flex-col items-center justify-center gap-2 border-2 border-dashed rounded-xl py-6 cursor-pointer transition-colors",
importMut.isPending ? "border-zinc-700 opacity-50 cursor-not-allowed" : "border-zinc-700 hover:border-zinc-500",
].join(" ")}>
<svg className="w-6 h-6 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
<span className="text-sm text-zinc-400">
{importMut.isPending ? "Importing…" : "Choose file or drop here"}
</span>
<input
type="file"
accept=".csv,.txt,text/plain,text/csv"
className="sr-only"
disabled={importMut.isPending}
onChange={(e) => { if (e.target.files[0]) handleImportFile(e.target.files[0]); }}
/>
</label>
{importResult && (
<p className="text-xs text-zinc-400">
Done followed <span className="text-accent font-semibold">{importResult.followed}</span> new
{importResult.already_following > 0 && `, ${importResult.already_following} already following`}
{importResult.new_channels > 0 && `, ${importResult.new_channels} new channel stubs created`}.
Run <span className="text-zinc-300">Sync all</span> to fetch their videos.
</p>
)}
{importMut.isError && (
<p className="text-xs text-red-400">Import failed check the file format.</p>
)}
</div>
)}
{/* Group filter pills */}
{groups.length > 0 && (
<div className="flex flex-wrap gap-2">
<button
onClick={() => 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
</button>
{groups.map(g => (
<button
key={g.id}
onClick={() => 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}
<span className="ml-1 opacity-60">{g.channel_ids.length}</span>
</button>
))}
</div>
)}
{/* Search + sort row */}
<div className="flex items-center gap-3">
{channels.length > 4 && (
<div className="relative flex-1">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={search}
onChange={(e) => { 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 && (
<button onClick={() => { setSearch(""); setPage(0); }} className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300">×</button>
)}
</div>
)}
<SortPicker value={channelSort} onChange={(v) => { setChannelSort(v); setPage(0); }} options={CHANNEL_SORTS} />
</div>
{filtered.length === 0 ? (
<p className="text-zinc-500 text-sm px-2">
{activeGroup !== null ? "No channels in this group." : `No channels match "${search}"`}
</p>
) : (
<>
{selectMode && pageChannels.length > 0 && (
<div className="flex items-center gap-3 px-3 py-1.5 bg-zinc-900 rounded-lg">
<input
type="checkbox"
checked={selectedIds.size === pageChannels.length && pageChannels.length > 0}
onChange={handleSelectAll}
className="accent-accent w-3.5 h-3.5"
/>
<span className="text-xs text-zinc-400">
{selectedIds.size === pageChannels.length ? "Deselect all" : "Select all"}
</span>
{selectedIds.size > 0 && (
<span className="text-xs text-zinc-500 ml-auto">{selectedIds.size} selected</span>
)}
</div>
)}
<div className="divide-y divide-zinc-800/50">
{pageChannels.map((ch) => (
<ChannelRow
key={ch.id}
channel={ch}
groups={groups}
onGroupToggle={handleGroupToggle}
hideSubCount={hideSubCount}
selectable={selectMode}
selected={selectedIds.has(ch.id)}
onSelect={handleSelectToggle}
/>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between px-1">
<button onClick={() => 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">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7"/></svg>
Prev
</button>
<span className="text-xs text-zinc-600 tabular-nums">{safePage + 1} / {totalPages}</span>
<button onClick={() => 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
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7"/></svg>
</button>
</div>
)}
</>
)}
{/* Sticky bulk action bar */}
{selectMode && selectedIds.size > 0 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 px-5 py-3 bg-zinc-800 border border-zinc-700 rounded-2xl shadow-2xl">
<span className="text-sm text-zinc-300 font-medium mr-1">{selectedIds.size} selected</span>
<button
onClick={() => 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
</button>
<button
onClick={() => 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
</button>
</div>
)}
</div>
)}
{/* ── Feed tab ── */}
{tab === "feed" && (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<SortPicker value={feedSort} onChange={setFeedSort} options={FEED_SORTS} />
</div>
{loadingFeed && feedOffset === 0 ? (
<div className="flex items-center justify-center py-8">
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
) : !sortedFeed.length ? (
<p className="text-zinc-500 text-sm">No videos indexed yet hit Sync all to pull the latest from YouTube.</p>
) : (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
{sortedFeed.map((v) => <VideoCard key={v.youtube_video_id} video={v} />)}
</div>
{hasMoreFeed && (
<div className="flex justify-center mt-2">
<button onClick={() => 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 ? <><Spinner /> Loading</> : "Load more"}
</button>
</div>
)}
</>
)}
</div>
)}
{/* ── Groups tab ── */}
{tab === "groups" && (
<GroupsPanel groups={groups} channels={channels} />
)}
</div>
);
}