- Extract notifyYouTubeActionError (new lib/youtubeErrors.ts): the "403 → Connect your YouTube account, else fallback toast" logic was duplicated in Channels.tsx and ChannelDiscovery.tsx. (Separate file, not lib/notifications, to avoid the api ↔ notifications import cycle.) - Extract format.formatCountOrDash(): the `n != null ? formatViews(n) : "—"` count cell was repeated across the subs/videos columns in Channels + ChannelDiscovery. - Register channelsTable / channelDiscoveryTable in storage.ts LS instead of bare "siftlode.*" string literals. - Delete 7 dead channels.json keys (filterPlaceholder, tags.newTag/createTag, row.stored/subs/getFullHistory/getFullHistoryHint) from en/hu/de — verified 0 refs (createTag matches were the unrelated api.createTag method, not the key). Behavior-neutral. tsc green, knip no new unused, localdev boots.
170 lines
5.8 KiB
TypeScript
170 lines
5.8 KiB
TypeScript
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 { 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,
|
|
}: {
|
|
canWrite: boolean;
|
|
onOpenWizard: () => 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} />
|
|
),
|
|
},
|
|
{
|
|
key: "subs",
|
|
header: t("channels.cols.subs"),
|
|
align: "right",
|
|
nowrap: true,
|
|
sortable: true,
|
|
sortValue: (c) => c.subscriber_count ?? -1,
|
|
render: (c) => (
|
|
<span className="text-muted tabular-nums">{formatCountOrDash(c.subscriber_count)}</span>
|
|
),
|
|
},
|
|
{
|
|
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>
|
|
);
|
|
}
|