The Channel manager lists the channels the Takeout history shows watched often but not followed, the most watched first: twenty at a time, the channel's About on hover, its name to its page, Follow, and Not interested with an Undo. A row's count opens the videos it counts, ready to play one or all. Before a history is uploaded it points to the upload; while the import still asks YouTube it says more channels may join.
228 lines
7.5 KiB
TypeScript
228 lines
7.5 KiB
TypeScript
import { lazy, Suspense, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { EyeOff, History, UserPlus } from "lucide-react";
|
|
import { qk } from "../../lib/queryKeys";
|
|
import { api, type WatchedChannel } from "../../lib/api";
|
|
import { formatDate } from "../../lib/format";
|
|
import { focusSettingsTab } from "../../lib/settingsTab";
|
|
import { useNavigationActions } from "../../features/navigation/navigationContext";
|
|
import Tooltip from "../../components/ui/Tooltip";
|
|
import DataTable, { type Column } from "../../components/ui/DataTable";
|
|
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
|
import { Button } from "../../components/ui/Button";
|
|
import ChannelLink from "./ChannelLink";
|
|
import AboutCell from "./AboutCell";
|
|
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
|
import { subsColumn } from "./channelColumns";
|
|
import { useChannelLayout } from "./channelsContext";
|
|
import { useWatchedChannelActions } from "./useWatchedChannelActions";
|
|
|
|
const WatchedVideosDialog = lazy(() => import("./WatchedVideosDialog"));
|
|
|
|
// How many channels the list shows at first, and how many more each "Show more" adds. The list is a
|
|
// ranking — the most watched first — so a short top is the point: past it, the reader asks for more.
|
|
const WATCHED_PAGE = 20;
|
|
|
|
// The Channel manager's "Discover from your history" tab: the channels the user's Takeout watch
|
|
// history shows them watching often but not following, the most watched first. A row's count opens
|
|
// the videos it counts, ready to play — a quick look at what the channel was, before following it.
|
|
export default function WatchedChannels({
|
|
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 layout = useChannelLayout();
|
|
const { setPage } = useNavigationActions();
|
|
const [shown, setShown] = useState(WATCHED_PAGE);
|
|
const [open, setOpen] = useState<WatchedChannel | null>(null);
|
|
const actions = useWatchedChannelActions(onOpenWizard);
|
|
|
|
const query = useQuery({ queryKey: qk.watchedChannels(), queryFn: api.watchedChannels });
|
|
|
|
const q = search.trim().toLowerCase();
|
|
const rows = (query.data?.channels ?? []).filter(
|
|
(c) => !q || `${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q),
|
|
);
|
|
const visible = rows.slice(0, shown);
|
|
|
|
// The order is the ranking itself, so no column sorts: a header sort would only reorder the top
|
|
// twenty and hide that it is a top.
|
|
const columns: Column<WatchedChannel>[] = [
|
|
{
|
|
key: "actions",
|
|
header: t("channels.cols.actions"),
|
|
align: "left",
|
|
nowrap: true,
|
|
cardLabel: false,
|
|
render: (c) => (
|
|
<div className="flex items-center gap-1.5">
|
|
<Tooltip
|
|
always={!canWrite}
|
|
hint={
|
|
canWrite
|
|
? t("channels.discovery.subscribeHint")
|
|
: t("channels.discovery.needWriteHint")
|
|
}
|
|
>
|
|
<Button
|
|
variant="glass"
|
|
size="xs"
|
|
icon={<UserPlus />}
|
|
onClick={() => void actions.onSubscribe(c)}
|
|
aria-disabled={!canWrite}
|
|
loading={actions.subscribing}
|
|
>
|
|
{t("channels.discovery.subscribe")}
|
|
</Button>
|
|
</Tooltip>
|
|
<Tooltip hint={t("channels.watched.dismissHint")}>
|
|
<Button variant="ghost" size="xs" icon={<EyeOff />} onClick={() => actions.dismiss(c)}>
|
|
{t("channels.watched.dismiss")}
|
|
</Button>
|
|
</Tooltip>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: "channel",
|
|
header: t("channels.cols.channel"),
|
|
cardPrimary: true,
|
|
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<WatchedChannel>(t, lang), sortable: false },
|
|
{
|
|
key: "watched",
|
|
header: t("channels.watched.cols.watched"),
|
|
align: "right",
|
|
nowrap: true,
|
|
render: (c) => (
|
|
<Tooltip hint={t("channels.watched.cols.watchedHint", { count: c.watched })}>
|
|
<Button
|
|
variant="link"
|
|
size="xs"
|
|
className="tabular-nums"
|
|
icon={<History />}
|
|
onClick={() => setOpen(c)}
|
|
>
|
|
{c.watched}
|
|
</Button>
|
|
</Tooltip>
|
|
),
|
|
},
|
|
{
|
|
key: "last",
|
|
header: t("channels.watched.cols.last"),
|
|
align: "right",
|
|
nowrap: true,
|
|
render: (c) => (
|
|
<span className="text-muted tabular-nums">{formatDate(c.last_watched_at, lang)}</span>
|
|
),
|
|
},
|
|
];
|
|
|
|
const pending = query.data?.pending ?? 0;
|
|
const more = Math.min(WATCHED_PAGE, rows.length - visible.length);
|
|
const body =
|
|
query.isError && !query.data ? (
|
|
<StateMessage tone="error" onRetry={() => query.refetch()}>
|
|
{t("common.loadError")}
|
|
</StateMessage>
|
|
) : query.isLoading ? (
|
|
<LoadingState shape="list" label={t("channels.watched.loading")} />
|
|
) : !query.data?.imported ? (
|
|
// The message and the way to the upload, one line: StateMessage lays its content in a row.
|
|
<StateMessage>
|
|
{t("channels.watched.notImported")}
|
|
<Button
|
|
size="sm"
|
|
icon={<History />}
|
|
onClick={() => {
|
|
focusSettingsTab("account");
|
|
setPage("settings");
|
|
}}
|
|
>
|
|
{t("channels.watched.openTakeout")}
|
|
</Button>
|
|
</StateMessage>
|
|
) : layout === "table" ? (
|
|
<DataTable
|
|
rows={visible}
|
|
columns={columns}
|
|
rowKey={(c) => c.id}
|
|
pager={false}
|
|
emptyText={t("channels.watched.empty")}
|
|
keyboardNav
|
|
narrowedBy={q}
|
|
/>
|
|
) : (
|
|
<ChannelLayoutGrid
|
|
rows={visible}
|
|
columns={columns}
|
|
rowKey={(c) => c.id}
|
|
aboutKey="about"
|
|
actionsKey="actions"
|
|
emptyText={t("channels.watched.empty")}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<div className="px-4 pb-4 max-w-384 mx-auto">
|
|
<p className="text-xs text-muted leading-relaxed pt-2">{t("channels.watched.intro")}</p>
|
|
{pending > 0 && (
|
|
<p className="text-xs text-muted leading-relaxed pt-1" role="status">
|
|
{t("channels.watched.pending", { count: pending })}
|
|
</p>
|
|
)}
|
|
{body}
|
|
{rows.length > 0 && (
|
|
<div className="flex items-center justify-between gap-3 pt-3 text-xs text-muted">
|
|
<span className="tabular-nums">
|
|
{t("channels.watched.shown", { shown: visible.length, total: rows.length })}
|
|
</span>
|
|
{more > 0 && (
|
|
<Button size="sm" onClick={() => setShown((n) => n + WATCHED_PAGE)}>
|
|
{t("channels.watched.more", { count: more })}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
{open && (
|
|
<Suspense fallback={null}>
|
|
<WatchedVideosDialog
|
|
channel={open}
|
|
canWrite={canWrite}
|
|
actions={actions}
|
|
onClose={() => setOpen(null)}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|