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>
1028 lines
44 KiB
JavaScript
1028 lines
44 KiB
JavaScript
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 (
|
||
<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>
|
||
);
|
||
}
|