Introduce lib/queryKeys.ts as the single source of truth for React Query keys — the R7 driver was ~200 hand-written key literals across 51 files (`["feed"]` alone in 15+), where one typo silently orphans a key so an invalidate never matches. S2a migrates the three highest-traffic domains (feed/video, channels, playlists) plus the shared me/my-status/tags roots: ~90 literal sites in 18 files now call qk.*(). Pure substitution — each factory fn returns the byte-identical array the call sites used before, so TanStack's prefix matching is unchanged (qk.feed() still invalidates qk.feed(filters)). Co-invalidation clusters are deliberately NOT bundled into helpers (they differ per site), so no query's refetch scope changes. Nullable keys (playlist(selectedId), ytSearch(q)) accept null so a null segment is preserved (["playlist", null]) while a no-arg call is the bare prefix key — guard is `!== undefined`. Gate green: typecheck, eslint (0 err), prettier, 68 vitest. Smoke: app boots, feed/facets/tags/my-status all 200, no console errors. Factory extended to plex/messaging/admin/downloads/notifications in S2b.
261 lines
9.9 KiB
TypeScript
261 lines
9.9 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { UserPlus } from "lucide-react";
|
|
import { api, type DiscoveredChannel } from "../lib/api";
|
|
import { accountKey, LS, readAccount, writeAccount } from "../lib/storage";
|
|
import { formatCountOrDash } from "../lib/format";
|
|
import { parseSortState, sortRows, type SortState } from "../lib/columnSort";
|
|
import { useCardPager } from "../lib/useCardPager";
|
|
import { subsColumn } from "./channelColumns";
|
|
import { notify } from "../lib/notifications";
|
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
|
import Tooltip from "./Tooltip";
|
|
import ChannelLink from "./ChannelLink";
|
|
import AboutCell from "./AboutCell";
|
|
import DataTable, { type Column } from "./DataTable";
|
|
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
|
import { LoadingState, StateMessage } from "./QueryState";
|
|
import ColumnSortControl from "./ColumnSortControl";
|
|
import Pager from "./Pager";
|
|
import { PageToolbar } from "./PageShell";
|
|
import { useConfirm } from "./ConfirmProvider";
|
|
import { useChannelLayout } from "./ChannelsProvider";
|
|
|
|
// 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,
|
|
search,
|
|
onOpenWizard,
|
|
onViewChannel,
|
|
}: {
|
|
canWrite: boolean;
|
|
// Client-side name filter from the shared top SearchBar (matches title + handle).
|
|
search: string;
|
|
onOpenWizard: () => void;
|
|
onViewChannel: (id: string, name: string) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const confirm = useConfirm();
|
|
// The table/cards density is shared with the Subscriptions tab (ChannelsProvider). The sort is
|
|
// this tab's own (its columns differ), lifted out of the table so the card layout shares it.
|
|
const layout = useChannelLayout();
|
|
const [sort, setSortState] = useState<SortState>(() =>
|
|
parseSortState(readAccount(LS.channelDiscoverySort, null)),
|
|
);
|
|
const setSort = (s: SortState) => {
|
|
setSortState(s);
|
|
writeAccount(LS.channelDiscoverySort, s);
|
|
};
|
|
|
|
const query = useQuery({
|
|
queryKey: qk.discoveredChannels(),
|
|
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: qk.discoveredChannels() });
|
|
qc.invalidateQueries({ queryKey: qk.channels() });
|
|
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
|
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 q = search.trim().toLowerCase();
|
|
const rows = (query.data ?? []).filter(
|
|
(c) => !q || `${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q),
|
|
);
|
|
|
|
const columns: Column<DiscoveredChannel>[] = [
|
|
// Actions first, mirroring the Subscriptions table (where it sits right after Prio) so the two
|
|
// tabs' action clusters line up in the same place.
|
|
{
|
|
key: "actions",
|
|
header: t("channels.cols.actions"),
|
|
align: "left",
|
|
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>
|
|
),
|
|
},
|
|
{
|
|
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,
|
|
// Cap the width (a max-w element inside the cell; table-layout:auto ignores a cell max-width) so
|
|
// a long-titled channel can't stretch the column and overflow the centered table.
|
|
render: (c) => (
|
|
<div className="max-w-[18rem]">
|
|
<ChannelLink
|
|
id={c.id}
|
|
title={c.title}
|
|
handle={c.handle}
|
|
thumbnailUrl={c.thumbnail_url}
|
|
onView={() => onViewChannel(c.id, c.title ?? c.id)}
|
|
/>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: "about",
|
|
header: t("channels.cols.about"),
|
|
hideInCard: true,
|
|
render: (c) => <AboutCell text={c.description} channelId={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>
|
|
),
|
|
},
|
|
];
|
|
|
|
// Card layout paginates like the table (slice the sorted rows; the Pager reports changes). Only
|
|
// computed for cards — DataTable sorts and slices internally, so doing it in table mode too would
|
|
// copy+sort the whole list every render for a result nothing reads.
|
|
const isCards = layout === "cards";
|
|
const sortedRows = isCards ? sortRows(rows, columns, sort) : rows;
|
|
const {
|
|
paged: pagedRows,
|
|
pagerProps,
|
|
resetPage,
|
|
} = useCardPager(sortedRows, LS.channelDiscoveryCardSize, isCards);
|
|
// Changing what you're looking at starts over at the first page. NOT keyed on the row count:
|
|
// subscribing drops a row from this tab, and being thrown to page 1 for it would punish the very
|
|
// action you just took — a list that shrank past your page is handled by the hook's clamp.
|
|
useEffect(() => {
|
|
resetPage();
|
|
}, [search, sort, resetPage]);
|
|
|
|
return (
|
|
<>
|
|
{/* Intro + the table controls are fixed chrome; only the table rows scroll (sticky header). */}
|
|
<PageToolbar>
|
|
<div className="px-4 pt-3 max-w-[96rem] mx-auto flex items-start justify-between gap-4">
|
|
<p className="text-xs text-muted leading-relaxed">{t("channels.discovery.intro")}</p>
|
|
{/* Cards has no column headers, so a dropdown drives the sort + a pager (table uses its own). */}
|
|
{layout === "cards" && (
|
|
<div className="flex items-center gap-3 shrink-0">
|
|
<Pager {...pagerProps} />
|
|
<ColumnSortControl
|
|
columns={columns}
|
|
sort={sort}
|
|
onChange={setSort}
|
|
label={t("channels.sort.label")}
|
|
defaultLabel={t("channels.sort.default")}
|
|
ascLabel={t("channels.sort.asc")}
|
|
descLabel={t("channels.sort.desc")}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</PageToolbar>
|
|
<div className="px-4 pb-4 max-w-[96rem] mx-auto">
|
|
{query.isError && !query.data ? (
|
|
<StateMessage tone="error" onRetry={() => query.refetch()}>
|
|
{t("common.loadError")}
|
|
</StateMessage>
|
|
) : query.isLoading ? (
|
|
<LoadingState label={t("channels.discovery.loading")} />
|
|
) : layout === "table" ? (
|
|
<DataTable
|
|
rows={rows}
|
|
columns={columns}
|
|
rowKey={(c) => c.id}
|
|
persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined}
|
|
controlsPosition="both"
|
|
controlsInBand
|
|
controlsBandClassName="px-4 max-w-[96rem] mx-auto"
|
|
sort={sort}
|
|
onSortChange={setSort}
|
|
emptyText={t("channels.discovery.empty")}
|
|
/>
|
|
) : (
|
|
<ChannelLayoutGrid
|
|
rows={pagedRows}
|
|
columns={columns}
|
|
rowKey={(c) => c.id}
|
|
aboutKey="about"
|
|
actionsKey="actions"
|
|
emptyText={t("channels.discovery.empty")}
|
|
/>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|