siftlode/frontend/src/components/Channels.tsx

423 lines
16 KiB
TypeScript
Raw Normal View History

import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowDown,
ArrowUp,
Eye,
EyeOff,
History,
Plus,
RefreshCw,
Search,
UserMinus,
X,
} from "lucide-react";
import { api, type ManagedChannel, type Tag } from "../lib/api";
import { formatEta } from "../lib/format";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
export default function Channels({
canWrite,
onViewChannel,
}: {
canWrite: boolean;
onViewChannel: (id: string, name: string) => void;
}) {
const qc = useQueryClient();
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
const [q, setQ] = useState("");
const [newTag, setNewTag] = useState("");
const invalidate = () => {
qc.invalidateQueries({ queryKey: ["channels"] });
qc.invalidateQueries({ queryKey: ["tags"] });
qc.invalidateQueries({ queryKey: ["my-status"] });
};
const userTags = (tagsQuery.data ?? []).filter((t) => !t.system);
const patch = useMutation({
mutationFn: (v: {
id: string;
body: { priority?: number; hidden?: boolean; deep_requested?: boolean };
}) => api.updateChannel(v.id, v.body),
// Requesting full history may have just pulled in a channel's recent uploads, so
// refresh the per-user status and feed too — not only the channel list.
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["channels"] });
qc.invalidateQueries({ queryKey: ["my-status"] });
qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: ["feed-count"] });
},
});
const attach = useMutation({
mutationFn: (v: { id: string; tagId: number }) => api.attachChannelTag(v.id, v.tagId),
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }),
});
const detach = useMutation({
mutationFn: (v: { id: string; tagId: number }) => api.detachChannelTag(v.id, v.tagId),
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }),
});
const syncSubs = useMutation({
mutationFn: () => api.syncSubscriptions(),
onSuccess: (r: { subscriptions?: number }) => {
invalidate();
notify({ level: "success", message: `Synced ${r.subscriptions ?? 0} subscriptions` });
},
onError: () => notify({ level: "error", message: "Subscription sync failed" }),
});
const createTag = useMutation({
mutationFn: (name: string) => api.createTag({ name, category: "other" }),
onSuccess: () => {
setNewTag("");
qc.invalidateQueries({ queryKey: ["tags"] });
},
});
const deleteTag = useMutation({
mutationFn: (id: number) => api.deleteTag(id),
onSuccess: () => invalidate(),
});
const unsubscribe = useMutation({
mutationFn: (id: string) => api.unsubscribeChannel(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["channels"] });
qc.invalidateQueries({ queryKey: ["my-status"] });
notify({ level: "success", message: "Unsubscribed on YouTube" });
},
onError: () => notify({ level: "error", message: "Unsubscribe failed" }),
});
const deepAll = useMutation({
mutationFn: () => api.deepAll(true),
onSuccess: (r: { updated?: number }) => {
qc.invalidateQueries({ queryKey: ["channels"] });
qc.invalidateQueries({ queryKey: ["my-status"] });
notify({
level: "success",
message: `Full history requested for ${r.updated ?? 0} channels`,
});
},
onError: () => notify({ level: "error", message: "Couldn't request full history" }),
});
const channels = (channelsQuery.data ?? []).filter(
(c) => !q || (c.title ?? "").toLowerCase().includes(q.toLowerCase())
);
const s = statusQuery.data;
return (
<div className="p-4 max-w-4xl mx-auto">
{/* Per-user sync status */}
{s && (
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 text-sm text-muted mb-4">
<Stat label="Channels" value={s.channels_total} hint="Channels you're subscribed to." />
<Stat
label="Recent synced"
value={`${s.channels_recent_synced}/${s.channels_total}`}
hint="Channels whose recent uploads are in the local DB and show in your feed."
/>
<Stat
label="Full history"
value={`${s.channels_deep_done}/${s.channels_deep_requested}`}
hint="Channels whose entire back-catalog is fetched, out of the ones you've requested full history for."
/>
{s.deep_pending_count > 0 && (
<Stat
label="left"
value={formatEta(s.deep_eta_seconds)}
hint={`Rough estimate to finish full-history backfill of ${s.deep_pending_count} pending channel(s), based on the shared daily quota.`}
/>
)}
<Stat label="My videos" value={s.my_videos.toLocaleString()} hint="Total videos available across your channels." />
<Stat
label="Quota left"
value={s.quota_remaining_today.toLocaleString()}
hint="Shared YouTube API budget left today (resets midnight US Pacific)."
/>
</div>
)}
{/* Toolbar */}
<div className="flex items-center gap-2 mb-4">
<div className="relative flex-1">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Filter channels…"
className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent"
/>
</div>
<Tooltip
side="bottom"
hint="Re-import your subscription list from YouTube — adds channels you've newly followed and drops ones you've unfollowed. The videos themselves keep syncing automatically in the background; this does not re-fetch them."
>
<button
onClick={() => syncSubs.mutate()}
disabled={syncSubs.isPending}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
Sync subscriptions
</button>
</Tooltip>
<Tooltip
side="bottom"
hint="Request full back-catalog backfill for every channel you're subscribed to. Older videos and search become complete as the shared daily quota allows — this can take a while."
>
<button
onClick={() => deepAll.mutate()}
disabled={deepAll.isPending}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<History className={`w-4 h-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />
Backfill everything
</button>
</Tooltip>
</div>
<p className="text-xs text-muted mb-4 leading-relaxed">
Set a channel's <b className="text-fg/80">priority</b> to push its videos up when you sort
by Channel priority, attach your own <b className="text-fg/80">tags</b> to filter the feed,
or <b className="text-fg/80">hide</b> a channel to drop it from the feed without unsubscribing.
</p>
{/* Your tags */}
<div className="flex flex-wrap items-center gap-1.5 mb-4">
<Tooltip hint="Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)">
<span className="text-xs uppercase tracking-wide text-muted mr-1 cursor-help underline decoration-dotted decoration-muted/40 underline-offset-4">
Your tags
</span>
</Tooltip>
{userTags.map((t) => (
<span
key={t.id}
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full bg-card border border-border"
>
{t.name}
<button onClick={() => deleteTag.mutate(t.id)} className="text-muted hover:text-red-400">
<X className="w-3 h-3" />
</button>
</span>
))}
<form
onSubmit={(e) => {
e.preventDefault();
if (newTag.trim()) createTag.mutate(newTag.trim());
}}
className="inline-flex items-center gap-1"
>
<input
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder="new tag"
className="w-24 bg-card border border-border rounded-full px-2.5 py-1 text-xs outline-none focus:border-accent"
/>
<button type="submit" className="text-muted hover:text-accent" title="Create tag">
<Plus className="w-4 h-4" />
</button>
</form>
</div>
{/* Channel list */}
{channelsQuery.isLoading ? (
<div className="text-muted py-8">Loading channels</div>
) : channels.length === 0 ? (
<div className="text-muted py-8">No channels.</div>
) : (
<div className="flex flex-col gap-1.5">
{channels.map((c) => (
<ChannelRow
key={c.id}
c={c}
userTags={userTags}
canWrite={canWrite}
onUnsubscribe={() => {
if (
window.confirm(
`Unsubscribe from "${c.title ?? c.id}" on YouTube? This changes your real YouTube account. To just remove it from your feed, hide it instead.`
)
)
unsubscribe.mutate(c.id);
}}
onView={() => onViewChannel(c.id, c.title ?? "This channel")}
onPriority={(d) => patch.mutate({ id: c.id, body: { priority: c.priority + d } })}
onHide={() => patch.mutate({ id: c.id, body: { hidden: !c.hidden } })}
onDeep={() =>
patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } })
}
onToggleTag={(tagId) =>
c.tag_ids.includes(tagId)
? detach.mutate({ id: c.id, tagId })
: attach.mutate({ id: c.id, tagId })
}
/>
))}
</div>
)}
</div>
);
}
function Stat({
label,
value,
hint,
}: {
label: string;
value: string | number;
hint?: string;
}) {
return (
<Tooltip hint={hint ?? ""}>
<span className={hint ? "cursor-help" : ""}>
<span className="text-fg font-semibold">{value}</span> {label}
</span>
</Tooltip>
);
}
function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: string }) {
return (
<Tooltip hint={hint ?? ""}>
<span
className={`text-[10px] px-1.5 py-0.5 rounded-full border ${
ok ? "border-accent/40 text-accent" : "border-border text-muted"
}`}
>
{label}
</span>
</Tooltip>
);
}
function ChannelRow({
c,
userTags,
canWrite,
onUnsubscribe,
onView,
onPriority,
onHide,
onDeep,
onToggleTag,
}: {
c: ManagedChannel;
userTags: Tag[];
canWrite: boolean;
onUnsubscribe: () => void;
onView: () => void;
onPriority: (delta: number) => void;
onHide: () => void;
onDeep: () => void;
onToggleTag: (tagId: number) => void;
}) {
return (
<div
className={`glass-card glass-hover flex items-center gap-3 p-2.5 rounded-xl transition ${
c.hidden ? "opacity-60" : ""
}`}
>
<Tooltip hint="Your ranking for this channel. Sort the feed by “Channel priority” to bring higher-priority channels to the top.">
<div className="flex flex-col items-center cursor-help">
<button onClick={() => onPriority(1)} className="text-muted hover:text-accent" aria-label="Raise priority">
<ArrowUp className="w-3.5 h-3.5" />
</button>
<span className="text-xs text-muted tabular-nums">{c.priority}</span>
<button onClick={() => onPriority(-1)} className="text-muted hover:text-accent" aria-label="Lower priority">
<ArrowDown className="w-3.5 h-3.5" />
</button>
</div>
</Tooltip>
{c.thumbnail_url ? (
<img src={c.thumbnail_url} alt="" className="w-10 h-10 rounded-full shrink-0" />
) : (
<div className="w-10 h-10 rounded-full bg-card shrink-0" />
)}
<div className="min-w-0 flex-1">
<button onClick={onView} className="text-sm font-semibold truncate hover:text-accent block max-w-full text-left">
{c.title ?? c.id}
</button>
<div className="flex items-center gap-2 text-[11px] text-muted">
<span>{c.stored_videos.toLocaleString()} stored</span>
{c.subscriber_count != null && <span>· {c.subscriber_count.toLocaleString()} subs</span>}
</div>
<div className="flex flex-wrap items-center gap-1 mt-1">
<SyncBadge
ok={c.recent_synced}
label="recent"
hint={c.recent_synced ? "Recent uploads synced." : "Recent uploads not fetched yet."}
/>
{c.backfill_done ? (
<SyncBadge ok label="full" hint="Full history fetched." />
) : (
<Tooltip
hint={
c.deep_requested
? "Full history requested — this channel's whole back-catalog will backfill as the shared quota allows. Click to cancel the request."
: "Only recent uploads so far. Click to request this channel's full back-catalog (older videos + complete search)."
}
>
<button
onClick={onDeep}
className={`inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full border transition ${
c.deep_requested
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border text-muted hover:border-accent"
}`}
>
<History className="w-3 h-3" />
{c.deep_requested ? "full history queued" : "get full history"}
</button>
</Tooltip>
)}
{userTags.map((t) => {
const on = c.tag_ids.includes(t.id);
return (
<button
key={t.id}
onClick={() => onToggleTag(t.id)}
className={`text-[10px] px-1.5 py-0.5 rounded-full border transition ${
on
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border text-muted hover:border-accent"
}`}
>
{t.name}
</button>
);
})}
</div>
</div>
<Tooltip
hint={
c.hidden
? "Hidden — this channel's videos are kept out of your feed. Click to show them again."
: "Hide this channel's videos from your feed. It stays subscribed; this doesn't unsubscribe on YouTube."
}
>
<button onClick={onHide} className="text-muted hover:text-fg shrink-0" aria-label={c.hidden ? "Unhide" : "Hide from feed"}>
{c.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</Tooltip>
{canWrite && (
<Tooltip hint="Unsubscribe from this channel on YouTube (changes your real account). Read-only mode hides this — use Hide instead.">
<button
onClick={onUnsubscribe}
className="text-muted hover:text-red-400 shrink-0"
aria-label="Unsubscribe on YouTube"
>
<UserMinus className="w-4 h-4" />
</button>
</Tooltip>
)}
</div>
);
}