Files
siftlode/frontend/src/modules/channels/ChannelDiscovery.tsx
T
peter 63a0fecc8b refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/
A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform
layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each
page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in
features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/
(ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows).

Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the
contract tests that scan sources now scan the whole app source, not only components/ — each glob
was counted before and after so none went green over fewer files. The locale-format rule matched
`/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton
check; it now covers every folder that holds components, with a test per folder.
2026-10-22 23:12:45 +02:00

264 lines
10 KiB
TypeScript

import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys";
import { invalidateSubscription } from "../../lib/subscriptionCache";
import { UserPlus } from "lucide-react";
import { api, type DiscoveredChannel } from "../../lib/api";
import { LS, ownKey } from "../../lib/storage";
import { formatCountOrDash } from "../../lib/format";
import { hydrateSort, sortRows, type SortState } from "../../lib/columnSort";
import { usePref } from "../../lib/usePref";
import { useCardPager } from "../../lib/useCardPager";
import { subsColumn } from "./channelColumns";
import { notify } from "../../lib/notifications";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import Tooltip from "../../components/ui/Tooltip";
import ChannelLink from "./ChannelLink";
import AboutCell from "./AboutCell";
import DataTable, { type Column } from "../../components/ui/DataTable";
import ChannelLayoutGrid from "./ChannelLayoutGrid";
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
import ColumnSortControl from "../../components/ui/ColumnSortControl";
import Pager from "../../components/ui/Pager";
import { PageToolbar } from "../../components/ui/PageShell";
import { useConfirm } from "../../components/ui/confirmContext";
import { useChannelLayout } from "./channelsContext";
import { Button } from "../../components/ui/Button";
// 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, i18n } = useTranslation();
const lang = i18n.language;
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, setSort] = usePref<SortState>("channelDiscoverySort", null, {
localKey: LS.channelDiscoverySort,
parse: (raw) => hydrateSort(raw == null ? null : JSON.stringify(raw), []),
});
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.
invalidateSubscription(qc, c.id);
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>[] = [
// Subscribe first: it is this table's primary action, the one thing a discovery row is for (the
// Subscriptions table has no such button — its row ends in the channel's "…").
{
key: "actions",
header: t("channels.cols.actions"),
align: "left",
nowrap: true,
width: "120px",
cardLabel: false,
render: (c) => (
<Tooltip
always={!canWrite}
hint={
canWrite ? t("channels.discovery.subscribeHint") : t("channels.discovery.needWriteHint")
}
>
<Button
variant="glass"
size="xs"
icon={<UserPlus />}
onClick={() => onSubscribe(c)}
aria-disabled={!canWrite}
loading={subscribe.isPending}
>
{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, lang),
{
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, lang)}</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, lang) : 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 (
<>
{/* The table controls are the page's band (B4); only the rows scroll (sticky header). The intro
scrolls with them: in the band it held two lines of a short window for text read once. */}
<PageToolbar>
{/* Cards has no column headers, so a dropdown drives the sort + a pager (table uses its own). */}
{layout === "cards" && (
<div className="px-4 pt-2 max-w-384 mx-auto flex items-center justify-end gap-3">
<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>
)}
</PageToolbar>
<div className="px-4 pb-4 max-w-384 mx-auto">
<p className="text-xs text-muted leading-relaxed pt-2">{t("channels.discovery.intro")}</p>
{query.isError && !query.data ? (
<StateMessage tone="error" onRetry={() => query.refetch()}>
{t("common.loadError")}
</StateMessage>
) : query.isLoading ? (
<LoadingState shape="list" label={t("channels.discovery.loading")} />
) : layout === "table" ? (
<DataTable
rows={rows}
columns={columns}
rowKey={(c) => c.id}
persistKey={ownKey(LS.channelDiscoveryTable) ?? undefined}
controlsPosition="both"
controlsInBand
controlsBandClassName="px-4 max-w-384 mx-auto"
sort={sort}
onSortChange={setSort}
emptyText={t("channels.discovery.empty")}
keyboardNav
narrowedBy={q}
/>
) : (
<ChannelLayoutGrid
rows={pagedRows}
columns={columns}
rowKey={(c) => c.id}
aboutKey="about"
actionsKey="actions"
emptyText={t("channels.discovery.empty")}
/>
)}
</div>
</>
);
}