2026-06-19 02:16:42 +02:00
|
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
2026-06-19 03:22:10 +02:00
|
|
|
import { UserPlus } from "lucide-react";
|
chore(channels-ui): Phase 2 #3 frontend cleanup — dedup 403 handler, count cell, LS keys, dead i18n
- 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.
2026-07-11 18:11:23 +02:00
|
|
|
import { api, type DiscoveredChannel } from "../lib/api";
|
|
|
|
|
import { accountKey, LS } from "../lib/storage";
|
|
|
|
|
import { formatCountOrDash } from "../lib/format";
|
2026-07-12 00:46:52 +02:00
|
|
|
import { subsColumn } from "./channelColumns";
|
2026-06-19 02:16:42 +02:00
|
|
|
import { notify } from "../lib/notifications";
|
chore(channels-ui): Phase 2 #3 frontend cleanup — dedup 403 handler, count cell, LS keys, dead i18n
- 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.
2026-07-11 18:11:23 +02:00
|
|
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
2026-06-19 02:16:42 +02:00
|
|
|
import Tooltip from "./Tooltip";
|
2026-06-19 03:22:10 +02:00
|
|
|
import ChannelLink from "./ChannelLink";
|
2026-06-19 02:16:42 +02:00
|
|
|
import DataTable, { type Column } from "./DataTable";
|
2026-06-19 11:18:28 +02:00
|
|
|
import { useConfirm } from "./ConfirmProvider";
|
2026-06-19 02:16:42 +02:00
|
|
|
|
|
|
|
|
// 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,
|
2026-07-13 02:28:06 +02:00
|
|
|
search,
|
2026-06-19 02:16:42 +02:00
|
|
|
onOpenWizard,
|
2026-07-12 16:12:39 +02:00
|
|
|
onViewChannel,
|
2026-06-19 02:16:42 +02:00
|
|
|
}: {
|
|
|
|
|
canWrite: boolean;
|
2026-07-13 02:28:06 +02:00
|
|
|
// Client-side name filter from the shared top SearchBar (matches title + handle).
|
|
|
|
|
search: string;
|
2026-06-19 02:16:42 +02:00
|
|
|
onOpenWizard: () => void;
|
2026-07-12 16:12:39 +02:00
|
|
|
onViewChannel: (id: string, name: string) => void;
|
2026-06-19 02:16:42 +02:00
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const qc = useQueryClient();
|
2026-06-19 11:18:28 +02:00
|
|
|
const confirm = useConfirm();
|
2026-06-19 02:16:42 +02:00
|
|
|
|
|
|
|
|
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 },
|
|
|
|
|
});
|
|
|
|
|
},
|
chore(channels-ui): Phase 2 #3 frontend cleanup — dedup 403 handler, count cell, LS keys, dead i18n
- 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.
2026-07-11 18:11:23 +02:00
|
|
|
onError: (err: unknown) =>
|
|
|
|
|
notifyYouTubeActionError(err, t("channels.discovery.subscribeFailed"), onOpenWizard),
|
2026-06-19 02:16:42 +02:00
|
|
|
});
|
|
|
|
|
|
2026-06-19 11:18:28 +02:00
|
|
|
// 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);
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-13 02:28:06 +02:00
|
|
|
const q = search.trim().toLowerCase();
|
|
|
|
|
const rows = (query.data ?? []).filter(
|
|
|
|
|
(c) => !q || `${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q)
|
|
|
|
|
);
|
2026-06-19 02:16:42 +02:00
|
|
|
|
|
|
|
|
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,
|
2026-06-19 03:22:10 +02:00
|
|
|
render: (c) => (
|
2026-07-12 16:12:39 +02:00
|
|
|
<ChannelLink
|
|
|
|
|
id={c.id}
|
|
|
|
|
title={c.title}
|
|
|
|
|
handle={c.handle}
|
|
|
|
|
thumbnailUrl={c.thumbnail_url}
|
|
|
|
|
onView={() => onViewChannel(c.id, c.title ?? c.id)}
|
|
|
|
|
/>
|
2026-06-19 03:22:10 +02:00
|
|
|
),
|
2026-06-19 02:16:42 +02:00
|
|
|
},
|
2026-07-12 00:46:52 +02:00
|
|
|
subsColumn<DiscoveredChannel>(t),
|
2026-06-19 11:18:28 +02:00
|
|
|
{
|
|
|
|
|
key: "videos",
|
|
|
|
|
header: t("channels.discovery.cols.totalVideos"),
|
|
|
|
|
align: "right",
|
|
|
|
|
nowrap: true,
|
|
|
|
|
sortable: true,
|
|
|
|
|
sortValue: (c) => c.video_count ?? -1,
|
|
|
|
|
render: (c) => (
|
chore(channels-ui): Phase 2 #3 frontend cleanup — dedup 403 handler, count cell, LS keys, dead i18n
- 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.
2026-07-11 18:11:23 +02:00
|
|
|
<span className="text-muted tabular-nums">{formatCountOrDash(c.video_count)}</span>
|
2026-06-19 11:18:28 +02:00
|
|
|
),
|
|
|
|
|
},
|
2026-06-19 02:16:42 +02:00
|
|
|
{
|
|
|
|
|
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
|
2026-06-19 11:18:28 +02:00
|
|
|
onClick={() => onSubscribe(c)}
|
2026-06-19 02:16:42 +02:00
|
|
|
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}
|
chore(channels-ui): Phase 2 #3 frontend cleanup — dedup 403 handler, count cell, LS keys, dead i18n
- 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.
2026-07-11 18:11:23 +02:00
|
|
|
persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined}
|
2026-06-19 02:16:42 +02:00
|
|
|
controlsPosition="top"
|
|
|
|
|
emptyText={t("channels.discovery.empty")}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|