siftlode/frontend/src/components/ChannelDiscovery.tsx

170 lines
5.7 KiB
TypeScript
Raw Normal View History

import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { UserPlus } from "lucide-react";
import { api, type DiscoveredChannel } from "../lib/api";
import { accountKey, LS } from "../lib/storage";
import { formatCountOrDash } from "../lib/format";
import { subsColumn } from "./channelColumns";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import Tooltip from "./Tooltip";
import ChannelLink from "./ChannelLink";
import DataTable, { type Column } from "./DataTable";
import { useConfirm } from "./ConfirmProvider";
// The Channel manager's "Discovery" tab: channels that turn up in the user's playlists but
// that they don't subscribe to. Subscribing here only creates the subscription + enriches
// the channel's metadata — the scheduler pulls its videos on its next run (see backend).
export default function ChannelDiscovery({
canWrite,
onOpenWizard,
onViewChannel,
}: {
canWrite: boolean;
onOpenWizard: () => void;
onViewChannel: (id: string, name: string) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const query = useQuery({
queryKey: ["discovered-channels"],
queryFn: api.discoveredChannels,
});
const subscribe = useMutation({
mutationFn: (c: DiscoveredChannel) => api.subscribeChannel(c.id),
onSuccess: (_data, c) => {
// The channel moves from "discovery" to "subscribed", and its videos will start
// arriving — refresh both lists plus the per-user status.
qc.invalidateQueries({ queryKey: ["discovered-channels"] });
qc.invalidateQueries({ queryKey: ["channels"] });
qc.invalidateQueries({ queryKey: ["my-status"] });
const name = c.title ?? c.id;
// Name the channel and ship a typed payload so the inbox can offer "open in the
// Channel manager" / "open on YouTube" links — kept after reload, when live
// callbacks are gone (see ChannelSubscribedMeta).
notify({
level: "success",
title: t("channels.discovery.subscribedTitle"),
message: t("channels.discovery.subscribedBody", { name }),
meta: { kind: "channel-subscribed", channelId: c.id, channelName: name },
});
},
onError: (err: unknown) =>
notifyYouTubeActionError(err, t("channels.discovery.subscribeFailed"), onOpenWizard),
});
// Subscribing changes the user's real YouTube account and spends a little quota — confirm
// first (mirrors the unsubscribe guard in the Subscriptions tab).
const onSubscribe = async (c: DiscoveredChannel) => {
const ok = await confirm({
title: t("channels.discovery.subscribe"),
message: t("channels.discovery.confirmSubscribe", { name: c.title ?? c.id }),
confirmLabel: t("channels.discovery.subscribe"),
});
if (ok) subscribe.mutate(c);
};
const rows = query.data ?? [];
const columns: Column<DiscoveredChannel>[] = [
{
key: "channel",
header: t("channels.cols.channel"),
sortable: true,
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
cardPrimary: true,
render: (c) => (
<ChannelLink
id={c.id}
title={c.title}
handle={c.handle}
thumbnailUrl={c.thumbnail_url}
onView={() => onViewChannel(c.id, c.title ?? c.id)}
/>
),
},
subsColumn<DiscoveredChannel>(t),
{
key: "videos",
header: t("channels.discovery.cols.totalVideos"),
align: "right",
nowrap: true,
sortable: true,
sortValue: (c) => c.video_count ?? -1,
render: (c) => (
<span className="text-muted tabular-nums">{formatCountOrDash(c.video_count)}</span>
),
},
{
key: "inPlaylists",
header: t("channels.discovery.cols.inPlaylists"),
align: "right",
nowrap: true,
sortable: true,
sortValue: (c) => c.playlist_video_count,
render: (c) => (
<Tooltip
hint={t("channels.discovery.cols.inPlaylistsHint", {
videos: c.playlist_video_count,
playlists: c.playlist_count,
})}
>
<span className="text-muted tabular-nums cursor-help">
{c.playlist_video_count} / {c.playlist_count}
</span>
</Tooltip>
),
},
{
key: "actions",
header: t("channels.cols.actions"),
align: "right",
nowrap: true,
width: "120px",
cardLabel: false,
render: (c) => (
<Tooltip
hint={
canWrite
? t("channels.discovery.subscribeHint")
: t("channels.discovery.needWriteHint")
}
>
<button
onClick={() => onSubscribe(c)}
disabled={!canWrite || subscribe.isPending}
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-50 transition"
>
<UserPlus className="w-3.5 h-3.5" />
{t("channels.discovery.subscribe")}
</button>
</Tooltip>
),
},
];
return (
<div className="px-4 pb-4 pt-3 max-w-7xl mx-auto">
<p className="text-xs text-muted mb-4 leading-relaxed">
{t("channels.discovery.intro")}
</p>
{query.isLoading ? (
<div className="text-muted py-8">{t("channels.discovery.loading")}</div>
) : (
<DataTable
rows={rows}
columns={columns}
rowKey={(c) => c.id}
persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined}
controlsPosition="top"
emptyText={t("channels.discovery.empty")}
/>
)}
</div>
);
}