- Add a theme-aware glass surface system (.glass/.glass-card + ambient backdrop, performance-mode opt-out) and apply it across panels, popovers, toasts, cards, sidebar widgets, channel rows, video cards and login. - SettingsPanel: slide in/out animation, glass styling, wrapping pill tabs (no horizontal scrollbar) with a prominent active state. - Notifications: auto-dismiss can be switched off (stays until closed); the test notification now also triggers the alert sound; resume a suspended AudioContext. - Add an app-wide, toggleable hint/tooltip system (lib/hints + Tooltip) and wire hints across the settings and channel-manager surfaces; persisted per account.
294 lines
10 KiB
TypeScript
294 lines
10 KiB
TypeScript
import { useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
ArrowDown,
|
|
ArrowUp,
|
|
Eye,
|
|
EyeOff,
|
|
Plus,
|
|
RefreshCw,
|
|
Search,
|
|
X,
|
|
} from "lucide-react";
|
|
import { api, type ManagedChannel, type Tag } from "../lib/api";
|
|
import { notify } from "../lib/notifications";
|
|
import Tooltip from "./Tooltip";
|
|
|
|
export default function Channels({
|
|
onViewChannel,
|
|
}: {
|
|
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 } }) =>
|
|
api.updateChannel(v.id, v.body),
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }),
|
|
});
|
|
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 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_total}`}
|
|
hint="Channels whose entire back-catalog is fetched. The rest backfill gradually."
|
|
/>
|
|
<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>
|
|
<button
|
|
onClick={() => syncSubs.mutate()}
|
|
disabled={syncSubs.isPending}
|
|
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border text-sm hover:border-accent disabled:opacity-50 transition"
|
|
>
|
|
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
|
|
Sync subscriptions
|
|
</button>
|
|
</div>
|
|
|
|
{/* Your tags */}
|
|
<div className="flex flex-wrap items-center gap-1.5 mb-4">
|
|
<span className="text-xs uppercase tracking-wide text-muted mr-1">Your tags</span>
|
|
{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}
|
|
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 } })}
|
|
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,
|
|
onView,
|
|
onPriority,
|
|
onHide,
|
|
onToggleTag,
|
|
}: {
|
|
c: ManagedChannel;
|
|
userTags: Tag[];
|
|
onView: () => void;
|
|
onPriority: (delta: number) => void;
|
|
onHide: () => 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" : ""
|
|
}`}
|
|
>
|
|
<div className="flex flex-col items-center">
|
|
<button onClick={() => onPriority(1)} className="text-muted hover:text-accent" title="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" title="Lower priority">
|
|
<ArrowDown className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
{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."}
|
|
/>
|
|
<SyncBadge
|
|
ok={c.backfill_done}
|
|
label="full"
|
|
hint={c.backfill_done ? "Full history fetched." : "Only recent videos so far; full history pending."}
|
|
/>
|
|
{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>
|
|
|
|
<button onClick={onHide} className="text-muted hover:text-fg shrink-0" title={c.hidden ? "Unhide" : "Hide from feed"}>
|
|
{c.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|