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.
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { api } from "../../lib/api";
|
||||
import ChannelAboutContent from "./ChannelAbout";
|
||||
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
|
||||
/**
|
||||
* A channel-description cell: one truncated line in the table, revealing the FULL channel "About"
|
||||
* (the same content as the detail page's About tab — description, links, country, language, topics,
|
||||
* keywords) in a glass hover card. The detail is fetched ON DEMAND on hover/focus, sharing the
|
||||
* ["channel", id] react-query cache with the channel page, so the list stays light. The card is
|
||||
* interactive (scrollable, links clickable) — the platform's safePolygon lets the cursor travel the
|
||||
* gap, replacing the hand-rolled 160 ms hide-timer bridge. Shared by the subscribed + discovery
|
||||
* tables.
|
||||
*
|
||||
* The card keeps the platform's default hover role (`tooltip`); making this rich, interactive panel
|
||||
* properly keyboard-reachable belongs to the focus/a11y work, not to the mechanical migration.
|
||||
*/
|
||||
export default function AboutCell({ text, channelId }: { text: string | null; channelId: string }) {
|
||||
const preview = (text ?? "").trim();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const pop = useAnchoredPopover({
|
||||
open: open && !!preview,
|
||||
onOpenChange: setOpen,
|
||||
mode: "hover",
|
||||
offset: 6,
|
||||
// Pop instantly like the old cell did; the close grace lets the cursor reach the card.
|
||||
hoverDelay: { open: 0, close: 160 },
|
||||
// Cap the panel to the space between the trigger and the viewport edge (publishes --fit-max-h) so a
|
||||
// long About never runs its top off-screen — at high browser zoom the short viewport made a fixed
|
||||
// max-height overflow above the top edge, where the scroll container can't reach.
|
||||
fitHeight: true,
|
||||
});
|
||||
|
||||
const { setReference } = pop.refs;
|
||||
|
||||
const { data: ch } = useQuery({
|
||||
queryKey: qk.channel(channelId),
|
||||
queryFn: () => api.channelDetail(channelId),
|
||||
enabled: open && !!channelId,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
if (!preview) return <span className="text-muted">—</span>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
tabIndex={0}
|
||||
className="block max-w-88 truncate text-muted cursor-help rounded-sm outline-hidden focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2"
|
||||
>
|
||||
{preview}
|
||||
</span>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
className={`${SURFACE.popover} w-120 overflow-y-auto px-3.5 py-3 animate-[fadeIn_0.12s_ease]`}
|
||||
// min(design cap, available space): keeps the 28rem cap on a tall viewport, shrinks to fit on a
|
||||
// short one (high zoom). --fit-max-h is set by the fitHeight `size` middleware once positioned.
|
||||
style={{ maxHeight: "min(28rem, var(--fit-max-h, 28rem))" }}
|
||||
>
|
||||
{/* Full About once the detail loads; until then the list's truncated description. */}
|
||||
{ch ? (
|
||||
<ChannelAboutContent ch={ch} />
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed">{preview}</p>
|
||||
)}
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import type { ChannelDetail } from "../../lib/api";
|
||||
import { linkify } from "../../lib/linkify";
|
||||
import { Badge } from "../../components/ui/Badge";
|
||||
import { languageName, regionName, vocab } from "../../lib/vocab";
|
||||
|
||||
// --- About-tab metadata helpers (shared by the channel page and the manager's About overlay) ---
|
||||
// YouTube keywords are ONE space-separated string with multi-word tags quoted: gaming "let's play".
|
||||
function parseKeywords(s: string): string[] {
|
||||
return (s.match(/"[^"]+"|\S+/g) ?? []).map((k) => k.replace(/^"|"$/g, "")).filter(Boolean);
|
||||
}
|
||||
// topicCategories are Wikipedia URLs (…/wiki/Music) → the page slug ("Pop_music"), de-duplicated. The
|
||||
// slug is the vocabulary word: named in the reader's language, else shown with spaces.
|
||||
function topicSlugs(urls: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
for (const url of urls) {
|
||||
const seg = url.split("/wiki/")[1] ?? url.split("/").pop() ?? url;
|
||||
let slug = seg;
|
||||
try {
|
||||
slug = decodeURIComponent(seg);
|
||||
} catch {
|
||||
/* keep raw */
|
||||
}
|
||||
slug = slug.trim();
|
||||
if (slug) seen.add(slug);
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel "About" content — description, external links, and the enriched metadata
|
||||
* (country, language, topics, keywords). Shared by the channel detail page's About tab and the
|
||||
* Channel manager's About-column hover overlay, so both show exactly the same thing.
|
||||
*/
|
||||
export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const topics = ch.topic_categories?.length ? topicSlugs(ch.topic_categories) : [];
|
||||
const keywords = ch.keywords ? parseKeywords(ch.keywords) : [];
|
||||
const hasAboutDetails =
|
||||
!!ch.country || !!ch.default_language || topics.length > 0 || keywords.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{ch.description ? (
|
||||
<p className="measure text-sm whitespace-pre-wrap wrap-break-word leading-relaxed">
|
||||
{linkify(ch.description)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted">{t("channel.noDescription")}</p>
|
||||
)}
|
||||
{ch.external_links && ch.external_links.length > 0 && (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{ch.external_links.map((l, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={l.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-accent hover:underline"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
{l.title || l.url}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasAboutDetails && (
|
||||
<div className="mt-5 space-y-2.5 text-sm border-t border-border pt-4">
|
||||
{ch.country && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted w-24 shrink-0">{t("channel.country")}</span>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{/^[A-Za-z]{2}$/.test(ch.country) && (
|
||||
// Real SVG flag (flag-icons) — Windows/Chrome don't render flag emoji.
|
||||
<span
|
||||
className={`fi fi-${ch.country.toLowerCase()} rounded-xs shrink-0`}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{regionName(t, ch.country, i18n.language)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{ch.default_language && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted w-24 shrink-0">{t("channel.language")}</span>
|
||||
<span>{languageName(ch.default_language, i18n.language, ch.default_language)}</span>
|
||||
</div>
|
||||
)}
|
||||
{topics.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted w-24 shrink-0 pt-0.5">{t("channel.topics")}</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{/* Future search epic: these topic chips become clickable filters. */}
|
||||
{topics.map((tp) => (
|
||||
<Badge key={tp}>{vocab(t, "topic", tp, tp.replace(/_/g, " "))}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{keywords.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted w-24 shrink-0 pt-0.5">{t("channel.keywords")}</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{keywords.map((kw, i) => (
|
||||
<Badge key={i}>{kw}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The channel manager's card layout as a keyboard list: each card is an item, and Enter opens
|
||||
// the channel through the name ChannelLink renders. VirtualGrid is replaced by a stand-in that mounts
|
||||
// every item — jsdom has no layout for the virtualizer to measure — and registers with listNav exactly
|
||||
// when the real one would: when it is asked to (`keyboardNav`).
|
||||
import { Fragment, useRef } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render } from "@testing-library/react";
|
||||
import type { VirtualGridProps } from "../../components/ui/VirtualGrid";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
vi.mock("../../components/ui/VirtualGrid", async () => {
|
||||
const { useListNav } = await import("../../lib/listNav");
|
||||
return {
|
||||
default: function GridStandIn({
|
||||
items,
|
||||
getKey,
|
||||
renderItem,
|
||||
keyboardNav,
|
||||
}: VirtualGridProps<unknown>) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useListNav(ref, { enabled: !!keyboardNav });
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{items.map((item, i) => (
|
||||
<Fragment key={getKey(item)}>{renderItem(item, 0, i)}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||
import ChannelLink from "./ChannelLink";
|
||||
import { type Column } from "../../components/ui/DataTable";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
type Ch = { id: string; title: string };
|
||||
const rows: Ch[] = [
|
||||
{ id: "UCa", title: "Alpha" },
|
||||
{ id: "UCb", title: "Beta" },
|
||||
];
|
||||
|
||||
function press(key: string) {
|
||||
act(() => {
|
||||
(document.activeElement ?? document.body).dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const focusedTitle = () =>
|
||||
(document.activeElement as HTMLElement).querySelector("[data-nav-open]")?.textContent;
|
||||
|
||||
describe("ChannelLayoutGrid · keyboard", () => {
|
||||
it("walks the cards with j/k and opens the channel on Enter", () => {
|
||||
const opened: string[] = [];
|
||||
const columns: Column<Ch>[] = [
|
||||
{
|
||||
key: "channel",
|
||||
header: "Channel",
|
||||
cardPrimary: true,
|
||||
render: (c) => (
|
||||
<ChannelLink
|
||||
id={c.id}
|
||||
title={c.title}
|
||||
thumbnailUrl={null}
|
||||
onView={() => opened.push(c.id)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
render(<ChannelLayoutGrid rows={rows} columns={columns} rowKey={(c) => c.id} />);
|
||||
press("j");
|
||||
expect(focusedTitle()).toBe("Alpha");
|
||||
press("j");
|
||||
expect(focusedTitle()).toBe("Beta");
|
||||
press("Enter");
|
||||
expect(opened).toEqual(["UCb"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChannelLayoutGrid · the card\'s "…"', () => {
|
||||
it("docks it in the heading row beside the name, not in the meta line", () => {
|
||||
const columns: Column<Ch>[] = [
|
||||
{ key: "channel", header: "Channel", cardPrimary: true, render: (c) => <b>{c.title}</b> },
|
||||
{ key: "stored", header: "Stored", render: () => <i>12</i> },
|
||||
{ key: "actions", header: "", render: (c) => <u data-testid={`more-${c.id}`}>…</u> },
|
||||
];
|
||||
const { getByTestId, getByText } = render(
|
||||
<ChannelLayoutGrid rows={rows} columns={columns} rowKey={(c) => c.id} moreKey="actions" />,
|
||||
);
|
||||
const heading = getByText("Alpha").closest("div")!.parentElement!;
|
||||
expect(heading.contains(getByTestId("more-UCa"))).toBe(true);
|
||||
// Exactly once: a slotted column is not also painted in the meta line.
|
||||
expect(document.querySelectorAll('[data-testid="more-UCa"]')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Column } from "../../components/ui/DataTable";
|
||||
import VirtualGrid from "../../components/ui/VirtualGrid";
|
||||
import { CHANNEL_LAYOUT_SPEC } from "../../lib/channelLayout";
|
||||
import { navItemAttrs } from "../../lib/listNav";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
|
||||
// The channel manager's `cards` layout, built by reusing the SAME `Column<T>` render closures
|
||||
// the DataTable draws — so the subscribed and discovery tables (which have different columns) both get
|
||||
// the card view for free, and a cell's behaviour (priority bump, tag toggle, sync, actions) is defined
|
||||
// once. The `table` layout keeps DataTable; this renders only when the manager's layout is `cards`.
|
||||
//
|
||||
// Named slots place the columns that need bespoke positions; everything else flows in the meta line.
|
||||
// Unlike DataTable's cramped narrow-screen card fallback, the desktop card is roomy, so it shows the
|
||||
// `hideInCard` columns too (About, priority) — the user wants those affordances in every layout.
|
||||
//
|
||||
// Each card is a keyboard item (lib/listNav): `j`/`k` and the arrows walk the cards, and Enter opens
|
||||
// the channel — the name ChannelLink renders is the card's open element.
|
||||
|
||||
interface ChannelLayoutGridProps<T> {
|
||||
rows: T[];
|
||||
columns: Column<T>[];
|
||||
rowKey: (row: T) => string;
|
||||
/** Column key docked at the top-right of the card (the priority stepper). */
|
||||
leadKey?: string;
|
||||
/** Column key rendered as a full-width description line (the About cell + its hover overlay). */
|
||||
aboutKey?: string;
|
||||
/** Column key rendered as the action cluster, pinned to the card's bottom edge. */
|
||||
actionsKey?: string;
|
||||
/** Column key docked at the top-right corner after the lead: the card's "…", which
|
||||
* belongs beside the name it acts on, not under the card's last line. */
|
||||
moreKey?: string;
|
||||
rowClassName?: (row: T) => string;
|
||||
/** Extra DOM props per card — same escape hatch, same single consumer, as `DataTable.rowProps`, so
|
||||
* a channel answers the right-click in the card layout exactly as it does in the table. */
|
||||
rowProps?: (row: T) => Record<string, unknown>;
|
||||
emptyText?: string;
|
||||
}
|
||||
|
||||
export default function ChannelLayoutGrid<T>({
|
||||
rows,
|
||||
columns,
|
||||
rowKey,
|
||||
leadKey,
|
||||
aboutKey,
|
||||
actionsKey,
|
||||
moreKey,
|
||||
rowClassName,
|
||||
rowProps,
|
||||
emptyText,
|
||||
}: ChannelLayoutGridProps<T>) {
|
||||
const { t } = useTranslation();
|
||||
const spec = CHANNEL_LAYOUT_SPEC.cards;
|
||||
|
||||
const slotted = new Set([leadKey, aboutKey, actionsKey, moreKey].filter(Boolean) as string[]);
|
||||
const primary = columns.filter((c) => c.cardPrimary);
|
||||
const lead = leadKey ? columns.find((c) => c.key === leadKey) : undefined;
|
||||
const about = aboutKey ? columns.find((c) => c.key === aboutKey) : undefined;
|
||||
const actions = actionsKey ? columns.find((c) => c.key === actionsKey) : undefined;
|
||||
const more = moreKey ? columns.find((c) => c.key === moreKey) : undefined;
|
||||
// Everything else flows in the meta line: not the heading, not a slotted column.
|
||||
const meta = columns.filter((c) => !c.cardPrimary && !slotted.has(c.key));
|
||||
|
||||
const renderCard = (row: T, index: number) => (
|
||||
<div
|
||||
{...rowProps?.(row)}
|
||||
{...navItemAttrs(index)}
|
||||
className={`${SURFACE.card} p-3 h-full flex flex-col gap-2 ${rowClassName?.(row) ?? ""}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1 font-medium">
|
||||
{primary.map((col) => (
|
||||
<Fragment key={col.key}>{col.render(row)}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
{lead && <div className="shrink-0">{lead.render(row)}</div>}
|
||||
{more && <div className="shrink-0 -my-1">{more.render(row)}</div>}
|
||||
</div>
|
||||
{about && <div className="min-w-0 text-sm">{about.render(row)}</div>}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-sm min-w-0">
|
||||
{meta.map((col) => (
|
||||
<span key={col.key} className="inline-flex items-center gap-1">
|
||||
{col.cardLabel !== false && <span className="text-muted text-xs">{col.header}</span>}
|
||||
{col.render(row)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{actions && <div className="mt-auto pt-1">{actions.render(row)}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="text-muted py-8 text-center text-sm">{emptyText ?? t("datatable.empty")}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VirtualGrid
|
||||
items={rows}
|
||||
getKey={rowKey}
|
||||
minCol={spec.minCol}
|
||||
colMaxWidth={null}
|
||||
rowEst={spec.rowEst}
|
||||
keyboardNav
|
||||
renderItem={(row, _itemWidth, index) => renderCard(row, index)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import Avatar from "../../components/ui/Avatar";
|
||||
import { channelYouTubeUrl } from "../../lib/format";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import { IconLink } from "../../components/ui/LinkButton";
|
||||
|
||||
// Avatar + channel name + "open on YouTube" link, shared by the Channel manager and the
|
||||
// discovery list (and anywhere else that lists a channel). When `onView` is given the name
|
||||
// is a button that opens the in-app channel view, with middle-click opening YouTube in a new
|
||||
// tab; without it the name is plain text.
|
||||
export default function ChannelLink({
|
||||
id,
|
||||
title,
|
||||
handle,
|
||||
thumbnailUrl,
|
||||
onView,
|
||||
}: {
|
||||
id: string;
|
||||
title: string | null;
|
||||
handle?: string | null;
|
||||
thumbnailUrl: string | null;
|
||||
onView?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const ytUrl = channelYouTubeUrl(id, handle);
|
||||
const name = title ?? id;
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Avatar src={thumbnailUrl} fallback={title ?? ""} className="w-8 h-8 rounded-full shrink-0" />
|
||||
{onView ? (
|
||||
<Pressable
|
||||
selectable
|
||||
onClick={onView}
|
||||
// The channel's "open" element: in a keyboard-walkable list (lib/listNav) Enter on the row or
|
||||
// card clicks this.
|
||||
data-nav-open=""
|
||||
onAuxClick={(e) => {
|
||||
// Middle-click opens the channel's YouTube page in a new tab; left-click keeps
|
||||
// opening the in-app channel detail.
|
||||
if (e.button === 1) {
|
||||
e.preventDefault();
|
||||
window.open(ytUrl, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 1) e.preventDefault(); // suppress middle-click autoscroll
|
||||
}}
|
||||
// title = the full name, so a truncated fluid-column name is still readable on hover.
|
||||
title={name}
|
||||
className="min-h-6 min-w-0 truncate text-sm font-medium hover:text-accent pointer-coarse:min-h-(--touch-target)"
|
||||
>
|
||||
{name}
|
||||
</Pressable>
|
||||
) : (
|
||||
<span title={name} className="text-sm font-medium truncate min-w-0">
|
||||
{name}
|
||||
</span>
|
||||
)}
|
||||
<IconLink
|
||||
href={ytUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
label={t("channels.row.openOnYouTube")}
|
||||
className="shrink-0"
|
||||
>
|
||||
<ExternalLink />
|
||||
</IconLink>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the catalog-stats hover chip: the compact trigger, the rows it reveals on open (value
|
||||
// formatting + the conditional "left" row), and — the point of a review fix — that each row
|
||||
// still carries its per-stat explanation as a `title`, so the guidance the old inline stats showed is
|
||||
// not lost. i18n is mocked to a key-passthrough so assertions read the stable keys, not translations.
|
||||
// The AnchoredPopover platform is floating-ui based (hover mode), so we stub the observers autoUpdate
|
||||
// wires and wrap in a FloatingTree, and open via focus (hover mode enables useFocus).
|
||||
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
|
||||
// Key-passthrough t so assertions read stable keys — but keep the rest of react-i18next intact
|
||||
// (i18n/index.ts calls i18n.use(initReactI18next) at import time).
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||
};
|
||||
});
|
||||
|
||||
import ChannelStatsPopover from "./ChannelStatsPopover";
|
||||
import { type MyStatus } from "../../lib/api";
|
||||
import { formatEta, formatNumber } from "../../lib/format";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const status = (over: Partial<MyStatus> = {}): MyStatus =>
|
||||
({
|
||||
channels_total: 10,
|
||||
channels_recent_synced: 3,
|
||||
channels_deep_done: 1,
|
||||
channels_deep_requested: 4,
|
||||
deep_pending_count: 0,
|
||||
deep_eta_seconds: 120,
|
||||
my_videos: 1234,
|
||||
quota_remaining_today: 5000,
|
||||
...over,
|
||||
}) as MyStatus;
|
||||
|
||||
const open = (s: MyStatus) => {
|
||||
render(
|
||||
<FloatingTree>
|
||||
<ChannelStatsPopover status={s} />
|
||||
</FloatingTree>,
|
||||
);
|
||||
fireEvent.focus(screen.getByRole("button", { name: "channels.stats.label" }));
|
||||
};
|
||||
|
||||
describe("ChannelStatsPopover", () => {
|
||||
it("renders a compact Stats trigger, panel closed until opened", () => {
|
||||
render(
|
||||
<FloatingTree>
|
||||
<ChannelStatsPopover status={status()} />
|
||||
</FloatingTree>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "channels.stats.label" })).toBeTruthy();
|
||||
expect(screen.queryByText("channels.stats.channels")).toBeNull();
|
||||
});
|
||||
|
||||
it("reveals one row per stat with formatted values", () => {
|
||||
open(status());
|
||||
expect(screen.getByText("channels.stats.recentSynced")).toBeTruthy();
|
||||
expect(screen.getByText("3/10")).toBeTruthy(); // recent-synced ratio
|
||||
expect(screen.getByText("1/4")).toBeTruthy(); // full-history ratio
|
||||
expect(screen.getByText(formatNumber(1234, "en"))).toBeTruthy(); // my_videos, locale-agnostic
|
||||
});
|
||||
|
||||
it("shows the 'left' ETA row only while a backfill is pending, with the formatted ETA value", () => {
|
||||
open(status({ deep_pending_count: 0 }));
|
||||
expect(screen.queryByText("channels.stats.left")).toBeNull();
|
||||
cleanup();
|
||||
open(status({ deep_pending_count: 2, deep_eta_seconds: 120 }));
|
||||
expect(screen.getByText("channels.stats.left")).toBeTruthy();
|
||||
// The value is the FORMATTED ETA (via formatEta), not raw seconds — asserting it guards the
|
||||
// formatEta wiring, which is untested anywhere else. Using the same formatEta keeps this
|
||||
// locale/impl-agnostic (expected === what the component renders).
|
||||
expect(
|
||||
screen.getByText(formatEta(((k: string) => k) as unknown as TFunction, 120)),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText("120")).toBeNull(); // never the raw seconds
|
||||
});
|
||||
|
||||
it("keeps each stat's explanation as a per-row title (hint not lost)", () => {
|
||||
open(status());
|
||||
const row = screen.getByText("channels.stats.channels").closest("div");
|
||||
expect(row?.getAttribute("title")).toBe("channels.stats.channelsHint");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
Clock,
|
||||
Film,
|
||||
History,
|
||||
RefreshCw,
|
||||
Users,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type MyStatus } from "../../lib/api";
|
||||
import { formatEta, formatNumber } from "../../lib/format";
|
||||
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
|
||||
// The channel-manager catalog stats, collapsed from a crowded inline row into a compact "Stats" chip
|
||||
// that reveals the numbers on hover (reuses the AnchoredPopover hover mode). One row per stat, each
|
||||
// with a leading icon. Kept in the manager's own toolbar (the header's SyncStatus is global; these
|
||||
// are channel-catalog figures).
|
||||
export default function ChannelStatsPopover({ status }: { status: MyStatus }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const [open, setOpen] = useState(false);
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "hover",
|
||||
placement: "bottom-end",
|
||||
offset: 8,
|
||||
});
|
||||
const { setReference } = pop.refs;
|
||||
|
||||
// Each row keeps the per-stat explanation the old inline stats carried (as a native `title` on the
|
||||
// row), so hovering a figure still tells the user what it counts — the guidance is not lost.
|
||||
const rows: { icon: LucideIcon; label: string; value: string | number; hint: string }[] = [
|
||||
{
|
||||
icon: Users,
|
||||
label: t("channels.stats.channels"),
|
||||
value: status.channels_total,
|
||||
hint: t("channels.stats.channelsHint"),
|
||||
},
|
||||
{
|
||||
icon: RefreshCw,
|
||||
label: t("channels.stats.recentSynced"),
|
||||
value: `${status.channels_recent_synced}/${status.channels_total}`,
|
||||
hint: t("channels.stats.recentSyncedHint"),
|
||||
},
|
||||
{
|
||||
icon: History,
|
||||
label: t("channels.stats.fullHistory"),
|
||||
value: `${status.channels_deep_done}/${status.channels_deep_requested}`,
|
||||
hint: t("channels.stats.fullHistoryHint"),
|
||||
},
|
||||
...(status.deep_pending_count > 0
|
||||
? [
|
||||
{
|
||||
icon: Clock,
|
||||
label: t("channels.stats.left"),
|
||||
value: formatEta(t, status.deep_eta_seconds),
|
||||
hint: t("channels.stats.leftHint", { count: status.deep_pending_count }),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: Film,
|
||||
label: t("channels.stats.myVideos"),
|
||||
value: formatNumber(status.my_videos, lang),
|
||||
hint: t("channels.stats.myVideosHint"),
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
label: t("channels.stats.quotaLeft"),
|
||||
value: formatNumber(status.quota_remaining_today, lang),
|
||||
hint: t("channels.stats.quotaLeftHint"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="glass"
|
||||
size="sm"
|
||||
icon={<BarChart3 aria-hidden="true" />}
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
>
|
||||
{t("channels.stats.label")}
|
||||
</Button>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={t("channels.stats.label")}
|
||||
className={`${SURFACE.popover} w-60 p-2 text-xs animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
{rows.map((r) => (
|
||||
<div
|
||||
key={r.label}
|
||||
title={r.hint}
|
||||
className="flex cursor-help items-center gap-2.5 px-1.5 py-1.5"
|
||||
>
|
||||
<r.icon className="h-4 w-4 shrink-0 text-muted" aria-hidden="true" />
|
||||
<span className="flex-1 text-muted">{r.label}</span>
|
||||
<span className="tabular-nums font-medium text-fg">{r.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The channel tag editor says a failed load of the tag catalog as one, with a retry — not as "no tags
|
||||
// yet, create one below", which invited a duplicate of a tag that exists. i18n is key-passthrough.
|
||||
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
import { ChannelTagsControl } from "./ChannelTags";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("the channel tag editor", () => {
|
||||
it("says a failed load of the tags as one, with a retry — not as no tags yet", async () => {
|
||||
vi.spyOn(api, "tags").mockRejectedValue(new Error("down"));
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<FloatingTree>
|
||||
<ChannelTagsControl
|
||||
userTags={[]}
|
||||
attachedIds={[]}
|
||||
onToggle={() => {}}
|
||||
onCreate={() => {}}
|
||||
/>
|
||||
</FloatingTree>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("channels.row.editTags"));
|
||||
const alert = await screen.findByRole("alert", { hidden: true });
|
||||
expect(alert.textContent).toContain("common.loadError");
|
||||
expect(screen.queryByText("channels.tags.noneYet")).toBeNull();
|
||||
expect(alert.closest("[role=menu]")).toBeNull();
|
||||
expect(await screen.findByText("common.retry")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the suggested-tag ghost chips: on an UNTAGGED channel, ChannelTagsControl renders
|
||||
// one dashed "ghost" chip per suggestion; clicking the chip body accepts it (onAcceptSuggestion with the
|
||||
// tag id), the trailing × dismisses it (onDismissSuggestion), and a suggestion whose tag is already
|
||||
// attached is never shown. i18n is key-passthrough; the control rides the floating-ui popover platform
|
||||
// and a ResizeObserver-driven fit measurer, so we stub the observer and wrap in a FloatingTree.
|
||||
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
// Interpolate the `name` option so per-tag accept labels are distinguishable ("…acceptSuggestion:docu").
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: { name?: string }) => (opts?.name ? `${key}:${opts.name}` : key),
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags";
|
||||
import type { Tag } from "../../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const tag = (id: number, name: string): Tag => ({
|
||||
id,
|
||||
name,
|
||||
color: null,
|
||||
category: "other",
|
||||
system: false,
|
||||
channel_count: 0,
|
||||
});
|
||||
const docu = tag(1, "docu");
|
||||
const history = tag(2, "history");
|
||||
const suggestions: SuggestedTag[] = [
|
||||
{ tag: docu, confidence: 0.95 },
|
||||
{ tag: history, confidence: 0.6 },
|
||||
];
|
||||
|
||||
function renderControl(props: Partial<Parameters<typeof ChannelTagsControl>[0]> = {}) {
|
||||
const onAccept = vi.fn();
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
<FloatingTree>
|
||||
<ChannelTagsControl
|
||||
userTags={[docu, history]}
|
||||
attachedIds={[]}
|
||||
onToggle={() => {}}
|
||||
onCreate={() => {}}
|
||||
suggestions={suggestions}
|
||||
onAcceptSuggestion={onAccept}
|
||||
onDismissSuggestion={onDismiss}
|
||||
{...props}
|
||||
/>
|
||||
</FloatingTree>,
|
||||
);
|
||||
return { onAccept, onDismiss };
|
||||
}
|
||||
|
||||
describe("ChannelTagsControl suggested ghost chips", () => {
|
||||
it("renders a ghost chip per suggestion and accepts on click", () => {
|
||||
const { onAccept } = renderControl();
|
||||
// Role query excludes the aria-hidden fit-measurer copy; the name carries the interpolated tag.
|
||||
fireEvent.click(screen.getByRole("button", { name: "channels.tags.acceptSuggestion:docu" }));
|
||||
expect(onAccept).toHaveBeenCalledWith(docu.id);
|
||||
});
|
||||
|
||||
it("dismisses via the × without accepting", () => {
|
||||
const { onAccept, onDismiss } = renderControl();
|
||||
const dismissButtons = screen.getAllByRole("button", {
|
||||
name: "channels.tags.dismissSuggestion",
|
||||
});
|
||||
// Two visible ghost chips (the measurer's copies are aria-hidden → excluded); order matches the
|
||||
// suggestions array, so the second × is the "history" suggestion.
|
||||
expect(dismissButtons).toHaveLength(2);
|
||||
fireEvent.click(dismissButtons[1]!);
|
||||
expect(onDismiss).toHaveBeenCalledWith(history.id);
|
||||
expect(onAccept).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the attached lane (no ghost chips) once the channel carries a user tag", () => {
|
||||
renderControl({ attachedIds: [docu.id] });
|
||||
// A tagged channel is not "untagged" → the ghost lane is not rendered at all (the server excludes
|
||||
// tagged channels from suggestions; the control mirrors that with its attached-XOR-ghost branch).
|
||||
expect(
|
||||
screen.queryAllByRole("button", { name: "channels.tags.dismissSuggestion" }),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,442 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Plus, Sparkles } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Tag } from "../../lib/api";
|
||||
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||
import { fitChipsWithPill } from "../../lib/chipOverflow";
|
||||
import { chipLaneClass, useChipOverflowFit } from "../../lib/useChipOverflowFit";
|
||||
import { Chip, DismissibleChip } from "../../components/ui/Chip";
|
||||
import { chipClass } from "../../components/ui/chipStyles";
|
||||
import { MembershipMenu } from "../../components/ui/MembershipMenu";
|
||||
import { useTags } from "../../lib/useTags";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
|
||||
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
|
||||
// page. Built on the popover platform. Two design goals it fixes over the old inline TagsCell:
|
||||
// 1. Stable edit anchor — the "+" edit trigger LEADS the row (fixed left slot), so toggling tags
|
||||
// (which grows the chip row to its right) never moves it, and the editor popover doesn't drift.
|
||||
// 2. Fixed footprint — the chip row lives in a width-bounded, single-line, overflow-hidden box and
|
||||
// collapses the excess into a "+N" pill (hover reveals the full set). The cell therefore never
|
||||
// wraps to a 2nd line, so it can't widen its table column and reflow every other row.
|
||||
// The "+" is ALWAYS present (even with zero tags), and its menu can CREATE a tag inline — tagging is
|
||||
// no longer gated on the top-of-page "Manage tags" dialog.
|
||||
|
||||
interface TagChipProps {
|
||||
tag: Tag;
|
||||
onClick?: () => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// `block truncate` on the name, not the chip: the chip is inline-flex, where text-overflow cannot bite.
|
||||
function TagChip({ tag, onClick, title }: TagChipProps) {
|
||||
const name = <span className="block max-w-32 truncate">{tag.name}</span>;
|
||||
return onClick ? (
|
||||
<Chip variant="tag" size="xs" onClick={onClick} title={title}>
|
||||
{name}
|
||||
</Chip>
|
||||
) : (
|
||||
<span className={chipClass("tag", "xs")}>{name}</span>
|
||||
);
|
||||
}
|
||||
|
||||
// A suggested user tag: a dashed "ghost" chip the user hasn't applied yet, learned from
|
||||
// their own tagging pattern. The chip body accepts it (one tap → attaches the real tag); the trailing
|
||||
// × dismisses it for the session. Rendered only on untagged channels, so it never competes with the
|
||||
// measured attached-chip row. (Two sibling buttons, not a button-in-button — invalid nesting.)
|
||||
export interface SuggestedTag {
|
||||
tag: Tag;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
function SuggestedChip({
|
||||
suggestion,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
}: {
|
||||
suggestion: SuggestedTag;
|
||||
onAccept: () => void;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const pct = Math.round(suggestion.confidence * 100);
|
||||
return (
|
||||
<DismissibleChip
|
||||
variant="suggest"
|
||||
size="xs"
|
||||
className="max-w-36 font-medium"
|
||||
icon={<Sparkles />}
|
||||
onClick={onAccept}
|
||||
bodyTitle={t("channels.tags.suggestedHint", { pct })}
|
||||
bodyLabel={t("channels.tags.acceptSuggestion", { name: suggestion.tag.name })}
|
||||
onDismiss={onDismiss}
|
||||
dismissLabel={t("channels.tags.dismissSuggestion")}
|
||||
>
|
||||
{suggestion.tag.name}
|
||||
</DismissibleChip>
|
||||
);
|
||||
}
|
||||
|
||||
/** A single-line lane of suggested ghost chips that collapses its overflow into a "+N" pill (hover
|
||||
* reveals the rest, still accept/dismiss-able), reusing the SAME fit machinery as the attached-chip
|
||||
* lane. Rendered in the untagged-channel case only, so it owns the full tags-cell width — this is what
|
||||
* stops a 2nd suggestion from being clipped mid-word right next to the edit "+". */
|
||||
function SuggestedChipsLane({
|
||||
suggestions,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
}: {
|
||||
suggestions: SuggestedTag[];
|
||||
onAccept: (tagId: number) => void;
|
||||
onDismiss: (tagId: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rowRef = useRef<HTMLDivElement | null>(null);
|
||||
const chipRefs = useRef<Array<HTMLElement | null>>([]);
|
||||
const pillRef = useRef<HTMLSpanElement | null>(null);
|
||||
const key = suggestions.map((s) => `${s.tag.id}:${s.tag.name}`).join("|");
|
||||
const visible = useChipOverflowFit<number>({
|
||||
rowRef,
|
||||
chipsRef: chipRefs,
|
||||
pillRef,
|
||||
deps: key,
|
||||
initial: suggestions.length,
|
||||
decide: (g) =>
|
||||
fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }),
|
||||
});
|
||||
// Clamped, and not defensively: `useChipOverflowFit` keeps `visible` in state and re-decides in a
|
||||
// layout effect, so the render in which the chip set SHRINKS still holds the previous measurement
|
||||
// and a raw subtraction goes negative. An unclamped `hidden` slips past the `=== 0` reset below —
|
||||
// the flag survives exactly the window it exists to close, and the popover springs back on the
|
||||
// next re-fit.
|
||||
const hidden = Math.max(0, suggestions.length - visible);
|
||||
|
||||
// Open only while there is a "+N" trigger to anchor it — derived, not closed by an effect, so the
|
||||
// frame in which the last collapsed chip fits again cannot also hold a menu pointing at a
|
||||
// reference that has gone. Same shape as TagFilterChips and the second row below, INCLUDING the
|
||||
// render-phase reset: deriving hides the card but never clears the flag (a hover card whose pill
|
||||
// unmounts under the cursor gets no mouseleave), so without this it springs back the next time
|
||||
// the lane overflows. Convergent — the write falsifies its own condition.
|
||||
const [overflowOpenRaw, setOverflowOpen] = useState(false);
|
||||
if (overflowOpenRaw && hidden === 0) setOverflowOpen(false);
|
||||
const overflowOpen = overflowOpenRaw && hidden > 0;
|
||||
const overflowPop = useAnchoredPopover({
|
||||
open: overflowOpen,
|
||||
onOpenChange: setOverflowOpen,
|
||||
mode: "hover",
|
||||
placement: "bottom",
|
||||
});
|
||||
const { setReference: setOverflowAnchor } = overflowPop.refs;
|
||||
|
||||
return (
|
||||
<div className={`${chipLaneClass} flex items-center gap-1 min-w-0 flex-1`}>
|
||||
{/* Hidden measurer — real ghost chips at natural width so the fit hook reads their right edges. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1 whitespace-nowrap"
|
||||
>
|
||||
{suggestions.map((s, i) => (
|
||||
<span
|
||||
key={s.tag.id}
|
||||
ref={(el) => {
|
||||
chipRefs.current[i] = el;
|
||||
}}
|
||||
className="inline-flex"
|
||||
>
|
||||
<SuggestedChip suggestion={s} onAccept={() => {}} onDismiss={() => {}} />
|
||||
</span>
|
||||
))}
|
||||
<span ref={pillRef} className={chipClass("suggest", "xs")}>
|
||||
+{suggestions.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div ref={rowRef} className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
|
||||
{suggestions.slice(0, visible).map((s) => (
|
||||
<SuggestedChip
|
||||
key={s.tag.id}
|
||||
suggestion={s}
|
||||
onAccept={() => onAccept(s.tag.id)}
|
||||
onDismiss={() => onDismiss(s.tag.id)}
|
||||
/>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<Chip
|
||||
variant="suggest"
|
||||
size="xs"
|
||||
ref={setOverflowAnchor}
|
||||
aria-label={t("channels.tags.moreCount", { count: hidden })}
|
||||
{...overflowPop.getReferenceProps()}
|
||||
>
|
||||
+{hidden}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hover: the overflowed suggestions, each still one-tap accept / × dismiss. */}
|
||||
<PopoverSurface
|
||||
popover={overflowPop}
|
||||
className={`${SURFACE.popover} max-w-[18rem] p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
{suggestions.slice(visible).map((s) => (
|
||||
<SuggestedChip
|
||||
key={s.tag.id}
|
||||
suggestion={s}
|
||||
onAccept={() => onAccept(s.tag.id)}
|
||||
onDismiss={() => onDismiss(s.tag.id)}
|
||||
/>
|
||||
))}
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The editor body: toggle the user's existing tags on/off for this channel, plus an inline
|
||||
* "create + attach" input. Rendered inside an anchored `menu` popover. The rows are the caller's
|
||||
* `userTags`; whether that list has loaded is read from the catalog query itself (mounted only while
|
||||
* the editor is open), so a failed or pending load is said as such, not as "no tags yet". */
|
||||
function TagEditorMenu({
|
||||
userTags,
|
||||
attachedIds,
|
||||
onToggle,
|
||||
onCreate,
|
||||
}: {
|
||||
userTags: Tag[];
|
||||
attachedIds: number[];
|
||||
onToggle: (tagId: number) => void;
|
||||
onCreate: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tags = useTags();
|
||||
const attached = new Set(attachedIds);
|
||||
return (
|
||||
<MembershipMenu
|
||||
dense
|
||||
rows={userTags.map((tg) => ({ id: tg.id, label: tg.name, checked: attached.has(tg.id) }))}
|
||||
onToggle={(row) => onToggle(row.id as number)}
|
||||
status={tags.isError && !tags.data ? "error" : tags.isPending ? "loading" : "ready"}
|
||||
onRetry={() => void tags.refetch()}
|
||||
emptyText={t("channels.tags.noneYet")}
|
||||
errorText={t("common.loadError")}
|
||||
create={{
|
||||
placeholder: t("channels.tags.createPlaceholder"),
|
||||
maxLength: 64,
|
||||
submitLabel: t("channels.tags.create"),
|
||||
onCreate: (name) => {
|
||||
onCreate(name);
|
||||
return true;
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The full tag control for a channel: a stable leading "+" trigger (opens the shared editor),
|
||||
* followed by a width-bounded chip row with "+N" overflow (hover reveals all). Fills its container
|
||||
* width, so a table cell bounds it via the column width and a card bounds it via the card. */
|
||||
export function ChannelTagsControl({
|
||||
userTags,
|
||||
attachedIds,
|
||||
onToggle,
|
||||
onCreate,
|
||||
onChipClick,
|
||||
suggestions,
|
||||
onAcceptSuggestion,
|
||||
onDismissSuggestion,
|
||||
bounded = true,
|
||||
}: {
|
||||
userTags: Tag[];
|
||||
attachedIds: number[];
|
||||
onToggle: (tagId: number) => void;
|
||||
onCreate: (name: string) => void;
|
||||
/** Manager only: clicking a chip filters the feed by that tag. Omit on the detail page. */
|
||||
onChipClick?: (tagId: number, name: string) => void;
|
||||
/** Manager only: suggested user tags for an UNTAGGED channel, shown as accept/dismiss ghost
|
||||
* chips after the (empty) attached row. Omit on the detail page. */
|
||||
suggestions?: SuggestedTag[];
|
||||
onAcceptSuggestion?: (tagId: number) => void;
|
||||
onDismissSuggestion?: (tagId: number) => void;
|
||||
/** Cap the chip row at a fixed 12rem so it can't widen a `table-layout:auto` column and reflow the
|
||||
* table (the manager). Set false where there is free width (the channel detail header), so tags
|
||||
* fit inline instead of needlessly collapsing into "+N". */
|
||||
bounded?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const attached = userTags.filter((tg) => attachedIds.includes(tg.id));
|
||||
// Suggestions only surface for channels with no user tag yet, but guard anyway: never suggest a tag
|
||||
// the channel already carries (an optimistic accept adds it before the query refetches).
|
||||
const shownSuggestions = (suggestions ?? []).filter((s) => !attachedIds.includes(s.tag.id));
|
||||
|
||||
// Deterministic single-line fit: a hidden measurer lays out ALL chips + a sample "+N" pill at their
|
||||
// natural widths; the shared measure core (useChipOverflowFit) reads each chip's right edge, the rem
|
||||
// gap and the pill width off real boxes, and fitChipsWithPill computes EXACTLY how many fit (reserving
|
||||
// the pill whenever any overflow). One pass — the count is right as tags are added/removed/renamed —
|
||||
// and it re-measures on width/scale change via the hook's ResizeObserver.
|
||||
const rowRef = useRef<HTMLDivElement | null>(null);
|
||||
const chipRefs = useRef<Array<HTMLElement | null>>([]);
|
||||
const pillRef = useRef<HTMLSpanElement | null>(null);
|
||||
// Key on id AND name so a RENAME (which changes a chip's width) re-runs the fit, not just add/remove.
|
||||
const tagsKey = attached.map((tg) => `${tg.id}:${tg.name}`).join("|");
|
||||
|
||||
const visible = useChipOverflowFit<number>({
|
||||
rowRef,
|
||||
chipsRef: chipRefs,
|
||||
pillRef,
|
||||
deps: tagsKey,
|
||||
initial: attached.length,
|
||||
decide: (g) =>
|
||||
fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }),
|
||||
});
|
||||
|
||||
// Clamped, and not defensively: `useChipOverflowFit` keeps `visible` in state and re-decides in a
|
||||
// layout effect, so the render in which the chip set SHRINKS still holds the previous measurement
|
||||
// and a raw subtraction goes negative. An unclamped `hidden` slips past the `=== 0` reset below —
|
||||
// the flag survives exactly the window it exists to close, and the popover springs back on the
|
||||
// next re-fit.
|
||||
const hidden = Math.max(0, attached.length - visible);
|
||||
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const editPop = useAnchoredPopover({
|
||||
open: editorOpen,
|
||||
onOpenChange: setEditorOpen,
|
||||
mode: "menu",
|
||||
placement: "bottom-start",
|
||||
// The menu contains a free-text "create tag" input — keep both typed chars and pointer-hover out
|
||||
// of the roving list so neither steals the field's caret.
|
||||
hostsInput: true,
|
||||
});
|
||||
const { setReference: setEditAnchor } = editPop.refs;
|
||||
|
||||
// Derived for the same reason as the suggestion row above: if tags change so nothing is collapsed
|
||||
// anymore, the "+N" trigger unmounts, and the popover must already be shut in that same render —
|
||||
// and the flag reset with it, or the card returns unasked on the next overflow.
|
||||
const [overflowOpenRaw, setOverflowOpen] = useState(false);
|
||||
if (overflowOpenRaw && hidden === 0) setOverflowOpen(false);
|
||||
const overflowOpen = overflowOpenRaw && hidden > 0;
|
||||
const overflowPop = useAnchoredPopover({
|
||||
open: overflowOpen,
|
||||
onOpenChange: setOverflowOpen,
|
||||
mode: "hover",
|
||||
placement: "bottom",
|
||||
});
|
||||
const { setReference: setOverflowAnchor } = overflowPop.refs;
|
||||
|
||||
// The "+N" is a filter-look chip (neutral): the tags themselves are the accent.
|
||||
const pillClass = chipClass("filter", "xs");
|
||||
|
||||
return (
|
||||
// When `bounded`, a FIXED width keeps the cell's footprint constant under `table-layout:auto`: a
|
||||
// content-driven cell re-solves EVERY column's width as chips are added, visibly shifting the whole
|
||||
// table. The fixed width keeps the table stable and centered; the chip row fits what it can and
|
||||
// collapses the rest into "+N". (max-w-full lets a narrower card shrink it.) Unbounded, the row
|
||||
// takes its container's width, so a wide header shows the tags inline instead of collapsing them.
|
||||
<div
|
||||
className={`${chipLaneClass} flex items-center gap-1 min-w-0 ${bounded ? "w-48 max-w-full" : "w-full"}`}
|
||||
>
|
||||
{/* The add trigger LEADS the row — a stable left anchor that never moves as chips are added
|
||||
(they grow to its right), so its editor popover can't drift. Compact icon in the width-
|
||||
bounded manager cell; a labelled "+ Tag" pill where there's free width (the detail header). */}
|
||||
<Chip
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
ref={setEditAnchor}
|
||||
title={t("channels.row.editTags")}
|
||||
aria-label={t("channels.row.editTags")}
|
||||
icon={<Plus className="size-3" />}
|
||||
className={`font-medium ${bounded ? "w-6 justify-center px-0" : ""}`}
|
||||
{...editPop.getReferenceProps()}
|
||||
>
|
||||
{!bounded && t("channels.tags.add")}
|
||||
</Chip>
|
||||
|
||||
{/* Untagged + has suggestions → the ghost-chip lane (its own overflow "+N"); otherwise the
|
||||
attached-chip lane. The two are mutually exclusive: suggestions are computed for untagged
|
||||
channels only, and accepting one removes the channel from the suggestion set — so a channel
|
||||
never shows both, and each lane gets the full cell width instead of clipping the other. */}
|
||||
{attached.length === 0 && shownSuggestions.length > 0 ? (
|
||||
<SuggestedChipsLane
|
||||
suggestions={shownSuggestions}
|
||||
onAccept={(tagId) => onAcceptSuggestion?.(tagId)}
|
||||
onDismiss={(tagId) => onDismissSuggestion?.(tagId)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths.
|
||||
Each chip is wrapped in a ref span (index-aligned) so the shared fit hook can read its
|
||||
right edge; the pill is the sample the hook reserves room for. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1 whitespace-nowrap"
|
||||
>
|
||||
{attached.map((tg, i) => (
|
||||
<span
|
||||
key={tg.id}
|
||||
ref={(el) => {
|
||||
chipRefs.current[i] = el;
|
||||
}}
|
||||
className="inline-flex"
|
||||
>
|
||||
<TagChip tag={tg} />
|
||||
</span>
|
||||
))}
|
||||
<span ref={pillRef} className={pillClass}>
|
||||
+{attached.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div ref={rowRef} className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
|
||||
{attached.slice(0, visible).map((tg) => (
|
||||
<TagChip
|
||||
key={tg.id}
|
||||
tag={tg}
|
||||
onClick={onChipClick ? () => onChipClick(tg.id, tg.name) : undefined}
|
||||
title={
|
||||
onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<Chip
|
||||
size="xs"
|
||||
ref={setOverflowAnchor}
|
||||
aria-label={t("channels.tags.moreCount", { count: hidden })}
|
||||
{...overflowPop.getReferenceProps()}
|
||||
>
|
||||
+{hidden}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Hover: reveal every attached tag when some are collapsed into "+N". */}
|
||||
<PopoverSurface
|
||||
popover={overflowPop}
|
||||
className={`${SURFACE.popover} max-w-[18rem] p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
{attached.map((tg) => (
|
||||
<TagChip
|
||||
key={tg.id}
|
||||
tag={tg}
|
||||
onClick={onChipClick ? () => onChipClick(tg.id, tg.name) : undefined}
|
||||
title={onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined}
|
||||
/>
|
||||
))}
|
||||
</PopoverSurface>
|
||||
|
||||
{/* Click: the shared editor (toggle existing + create inline). */}
|
||||
<PopoverSurface
|
||||
popover={editPop}
|
||||
className={`${SURFACE.menu} w-56 animate-[popIn_0.16s_ease]`}
|
||||
listInside
|
||||
>
|
||||
<TagEditorMenu
|
||||
userTags={userTags}
|
||||
attachedIds={attachedIds}
|
||||
onToggle={onToggle}
|
||||
onCreate={onCreate}
|
||||
/>
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,885 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { createStore, type Store } from "../../lib/store";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { Ban, Eye, EyeOff, History, LayoutGrid, Pencil, RefreshCw, Table, X } from "lucide-react";
|
||||
import ExcludedChannelList from "./ExcludedChannelList";
|
||||
import { api, NO_TAG_SENTINEL, type ManagedChannel } from "../../lib/api";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
|
||||
import { LS, ownKey } from "../../lib/storage";
|
||||
import { buildChannelColumns, applySyncFilter, tagFilterChips } from "./channelColumns";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { Badge } from "../../components/ui/Badge";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import Tooltip from "../../components/ui/Tooltip";
|
||||
import DataTable from "../../components/ui/DataTable";
|
||||
import Tabs, { type TabDef } from "../../components/ui/Tabs";
|
||||
import { useTabPanel } from "../../components/ui/tabPanel";
|
||||
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||
import { pinChannel, useComfortPin } from "../../features/comfort/comfortPin";
|
||||
import { ContextMenu } from "../../components/ui/ContextMenu";
|
||||
import { MoreButton } from "../../components/ui/MoreButton";
|
||||
import { useChannelActions } from "./useChannelActions";
|
||||
import { useContextMenu } from "../../components/ui/useContextMenu";
|
||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||
import { useChannelTagActions } from "../../lib/useChannelTagActions";
|
||||
import { useTagSuggestions } from "./useTagSuggestions";
|
||||
import { useChannelPriority } from "../../lib/useChannelPriority";
|
||||
import { placeByPriority } from "../../lib/channelOrder";
|
||||
import { focusNavItem } from "../../lib/listNav";
|
||||
import ViewSwitcher, { type ViewOption } from "../../components/ui/ViewSwitcher";
|
||||
import ColumnSortControl from "../../components/ui/ColumnSortControl";
|
||||
import FilterChipGroup from "../../components/ui/FilterChipGroup";
|
||||
import TagFilterChips from "../feed/TagFilterChips";
|
||||
import ActionMenu, { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
import ChannelStatsPopover from "./ChannelStatsPopover";
|
||||
import Pager from "../../components/ui/Pager";
|
||||
import { CHANNEL_LAYOUTS, type ChannelLayout } from "../../lib/channelLayout";
|
||||
import { sortRows } from "../../lib/columnSort";
|
||||
import { filterRows, isActive } from "../../lib/tableColumns";
|
||||
import { useFilterFold } from "../../lib/useFilterFold";
|
||||
import { FilterFoldButton } from "../../components/ui/FilterFold";
|
||||
import { HiddenFilterCue } from "../../components/ui/HiddenFilterCue";
|
||||
import { useCardPager } from "../../lib/useCardPager";
|
||||
import { PageToolbar } from "../../components/ui/PageShell";
|
||||
import { PageMenu } from "../../components/ui/PageMenu";
|
||||
import { menuGroups } from "../../components/ui/menuGroups";
|
||||
import { choiceSubmenu, sortSubmenu } from "../../components/ui/pageMenuItems";
|
||||
import { optionsFromColumns } from "../../lib/sortOptions";
|
||||
import ChannelDiscovery from "./ChannelDiscovery";
|
||||
import TagManager from "./TagManager";
|
||||
import { useChannelLayout, useChannels, useChannelsActions } from "./channelsContext";
|
||||
import { useNavigationActions } from "../../features/navigation/navigationContext";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "../feed/feedFiltersContext";
|
||||
import { useWizard } from "../../features/wizard/wizardContext";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
import { useTags } from "../../lib/useTags";
|
||||
|
||||
export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked";
|
||||
|
||||
// The tag filter has its own chip row above the list, so the hidden-filter cue does not name it again.
|
||||
const SURFACED_FILTERS = ["tags"];
|
||||
|
||||
// The top sync chips are a controlled view over the subscribed table's `sync` column filter (shared
|
||||
// via ChannelsProvider): each chip writes `filters.sync`, using the SAME vocab + predicate as the
|
||||
// Sync column funnel (channelColumns.tsx) so the two can't drift. "" = all (isActive treats it as
|
||||
// off). fullySynced = recent_synced && backfill_done (matches the cell's own "fully synced" badge).
|
||||
const SYNC_CHIPS: { value: string; labelKey: string }[] = [
|
||||
{ value: "", labelKey: "channels.filters.all" },
|
||||
{ value: "needsFull", labelKey: "channels.filters.needsFull" },
|
||||
{ value: "fully", labelKey: "channels.filters.fullySynced" },
|
||||
];
|
||||
|
||||
const LAYOUT_ICON: Record<ChannelLayout, typeof Table> = {
|
||||
table: Table,
|
||||
cards: LayoutGrid,
|
||||
};
|
||||
|
||||
// The row menu's identity for a channel. Module scope, as useContextMenu asks: a fresh function per
|
||||
// render rebuilt its `buttonProps` on every render, and with them every column.
|
||||
const channelKey = (c: ManagedChannel) => c.id;
|
||||
|
||||
// Which channel's row menu is open, for its "…" (`aria-expanded`, and staying shown while open). A
|
||||
// tiny store rather than a context: a context change walks the whole table to find its readers (~20
|
||||
// ms on 400 rows, each open and close), while a store calls its subscribers and re-renders only the
|
||||
// two buttons whose answer changed (each reads a boolean, so a change elsewhere renders nothing).
|
||||
type OpenRowStore = Store<string | null>;
|
||||
|
||||
function RowMoreButton({
|
||||
id,
|
||||
label,
|
||||
menuProps,
|
||||
register,
|
||||
openRow,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
menuProps: Record<string, unknown>;
|
||||
register: (id: string, el: HTMLButtonElement | null) => void;
|
||||
openRow: OpenRowStore;
|
||||
}) {
|
||||
const mine = useSyncExternalStore(openRow.subscribe, () => openRow.get() === id);
|
||||
return (
|
||||
<MoreButton
|
||||
ref={(el) => register(id, el)}
|
||||
label={label}
|
||||
expanded={mine}
|
||||
menuProps={menuProps}
|
||||
className={mine ? undefined : "reveal"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Channels() {
|
||||
const me = useMe();
|
||||
const panel = useTabPanel();
|
||||
const canWrite = me.can_write;
|
||||
// "Connect YouTube" onboarding lives in WizardProvider (a notify action / a mutation-error CTA).
|
||||
const { openWizard: onOpenWizard } = useWizard();
|
||||
// The manager's name filter, status chip, active tab, reset token, and focus-channel intent live
|
||||
// in ChannelsProvider (shared with the header search box + the cross-module "focus this channel"
|
||||
// callers); this page is their primary editor.
|
||||
const {
|
||||
search,
|
||||
filters,
|
||||
view,
|
||||
sort,
|
||||
filtersResetToken,
|
||||
focusName: focusChannelName,
|
||||
focusToken: focusChannelToken,
|
||||
} = useChannels();
|
||||
const layout = useChannelLayout();
|
||||
const {
|
||||
setSearch,
|
||||
setFilters,
|
||||
setView,
|
||||
setLayout,
|
||||
setSort,
|
||||
focusChannel: onFocusChannel,
|
||||
consumeFocus,
|
||||
} = useChannelsActions();
|
||||
const { openChannel: onViewChannel, setPage } = useNavigationActions();
|
||||
const feedFilters = useFeedFilters();
|
||||
const { setFilters: setFeedFilters } = useFeedFiltersActions();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
|
||||
// Clicking a tag chip filters the feed by that tag and jumps there. Internalised (was an App
|
||||
// callback) now that Channels reads the feed filters + navigation straight from their providers.
|
||||
// useCallback (like `onView`): the column memo below names it, and the hook lint cannot see the
|
||||
// compiler's memo of a plain closure.
|
||||
const onFilterByTag = useCallback(
|
||||
(tagId: number, name: string) => {
|
||||
setFeedFilters({ ...feedFilters, tags: [tagId], channelIds: [] });
|
||||
setPage("feed");
|
||||
notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) });
|
||||
},
|
||||
[setFeedFilters, feedFilters, setPage, t],
|
||||
);
|
||||
|
||||
const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels });
|
||||
const tagsQuery = useTags();
|
||||
const statusQuery = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus });
|
||||
const blockedQuery = useQuery({ queryKey: qk.blockedChannels(), queryFn: api.blockedChannels });
|
||||
const [tagManagerOpen, setTagManagerOpen] = useState(false);
|
||||
const unblock = useMutation({
|
||||
mutationFn: (id: string) => api.unblockChannel(id),
|
||||
meta: { failTitle: "errors.writeFailed.unblockChannel" },
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.blockedChannels() });
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
},
|
||||
});
|
||||
|
||||
const invalidate = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.tags() });
|
||||
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
||||
// FB2: syncing subscriptions can change the feed immediately — newly-followed channels may
|
||||
// already have videos in the shared catalog, so refresh the feed too (not just the manager).
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
qc.invalidateQueries({ queryKey: qk.feedSummary() });
|
||||
};
|
||||
|
||||
// A memo for the same reason as the tag actions below: the column memo names it.
|
||||
const allTags = tagsQuery.data;
|
||||
const userTags = useMemo(() => (allTags ?? []).filter((t) => !t.system), [allTags]);
|
||||
|
||||
// The row's stepper updates priority optimistically in place and does NOT re-sort/refetch, so the
|
||||
// list doesn't jump under a press-and-hold (the server list is priority-sorted; refetching would
|
||||
// reorder rows and lose your scroll position) — its new order shows next time the page loads. A
|
||||
// pick from the row's menu DOES re-sort (`pickPriority` below). Press-and-hold is debounced +
|
||||
// flushed on unmount by the shared hook (same flow the channel detail page uses).
|
||||
const { setPriority, bumpPriority } = useChannelPriority({
|
||||
read: (id) =>
|
||||
qc.getQueryData<ManagedChannel[]>(qk.channels())?.find((c) => c.id === id)?.priority,
|
||||
optimistic: (id, next) =>
|
||||
qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) =>
|
||||
(old ?? []).map((ch) => (ch.id === id ? { ...ch, priority: next } : ch)),
|
||||
),
|
||||
onError: () => qc.invalidateQueries({ queryKey: qk.channels() }),
|
||||
});
|
||||
|
||||
// A priority step picked from the row's menu (the owner's call): the one thing a pick is FOR is
|
||||
// to move the channel up (or down) the list, so the list re-sorts at once — only that row moves
|
||||
// (`placeByPriority`, the server's order) — and the page and the focus follow it (`follow`): the
|
||||
// table/card pager turns to its page, and the effect below hands the focus to its "…" (its node
|
||||
// may have been re-inserted, which drops the focus to <body>, or be a virtualized card not yet
|
||||
// mounted). Picked again from there, the next step is one Enter away.
|
||||
const [follow, setFollow] = useState<{ key: string; seq: number } | null>(null);
|
||||
const moreRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const pickPriority = (c: ManagedChannel, next: number) => {
|
||||
setPriority(c.id, next);
|
||||
qc.setQueryData<ManagedChannel[]>(
|
||||
qk.channels(),
|
||||
(old) => old && placeByPriority(old, c.id, me.preferences),
|
||||
);
|
||||
setFollow((f) => ({ key: c.id, seq: (f?.seq ?? 0) + 1 }));
|
||||
};
|
||||
|
||||
// Tagging is updated optimistically in place (no refetch of the whole channel list, which
|
||||
// made the toggle feel ~2s slow); only revert by refetching if the server call fails. The
|
||||
// attach/detach + create-and-attach flow is shared with the channel detail page.
|
||||
const tagActionOpts = useMemo<Parameters<typeof useChannelTagActions>[0]>(
|
||||
() => ({
|
||||
optimisticToggle: (id, tagId, attach) =>
|
||||
qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) =>
|
||||
(old ?? []).map((ch) =>
|
||||
ch.id === id
|
||||
? {
|
||||
...ch,
|
||||
tag_ids: attach ? [...ch.tag_ids, tagId] : ch.tag_ids.filter((x) => x !== tagId),
|
||||
}
|
||||
: ch,
|
||||
),
|
||||
),
|
||||
// Keep the channel DETAIL cache in step with the manager list (the detail page reads its tags
|
||||
// from qk.channel(id)); mirrors the detail page invalidating the manager list on its side. Also
|
||||
// recompute suggestions: a channel that just gained/lost a user tag changes the untagged set and
|
||||
// the learned pattern.
|
||||
onSuccess: (id) => {
|
||||
qc.invalidateQueries({ queryKey: qk.channel(id) });
|
||||
qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
|
||||
},
|
||||
// A failed attach/detach reverts the optimistic change by refetching the list. Also refetch
|
||||
// suggestions: acceptSuggestion optimistically dropped the channel from that cache, and on
|
||||
// failure the tag never actually attached — so the server still suggests it and the ghost chips
|
||||
// must come back rather than leaving an empty cell (honest states). The hook reports the failure.
|
||||
onError: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
|
||||
},
|
||||
}),
|
||||
[qc],
|
||||
);
|
||||
// The options are a memo so the hook's toggleTag/createAndAttachTag stay the same functions: the
|
||||
// column memo below names them (see `columns`).
|
||||
const { toggleTag, createAndAttachTag } = useChannelTagActions(tagActionOpts);
|
||||
// --- Suggested tags: learned from the user's own manual-tag pattern, shown as accept/dismiss
|
||||
// ghost chips on untagged channels. Dismissal is session-only (an accepted suggestion self-clears —
|
||||
// the channel becomes tagged and the server drops it; a dismissed one just hides until reload). The
|
||||
// /tags/suggestions query, the session dismiss set, and the optimistic accept/dismiss/bulk logic all
|
||||
// live in useTagSuggestions so they can be unit-tested apart from this component.
|
||||
const {
|
||||
suggestionsFor,
|
||||
dismissSuggestion,
|
||||
acceptSuggestion,
|
||||
visibleSuggestions,
|
||||
highConfidence,
|
||||
acceptAllHighConfidence,
|
||||
} = useTagSuggestions({ tags: tagsQuery.data ?? [], toggleTag });
|
||||
|
||||
const syncSubs = useMutation({
|
||||
mutationFn: () => api.syncSubscriptions(),
|
||||
onSuccess: (r: { subscriptions?: number }) => {
|
||||
invalidate();
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("channels.notify.synced", { count: r.subscriptions ?? 0 }),
|
||||
});
|
||||
},
|
||||
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.syncFailed"), onOpenWizard),
|
||||
});
|
||||
const deepAll = useMutation({
|
||||
mutationFn: () => api.deepAll(true),
|
||||
onSuccess: (r: { updated?: number }) => {
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }),
|
||||
});
|
||||
},
|
||||
onError: (e) =>
|
||||
notifyYouTubeActionError(e, t("channels.notify.fullHistoryFailed"), onOpenWizard),
|
||||
});
|
||||
|
||||
// The tag-chip row is a controlled view over the Tags COLUMN filter (`filters.tags`, a string[] of
|
||||
// tag ids), the same way the sync chips drive `filters.sync` — so the top chips and the Tags column
|
||||
// funnel share ONE state and stay in sync (bidirectional). Toggling a chip rewrites `filters.tags`;
|
||||
// filterRows / DataTable then applies it. Channel-name search stays separate (the shared top box).
|
||||
const tagFilter = Array.isArray(filters.tags) ? filters.tags : [];
|
||||
// B4: on a short window the band's filters — the tag chip lane and the status /
|
||||
// pager row — fold behind one "Filters · n"; the tabs and the TAGS editor stay. n = each picked tag,
|
||||
// plus every other active column filter (the status chips are the Sync column's).
|
||||
const fold = useFilterFold();
|
||||
const foldCount =
|
||||
tagFilter.length +
|
||||
Object.entries(filters).filter(([k, v]) => k !== "tags" && isActive(v)).length;
|
||||
// Picking a real tag drops the "No tag" sentinel (item B) — they're mutually exclusive, as in the feed.
|
||||
const toggleTagFilter = (tagId: number) => {
|
||||
const id = String(tagId);
|
||||
const base = tagFilter.filter((x) => x !== NO_TAG_SENTINEL);
|
||||
const next = base.includes(id) ? base.filter((x) => x !== id) : [...base, id];
|
||||
setFilters({ ...filters, tags: next });
|
||||
};
|
||||
// The "No tag" chip: filter the table to channels you haven't personally tagged yet. Exclusive with
|
||||
// real tag chips (clicking it drops them); the Tags column's test reads the sentinel (channelColumns).
|
||||
const noTagOn = tagFilter.includes(NO_TAG_SENTINEL);
|
||||
const toggleNoTagFilter = () =>
|
||||
setFilters({ ...filters, tags: noTagOn ? [] : [NO_TAG_SENTINEL] });
|
||||
// The tag-filter chips, fed to TagFilterChips which folds any overflow into a "+N" pill so the row
|
||||
// stays one line and the trailing "…" actions can't wrap.
|
||||
const tagChips = tagFilterChips({
|
||||
userTags,
|
||||
tags: tagsQuery,
|
||||
picked: tagFilter,
|
||||
noTagOn,
|
||||
toggle: toggleTagFilter,
|
||||
toggleNoTag: toggleNoTagFilter,
|
||||
t,
|
||||
});
|
||||
// A focus-channel intent (header "without full history" / tag manager) seeds the search box.
|
||||
// The TOKEN is the intent (it advances even when the same channel is re-focused); the name is the
|
||||
// payload, so it is read through an effect event. Naming it would re-seed the box on any render that
|
||||
// changed it, overwriting whatever the user had since typed.
|
||||
//
|
||||
// `consumeFocus` is what stops this page's OWN remounts from re-applying it: this effect runs on
|
||||
// every mount, and this page unmounts on every navigation away — and on opening a channel page,
|
||||
// which does not even change `page`. The provider then reports `focusName` as null, so a later
|
||||
// visit leaves the box exactly as the user left it.
|
||||
const applyFocus = useEffectEvent(() => {
|
||||
if (!focusChannelName) return;
|
||||
setSearch(focusChannelName);
|
||||
consumeFocus();
|
||||
});
|
||||
useEffect(() => applyFocus(), [focusChannelToken]);
|
||||
// The header's reset intent clears the search box (skip the initial mount); the tag chips ride in
|
||||
// `filters.tags`, which goToFullHistory already clears by setting a fresh map.
|
||||
const resetRef = useRef(filtersResetToken);
|
||||
useEffect(() => {
|
||||
if (filtersResetToken === resetRef.current) return;
|
||||
resetRef.current = filtersResetToken;
|
||||
setSearch("");
|
||||
}, [filtersResetToken, setSearch]);
|
||||
|
||||
const onView = useCallback(
|
||||
(c: ManagedChannel) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel")),
|
||||
[onViewChannel, t],
|
||||
);
|
||||
// The channel's ONE action list — ONE menu for the list, shared by the table and the card
|
||||
// layout: they are two paints of the same rows, so a right-click, a long-press and the row's "…"
|
||||
// must mean the same thing in both. Only mounted under the SUBSCRIBED branch below: a discovery
|
||||
// result is not a `Channel` row yet, so pinning or hiding one could only 404.
|
||||
// Keyed by id: a tag toggled in the menu, a priority picked, a poll — each rebuilds the row object
|
||||
// under the open menu, and its "…" must still know the menu is its own.
|
||||
const rowMenu = useContextMenu<ManagedChannel>({ keyOf: channelKey });
|
||||
const rowPin = useComfortPin(rowMenu.open);
|
||||
// The columns read only the menu's STABLE handles. Which row's menu is open reaches each "…" through
|
||||
// the open-row store instead: read in the columns, it rebuilt every column on each open, close and
|
||||
// reposition, and so re-rendered the whole table — ~0.8 s to open the menu on 400 rows.
|
||||
const { buttonProps: rowButtonProps, triggerProps: rowTriggerProps } = rowMenu;
|
||||
const openRowId = rowMenu.open ? (rowMenu.target?.id ?? null) : null;
|
||||
const [openRow] = useState(() => createStore<string | null>(null));
|
||||
useEffect(() => openRow.set(openRowId), [openRow, openRowId]);
|
||||
const channelActions = useChannelActions({
|
||||
onView,
|
||||
onOpenWizard,
|
||||
tags: userTags,
|
||||
tagsState: tagsQuery.isPending ? "loading" : tagsQuery.isError ? "error" : "ready",
|
||||
onToggleTag: (c, tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId)),
|
||||
onPickPriority: pickPriority,
|
||||
});
|
||||
// The row as the cache holds it NOW, not as it was when the menu opened: the Tags submenu stays
|
||||
// open across toggles, and each tick (and the next toggle's attach-or-detach) reads this.
|
||||
const menuRow =
|
||||
rowMenu.target &&
|
||||
((channelsQuery.data ?? []).find((c) => c.id === rowMenu.target!.id) ?? rowMenu.target);
|
||||
const menuItems = menuRow
|
||||
? channelActions.items(menuRow, {
|
||||
pin: rowPin.item(pinChannel(menuRow.id), menuRow.title ?? t("channels.row.thisChannel")),
|
||||
})
|
||||
: [];
|
||||
|
||||
// Tag/priority/text filters stay controlled COLUMN filters (`filters.tags` etc.) shared by the top
|
||||
// chips, the column funnels and the cards. The sync-status filter (`filters.sync`) is no longer a
|
||||
// column — the retired Sync column became a ✓ charm on Stored — so it's
|
||||
// applied as a pre-filter (`applySyncFilter`) over the rows fed to BOTH the table and the cards,
|
||||
// still driven by the same top chips. Hidden channels have their own tab (excluded here). Only the
|
||||
// name search filters at this step; the remaining column funnels run in DataTable / filterRows below.
|
||||
//
|
||||
// The table's rows and columns are explicit memos, not inline. Both later reach calls the React
|
||||
// Compiler must assume may mutate them (`filterRows`, `sortRows`, `optionsFromColumns`) past this
|
||||
// page's hooks, and a compiler memo cannot span a hook call — so inline, the compiler dropped their
|
||||
// memo without a word, and every render (each open, close and reposition of the row menu) handed
|
||||
// DataTable new rows and columns: ~0.9 s to open the menu on 400 rows. A useMemo result is frozen.
|
||||
const hiddenChannels = (channelsQuery.data ?? []).filter((c) => c.hidden);
|
||||
const allChannels = channelsQuery.data;
|
||||
const q = search.trim().toLowerCase();
|
||||
const syncFilterValue = filters.sync;
|
||||
const syncedChannels = useMemo(
|
||||
() =>
|
||||
// Sync-status pre-filter (top chips → `filters.sync`), applied before the table/cards see the rows.
|
||||
applySyncFilter(
|
||||
(allChannels ?? []).filter(
|
||||
(c) =>
|
||||
!c.hidden && (!q || `${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q)),
|
||||
),
|
||||
syncFilterValue,
|
||||
),
|
||||
[allChannels, q, syncFilterValue],
|
||||
);
|
||||
const s = statusQuery.data;
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const registerMore = (id: string, el: HTMLButtonElement | null) => {
|
||||
if (el) moreRefs.current.set(id, el);
|
||||
else moreRefs.current.delete(id);
|
||||
};
|
||||
return buildChannelColumns({
|
||||
t,
|
||||
lang,
|
||||
// Distinct priority levels present in the catalog → the priority column's filter options.
|
||||
prioOptions: [...new Set((allChannels ?? []).map((c) => c.priority))]
|
||||
.sort((a, b) => b - a)
|
||||
.map((p) => ({ value: String(p), label: String(p) })),
|
||||
userTags,
|
||||
bumpPriority,
|
||||
setPriority,
|
||||
more: (c) => (
|
||||
<RowMoreButton
|
||||
id={c.id}
|
||||
label={t("channels.row.menu")}
|
||||
menuProps={rowButtonProps(c)}
|
||||
register={registerMore}
|
||||
openRow={openRow}
|
||||
/>
|
||||
),
|
||||
onView,
|
||||
toggleTag,
|
||||
createAndAttachTag,
|
||||
onFilterByTag,
|
||||
suggestionsFor,
|
||||
onAcceptSuggestion: acceptSuggestion,
|
||||
onDismissSuggestion: dismissSuggestion,
|
||||
});
|
||||
}, [
|
||||
t,
|
||||
lang,
|
||||
allChannels,
|
||||
userTags,
|
||||
bumpPriority,
|
||||
setPriority,
|
||||
rowButtonProps,
|
||||
openRow,
|
||||
onView,
|
||||
toggleTag,
|
||||
createAndAttachTag,
|
||||
onFilterByTag,
|
||||
suggestionsFor,
|
||||
acceptSuggestion,
|
||||
dismissSuggestion,
|
||||
]);
|
||||
|
||||
// Status chips sit in the table's controls row (next to paging) — compact and close to
|
||||
// where they act.
|
||||
const syncFilter = typeof filters.sync === "string" ? filters.sync : "";
|
||||
const statusChips = (
|
||||
<FilterChipGroup
|
||||
ariaLabel={t("channels.filters.syncLabel")}
|
||||
className="flex flex-wrap items-center gap-1.5"
|
||||
options={SYNC_CHIPS.map((c) => ({ value: c.value, label: t(c.labelKey) }))}
|
||||
value={syncFilter}
|
||||
onChange={(sync) => setFilters({ ...filters, sync })}
|
||||
/>
|
||||
);
|
||||
|
||||
// The catalog-wide stats collapse into a compact "Stats" hover chip (right of the tab row, subscribed
|
||||
// only) instead of a crowded inline row.
|
||||
const statsBlock = s ? <ChannelStatsPopover status={s} /> : null;
|
||||
|
||||
// Catalog-wide, occasional actions — tucked into a shared ⋯ overflow menu so they stop crowding the
|
||||
// tags row. Pending mutations keep the item visible but disabled, with a spinning icon.
|
||||
const catalogActions: ActionMenuItem[] = [
|
||||
{
|
||||
id: "sync",
|
||||
label: t("channels.syncSubscriptions"),
|
||||
title: t("channels.syncSubscriptionsHint"),
|
||||
icon: <RefreshCw className={`h-4 w-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />,
|
||||
onSelect: () => syncSubs.mutate(),
|
||||
disabled: syncSubs.isPending,
|
||||
},
|
||||
{
|
||||
id: "backfill",
|
||||
label: t("channels.backfillEverything"),
|
||||
title: t("channels.backfillEverythingHint"),
|
||||
icon: (
|
||||
<History className={`h-4 w-4 ${deepAll.isPending ? "motion-safe:animate-pulse" : ""}`} />
|
||||
),
|
||||
onSelect: () => deepAll.mutate(),
|
||||
disabled: deepAll.isPending,
|
||||
},
|
||||
];
|
||||
const actionButtons = (
|
||||
<ActionMenu ariaLabel={t("channels.actionsLabel")} items={catalogActions} />
|
||||
);
|
||||
|
||||
// Table / cards density — shared by the subscribed + discovery tables (blocked is a chip list, no
|
||||
// layout to switch). Rides the same generic ViewSwitcher the feed uses.
|
||||
const layoutOptions: ViewOption<ChannelLayout>[] = CHANNEL_LAYOUTS.map((id) => ({
|
||||
id,
|
||||
label: t("channels.layout." + id),
|
||||
icon: LAYOUT_ICON[id],
|
||||
}));
|
||||
const layoutSwitcher = (
|
||||
<ViewSwitcher
|
||||
value={layout}
|
||||
options={layoutOptions}
|
||||
onChange={setLayout}
|
||||
label={t("channels.layoutLabel")}
|
||||
labelClassName="hidden lg:inline"
|
||||
/>
|
||||
);
|
||||
const hasLayout = view === "subscribed" || view === "discovery";
|
||||
// In cards there are no column headers to sort by, so a dropdown drives the SAME shared sort state
|
||||
// the table header uses (table ↔ cards stay in sync). Table mode keeps the header clicks.
|
||||
const sortControl = (
|
||||
<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")}
|
||||
/>
|
||||
);
|
||||
// The card layout paginates like the table (a page of cards + a pager), rather than one long
|
||||
// infinite scroll. Slice the sorted rows; the Pager reports page/size changes.
|
||||
// Only computed for cards: in table mode DataTable sorts and slices internally, so doing it here
|
||||
// too would copy+sort the whole ~300-row array on every render for a result nothing reads.
|
||||
const isCards = layout === "cards";
|
||||
// Cards have no column headers, so apply the shared column filters (sync + any funnel set in table
|
||||
// mode) here via the exported predicate — the same filterRows DataTable runs internally — then sort.
|
||||
const sortedChannels = isCards
|
||||
? sortRows(filterRows(syncedChannels, columns, filters), columns, sort, lang)
|
||||
: syncedChannels;
|
||||
const {
|
||||
paged: pagedCards,
|
||||
pagerProps,
|
||||
resetPage,
|
||||
} = useCardPager(
|
||||
sortedChannels,
|
||||
LS.channelCardSize,
|
||||
isCards,
|
||||
follow && { seq: follow.seq, match: (c) => c.id === follow.key },
|
||||
);
|
||||
// The focus half of `follow` (the pagers took the page half during render). A mounted row names
|
||||
// its own place; a card the virtualized grid has not mounted yet is found by its place on the page.
|
||||
const focusFollowed = useEffectEvent((key: string) => {
|
||||
const wrap = listRef.current;
|
||||
if (!wrap) return;
|
||||
const button = () => moreRefs.current.get(key) ?? null;
|
||||
const item = button()?.closest("[data-nav-index]");
|
||||
const at = item
|
||||
? Number(item.getAttribute("data-nav-index"))
|
||||
: isCards
|
||||
? sortedChannels.findIndex((c) => c.id === key) -
|
||||
pagerProps.page * Math.max(0, pagerProps.size)
|
||||
: -1;
|
||||
// Filtered out by its new priority (a Prio column filter): nothing to follow — honest, not a
|
||||
// jump to some other row.
|
||||
if (at >= 0) focusNavItem(wrap, at, button);
|
||||
});
|
||||
useEffect(() => {
|
||||
if (follow) focusFollowed(follow.key);
|
||||
}, [follow]);
|
||||
// Changing what you're looking at starts over at the first page, the way DataTable resets its own
|
||||
// page on a filter/sort change. Deliberately NOT keyed on the row count: the qk.channels() query
|
||||
// refetches on window focus and after every sync, so a catalogue that merely gained a channel would
|
||||
// otherwise yank you off page 7 for something you didn't do — a list that shrank past your page is
|
||||
// handled by the hook's clamp instead.
|
||||
useEffect(() => {
|
||||
resetPage();
|
||||
}, [filters, search, sort, resetPage]);
|
||||
const cardPager = <Pager {...pagerProps} />;
|
||||
|
||||
const viewTabs: TabDef[] = useMemo(
|
||||
() =>
|
||||
(["subscribed", "discovery", "hidden", "blocked"] as ChannelsView[]).map((v) => ({
|
||||
id: v,
|
||||
label: t(`channels.tabs.${v}`),
|
||||
})),
|
||||
[t],
|
||||
);
|
||||
const tabs = (
|
||||
<div className="px-4 pt-2 max-w-384 mx-auto flex items-center justify-between gap-4 flex-wrap">
|
||||
<Tabs
|
||||
idBase={panel.idBase}
|
||||
tabs={viewTabs}
|
||||
active={view}
|
||||
onChange={(id) => setView(id as ChannelsView)}
|
||||
className="flex flex-wrap items-center gap-1"
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
{view === "subscribed" && statsBlock}
|
||||
{view === "subscribed" && layout === "cards" && sortControl}
|
||||
{hasLayout && layoutSwitcher}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// The page's background menu (DESIGN "Background menu"): the toolbar's own controls, no more — the
|
||||
// layout, the cards' sort, the status and tag filters, the tag editor and the catalog sync. Backfill
|
||||
// stays in the "…" only: a long quota-heavy run is not a thing to start from a stray right-click.
|
||||
const syncOptions = SYNC_CHIPS.map((c) => ({ value: c.value, label: t(c.labelKey) }));
|
||||
const pageMenuItems = menuGroups(
|
||||
hasLayout
|
||||
? [
|
||||
choiceSubmenu({
|
||||
id: "layout",
|
||||
label: t("channels.layoutLabel"),
|
||||
options: layoutOptions.map((o) => ({ value: o.id, label: o.label })),
|
||||
selected: layout,
|
||||
onPick: setLayout,
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
view === "subscribed" && isCards
|
||||
? [
|
||||
sortSubmenu({
|
||||
id: "sort",
|
||||
label: t("channels.sort.label"),
|
||||
options: optionsFromColumns(columns),
|
||||
sort,
|
||||
onChange: setSort,
|
||||
ascLabel: t("channels.sort.asc"),
|
||||
descLabel: t("channels.sort.desc"),
|
||||
defaultLabel: t("channels.sort.default"),
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
view === "subscribed"
|
||||
? [
|
||||
choiceSubmenu({
|
||||
id: "status",
|
||||
label: t("channels.filters.syncLabel"),
|
||||
options: syncOptions,
|
||||
selected: syncFilter,
|
||||
onPick: (sync) => setFilters({ ...filters, sync }),
|
||||
}),
|
||||
{ id: "tags", label: t("channels.tags.edit"), onSelect: () => setTagManagerOpen(true) },
|
||||
...(tagFilter.length > 0
|
||||
? [
|
||||
{
|
||||
id: "clearTags",
|
||||
label: t("channels.tags.clearFilter"),
|
||||
onSelect: () => setFilters({ ...filters, tags: [] }),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
: [],
|
||||
view === "subscribed" ? catalogActions.filter((a) => a.id === "sync") : [],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{pageMenuItems.length > 0 && (
|
||||
<PageMenu ariaLabel={t("common.pageMenu")} items={pageMenuItems} />
|
||||
)}
|
||||
{/* The tab bar is always fixed chrome (it portals into the shell's fixed band, so it never
|
||||
scrolls and doesn't jump between tabs). On Subscriptions the stats/description/tags ride
|
||||
with it, and the status-chip + pager row lands just below (DataTable's controlsInBand). */}
|
||||
<PageToolbar>
|
||||
{tabs}
|
||||
{view === "subscribed" && (
|
||||
<div className="@container/tagrow px-4 pt-2 pb-2 max-w-384 mx-auto">
|
||||
{/* One row: the TAGS editor trigger (left) + the tag-filter chip lane (folds overflow into
|
||||
"+N") + Clear, then the catalog "…" actions pinned right, so the "…" menu always stays on the
|
||||
first line. Only the suggestions block wraps: below 40rem it takes a line of its own under
|
||||
the row (the owner's pick) — at 375 it had squeezed the chip lane to 0 px and
|
||||
pushed the "…" off the screen. Channel-name search lives in the shared top SearchBar. */}
|
||||
<div className="flex flex-wrap items-center gap-2 min-w-0">
|
||||
{/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate
|
||||
"Manage tags" button; the tooltip explains what tags are + that it's clickable. */}
|
||||
<Tooltip hint={t("channels.tags.yourTagsHint")}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="shrink-0 uppercase tracking-wide"
|
||||
onClick={() => setTagManagerOpen(true)}
|
||||
icon={<Pencil className="h-3 w-3" />}
|
||||
>
|
||||
{t("channels.tags.label")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<FilterFoldButton fold={fold} label={t("sidebar.filters")} count={foldCount} />
|
||||
{!fold.folded && <TagFilterChips chips={tagChips} className="flex-1" />}
|
||||
{!fold.folded && tagFilter.length > 0 && (
|
||||
<Button
|
||||
variant="link-muted"
|
||||
onClick={() => setFilters({ ...filters, tags: [] })}
|
||||
className="text-xs px-2"
|
||||
>
|
||||
{t("datatable.clear")}
|
||||
</Button>
|
||||
)}
|
||||
{/* Suggested-tags affordance: a sparkle count of the visible suggestions, with a
|
||||
one-click bulk-accept for the high-confidence ones. The per-row ghost chips handle the
|
||||
rest. Hidden when there's nothing to suggest. */}
|
||||
{visibleSuggestions.length > 0 && (
|
||||
<div
|
||||
data-tag-suggestions
|
||||
className="shrink-0 flex items-center gap-1.5 @max-[40rem]/tagrow:order-last @max-[40rem]/tagrow:basis-full"
|
||||
>
|
||||
<Tooltip hint={t("channels.tags.suggestionsHint")}>
|
||||
<Badge tone="accent">
|
||||
<Sparkles />
|
||||
{t("channels.tags.suggestionsCount", { count: visibleSuggestions.length })}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
{highConfidence.length > 0 && (
|
||||
<Button variant="outline" size="xs" onClick={acceptAllHighConfidence}>
|
||||
{t("channels.tags.acceptAllHigh", { count: highConfidence.length })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="shrink-0">{actionButtons}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* In table mode the status chips + pager ride in DataTable's own in-band controls row. The card
|
||||
layout has no such row, so surface the same status chips (left) and its pager (right) here. */}
|
||||
{view === "subscribed" && layout === "cards" && !fold.folded && (
|
||||
<div className="px-4 pb-2 max-w-384 mx-auto flex items-center justify-between gap-3 flex-wrap">
|
||||
{statusChips}
|
||||
{cardPager}
|
||||
</div>
|
||||
)}
|
||||
</PageToolbar>
|
||||
{tagManagerOpen && (
|
||||
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
|
||||
)}
|
||||
|
||||
<div {...panel.panelProps(view)}>
|
||||
{view === "discovery" ? (
|
||||
<ChannelDiscovery
|
||||
canWrite={canWrite}
|
||||
search={search}
|
||||
onOpenWizard={onOpenWizard}
|
||||
onViewChannel={onViewChannel}
|
||||
/>
|
||||
) : view === "blocked" ? (
|
||||
/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
|
||||
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
|
||||
<ExcludedChannelList
|
||||
channels={blockedQuery.data ?? []}
|
||||
icon={<Ban className="w-4 h-4 text-muted" />}
|
||||
title={t("channels.blocked.title")}
|
||||
hint={t("channels.blocked.hint")}
|
||||
emptyText={t("channels.blocked.empty")}
|
||||
actionLabel={t("channel.unblock")}
|
||||
actionIcon={<X className="w-3.5 h-3.5" />}
|
||||
onAction={(id) => unblock.mutate(id)}
|
||||
onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
|
||||
/>
|
||||
</div>
|
||||
) : view === "hidden" ? (
|
||||
/* Hidden channels — kept out of the Mine feed but still in the shared Library; managed here
|
||||
instead of dimmed in the subscribed table. Same card grid as Blocked (ExcludedChannelList). */
|
||||
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
|
||||
<ExcludedChannelList
|
||||
channels={hiddenChannels}
|
||||
icon={<EyeOff className="w-4 h-4 text-muted" />}
|
||||
title={t("channels.hidden.title")}
|
||||
hint={t("channels.hidden.hint")}
|
||||
emptyText={t("channels.hidden.empty")}
|
||||
actionLabel={t("channels.hidden.unhide")}
|
||||
actionIcon={<Eye className="w-3.5 h-3.5" />}
|
||||
onAction={channelActions.unhide}
|
||||
onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when
|
||||
`position: sticky` engages on the first scroll (the band's controls row above gives the
|
||||
visual gap). */
|
||||
<div ref={listRef} className="px-4 pb-4 max-w-384 mx-auto">
|
||||
{/* Channel table — only the rows scroll; the header row is sticky, and the controls row
|
||||
(status chips + pager) rides in the fixed band right above it (controlsInBand). The
|
||||
card layout swaps the table for a virtualized grid over the same rows (its pager and
|
||||
status chips ride in the fixed band above instead). */}
|
||||
{channelsQuery.isError && !channelsQuery.data ? (
|
||||
<StateMessage tone="error" onRetry={() => channelsQuery.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : channelsQuery.isLoading ? (
|
||||
<LoadingState shape="table" label={t("channels.loading")} />
|
||||
) : layout === "table" ? (
|
||||
<DataTable
|
||||
rows={syncedChannels}
|
||||
columns={columns}
|
||||
rowKey={(c) => c.id}
|
||||
persistKey={ownKey(LS.channelsTable) ?? undefined}
|
||||
// Folded, the status / pager row leaves the band; the list's own end keeps it.
|
||||
controlsPosition={fold.folded ? "bottom" : "both"}
|
||||
controlsLeading={statusChips}
|
||||
controlsInBand
|
||||
controlsBandClassName="px-4 max-w-384 mx-auto"
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
fixedLayout
|
||||
emptyText={t("channels.empty")}
|
||||
rowProps={rowTriggerProps}
|
||||
// `group`: the row's "…" reveals on its hover or focus (`.reveal`), and stays on touch.
|
||||
rowClassName={() => "group"}
|
||||
keyboardNav
|
||||
reveal={follow}
|
||||
surfacedFilters={SURFACED_FILTERS}
|
||||
narrowedBy={q}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Cards have no funnels: a Priority or name filter set in the table would narrow them
|
||||
unseen. The tags have their own chip row. */}
|
||||
<HiddenFilterCue
|
||||
columns={columns}
|
||||
filters={filters}
|
||||
by="cards"
|
||||
surfaced={SURFACED_FILTERS}
|
||||
onFilter={(key, value) => setFilters({ ...filters, [key]: value })}
|
||||
/>
|
||||
<ChannelLayoutGrid
|
||||
rows={pagedCards}
|
||||
columns={columns}
|
||||
rowKey={(c) => c.id}
|
||||
leadKey="priority"
|
||||
aboutKey="about"
|
||||
moreKey="actions"
|
||||
emptyText={t("channels.empty")}
|
||||
rowProps={rowTriggerProps}
|
||||
rowClassName={() => "group"}
|
||||
/>
|
||||
{/* Folded, the status / pager row leaves the band, as the table's does — and the
|
||||
list's own end keeps it here too (the cards had once lost their pager). */}
|
||||
{fold.folded && (
|
||||
<div className="pt-3 flex items-center justify-between gap-3 flex-wrap">
|
||||
{statusChips}
|
||||
{cardPager}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ContextMenu menu={rowMenu} ariaLabel={t("channels.row.menu")} items={menuItems} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// ChannelsProvider's tab-history behaviour: setView pushes a `_chTab` history entry + persists the tab,
|
||||
// and a genuine browser Back (popstate carrying `_chTab`) restores + re-persists it. This wiring was
|
||||
// re-routed through useAccountPersistedState, so it's exercised here rather than only by hand E2E.
|
||||
//
|
||||
// Uses REAL storage (jsdom localStorage/sessionStorage) with account 1's local state claimed, so
|
||||
// useAccountPersistedState / useSortState / useFilterState all resolve their account-scoped keys; only
|
||||
// navigationContext is mocked, pinned to the channels page. The layout is an account preference
|
||||
// (usePref), read from the `me` cache BootGate fills before the app mounts.
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { renderHook, act, render as renderRoot } from "@testing-library/react";
|
||||
import { useEffect, useEffectEvent, type ReactNode } from "react";
|
||||
|
||||
vi.mock("../../features/navigation/navigationContext", () => ({
|
||||
useNavigation: () => ({ page: "channels" }),
|
||||
useNavigationActions: () => ({ setPage: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { ChannelsProvider } from "./ChannelsProvider";
|
||||
import { useChannelLayout, useChannels, useChannelsActions } from "./channelsContext";
|
||||
import { claimLocalData } from "../../lib/storage";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
|
||||
let qc = new QueryClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>
|
||||
<ChannelsProvider>{children}</ChannelsProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const withClient = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
const render = () => renderHook(() => ({ ...useChannels(), ...useChannelsActions() }), { wrapper });
|
||||
const VIEW_KEY = "siftlode.channelsView"; // the account's (claimed in beforeEach)
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
claimLocalData(1); // the browser holds account 1's state
|
||||
qc = new QueryClient();
|
||||
qc.setQueryData(qk.me(), { id: 1, preferences: {} });
|
||||
// Reset BOTH the history state and the query string: setView only owns an entry on the channels page,
|
||||
// and the URL mirror writes ctab/csort/cf — a stale key left by a prior test would be read back by
|
||||
// this provider's mount-time URL seed and change the starting tab.
|
||||
window.history.replaceState({ sfPage: "channels" }, "", "/");
|
||||
});
|
||||
|
||||
describe("ChannelsProvider tab history", () => {
|
||||
it("setView pushes a _chTab history entry and persists the tab", () => {
|
||||
const { result } = render();
|
||||
expect(result.current.view).toBe("subscribed"); // default
|
||||
|
||||
act(() => result.current.setView("hidden"));
|
||||
|
||||
expect(result.current.view).toBe("hidden");
|
||||
expect(window.history.state).toMatchObject({ sfPage: "channels", _chTab: "hidden" });
|
||||
expect(localStorage.getItem(VIEW_KEY)).toBe("hidden");
|
||||
});
|
||||
|
||||
it("does NOT push a new entry when re-selecting the current tab", () => {
|
||||
const { result } = render();
|
||||
const spy = vi.spyOn(window.history, "pushState");
|
||||
act(() => result.current.setView("subscribed")); // already the current tab
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("a genuine Back (popstate carrying _chTab) restores and re-persists that tab", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.setView("blocked"));
|
||||
expect(result.current.view).toBe("blocked");
|
||||
|
||||
// Browser Back to an entry stamped with a different tab.
|
||||
act(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { _chTab: "discovery" } }));
|
||||
});
|
||||
|
||||
expect(result.current.view).toBe("discovery");
|
||||
expect(localStorage.getItem(VIEW_KEY)).toBe("discovery");
|
||||
});
|
||||
|
||||
it("ignores a popstate for an entry outside the manager (no _chTab)", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.setView("hidden"));
|
||||
act(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "feed" } }));
|
||||
});
|
||||
expect(result.current.view).toBe("hidden"); // unchanged
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChannelsProvider — shareable URL", () => {
|
||||
it("seeds tab + sort + filters from a shared manager link at mount (URL wins over stored)", () => {
|
||||
// Stored view says "blocked"; the shared link says "hidden" + a sort + a filter → the URL wins.
|
||||
localStorage.setItem(VIEW_KEY, "blocked");
|
||||
const cf = encodeURIComponent(JSON.stringify({ sync: "needsFull" }));
|
||||
window.history.replaceState(
|
||||
{ sfPage: "channels" },
|
||||
"",
|
||||
`/?ctab=hidden&csort=priority:desc&cf=${cf}`,
|
||||
);
|
||||
const { result } = render();
|
||||
expect(result.current.view).toBe("hidden");
|
||||
expect(result.current.sort).toEqual({ key: "priority", dir: "desc" });
|
||||
expect(result.current.filters).toEqual({ sync: "needsFull" });
|
||||
});
|
||||
|
||||
it("mirrors the tab into the address bar and clears it back to the default", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.setView("blocked"));
|
||||
expect(new URLSearchParams(window.location.search).get("ctab")).toBe("blocked");
|
||||
// Back to the default tab clears the key (only non-defaults are emitted).
|
||||
act(() => result.current.setView("subscribed"));
|
||||
expect(new URLSearchParams(window.location.search).has("ctab")).toBe(false);
|
||||
});
|
||||
|
||||
it("a PARTIAL shared link does not clobber the account's stored sort/filters", () => {
|
||||
// Regression: the seed's base for the OMITTED dimensions used to be read as defaults, and
|
||||
// persisting the seed overwrote the account's real stored sort + filter map.
|
||||
localStorage.setItem(
|
||||
"siftlode.channelSort",
|
||||
JSON.stringify({ key: "subscribers", dir: "desc" }),
|
||||
);
|
||||
localStorage.setItem("siftlode.channelsFilterMap", JSON.stringify({ sync: "needsFull" }));
|
||||
window.history.replaceState({ sfPage: "channels" }, "", "/?ctab=hidden"); // only the tab in the URL
|
||||
|
||||
const { result } = render();
|
||||
|
||||
// The present dim (tab) is adopted from the link...
|
||||
expect(result.current.view).toBe("hidden");
|
||||
// ...but the omitted sort + filters keep the account's real stored values, in state AND storage.
|
||||
expect(result.current.sort).toEqual({ key: "subscribers", dir: "desc" });
|
||||
expect(result.current.filters).toEqual({ sync: "needsFull" });
|
||||
expect(JSON.parse(localStorage.getItem("siftlode.channelSort")!)).toEqual({
|
||||
key: "subscribers",
|
||||
dir: "desc",
|
||||
});
|
||||
expect(JSON.parse(localStorage.getItem("siftlode.channelsFilterMap")!)).toEqual({
|
||||
sync: "needsFull",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- the focus-a-channel intent is consumed, not masked -------------------------------------------
|
||||
// `focusChannel` sends the user to the manager with its name filter seeded. The manager is a page
|
||||
// chunk: it unmounts on every navigation away, and on opening a channel page (which does not even
|
||||
// change `page`), and its seeding effect runs again on each mount. So the "already applied" marker
|
||||
// has to live in the PROVIDER — inside the page it would reset with the page and re-apply an intent
|
||||
// from earlier in the session over whatever the user has typed into the search box since.
|
||||
describe("ChannelsProvider — the focus-a-channel intent", () => {
|
||||
/** Stands in for Channels.tsx's `applyFocus` — the same effect event, the same consume call. */
|
||||
function Manager() {
|
||||
const { focusName, focusToken } = useChannels();
|
||||
const { setSearch, consumeFocus } = useChannelsActions();
|
||||
const applyFocus = useEffectEvent(() => {
|
||||
if (!focusName) return;
|
||||
setSearch(focusName);
|
||||
consumeFocus();
|
||||
});
|
||||
useEffect(() => applyFocus(), [focusToken]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function Harness({ mounted }: { mounted: boolean }) {
|
||||
return <>{mounted && <Manager />}</>;
|
||||
}
|
||||
|
||||
it("seeds the search box once, and a later visit does not re-seed it", () => {
|
||||
let api!: ReturnType<typeof useChannels> & ReturnType<typeof useChannelsActions>;
|
||||
function Probe() {
|
||||
api = { ...useChannels(), ...useChannelsActions() };
|
||||
return null;
|
||||
}
|
||||
const { rerender } = renderRoot(
|
||||
<ChannelsProvider>
|
||||
<Probe />
|
||||
<Harness mounted />
|
||||
</ChannelsProvider>,
|
||||
{ wrapper: withClient },
|
||||
);
|
||||
|
||||
act(() => api.focusChannel("Kurzgesagt"));
|
||||
expect(api.search).toBe("Kurzgesagt");
|
||||
|
||||
// The user looks for something else instead.
|
||||
act(() => api.setSearch("veritasium"));
|
||||
|
||||
// Leave the manager (the page chunk unmounts) and come back.
|
||||
rerender(
|
||||
<ChannelsProvider>
|
||||
<Probe />
|
||||
<Harness mounted={false} />
|
||||
</ChannelsProvider>,
|
||||
);
|
||||
rerender(
|
||||
<ChannelsProvider>
|
||||
<Probe />
|
||||
<Harness mounted />
|
||||
</ChannelsProvider>,
|
||||
);
|
||||
|
||||
expect(api.search).toBe("veritasium");
|
||||
});
|
||||
|
||||
it("re-seeds when the channel is focused again — the token is the intent", () => {
|
||||
let api!: ReturnType<typeof useChannels> & ReturnType<typeof useChannelsActions>;
|
||||
function Probe() {
|
||||
api = { ...useChannels(), ...useChannelsActions() };
|
||||
return null;
|
||||
}
|
||||
renderRoot(
|
||||
<ChannelsProvider>
|
||||
<Probe />
|
||||
<Harness mounted />
|
||||
</ChannelsProvider>,
|
||||
{ wrapper: withClient },
|
||||
);
|
||||
|
||||
act(() => api.focusChannel("Kurzgesagt"));
|
||||
act(() => api.setSearch("veritasium"));
|
||||
act(() => api.focusChannel("Kurzgesagt")); // the same channel, a new intent
|
||||
expect(api.search).toBe("Kurzgesagt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChannelsProvider — the layout", () => {
|
||||
// The table or the cards is a choice about the account, so it follows it to every device: the
|
||||
// server's word wins over what this browser last showed.
|
||||
it("is the account's preference, over this browser's copy", () => {
|
||||
localStorage.setItem("siftlode.channelLayout", "table");
|
||||
qc.setQueryData(qk.me(), { id: 1, preferences: { channelLayout: "cards" } });
|
||||
const { result } = renderHook(useChannelLayout, { wrapper });
|
||||
expect(result.current).toBe("cards");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { getAccountRaw, LS, useAccountPersistedState } from "../../lib/storage";
|
||||
import { parseChannelLayout } from "../../lib/channelLayout";
|
||||
import { hydrateSort } from "../../lib/columnSort";
|
||||
import { useLatest } from "../../lib/useLatest";
|
||||
import { useSortState } from "../../lib/useSortState";
|
||||
import { usePref } from "../../lib/usePref";
|
||||
import { useFilterState } from "../../lib/useFilterState";
|
||||
import { useUrlMirror } from "../../lib/useUrlMirror";
|
||||
import { rememberNavPosition } from "../../lib/history";
|
||||
import {
|
||||
channelsParamsToState,
|
||||
channelsParamUpdates,
|
||||
hasChannelsParams,
|
||||
type ChannelsUrlState,
|
||||
} from "../../lib/channelsUrlState";
|
||||
import type { FilterMap } from "../../lib/tableColumns";
|
||||
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
|
||||
import type { ChannelsView } from "./Channels";
|
||||
import {
|
||||
ChannelLayoutContext,
|
||||
ChannelsActionsContext,
|
||||
ChannelsStateContext,
|
||||
type ChannelsActions,
|
||||
type ChannelsState,
|
||||
} from "./channelsContext";
|
||||
|
||||
// Owns the Channels module's shared local state — the rail-less manager's name filter, column-filter
|
||||
// map, active tab, the "drop a stale column filter" token, and the focus-a-channel intent — lifted out of
|
||||
// App so the Channels page, the header search box, and the cross-module callers (the feed filter
|
||||
// sidebar and the notifications inbox both "focus this channel in the manager") read it via hooks
|
||||
// instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts,
|
||||
// stable useCallback setters, one-line hooks; composed in main.tsx around <App/>).
|
||||
//
|
||||
// Nested INSIDE NavigationProvider so its actions can drive navigation: focusChannel and
|
||||
// goToFullHistory both jump to the Channels page (and force the subscriptions tab). filters / view are
|
||||
// per-account persisted (localStorage); search / focus / the reset token are ephemeral. An account
|
||||
// switch does a full reload, so init-from-storage is all the hydration the persisted ones need.
|
||||
//
|
||||
// The contexts, their value types and the hooks live in channelsContext.ts.
|
||||
|
||||
function clampView(v: string | null): ChannelsView {
|
||||
return v === "discovery" || v === "blocked" || v === "hidden" ? v : "subscribed";
|
||||
}
|
||||
|
||||
// One-time carry-forward of the retired single-value sync chip (LS.legacyChannelFilter, vocab
|
||||
// all/needs_full/fully_synced) into the new filter map's `sync` key (vocab ""/needsFull/fully), so a
|
||||
// user who had a chip pinned keeps it. Only consulted when channelsFilterMap is still empty.
|
||||
function migrateSyncChip(): FilterMap | null {
|
||||
const old = getAccountRaw(LS.legacyChannelFilter);
|
||||
if (old === "needs_full") return { sync: "needsFull" };
|
||||
if (old === "fully_synced") return { sync: "fully" };
|
||||
return null;
|
||||
}
|
||||
|
||||
// The manager's stored column-filter map, healed to a plain string/string[] object (same discipline as
|
||||
// useFilterState's hydrate). Used only to build the seed's base — the URL keeps whatever the stored map
|
||||
// has for any dimension a partial shared link omits.
|
||||
function readStoredFilters(): FilterMap {
|
||||
const raw = getAccountRaw(LS.channelsFilterMap);
|
||||
if (raw) {
|
||||
try {
|
||||
const v = JSON.parse(raw);
|
||||
if (v && typeof v === "object" && !Array.isArray(v)) return v as FilterMap;
|
||||
} catch {
|
||||
/* fall through to the migrate seed */
|
||||
}
|
||||
}
|
||||
return migrateSyncChip() ?? {};
|
||||
}
|
||||
|
||||
// A shared manager link carries the content state in namespaced URL keys (ctab/csort/cf). When
|
||||
// present, the URL wins over this account's stored view — read once at mount and applied over the slice
|
||||
// hooks (see the provider). The keys are channels-unique, so their mere presence identifies a shared
|
||||
// manager view; no ?page check is needed (the mirror below is page-gated so it never pollutes others).
|
||||
// The base is this account's stored view (BootGate claims the local state before this provider mounts), so
|
||||
// a PARTIAL link — one carrying only the tab, say — keeps the stored sort and filter map for the rest.
|
||||
function readChannelsUrlSeed(): ChannelsUrlState | null {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (!hasChannelsParams(params)) return null;
|
||||
return channelsParamsToState(params, {
|
||||
view: clampView(getAccountRaw(LS.channelsView)),
|
||||
sort: hydrateSort(getAccountRaw(LS.channelSort), [], null),
|
||||
filters: readStoredFilters(),
|
||||
});
|
||||
}
|
||||
|
||||
export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
const { page } = useNavigation();
|
||||
const { setPage } = useNavigationActions();
|
||||
|
||||
const [search, setSearchState] = useState("");
|
||||
// The subscribed table's column-filter map, owned here (lifted out of DataTable) so the top status
|
||||
// chips, the Sync column funnel, and the card layout share one state; useFilterState persists it.
|
||||
const [filters, setFilters] = useFilterState(LS.channelsFilterMap, migrateSyncChip);
|
||||
// The view is this browser's (useAccountPersistedState), the layout an account preference
|
||||
// (usePref). Stored un-clamped; clampView/parseChannelLayout heal them for display. setView layers
|
||||
// the history logic (below) on top of the raw setter.
|
||||
const [viewRaw, setViewRaw] = useAccountPersistedState(LS.channelsView, "subscribed");
|
||||
const view = clampView(viewRaw);
|
||||
const [layoutRaw, setLayout] = usePref<string>("channelLayout", "table", {
|
||||
localKey: LS.channelLayout,
|
||||
parse: (raw) => (typeof raw === "string" ? raw : "table"),
|
||||
});
|
||||
const layout = parseChannelLayout(layoutRaw);
|
||||
// Sort is owned here (lifted out of DataTable) so the card layout shares it; ColumnSortControl heals
|
||||
// it against the live columns for display, so the provider stores it un-healed (no options passed).
|
||||
const [sort, setSort] = useSortState(LS.channelSort);
|
||||
const [filtersResetToken, setFiltersResetToken] = useState(0);
|
||||
const [focusNameRaw, setFocusName] = useState<string | null>(null);
|
||||
const [focusToken, setFocusToken] = useState(0);
|
||||
|
||||
// Shareable manager view. Captured once at mount (or null when no manager URL keys are present).
|
||||
const [urlSeed] = useState(readChannelsUrlSeed);
|
||||
const seededRef = useRef(false);
|
||||
|
||||
// Apply the shared-link seed once, BEFORE paint, through the slice setters — which persist it, so the
|
||||
// shared view also survives a later PLAIN reload (no URL). setViewRaw (not setView) restores the tab
|
||||
// without pushing a phantom history entry. A normal load has urlSeed === null and this is a no-op.
|
||||
// `view` is frozen at mount for the same reason `urlSeed` is: the comparison is against the tab
|
||||
// this load started on, and the three setters are stable by construction (useFilterState,
|
||||
// useSortState and useAccountPersistedState all return a useCallback). So the dep list can name
|
||||
// every value the body reads without the seed re-arming on a later tab switch.
|
||||
const [mountView] = useState(view);
|
||||
useLayoutEffect(() => {
|
||||
if (!urlSeed || seededRef.current) return;
|
||||
seededRef.current = true;
|
||||
if (urlSeed.view !== mountView) setViewRaw(urlSeed.view);
|
||||
setFilters(urlSeed.filters);
|
||||
setSort(urlSeed.sort);
|
||||
}, [urlSeed, mountView, setViewRaw, setFilters, setSort]);
|
||||
|
||||
// Latest-value ref so the STABLE setView can compare against the current tab without depending on
|
||||
// it (a dep would recreate the action on every switch and re-render every actions consumer).
|
||||
const viewRef = useLatest(view);
|
||||
|
||||
const setSearch = useCallback((q: string) => setSearchState(q), []);
|
||||
// The active tab rides in history.state (`_chTab`), the same way the channel page (`_chan`) and the
|
||||
// live YouTube search (`_yt`) do — so Back steps back through the tabs you actually visited instead
|
||||
// of jumping straight out to the previous module.
|
||||
const setView = useCallback(
|
||||
(v: ChannelsView) => {
|
||||
// Re-selecting the tab you are already on is not a navigation — bail before pushing, or clicking
|
||||
// the active tab (and goToFullHistory forcing "subscribed") stacks Back entries that do nothing.
|
||||
if (v === viewRef.current) return;
|
||||
setViewRaw(v); // persists (via useAccountPersistedState)
|
||||
// Only own an entry while the manager is already on screen. Arriving from elsewhere
|
||||
// (focusChannel / goToFullHistory) lets setPage push the entry — the stamp effect labels it — so
|
||||
// one navigation never costs two Back presses.
|
||||
if (window.history.state?.sfPage === "channels") {
|
||||
// A FRESH state, like setPage's: carrying the old entry's `_chan`/`_yt` markers over would make
|
||||
// Back re-open the channel overlay or a spent search on top of the tab.
|
||||
rememberNavPosition();
|
||||
window.history.pushState({ sfPage: "channels", _chTab: v }, "");
|
||||
}
|
||||
},
|
||||
[setViewRaw, viewRef],
|
||||
);
|
||||
const focusChannel = useCallback(
|
||||
(name: string) => {
|
||||
setFocusName(name);
|
||||
setFocusToken((n) => n + 1); // re-seed even when the same channel is re-focused
|
||||
setView("subscribed"); // the name filter applies to the subscriptions table
|
||||
setPage("channels");
|
||||
},
|
||||
[setView, setPage],
|
||||
);
|
||||
const goToFullHistory = useCallback(() => {
|
||||
// One controlled map replaces the old status chip: sync=needsFull, and dropping every other
|
||||
// column filter (the whole map is set fresh) so no stale funnel hides the rows we're sent to see.
|
||||
setFilters({ sync: "needsFull" });
|
||||
setView("subscribed"); // the sync filter applies to the subscriptions tab
|
||||
setFiltersResetToken((n) => n + 1); // also clear the ephemeral name search + tag chips
|
||||
setPage("channels");
|
||||
}, [setFilters, setView, setPage]);
|
||||
|
||||
// The manager focus only means anything ON the Channels page, so it is DERIVED from the page
|
||||
// rather than cleared by an effect when you leave. focusChannel sets the name and navigates in the
|
||||
// same handler, so the value is live by the time channels renders; everywhere else it reads null,
|
||||
// one render earlier than the effect used to make it.
|
||||
//
|
||||
// But an intent has to be CONSUMED, not merely masked, and the consumed marker has to live HERE.
|
||||
// Channels is a page chunk that unmounts on every navigation (and on opening a channel page, which
|
||||
// does not even change `page`), so its seeding effect runs again on each remount. A marker inside
|
||||
// it would reset with it and re-apply an intent from earlier in the session over whatever the user
|
||||
// has since typed into the search box. Keyed on the token rather than the name, because
|
||||
// re-focusing the same channel is a new intent.
|
||||
const [focusApplied, setFocusApplied] = useState(0);
|
||||
const consumeFocus = useCallback(() => setFocusApplied(focusToken), [focusToken]);
|
||||
const focusName = page === "channels" && focusApplied !== focusToken ? focusNameRaw : null;
|
||||
|
||||
// Label the entry the manager was REACHED on (setPage pushes a bare { sfPage }), so stepping back
|
||||
// onto it later restores the tab it belonged to rather than leaving whatever tab is current.
|
||||
useEffect(() => {
|
||||
if (page !== "channels") return;
|
||||
const st = window.history.state || {};
|
||||
if (st._chTab === undefined) window.history.replaceState({ ...st, _chTab: view }, "");
|
||||
}, [page, view]);
|
||||
|
||||
// Back/Forward across tab entries: restore the tab the entry carries.
|
||||
useEffect(() => {
|
||||
function onPop(e: PopStateEvent) {
|
||||
const tab = e.state?._chTab;
|
||||
if (!tab) return; // an entry outside the manager — leave the tab alone
|
||||
setViewRaw(clampView(tab as string)); // persists the restored tab (setViewRaw is stable)
|
||||
}
|
||||
window.addEventListener("popstate", onPop);
|
||||
return () => window.removeEventListener("popstate", onPop);
|
||||
}, [setViewRaw]);
|
||||
|
||||
const state = useMemo<ChannelsState>(
|
||||
() => ({ search, filters, view, sort, filtersResetToken, focusName, focusToken }),
|
||||
[search, filters, view, sort, filtersResetToken, focusName, focusToken],
|
||||
);
|
||||
const actions = useMemo<ChannelsActions>(
|
||||
() => ({
|
||||
setSearch,
|
||||
setFilters,
|
||||
setView,
|
||||
setLayout,
|
||||
setSort,
|
||||
focusChannel,
|
||||
consumeFocus,
|
||||
goToFullHistory,
|
||||
}),
|
||||
[
|
||||
setSearch,
|
||||
setFilters,
|
||||
setView,
|
||||
setLayout,
|
||||
setSort,
|
||||
focusChannel,
|
||||
consumeFocus,
|
||||
goToFullHistory,
|
||||
],
|
||||
);
|
||||
|
||||
// Mirror the content state (tab + sort + column filters) into the address bar under the namespaced
|
||||
// keys so a manager view is shareable. Gated on page===channels: off the manager the keys are cleared
|
||||
// (localStorage still holds the state; it re-mirrors on return). The name `search` stays OUT — it's a
|
||||
// deliberately transient rail-search, like the feed's and plex's.
|
||||
useUrlMirror(channelsParamUpdates(page === "channels" ? { view, sort, filters } : null));
|
||||
|
||||
return (
|
||||
<ChannelsActionsContext.Provider value={actions}>
|
||||
<ChannelLayoutContext.Provider value={layout}>
|
||||
<ChannelsStateContext.Provider value={state}>{children}</ChannelsStateContext.Provider>
|
||||
</ChannelLayoutContext.Provider>
|
||||
</ChannelsActionsContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Hidden / Blocked channel cards as a keyboard list: `j`/`k` walk the cards, Enter opens
|
||||
// the channel — the ChannelLink name is the card's open element — and the card's own action button
|
||||
// keeps its Enter.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
import ExcludedChannelList from "./ExcludedChannelList";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function press(key: string) {
|
||||
act(() => {
|
||||
(document.activeElement ?? document.body).dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const channels = [
|
||||
{ id: "UCa", title: "Alpha", thumbnail_url: null },
|
||||
{ id: "UCb", title: "Beta", thumbnail_url: null },
|
||||
];
|
||||
|
||||
describe("ExcludedChannelList · keyboard", () => {
|
||||
it("walks the cards with j/k and opens the channel on Enter", () => {
|
||||
const onView = vi.fn();
|
||||
render(
|
||||
<ExcludedChannelList
|
||||
channels={channels}
|
||||
icon={null}
|
||||
title="Hidden"
|
||||
hint=""
|
||||
emptyText=""
|
||||
actionLabel="Unhide"
|
||||
actionIcon={null}
|
||||
onAction={() => {}}
|
||||
onView={onView}
|
||||
/>,
|
||||
);
|
||||
press("j");
|
||||
press("j");
|
||||
expect(document.activeElement?.textContent).toContain("Beta");
|
||||
press("k");
|
||||
expect(document.activeElement?.textContent).toContain("Alpha");
|
||||
press("Enter");
|
||||
expect(onView).toHaveBeenCalledWith(channels[0]);
|
||||
expect(screen.getAllByRole("button", { name: "Unhide" })).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { type ReactNode } from "react";
|
||||
import ChannelLink from "./ChannelLink";
|
||||
import NavGrid from "../../components/ui/NavGrid";
|
||||
import { navItemAttrs } from "../../lib/listNav";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
import { SectionLabel } from "../../components/ui/Headings";
|
||||
|
||||
// The presentation shared by the channel manager's "excluded channel" tabs — Hidden and Blocked.
|
||||
// A responsive grid of proper channel cards (avatar + name + open-on-YouTube, via ChannelLink) each
|
||||
// with one reversing action, instead of a bare row of name chips. Both tabs pass the same shape; only
|
||||
// the header icon/label, the action, and the copy differ.
|
||||
export interface ExcludedChannel {
|
||||
id: string;
|
||||
title: string | null;
|
||||
handle?: string | null;
|
||||
thumbnail_url: string | null;
|
||||
}
|
||||
|
||||
export default function ExcludedChannelList({
|
||||
channels,
|
||||
icon,
|
||||
title,
|
||||
hint,
|
||||
emptyText,
|
||||
actionLabel,
|
||||
actionIcon,
|
||||
onAction,
|
||||
onView,
|
||||
}: {
|
||||
channels: ExcludedChannel[];
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
hint: string;
|
||||
emptyText: string;
|
||||
actionLabel: string;
|
||||
actionIcon: ReactNode;
|
||||
onAction: (id: string) => void;
|
||||
onView?: (c: ExcludedChannel) => void;
|
||||
}) {
|
||||
if (channels.length === 0) return <p className="text-sm text-muted py-8">{emptyText}</p>;
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{icon}
|
||||
<SectionLabel>{title}</SectionLabel>
|
||||
</div>
|
||||
<NavGrid className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{channels.map((c, i) => (
|
||||
<div
|
||||
key={c.id}
|
||||
{...navItemAttrs(i)}
|
||||
className={`${SURFACE.card} flex items-center gap-2 px-3 py-2`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<ChannelLink
|
||||
id={c.id}
|
||||
title={c.title}
|
||||
handle={c.handle}
|
||||
thumbnailUrl={c.thumbnail_url}
|
||||
onView={onView ? () => onView(c) : undefined}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="xs" icon={actionIcon} onClick={() => onAction(c.id)}>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</NavGrid>
|
||||
<p className="text-2xs text-muted mt-3">{hint}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
|
||||
import { api, type MyStatus } from "../../lib/api";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { formatCompact } from "../../lib/format";
|
||||
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
|
||||
// Per-user status (not the global catalog): shows the number of videos available to *this*
|
||||
// user, how many of *their* channels are still being fetched, and how many lack full
|
||||
// history (clickable -> channel manager filtered to those). The pause control is admin-only.
|
||||
export default function SyncStatus({
|
||||
isAdmin,
|
||||
onGoToFullHistory,
|
||||
}: {
|
||||
isAdmin: boolean;
|
||||
onGoToFullHistory: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
// A read-out with a couple of controls rather than a list of choices, so it is a `dialog`
|
||||
// popover. It portals out of the header, whose glass backdrop-filter would otherwise trap its
|
||||
// stacking and positioning.
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "dialog",
|
||||
role: "dialog",
|
||||
placement: "bottom-end",
|
||||
offset: 8,
|
||||
});
|
||||
const { setReference } = pop.refs;
|
||||
const { data } = useQuery({
|
||||
queryKey: qk.myStatus(),
|
||||
queryFn: api.myStatus,
|
||||
// Track the scheduler near-real-time: poll even when the tab is backgrounded, and
|
||||
// refresh immediately on tab focus (the global default disables focus refetch), so the
|
||||
// "N without full history" count keeps ticking down without a manual reload. Poll faster
|
||||
// while a job is running or work is pending — so the live "syncing" state is actually
|
||||
// caught — and ease back to a slow idle tick once everything's settled.
|
||||
refetchInterval: (query) => {
|
||||
const d = query.state.data as MyStatus | undefined;
|
||||
const busy =
|
||||
!!d && (d.sync_active || d.channels_recent_pending > 0 || d.channels_deep_pending > 0);
|
||||
return busy ? 8_000 : 30_000;
|
||||
},
|
||||
refetchIntervalInBackground: true,
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 5_000,
|
||||
});
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationFn: () => (data?.paused ? api.resumeSync() : api.pauseSync()),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.myStatus() }),
|
||||
onError: (e) =>
|
||||
notifyActionError(
|
||||
e,
|
||||
t(data?.paused ? "errors.writeFailed.syncResume" : "errors.writeFailed.syncPause"),
|
||||
),
|
||||
});
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const syncing = data.channels_recent_pending;
|
||||
const notFull = data.channels_deep_pending; // your channels without full history yet
|
||||
// Spin only while a sync job is actually running; otherwise pending work shows as a calm,
|
||||
// static state (or, for deep history, just the "N without full history" link below).
|
||||
const active = data.sync_active;
|
||||
const showMain = data.paused || active || syncing > 0 || notFull === 0;
|
||||
|
||||
// Calm one-liner describing the current sync state.
|
||||
const stateNode = data.paused ? (
|
||||
<span className="text-accent font-medium">{t("header.sync.paused")}</span>
|
||||
) : active ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
{syncing > 0
|
||||
? t("header.sync.syncing", { count: syncing })
|
||||
: notFull > 0
|
||||
? t("header.sync.backfillingHistory")
|
||||
: t("header.sync.working")}
|
||||
</span>
|
||||
) : syncing > 0 ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{t("header.sync.recentQueued", { count: syncing })}
|
||||
</span>
|
||||
) : (
|
||||
// A small check anchors the "all synced" state so it doesn't read as orphaned text.
|
||||
<span className="flex items-center gap-1 text-success">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
<span className="text-muted">{t("header.sync.allSynced")}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
const pauseBtn = isAdmin && (data.paused || syncing > 0 || notFull > 0) && (
|
||||
<IconButton
|
||||
onClick={() => toggle.mutate()}
|
||||
loading={toggle.isPending}
|
||||
label={data.paused ? t("header.sync.resume") : t("header.sync.pause")}
|
||||
>
|
||||
{data.paused ? <Play /> : <Pause />}
|
||||
</IconButton>
|
||||
);
|
||||
|
||||
// Chip: the headline count rides in the header; the state, the history link and pause live in a
|
||||
// popover. An accent dot flags the only two things worth interrupting you for (paused / missing
|
||||
// history) so the collapsed chip still tells you when to look.
|
||||
const needsAttention = data.paused || notFull > 0;
|
||||
const countsText = `${formatCompact(data.my_videos, lang)} ${t("header.sync.yours")} / ${formatCompact(
|
||||
data.total_videos,
|
||||
lang,
|
||||
)} ${t("header.sync.total")}`;
|
||||
return (
|
||||
<div className="pointer-events-auto shrink-0">
|
||||
<Pressable
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
// Short label only: the popover already explains the numbers, and the long one overflowed
|
||||
// the viewport edge from here.
|
||||
title={countsText}
|
||||
className={`${SURFACE.floating} glass-hover relative flex h-10 items-center gap-1.5 px-3 text-xs text-muted touch-target`}
|
||||
>
|
||||
{active ? (
|
||||
<Loader2 className="w-3.5 h-3.5 shrink-0 animate-spin text-accent" />
|
||||
) : (
|
||||
<Database className="w-3.5 h-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="text-fg font-medium tabular-nums">
|
||||
{formatCompact(data.my_videos, lang)}
|
||||
</span>
|
||||
{needsAttention && (
|
||||
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-accent ring-2 ring-bg" />
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={countsText}
|
||||
className={`${SURFACE.popover} w-64 p-3 text-xs text-muted animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
<div
|
||||
title={t("header.sync.countTooltip")}
|
||||
className="flex items-center gap-1.5 pb-2 border-b border-border/60"
|
||||
>
|
||||
<Database className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>
|
||||
<span className="text-fg font-medium">{formatCompact(data.my_videos, lang)}</span>{" "}
|
||||
{t("header.sync.yours")}
|
||||
<span className="opacity-40"> / </span>
|
||||
{formatCompact(data.total_videos, lang)} {t("header.sync.total")}
|
||||
</span>
|
||||
</div>
|
||||
{showMain && (
|
||||
<div className="flex items-center justify-between gap-1.5 pt-2">
|
||||
<span className="flex items-center gap-1.5 min-w-0">{stateNode}</span>
|
||||
{pauseBtn}
|
||||
</div>
|
||||
)}
|
||||
{notFull > 0 && (
|
||||
<div className="flex items-center justify-between gap-1.5 pt-2">
|
||||
<Button
|
||||
variant="link-muted"
|
||||
icon={<History className="size-3.5!" />}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onGoToFullHistory();
|
||||
}}
|
||||
title={t("header.sync.fullHistoryTooltip")}
|
||||
className="gap-1 underline decoration-dotted decoration-muted/40 underline-offset-4"
|
||||
>
|
||||
{t("header.sync.withoutFullHistory", { count: notFull })}
|
||||
</Button>
|
||||
{!showMain && pauseBtn}
|
||||
</div>
|
||||
)}
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { api, type Tag } from "../../lib/api";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { useScrollFade } from "../../lib/useScrollFade";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import Modal from "../../components/ui/Modal";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
import { useTags } from "../../lib/useTags";
|
||||
|
||||
// One editable row: rename in place (commit on Enter/blur), delete with a confirm, and a
|
||||
// hover popover on the count listing the tagged channels (each a link that focuses it in the
|
||||
// Channel manager). The popover rides the shared platform, so the list's own scroll can't clip it and
|
||||
// the cursor can travel the gap into it (safePolygon) without the old hand-rolled 150 ms timer.
|
||||
function TagRow({
|
||||
tag,
|
||||
channels,
|
||||
onRename,
|
||||
onDelete,
|
||||
onPickChannel,
|
||||
}: {
|
||||
tag: Tag;
|
||||
channels: { id: string; title: string }[];
|
||||
onRename: (name: string) => void;
|
||||
onDelete: () => void;
|
||||
onPickChannel: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState(tag.name);
|
||||
const [open, setOpen] = useState(false);
|
||||
const { ref: popFadeRef, style: popFadeStyle } = useScrollFade();
|
||||
const commit = () => {
|
||||
const v = name.trim();
|
||||
if (v && v !== tag.name) onRename(v);
|
||||
else setName(tag.name);
|
||||
};
|
||||
const pop = useAnchoredPopover({
|
||||
open: open && channels.length > 0,
|
||||
onOpenChange: setOpen,
|
||||
mode: "hover",
|
||||
placement: "right-start",
|
||||
offset: 8,
|
||||
hoverDelay: { open: 0, close: 150 },
|
||||
});
|
||||
const { setReference } = pop.refs;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="flex-1"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
|
||||
/>
|
||||
<span
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
className={`text-xs text-muted tabular-nums shrink-0 ${
|
||||
channels.length
|
||||
? "cursor-help underline decoration-dotted decoration-muted/40 underline-offset-2"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{t("tagManager.channels", { count: tag.channel_count })}
|
||||
</span>
|
||||
<IconButton variant="danger" onClick={onDelete} label={t("tagManager.delete")}>
|
||||
<Trash2 />
|
||||
</IconButton>
|
||||
<PopoverSurface popover={pop} className={`${SURFACE.menu} w-56 animate-[popIn_0.16s_ease]`}>
|
||||
<div className="text-2xs uppercase tracking-wide text-muted px-2 py-1">
|
||||
{t("tagManager.onChannels")}
|
||||
</div>
|
||||
{/* Inner scroller so the scroll-fade mask rides the scrolling box, not the glass panel. */}
|
||||
<div
|
||||
ref={popFadeRef}
|
||||
style={popFadeStyle}
|
||||
className="max-h-72 overflow-y-auto no-scrollbar"
|
||||
>
|
||||
{channels.map((ch) => (
|
||||
<Pressable
|
||||
key={ch.id}
|
||||
layer
|
||||
onClick={() => onPickChannel(ch.title)}
|
||||
className="w-full text-sm px-2 py-1.5 rounded-md text-muted hover:text-fg truncate"
|
||||
>
|
||||
{ch.title}
|
||||
</Pressable>
|
||||
))}
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TagManager({
|
||||
onClose,
|
||||
onFocusChannel,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onFocusChannel: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const { ref: listFadeRef, style: listFadeStyle } = useScrollFade();
|
||||
const [newName, setNewName] = useState("");
|
||||
const tagsQuery = useTags();
|
||||
const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels });
|
||||
const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system);
|
||||
const channelsForTag = (tagId: number) =>
|
||||
(channelsQuery.data ?? [])
|
||||
.filter((c) => c.tag_ids.includes(tagId))
|
||||
.map((c) => ({ id: c.id, title: c.title ?? c.id }));
|
||||
|
||||
const invalidate = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.tags() });
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
};
|
||||
const create = useMutation({
|
||||
mutationFn: (name: string) => api.createTag({ name, category: "other" }),
|
||||
meta: { failTitle: "channels.tags.createFailed" },
|
||||
onSuccess: () => {
|
||||
setNewName("");
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
// A failure is said and the list refetched — a tag deleted in another tab used to fail in silence
|
||||
// (a 404 `not_found/tag` gets no global dialog) and stay listed.
|
||||
const onFailed = (e: unknown, fallback: string) => {
|
||||
notifyActionError(e, fallback);
|
||||
invalidate();
|
||||
};
|
||||
const rename = useMutation({
|
||||
mutationFn: (v: { id: number; name: string }) => api.updateTag(v.id, { name: v.name }),
|
||||
onSuccess: invalidate,
|
||||
onError: (e) => onFailed(e, t("tagManager.renameFailed")),
|
||||
});
|
||||
const del = useMutation({
|
||||
mutationFn: (v: { id: number; name: string }) => api.deleteTag(v.id),
|
||||
onSuccess: (_d, v) => {
|
||||
invalidate();
|
||||
notify({ level: "success", message: t("tagManager.deleted", { name: v.name }) });
|
||||
},
|
||||
onError: (e) => onFailed(e, t("tagManager.deleteFailed")),
|
||||
});
|
||||
|
||||
// Picking a channel from a tag's popover: close the dialog, then focus it in the manager.
|
||||
const pickChannel = (name: string) => {
|
||||
onClose();
|
||||
onFocusChannel(name);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal initialFocus="close" title={t("tagManager.title")} onClose={onClose}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{userTags.length === 0 ? (
|
||||
<p className="text-sm text-muted">{t("tagManager.empty")}</p>
|
||||
) : (
|
||||
// Cap at ~8 rows tall, then scroll — the list can grow long.
|
||||
<div
|
||||
ref={listFadeRef}
|
||||
style={listFadeStyle}
|
||||
className="flex flex-col gap-2 max-h-88 overflow-y-auto no-scrollbar pr-1"
|
||||
>
|
||||
{userTags.map((tag) => (
|
||||
<TagRow
|
||||
key={tag.id}
|
||||
tag={tag}
|
||||
channels={channelsForTag(tag.id)}
|
||||
onPickChannel={pickChannel}
|
||||
onRename={(name) => rename.mutate({ id: tag.id, name })}
|
||||
onDelete={async () => {
|
||||
// Only confirm when the tag is actually in use; an unused tag deletes outright.
|
||||
if (tag.channel_count > 0) {
|
||||
const ok = await confirm({
|
||||
title: t("tagManager.deleteTitle"),
|
||||
message: t("tagManager.confirmDelete", {
|
||||
name: tag.name,
|
||||
count: tag.channel_count,
|
||||
}),
|
||||
confirmLabel: t("tagManager.delete"),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
del.mutate({ id: tag.id, name: tag.name });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (newName.trim()) create.mutate(newName.trim());
|
||||
}}
|
||||
className="flex items-center gap-2 mt-2 pt-3 border-t border-border"
|
||||
>
|
||||
<Plus className="w-4 h-4 text-muted shrink-0" />
|
||||
<Input
|
||||
className="flex-1"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder={t("tagManager.newPlaceholder")}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
disabled={!newName.trim()}
|
||||
>
|
||||
{t("tagManager.add")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check } from "lucide-react";
|
||||
import type { ManagedChannel } from "../../lib/api";
|
||||
import { formatNumber } from "../../lib/format";
|
||||
import Tooltip from "../../components/ui/Tooltip";
|
||||
import { isFullySynced } from "./channelSync";
|
||||
|
||||
// The Channels-manager cells that are components in their own right (they read i18n), split out of
|
||||
// channelColumns so that file holds only the column factory and its predicates (Fast Refresh).
|
||||
|
||||
// The Stored count, with a "fully synced" ✓ charm to the left of the number when recent uploads and
|
||||
// full history are both in — folding the retired Sync column's at-a-glance status into the value it
|
||||
// describes. Full detail stays in the hover tooltip.
|
||||
export function StoredCell({ c }: { c: ManagedChannel }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
return (
|
||||
<span className="inline-flex items-center justify-end gap-1 text-muted tabular-nums">
|
||||
{isFullySynced(c) && (
|
||||
<Tooltip hint={t("channels.row.fullySyncedHint")}>
|
||||
<Check
|
||||
role="img"
|
||||
className="w-3.5 h-3.5 text-accent shrink-0 cursor-help"
|
||||
aria-label={t("channels.row.fullySynced")}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{formatNumber(c.stored_videos, lang)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// applySyncFilter is the sync-status pre-filter the top chips drive (the Sync column was retired and
|
||||
// its status folded into a Stored charm). It replaced a column `filter.test`, so it carries the exact
|
||||
// "fully" | "needsFull" | "" vocab + predicate — asserted here so a regression can't silently change
|
||||
// which channels the top chips show.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
applySyncFilter,
|
||||
buildChannelColumns,
|
||||
tagFilterChips,
|
||||
tagsColumnTest,
|
||||
} from "./channelColumns";
|
||||
import { NO_TAG_SENTINEL, type ManagedChannel, type Tag } from "../../lib/api";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
const ch = (over: Partial<ManagedChannel>): ManagedChannel => ({
|
||||
id: "c",
|
||||
title: null,
|
||||
handle: null,
|
||||
thumbnail_url: null,
|
||||
subscriber_count: null,
|
||||
video_count: null,
|
||||
description: null,
|
||||
stored_videos: 0,
|
||||
last_video_at: null,
|
||||
total_duration_seconds: 0,
|
||||
priority: 0,
|
||||
hidden: false,
|
||||
deep_requested: false,
|
||||
deep_in_queue: false,
|
||||
tag_ids: [],
|
||||
details_synced: false,
|
||||
recent_synced: false,
|
||||
backfill_done: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
const rows = [
|
||||
ch({ id: "fully", recent_synced: true, backfill_done: true }),
|
||||
ch({ id: "recentOnly", recent_synced: true, backfill_done: false }),
|
||||
ch({ id: "none", recent_synced: false, backfill_done: false }),
|
||||
];
|
||||
|
||||
describe("applySyncFilter", () => {
|
||||
it("returns every row for the empty/all value (and unknown values)", () => {
|
||||
expect(applySyncFilter(rows, "").map((r) => r.id)).toEqual(["fully", "recentOnly", "none"]);
|
||||
expect(applySyncFilter(rows, undefined)).toHaveLength(3);
|
||||
expect(applySyncFilter(rows, "bogus")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("'fully' keeps only channels with recent uploads AND full history in", () => {
|
||||
expect(applySyncFilter(rows, "fully").map((r) => r.id)).toEqual(["fully"]);
|
||||
});
|
||||
|
||||
it("'needsFull' keeps every channel whose back-catalogue is not done", () => {
|
||||
expect(applySyncFilter(rows, "needsFull").map((r) => r.id)).toEqual(["recentOnly", "none"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tagsColumnTest (item B — 'No tag')", () => {
|
||||
const tagged = ch({ id: "tagged", tag_ids: [1, 2] });
|
||||
const untagged = ch({ id: "untagged", tag_ids: [] });
|
||||
|
||||
it("the No-tag sentinel keeps only channels with no user tag", () => {
|
||||
expect(tagsColumnTest(tagged, [NO_TAG_SENTINEL])).toBe(false);
|
||||
expect(tagsColumnTest(untagged, [NO_TAG_SENTINEL])).toBe(true);
|
||||
});
|
||||
|
||||
it("real tag values match ANY selected id (sentinel absent)", () => {
|
||||
expect(tagsColumnTest(tagged, ["2"])).toBe(true);
|
||||
expect(tagsColumnTest(tagged, ["3"])).toBe(false);
|
||||
expect(tagsColumnTest(untagged, ["1"])).toBe(false);
|
||||
});
|
||||
|
||||
it("the sentinel wins if it somehow co-exists with real ids", () => {
|
||||
expect(tagsColumnTest(tagged, [NO_TAG_SENTINEL, "1"])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tagFilterChips — a pick whose tag is gone", () => {
|
||||
const mine = { id: 1, name: "Music", system: false } as Tag;
|
||||
const t = ((k: string) => k) as unknown as TFunction;
|
||||
type State = "loaded" | "loading" | "failed";
|
||||
const chips = (state: State, picked: string[]) =>
|
||||
tagFilterChips({
|
||||
userTags: state === "loaded" ? [mine] : [],
|
||||
tags: {
|
||||
data: state === "loaded" ? [mine] : undefined,
|
||||
isSuccess: state === "loaded",
|
||||
isPending: state === "loading",
|
||||
},
|
||||
picked,
|
||||
noTagOn: false,
|
||||
toggle: () => {},
|
||||
toggleNoTag: () => {},
|
||||
t,
|
||||
});
|
||||
|
||||
it("stays a visible, active chip instead of an empty table with no reason", () => {
|
||||
expect(chips("loaded", ["1", "77"]).map((c) => [c.key, c.label, c.active])).toEqual([
|
||||
["t77", "common.deletedTag", true], // first: the "+N" overflow must not fold it away
|
||||
["t1", "Music", true],
|
||||
[NO_TAG_SENTINEL, "channels.tags.noTag", false],
|
||||
]);
|
||||
});
|
||||
|
||||
// Before, a pick had NO chip at all until the list loaded — or ever, after a failed
|
||||
// load — while it still narrowed the table.
|
||||
it("is a placeholder while the tag list loads, never called deleted", () => {
|
||||
expect(chips("loading", ["77"]).map((c) => [c.key, c.label, c.pending])).toEqual([
|
||||
["t77", "77", true],
|
||||
[NO_TAG_SENTINEL, "channels.tags.noTag", undefined],
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows the id after a failed load, still a visible, removable chip", () => {
|
||||
expect(chips("failed", ["77"]).map((c) => [c.key, c.label, c.pending, c.active])).toEqual([
|
||||
["t77", "77", false, true],
|
||||
[NO_TAG_SENTINEL, "channels.tags.noTag", undefined, false],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildChannelColumns — the row's end", () => {
|
||||
it('ends every row in the channel\'s "…", with no action buttons before it', () => {
|
||||
const more = (c: ManagedChannel) => `more:${c.id}`;
|
||||
const noop = () => {};
|
||||
const cols = buildChannelColumns({
|
||||
t: ((k: string) => k) as never,
|
||||
lang: "en",
|
||||
prioOptions: [],
|
||||
userTags: [],
|
||||
bumpPriority: noop,
|
||||
setPriority: noop,
|
||||
more,
|
||||
onView: noop,
|
||||
toggleTag: noop,
|
||||
createAndAttachTag: noop,
|
||||
onFilterByTag: noop,
|
||||
suggestionsFor: () => [],
|
||||
onAcceptSuggestion: noop,
|
||||
onDismissSuggestion: noop,
|
||||
});
|
||||
expect(cols[cols.length - 1]!.key).toBe("actions");
|
||||
expect(cols.filter((c) => c.key === "actions")).toHaveLength(1);
|
||||
expect(cols[cols.length - 1]!.render(ch({ id: "UCz" }))).toBe("more:UCz");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { NO_TAG_SENTINEL, type ManagedChannel, type Tag } from "../../lib/api";
|
||||
import { tagPickLabel, type TagList } from "../../lib/tagPicks";
|
||||
import { formatCountOrDash, formatTotalHours } from "../../lib/format";
|
||||
import type { Column } from "../../components/ui/DataTable";
|
||||
import { PriorityStepper } from "../../components/ui/PriorityStepper";
|
||||
import ChannelLink from "./ChannelLink";
|
||||
import AboutCell from "./AboutCell";
|
||||
import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags";
|
||||
import { StoredCell } from "./channelCells";
|
||||
import { isFullySynced } from "./channelSync";
|
||||
import { RelativeTime } from "../../components/ui/RelativeTime";
|
||||
import type { FilterChip } from "../feed/TagFilterChips";
|
||||
|
||||
/** The shared "subscribers" DataTable column (right-aligned, sortable, em-dash for null) — identical
|
||||
* in the Channels manager and the ChannelDiscovery table, which show subscriber counts on different
|
||||
* channel row shapes. */
|
||||
export function subsColumn<T extends { subscriber_count: number | null }>(
|
||||
t: TFunction,
|
||||
lang: string,
|
||||
): Column<T> {
|
||||
return {
|
||||
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, lang)}</span>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Sync-status pre-filter for the subscribed table. The Sync COLUMN is gone — its status folded
|
||||
* into a charm on Stored — so the top chips' `filters.sync` value is applied here instead of via a
|
||||
* column predicate. Same vocab as the old column filter: "fully" | "needsFull" | "" (all). */
|
||||
export function applySyncFilter(rows: ManagedChannel[], value: unknown): ManagedChannel[] {
|
||||
if (value === "fully") return rows.filter(isFullySynced);
|
||||
if (value === "needsFull") return rows.filter((c) => !c.backfill_done);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** The Tags column filter predicate (item B): the "No tag" sentinel keeps channels the user hasn't
|
||||
* personally tagged (empty `tag_ids`); it's exclusive with real tag picks (the chip row enforces that),
|
||||
* so when present it wins. Otherwise a channel passes if it carries ANY selected tag id. */
|
||||
export function tagsColumnTest(c: ManagedChannel, values: string[]): boolean {
|
||||
if (values.includes(NO_TAG_SENTINEL)) return c.tag_ids.length === 0;
|
||||
return values.some((v) => c.tag_ids.includes(Number(v)));
|
||||
}
|
||||
|
||||
/** The manager's tag-filter chip row (the Tags column filter's values): any picked id that is not one
|
||||
* of the user's tags, then each user tag, then the "No tag" sentinel. A stored pick can outlive its
|
||||
* tag (deleted here or on another device) and still narrows the table to nothing, so it stays a
|
||||
* visible, removable chip — FIRST, so the row's "+N" overflow never folds the reason away —
|
||||
* and so does every pick while the tag list loads or after it failed, named by lib/tagPicks' rule
|
||||
* (a placeholder, then the name, "Deleted tag" or the id), not hidden. */
|
||||
export function tagFilterChips(o: {
|
||||
userTags: Tag[];
|
||||
tags: TagList;
|
||||
picked: string[];
|
||||
noTagOn: boolean;
|
||||
toggle: (tagId: number) => void;
|
||||
toggleNoTag: () => void;
|
||||
t: TFunction;
|
||||
}): FilterChip[] {
|
||||
const mine = new Set(o.userTags.map((tg) => String(tg.id)));
|
||||
const others = o.picked.filter((id) => id !== NO_TAG_SENTINEL && !mine.has(id));
|
||||
return [
|
||||
...others.map((id) => ({
|
||||
key: `t${id}`,
|
||||
label: tagPickLabel(Number(id), o.tags, o.t),
|
||||
pending: o.tags.isPending,
|
||||
active: true,
|
||||
onToggle: () => o.toggle(Number(id)),
|
||||
})),
|
||||
...o.userTags.map((tg) => ({
|
||||
key: `t${tg.id}`,
|
||||
label: tg.name,
|
||||
active: o.picked.includes(String(tg.id)),
|
||||
onToggle: () => o.toggle(tg.id),
|
||||
})),
|
||||
{
|
||||
key: NO_TAG_SENTINEL,
|
||||
label: o.t("channels.tags.noTag"),
|
||||
active: o.noTagOn,
|
||||
onToggle: o.toggleNoTag,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Everything the Channels-manager column set closes over. The manager owns the state + mutations;
|
||||
* this factory just wires them into `Column<ManagedChannel>` render/filter/sort defs so the ~150-line
|
||||
* column block lives outside the page component (its cell components are in channelCells). */
|
||||
export interface ChannelColumnDeps {
|
||||
t: TFunction;
|
||||
/** The UI language, for the numbers — the compiler re-builds the columns when it changes. */
|
||||
lang: string;
|
||||
prioOptions: { value: string; label: string }[];
|
||||
userTags: ComponentProps<typeof ChannelTagsControl>["userTags"];
|
||||
bumpPriority: (id: string, delta: number) => void;
|
||||
setPriority: (id: string, value: number) => void;
|
||||
/** The row's "…" — the door to the channel's one action list (`useChannelActions`). */
|
||||
more: (c: ManagedChannel) => ReactNode;
|
||||
onView: (c: ManagedChannel) => void;
|
||||
toggleTag: (channelId: string, tagId: number, hasTag: boolean) => void;
|
||||
createAndAttachTag: (channelId: string, name: string) => void;
|
||||
onFilterByTag: (tagId: number, name: string) => void;
|
||||
// Suggested user tags per channel (resolved to Tag + confidence), plus accept/dismiss.
|
||||
suggestionsFor: (channelId: string) => SuggestedTag[];
|
||||
onAcceptSuggestion: (channelId: string, tagId: number) => void;
|
||||
onDismissSuggestion: (channelId: string, tagId: number) => void;
|
||||
}
|
||||
|
||||
export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChannel>[] {
|
||||
const {
|
||||
t,
|
||||
lang,
|
||||
prioOptions,
|
||||
userTags,
|
||||
bumpPriority,
|
||||
setPriority,
|
||||
more,
|
||||
onView,
|
||||
toggleTag,
|
||||
createAndAttachTag,
|
||||
onFilterByTag,
|
||||
suggestionsFor,
|
||||
onAcceptSuggestion,
|
||||
onDismissSuggestion,
|
||||
} = deps;
|
||||
return [
|
||||
{
|
||||
key: "priority",
|
||||
header: t("channels.cols.priority"),
|
||||
align: "center",
|
||||
// Sized to the longer language's label + the funnel + a sort arrow ("Prioritás ▼ ⧩"): fixed layout
|
||||
// clips a header that outgrows its column instead of widening it.
|
||||
width: "7.5rem",
|
||||
// Off on a tablet-wide table, with About and Length: the row's "…" menu sets the priority too.
|
||||
tabletHidden: true,
|
||||
sortable: true,
|
||||
hideInCard: true,
|
||||
sortValue: (c) => c.priority,
|
||||
filter: { kind: "select", options: prioOptions, test: (c, v) => String(c.priority) === v },
|
||||
render: (c) => (
|
||||
<PriorityStepper
|
||||
priority={c.priority}
|
||||
onPriority={(d) => bumpPriority(c.id, d)}
|
||||
onSet={(v) => setPriority(c.id, v)}
|
||||
hint={t("channels.row.priorityHint")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
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,
|
||||
// A FLUID column: no width cap. Under the manager table's fixed layout this cell takes a
|
||||
// share of the leftover space and ChannelLink truncates the name to fit (full name on hover via
|
||||
// title), so a long name can't push the table wider than its container at any zoom.
|
||||
render: (c) => (
|
||||
<div className="min-w-0">
|
||||
<ChannelLink
|
||||
id={c.id}
|
||||
title={c.title}
|
||||
handle={c.handle}
|
||||
thumbnailUrl={c.thumbnail_url}
|
||||
onView={() => onView(c)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "about",
|
||||
header: t("channels.cols.about"),
|
||||
hideInCard: true,
|
||||
tabletHidden: true,
|
||||
// The other FLUID column — AboutCell already truncates to its cell.
|
||||
render: (c) => <AboutCell text={c.description} channelId={c.id} />,
|
||||
},
|
||||
{
|
||||
key: "stored",
|
||||
header: t("channels.cols.stored"),
|
||||
align: "right",
|
||||
nowrap: true,
|
||||
width: "5rem",
|
||||
sortable: true,
|
||||
sortValue: (c) => c.stored_videos,
|
||||
// The stored count carries the "fully synced" ✓ charm (the retired Sync column folded in).
|
||||
render: (c) => <StoredCell c={c} />,
|
||||
},
|
||||
{ ...subsColumn<ManagedChannel>(t, lang), width: "6rem" },
|
||||
{
|
||||
key: "lastUpload",
|
||||
header: t("channels.cols.lastUpload"),
|
||||
nowrap: true,
|
||||
width: "8rem", // "Utolsó feltöltés" + a sort arrow, as the priority column above
|
||||
sortable: true,
|
||||
sortValue: (c) => (c.last_video_at ? new Date(c.last_video_at).getTime() : 0),
|
||||
render: (c) => <RelativeTime at={c.last_video_at} empty="—" className="text-muted" />,
|
||||
},
|
||||
{
|
||||
key: "length",
|
||||
header: t("channels.cols.length"),
|
||||
align: "right",
|
||||
nowrap: true,
|
||||
width: "4.5rem",
|
||||
tabletHidden: true,
|
||||
sortable: true,
|
||||
sortValue: (c) => c.total_duration_seconds,
|
||||
render: (c) => (
|
||||
<span className="text-muted tabular-nums">
|
||||
{formatTotalHours(c.total_duration_seconds, lang)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tags",
|
||||
header: t("channels.cols.tags"),
|
||||
cardLabel: false,
|
||||
// Bounded width so the cell can't widen the column and reflow the whole table; the control
|
||||
// fills it and collapses overflow into "+N" (see ChannelTagsControl).
|
||||
width: "9rem",
|
||||
filter: {
|
||||
kind: "multi",
|
||||
options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })),
|
||||
test: tagsColumnTest,
|
||||
},
|
||||
render: (c) => (
|
||||
<ChannelTagsControl
|
||||
userTags={userTags}
|
||||
attachedIds={c.tag_ids}
|
||||
onToggle={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))}
|
||||
onCreate={(name) => createAndAttachTag(c.id, name)}
|
||||
onChipClick={onFilterByTag}
|
||||
suggestions={suggestionsFor(c.id)}
|
||||
onAcceptSuggestion={(tagId) => onAcceptSuggestion(c.id, tagId)}
|
||||
onDismissSuggestion={(tagId) => onDismissSuggestion(c.id, tagId)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "",
|
||||
align: "right",
|
||||
// Room for the "…" at 44px on touch (`.touch-target`), not just its 28px under a mouse.
|
||||
width: "3.25rem",
|
||||
nowrap: true,
|
||||
cardLabel: false,
|
||||
cardRow: true,
|
||||
// The row's full-history, hide and unsubscribe buttons went into the menu: the row
|
||||
// keeps what is read or tuned all the time — priority, tags, the ✓ — and ends in its "…".
|
||||
render: more,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ManagedChannel } from "../../lib/api";
|
||||
|
||||
// A channel is "fully synced" when its recent uploads AND full back-catalogue are both in — the
|
||||
// predicate the retired Sync column used, now surfaced as a ✓ charm on the Stored count and driven
|
||||
// by the top sync-filter chips.
|
||||
export const isFullySynced = (c: ManagedChannel) => c.recent_synced && c.backfill_done;
|
||||
@@ -0,0 +1,78 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { ChannelLayout } from "../../lib/channelLayout";
|
||||
import type { SortState } from "../../lib/columnSort";
|
||||
import type { FilterMap } from "../../lib/tableColumns";
|
||||
import type { ChannelsView } from "./Channels";
|
||||
|
||||
// The Channels module's contexts + hooks, split out of ChannelsProvider so that file exports only
|
||||
// components (Fast Refresh). The provider owns the state; this is the read side every consumer imports.
|
||||
|
||||
export type ChannelsState = {
|
||||
search: string;
|
||||
// The subscribed table's column filters, lifted out of DataTable so the top status chips, the Sync
|
||||
// column funnel, and the card layout share ONE filter state (the sync chip lives at `filters.sync`).
|
||||
filters: FilterMap;
|
||||
view: ChannelsView;
|
||||
// The subscribed table's sort, lifted out of DataTable so the card layout shares it.
|
||||
sort: SortState;
|
||||
// Bumped to tell the manager to drop a stale column filter when an intent sends the user to a
|
||||
// specific set (the header's "without full history" link).
|
||||
filtersResetToken: number;
|
||||
focusName: string | null;
|
||||
// Bumped every time a focus intent is (re-)issued, so re-focusing the same channel re-seeds the
|
||||
// manager's name filter even when the name is unchanged.
|
||||
focusToken: number;
|
||||
};
|
||||
|
||||
export interface ChannelsActions {
|
||||
setSearch: (q: string) => void;
|
||||
setFilters: (f: FilterMap) => void;
|
||||
setView: (v: ChannelsView) => void;
|
||||
setLayout: (l: ChannelLayout) => void;
|
||||
setSort: (s: SortState) => void;
|
||||
// "Focus this channel in the manager": jump to Channels (subscriptions tab) and seed its name
|
||||
// filter so the channel is isolated. Called from the feed sidebar and the notifications inbox.
|
||||
focusChannel: (name: string) => void;
|
||||
// Mark the current focus intent as applied. The manager calls this the moment it seeds its name
|
||||
// filter, so the intent cannot be re-applied when the page chunk remounts on a later visit and
|
||||
// overwrite a search the user has typed since.
|
||||
consumeFocus: () => void;
|
||||
// Jump to the manager filtered to channels still missing full history (the header sync chip).
|
||||
goToFullHistory: () => void;
|
||||
}
|
||||
|
||||
export const ChannelsStateContext = createContext<ChannelsState>({
|
||||
search: "",
|
||||
filters: {},
|
||||
view: "subscribed",
|
||||
sort: null,
|
||||
filtersResetToken: 0,
|
||||
focusName: null,
|
||||
focusToken: 0,
|
||||
});
|
||||
// The table/cards density gets its OWN context, deliberately narrow: App reads it (to decide the
|
||||
// shell's top fade) and would otherwise re-render the whole app on every keystroke in the manager's
|
||||
// search box, since the state context above changes with it.
|
||||
export const ChannelLayoutContext = createContext<ChannelLayout>("table");
|
||||
export const ChannelsActionsContext = createContext<ChannelsActions>({
|
||||
setSearch: () => {},
|
||||
setFilters: () => {},
|
||||
setView: () => {},
|
||||
setLayout: () => {},
|
||||
setSort: () => {},
|
||||
focusChannel: () => {},
|
||||
consumeFocus: () => {},
|
||||
goToFullHistory: () => {},
|
||||
});
|
||||
|
||||
export function useChannels(): ChannelsState {
|
||||
return useContext(ChannelsStateContext);
|
||||
}
|
||||
/** The manager's table/cards density on its own — subscribe to this instead of the whole state when
|
||||
* the layout is all you need (App does, for the shell's top fade). */
|
||||
export function useChannelLayout(): ChannelLayout {
|
||||
return useContext(ChannelLayoutContext);
|
||||
}
|
||||
export function useChannelsActions(): ChannelsActions {
|
||||
return useContext(ChannelsActionsContext);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// The Channels tag row at 375: the suggestions block squeezed the tag-filter chip lane to
|
||||
// 0 px and pushed the row's "…" off the screen. Below 40rem of its own container it now takes a line of
|
||||
// its own under the row, which keeps the "…" on the first line. Channels.tsx needs the whole provider
|
||||
// tree to render, so this holds the classes the layout rests on; the widths were measured in a browser.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
const src = readFileSync(new URL("./Channels.tsx", import.meta.url), "utf8");
|
||||
|
||||
describe("the Channels tag row", () => {
|
||||
it("is a size container whose row may wrap", () => {
|
||||
expect(src).toContain('className="@container/tagrow ');
|
||||
expect(src).toMatch(/className="flex flex-wrap items-center gap-2 min-w-0"/);
|
||||
});
|
||||
|
||||
it("sends only the suggestions block to a line of its own when narrow", () => {
|
||||
const block = src.slice(
|
||||
src.indexOf("data-tag-suggestions"),
|
||||
src.indexOf("data-tag-suggestions") + 400,
|
||||
);
|
||||
expect(block).toContain("@max-[40rem]/tagrow:order-last");
|
||||
expect(block).toContain("@max-[40rem]/tagrow:basis-full");
|
||||
});
|
||||
});
|
||||
|
||||
// Every other page band folds its filter row on a short window (B4, `useFilterFold`);
|
||||
// the Channels band never did — its tag chips and the status / pager row kept a laptop's short window
|
||||
// for themselves. They fold behind one "Filters · n", the tabs and the TAGS editor stay.
|
||||
describe("the Channels band on a short window", () => {
|
||||
it("folds the tag chip lane and the status / pager row behind the fold button", () => {
|
||||
expect(src).toContain("const fold = useFilterFold();");
|
||||
expect(src).toMatch(/<FilterFoldButton\s[^>]*fold=\{fold\}/);
|
||||
expect(src).toMatch(/!fold\.folded && <TagFilterChips/);
|
||||
expect(src).toMatch(/controlsPosition=\{fold\.folded \? "bottom" : "both"\}/);
|
||||
expect(src).toMatch(/layout === "cards" && !fold\.folded/);
|
||||
});
|
||||
|
||||
// Folded, the table's status / pager row moves to the list's end; the cards' one
|
||||
// simply vanished, and with it the only way to page them.
|
||||
it("keeps the cards' status / pager row at the list's end while folded", () => {
|
||||
const grid = src.slice(src.indexOf("<ChannelLayoutGrid"));
|
||||
expect(grid).toMatch(/\{fold\.folded && \(\s*<div[^>]*>\s*\{statusChips\}\s*\{cardPager\}/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A subscribed channel's ONE action list: what the manager row's "…" (and its right-click,
|
||||
// long-press and Shift+F10) offers — and the row buttons it replaced, now rows here: full history,
|
||||
// hide (an undo toast instead of a confirm), unsubscribe (danger, confirmed) — and the Tags and
|
||||
// Priority submenus.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { renderHook, act, cleanup, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
notify: vi.fn(),
|
||||
confirm: vi.fn(async () => true),
|
||||
role: "user",
|
||||
canWrite: true,
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||
};
|
||||
});
|
||||
vi.mock("../../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../lib/notifications")>()),
|
||||
notify: (...a: unknown[]) => h.notify(...a),
|
||||
}));
|
||||
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => h.confirm }));
|
||||
vi.mock("../../lib/useMe", () => ({ useMe: () => ({ role: h.role, can_write: h.canWrite }) }));
|
||||
|
||||
import { useChannelActions } from "./useChannelActions";
|
||||
import { api, type ManagedChannel, type Tag } from "../../lib/api";
|
||||
import type { ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
h.notify.mockReset();
|
||||
h.confirm.mockReset();
|
||||
h.confirm.mockImplementation(async () => true);
|
||||
h.role = "user";
|
||||
h.canWrite = true;
|
||||
tags = [];
|
||||
tagsState = "ready";
|
||||
onToggleTag.mockReset();
|
||||
onPickPriority.mockReset();
|
||||
});
|
||||
|
||||
const ch = (over: Partial<ManagedChannel> = {}): ManagedChannel => ({
|
||||
id: "UC1",
|
||||
title: "Chan",
|
||||
handle: "chan",
|
||||
thumbnail_url: null,
|
||||
subscriber_count: null,
|
||||
video_count: null,
|
||||
description: null,
|
||||
stored_videos: 0,
|
||||
last_video_at: null,
|
||||
total_duration_seconds: 0,
|
||||
priority: 0,
|
||||
hidden: false,
|
||||
deep_requested: false,
|
||||
deep_in_queue: false,
|
||||
tag_ids: [],
|
||||
details_synced: false,
|
||||
recent_synced: false,
|
||||
backfill_done: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
const onView = vi.fn();
|
||||
const onToggleTag = vi.fn();
|
||||
const onPickPriority = vi.fn();
|
||||
let tags: Tag[] = [];
|
||||
let tagsState: "loading" | "error" | "ready" = "ready";
|
||||
const tag = (id: number, name: string) => ({ id, name }) as Tag;
|
||||
function mount() {
|
||||
const qc = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
return renderHook(
|
||||
() =>
|
||||
useChannelActions({
|
||||
onView,
|
||||
onOpenWizard: () => {},
|
||||
tags,
|
||||
tagsState,
|
||||
onToggleTag,
|
||||
onPickPriority,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
}
|
||||
const pin = [{ id: "pin", label: "pin", onSelect: () => {} }];
|
||||
const ids = (items: { id: string }[]) => items.map((i) => i.id);
|
||||
const rowsFor = (c: ManagedChannel) => mount().result.current.items(c, { pin });
|
||||
const row = (c: ManagedChannel, id: string) => rowsFor(c).find((i) => i.id === id)!;
|
||||
|
||||
describe("useChannelActions — the menu", () => {
|
||||
it("lists open, pin, tags + priority, the YouTube + copy group, full history, hide and unsubscribe", () => {
|
||||
const items = rowsFor(ch());
|
||||
expect(ids(items)).toEqual([
|
||||
"open",
|
||||
"pin",
|
||||
"tags",
|
||||
"priority",
|
||||
"open-youtube",
|
||||
"copy-text",
|
||||
"copy-link",
|
||||
"backfill",
|
||||
"hide",
|
||||
"unsubscribe",
|
||||
]);
|
||||
// Groups: tuning, YouTube/copy, the channel's own settings, then the destructive row alone.
|
||||
expect(items.filter((i) => i.separatorBefore).map((i) => i.id)).toEqual([
|
||||
"tags",
|
||||
"open-youtube",
|
||||
"backfill",
|
||||
"unsubscribe",
|
||||
]);
|
||||
expect(items.find((i) => i.id === "unsubscribe")).toMatchObject({ danger: true });
|
||||
expect(items.find((i) => i.id === "copy-text")!.label).toBe("common.copy.name");
|
||||
});
|
||||
|
||||
it("has no unsubscribe without the YouTube write scope", () => {
|
||||
h.canWrite = false;
|
||||
expect(ids(rowsFor(ch()))).not.toContain("unsubscribe");
|
||||
});
|
||||
|
||||
it("opens the channel page, and YouTube in a new tab inside the click", () => {
|
||||
const open = vi.spyOn(window, "open").mockReturnValue(null);
|
||||
row(ch(), "open").onSelect!();
|
||||
expect(onView).toHaveBeenCalledWith(expect.objectContaining({ id: "UC1" }));
|
||||
const yt = row(ch(), "open-youtube");
|
||||
expect("inGesture" in yt && yt.inGesture).toBe(true);
|
||||
yt.onSelect!();
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
"https://www.youtube.com/@chan",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useChannelActions — full history", () => {
|
||||
it("asks for it, and takes a pending ask back", async () => {
|
||||
const upd = vi.spyOn(api, "updateChannel").mockResolvedValue({} as never);
|
||||
const ask = row(ch(), "backfill");
|
||||
expect(ask.label).toBe("channels.row.backfillThis");
|
||||
act(() => ask.onSelect!());
|
||||
await waitFor(() => expect(upd).toHaveBeenCalledWith("UC1", { deep_requested: true }));
|
||||
const cancel = row(ch({ deep_requested: true }), "backfill");
|
||||
expect(cancel.label).toBe("channels.row.cancelBackfill");
|
||||
act(() => cancel.onSelect!());
|
||||
await waitFor(() => expect(upd).toHaveBeenCalledWith("UC1", { deep_requested: false }));
|
||||
});
|
||||
|
||||
it("stays in place, off, with the reason, once it is in or on its way for everyone", () => {
|
||||
expect(row(ch({ backfill_done: true }), "backfill")).toMatchObject({
|
||||
disabled: true,
|
||||
note: "channels.row.fullHint",
|
||||
});
|
||||
expect(row(ch({ deep_in_queue: true }), "backfill")).toMatchObject({
|
||||
disabled: true,
|
||||
note: "channels.row.queuedByOtherHint",
|
||||
});
|
||||
});
|
||||
|
||||
it("gives an admin the reset, behind a confirm that names the reset", async () => {
|
||||
h.role = "admin";
|
||||
const reset = vi.spyOn(api, "resetChannelBackfill").mockResolvedValue({} as never);
|
||||
const it_ = row(ch({ backfill_done: true }), "backfill");
|
||||
expect(it_).toMatchObject({ label: "channels.row.resetBackfill" });
|
||||
expect(it_.disabled).toBeFalsy();
|
||||
await act(async () => it_.onSelect!());
|
||||
expect(h.confirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "channels.row.resetBackfill",
|
||||
confirmLabel: "channels.row.resetBackfill",
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(reset).toHaveBeenCalledWith("UC1"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("useChannelActions — hide and unsubscribe", () => {
|
||||
it("hides without a confirm, and the toast's Undo shows the channel again", async () => {
|
||||
const upd = vi.spyOn(api, "updateChannel").mockResolvedValue({} as never);
|
||||
const { result } = mount();
|
||||
act(() => result.current.items(ch(), { pin }).find((i) => i.id === "hide")!.onSelect!());
|
||||
await waitFor(() => expect(h.notify).toHaveBeenCalled());
|
||||
expect(h.confirm).not.toHaveBeenCalled();
|
||||
expect(upd).toHaveBeenCalledWith("UC1", { hidden: true });
|
||||
const toast = h.notify.mock.calls[0]![0] as {
|
||||
message: string;
|
||||
action: { onClick: () => void };
|
||||
};
|
||||
expect(toast.message).toBe("channels.notify.hidden");
|
||||
act(() => toast.action.onClick());
|
||||
await waitFor(() => expect(upd).toHaveBeenCalledWith("UC1", { hidden: false }));
|
||||
});
|
||||
|
||||
it("unsubscribes only after the danger confirm says yes", async () => {
|
||||
const unsub = vi.spyOn(api, "unsubscribeChannel").mockResolvedValue({} as never);
|
||||
const { result } = mount();
|
||||
const unsubRow = () => result.current.items(ch(), { pin }).find((i) => i.id === "unsubscribe")!;
|
||||
h.confirm.mockImplementation(async () => false);
|
||||
await act(async () => unsubRow().onSelect!());
|
||||
expect(h.confirm).toHaveBeenCalledWith(expect.objectContaining({ danger: true }));
|
||||
expect(unsub).not.toHaveBeenCalled();
|
||||
h.confirm.mockImplementation(async () => true);
|
||||
await act(async () => unsubRow().onSelect!());
|
||||
await waitFor(() => expect(unsub).toHaveBeenCalledWith("UC1"));
|
||||
});
|
||||
});
|
||||
|
||||
const sub = (it: ActionMenuItem) => (it.items ?? []) as ActionMenuItem[];
|
||||
|
||||
describe("useChannelActions — the Tags submenu", () => {
|
||||
it("lists the reader's tags A–Z, the channel's own ticked, each a toggle that stays open", () => {
|
||||
tags = [tag(3, "rust"), tag(1, "Go"), tag(2, "music")];
|
||||
const items = sub(row(ch({ tag_ids: [2] }), "tags"));
|
||||
expect(items.map((i) => i.label)).toEqual(["Go", "music", "rust"]);
|
||||
expect(items.map((i) => ("check" in i ? i.check : null))).toEqual([
|
||||
{ kind: "many", checked: false },
|
||||
{ kind: "many", checked: true },
|
||||
{ kind: "many", checked: false },
|
||||
]);
|
||||
items[1]!.onSelect!();
|
||||
expect(onToggleTag).toHaveBeenCalledWith(expect.objectContaining({ id: "UC1" }), 2);
|
||||
});
|
||||
|
||||
// A failed /tags read said "no tags yet" — an error dressed as an empty list.
|
||||
it("says the tags are loading, or failed to load, instead of 'no tags yet'", () => {
|
||||
tagsState = "loading";
|
||||
expect(row(ch(), "tags")).toMatchObject({ disabled: true, note: "common.loading" });
|
||||
tagsState = "error";
|
||||
expect(row(ch(), "tags")).toMatchObject({ disabled: true, note: "common.loadError" });
|
||||
});
|
||||
|
||||
it("stays in place, off, saying where tags are made, with no tag yet", () => {
|
||||
expect(row(ch(), "tags")).toMatchObject({ disabled: true, note: "channels.row.noTagsYet" });
|
||||
expect(row(ch(), "tags").items).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useChannelActions — the Priority submenu", () => {
|
||||
it("offers the fixed steps with the current one ticked, and picks a new one", () => {
|
||||
const items = sub(row(ch({ priority: 3 }), "priority"));
|
||||
expect(items.map((i) => i.label)).toEqual(["10", "5", "3", "2", "1", "0"]);
|
||||
expect(items.filter((i) => "check" in i && i.check?.checked).map((i) => i.label)).toEqual([
|
||||
"3",
|
||||
]);
|
||||
expect(items.every((i) => "check" in i && i.check?.kind === "one")).toBe(true);
|
||||
items[0]!.onSelect!();
|
||||
expect(onPickPriority).toHaveBeenCalledWith(expect.objectContaining({ id: "UC1" }), 10);
|
||||
});
|
||||
|
||||
it("shows an off-scale priority ticked in its place, and re-picking the current one is a no-op", () => {
|
||||
const items = sub(row(ch({ priority: 7 }), "priority"));
|
||||
expect(items.map((i) => i.label)).toEqual(["10", "7", "5", "3", "2", "1", "0"]);
|
||||
items[1]!.onSelect!();
|
||||
expect(onPickPriority).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUpDown, EyeOff, ExternalLink, History, Tags, Tv, UserMinus } from "lucide-react";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { invalidateSubscription } from "../../lib/subscriptionCache";
|
||||
import { api, type ManagedChannel, type Tag } from "../../lib/api";
|
||||
import { priorityChoices } from "../../lib/channelOrder";
|
||||
import { compareText } from "../../lib/collate";
|
||||
import { channelYouTubeUrl } from "../../lib/format";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import { useCopyItems } from "../../components/ui/copyItems";
|
||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
|
||||
// A subscribed channel's ONE action list: the manager row's "…", its right-click, long-press
|
||||
// and Shift+F10, in the table and in the card layout alike. The row keeps what the reader looks at
|
||||
// or tunes all the time (priority, tags, the stored count's ✓); everything that was a button on the
|
||||
// row — full history, hide, unsubscribe — is here, beside open, the pin and the copies. Tags and
|
||||
// priority are here TOO, as submenus (owner, 2026-09-26): tagging a run of channels, or bubbling one
|
||||
// up the list, from the menu the hand is already in.
|
||||
|
||||
export function useChannelActions({
|
||||
onView,
|
||||
onOpenWizard,
|
||||
tags,
|
||||
tagsState = "ready",
|
||||
onToggleTag,
|
||||
onPickPriority,
|
||||
}: {
|
||||
onView: (c: ManagedChannel) => void;
|
||||
onOpenWizard: () => void;
|
||||
/** The reader's own tags (no system tags) — the Tags submenu lists them A–Z. */
|
||||
tags: readonly Tag[];
|
||||
/** Whether `tags` is the answer yet: loading or failed is said as such, never as "no tags yet". */
|
||||
tagsState?: "loading" | "error" | "ready";
|
||||
onToggleTag: (c: ManagedChannel, tagId: number) => void;
|
||||
/** A priority step picked: the caller re-sorts its list and follows the row. */
|
||||
onPickPriority: (c: ManagedChannel, priority: number) => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const confirm = useConfirm();
|
||||
const me = useMe();
|
||||
const isAdmin = me.role === "admin";
|
||||
const copy = useCopyItems();
|
||||
const nameOf = (c: ManagedChannel) => c.title ?? t("channels.row.thisChannel");
|
||||
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
qc.invalidateQueries({ queryKey: qk.feedSummary() });
|
||||
};
|
||||
|
||||
const patch = useMutation({
|
||||
mutationFn: (v: { id: string; body: { hidden?: boolean; deep_requested?: boolean } }) =>
|
||||
api.updateChannel(v.id, v.body),
|
||||
meta: { failTitle: "errors.writeFailed.channelSettings" },
|
||||
onSuccess: refresh,
|
||||
});
|
||||
// Hiding moves the channel to the Hidden tab, out of sight: the toast's Undo is the way back from
|
||||
// where the reader is, so hiding needs no confirm (the same bargain as a playlist row's Remove).
|
||||
const hide = useMutation({
|
||||
mutationFn: (c: ManagedChannel) => api.updateChannel(c.id, { hidden: true }),
|
||||
meta: { failTitle: "errors.writeFailed.hideChannel" },
|
||||
onSuccess: (_d, c) => {
|
||||
refresh();
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("channels.notify.hidden", { name: nameOf(c) }),
|
||||
action: {
|
||||
label: t("common.undo"),
|
||||
onClick: () => patch.mutate({ id: c.id, body: { hidden: false } }),
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const unsubscribe = useMutation({
|
||||
mutationFn: (c: ManagedChannel) => api.unsubscribeChannel(c.id),
|
||||
onSuccess: (_d, c) => {
|
||||
invalidateSubscription(qc, c.id);
|
||||
notify({ level: "success", message: t("channels.notify.unsubscribed", { name: nameOf(c) }) });
|
||||
},
|
||||
onError: (e) =>
|
||||
notifyYouTubeActionError(e, t("channels.notify.unsubscribeFailed"), onOpenWizard),
|
||||
});
|
||||
const resetBackfill = useMutation({
|
||||
mutationFn: (c: ManagedChannel) => api.resetChannelBackfill(c.id),
|
||||
onSuccess: (_d, c) => {
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
||||
notify({ level: "success", message: t("channels.notify.resetDone", { name: nameOf(c) }) });
|
||||
},
|
||||
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.resetFailed"), onOpenWizard),
|
||||
});
|
||||
|
||||
async function onUnsubscribe(c: ManagedChannel) {
|
||||
const ok = await confirm({
|
||||
title: t("channels.row.unsubscribeOnYoutube"),
|
||||
message: t("channels.confirmUnsubscribe", { name: c.title ?? c.id }),
|
||||
confirmLabel: t("channels.row.unsubscribeOnYoutube"),
|
||||
danger: true,
|
||||
});
|
||||
if (ok) unsubscribe.mutate(c);
|
||||
}
|
||||
async function onReset(c: ManagedChannel) {
|
||||
const ok = await confirm({
|
||||
title: t("channels.row.resetBackfill"),
|
||||
message: t("channels.confirmReset", { name: c.title ?? c.id }),
|
||||
confirmLabel: t("channels.row.resetBackfill"),
|
||||
});
|
||||
if (ok) resetBackfill.mutate(c);
|
||||
}
|
||||
|
||||
/** The full-history row. An admin re-fetches any channel from scratch (a confirm: it spends
|
||||
* quota). A reader asks for the back-catalogue, or takes the ask back; once it is in, or someone
|
||||
* else's ask already brings it, the row stays in place, off, and says why. */
|
||||
function backfillItem(c: ManagedChannel): ActionMenuItem {
|
||||
const icon = <History className="w-4 h-4" />;
|
||||
if (isAdmin)
|
||||
return {
|
||||
id: "backfill",
|
||||
label: t("channels.row.resetBackfill"),
|
||||
title: t("channels.row.resetHint"),
|
||||
icon,
|
||||
disabled: resetBackfill.isPending,
|
||||
onSelect: () => void onReset(c),
|
||||
};
|
||||
const requested = !c.backfill_done && c.deep_requested;
|
||||
const note = c.backfill_done
|
||||
? t("channels.row.fullHint")
|
||||
: c.deep_in_queue && !requested
|
||||
? t("channels.row.queuedByOtherHint")
|
||||
: undefined;
|
||||
return {
|
||||
id: "backfill",
|
||||
label: requested ? t("channels.row.cancelBackfill") : t("channels.row.backfillThis"),
|
||||
title: requested ? t("channels.row.queuedRequestedHint") : t("channels.row.backfillThisHint"),
|
||||
icon,
|
||||
disabled: !!note || patch.isPending,
|
||||
note,
|
||||
onSelect: () => patch.mutate({ id: c.id, body: { deep_requested: !requested } }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Tags A–Z, one check each, toggled in place (the menu stays open for the next one). With no tag
|
||||
* yet the row stays, off, and says where tags are made. */
|
||||
function tagsItem(c: ManagedChannel): ActionMenuItem {
|
||||
const base = {
|
||||
id: "tags",
|
||||
label: t("channels.tags.label"),
|
||||
icon: <Tags className="w-4 h-4" />,
|
||||
};
|
||||
if (tags.length === 0) {
|
||||
const note =
|
||||
tagsState === "loading"
|
||||
? t("common.loading")
|
||||
: tagsState === "error"
|
||||
? t("common.loadError")
|
||||
: t("channels.row.noTagsYet");
|
||||
return { ...base, disabled: true, note, onSelect: () => {} };
|
||||
}
|
||||
const on = new Set(c.tag_ids);
|
||||
return {
|
||||
...base,
|
||||
items: [...tags]
|
||||
.sort((a, b) => compareText(a.name, b.name, lang))
|
||||
.map((tg) => ({
|
||||
id: `tag-${tg.id}`,
|
||||
label: tg.name,
|
||||
check: { kind: "many" as const, checked: on.has(tg.id) },
|
||||
onSelect: () => onToggleTag(c, tg.id),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** The fixed priority steps, the current one ticked; a pick re-sorts the list (the caller). */
|
||||
function priorityItem(c: ManagedChannel): ActionMenuItem {
|
||||
return {
|
||||
id: "priority",
|
||||
label: t("channels.row.priority"),
|
||||
icon: <ArrowUpDown className="w-4 h-4" />,
|
||||
items: priorityChoices(c.priority).map((p) => ({
|
||||
id: `priority-${p}`,
|
||||
label: String(p),
|
||||
check: { kind: "one" as const, checked: p === c.priority },
|
||||
onSelect: () => {
|
||||
if (p !== c.priority) onPickPriority(c, p);
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** The channel's menu rows; `pin` is the caller's comfort-shelf row (its "is it pinned?" read
|
||||
* follows the caller's own menu). */
|
||||
function items(c: ManagedChannel, { pin }: { pin: ActionMenuItem[] }): ActionMenuItem[] {
|
||||
const url = channelYouTubeUrl(c.id, c.handle);
|
||||
return [
|
||||
{
|
||||
id: "open",
|
||||
label: t("card.menu.openChannel"),
|
||||
icon: <Tv className="w-4 h-4" />,
|
||||
onSelect: () => onView(c),
|
||||
},
|
||||
...pin,
|
||||
{ ...tagsItem(c), separatorBefore: true },
|
||||
priorityItem(c),
|
||||
...[
|
||||
{
|
||||
id: "open-youtube",
|
||||
label: t("channels.row.openOnYouTube"),
|
||||
icon: <ExternalLink className="w-4 h-4" />,
|
||||
// A new tab only opens inside the click (a popup blocker refuses one a task later).
|
||||
inGesture: true,
|
||||
onSelect: () => void window.open(url, "_blank", "noopener,noreferrer"),
|
||||
},
|
||||
...copy(c.title, url, "name"),
|
||||
].map((it, i) => ({ ...it, separatorBefore: i === 0 })),
|
||||
{ ...backfillItem(c), separatorBefore: true },
|
||||
{
|
||||
id: "hide",
|
||||
label: t("channels.row.hideFromFeed"),
|
||||
title: t("channels.row.hideHint"),
|
||||
icon: <EyeOff className="w-4 h-4" />,
|
||||
disabled: hide.isPending,
|
||||
onSelect: () => hide.mutate(c),
|
||||
},
|
||||
...(me.can_write
|
||||
? [
|
||||
{
|
||||
id: "unsubscribe",
|
||||
label: t("channels.row.unsubscribeOnYoutube"),
|
||||
title: t("channels.row.unsubscribeHint"),
|
||||
icon: <UserMinus className="w-4 h-4" />,
|
||||
danger: true,
|
||||
separatorBefore: true,
|
||||
onSelect: () => void onUnsubscribe(c),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
/** Unhide from the Hidden tab — the same flag, the same refresh. */
|
||||
const unhide = (id: string) => patch.mutate({ id, body: { hidden: false } });
|
||||
|
||||
return { items, unhide };
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Container-logic contract of the tag-suggestion hook, the wiring the presentational
|
||||
// ChannelTagsControl test can't reach: suggestionsFor resolves tag ids to Tags and DROPS a suggestion
|
||||
// whose tag was deleted since the last compute; accept optimistically removes the WHOLE channel from
|
||||
// the /tags/suggestions cache and toggles the tag on; dismiss is session-only and scoped to one
|
||||
// (channel, tag); visible/high-confidence derive correctly. Runs against a real QueryClient so the
|
||||
// optimistic cache write is the actual code path (not a re-implementation).
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
// The confirm dialog's answer is controllable per test (the bulk-accept path gates on it).
|
||||
const confirmState = vi.hoisted(() => ({ result: true }));
|
||||
vi.mock("../../components/ui/confirmContext", () => ({
|
||||
useConfirm: () => () => Promise.resolve(confirmState.result),
|
||||
}));
|
||||
|
||||
import { api, type Tag } from "../../lib/api";
|
||||
import type { TagSuggestions } from "../../lib/api/tags";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { useTagSuggestions } from "./useTagSuggestions";
|
||||
|
||||
const tag = (id: number, name: string): Tag => ({
|
||||
id,
|
||||
name,
|
||||
color: null,
|
||||
category: "other",
|
||||
system: false,
|
||||
channel_count: 0,
|
||||
});
|
||||
const docu = tag(1, "docu");
|
||||
const history = tag(2, "history");
|
||||
const tags = [docu, history];
|
||||
|
||||
// UC_a: two suggestions (one high-confidence). UC_b: one. UC_ghost: a suggestion for a tag id that no
|
||||
// longer exists in the catalog (deleted since compute) → must be filtered out everywhere.
|
||||
const seed = (): TagSuggestions => ({
|
||||
UC_a: [
|
||||
{ tag_id: docu.id, confidence: 0.95 },
|
||||
{ tag_id: history.id, confidence: 0.6 },
|
||||
],
|
||||
UC_b: [{ tag_id: history.id, confidence: 0.7 }],
|
||||
UC_ghost: [{ tag_id: 999, confidence: 0.99 }],
|
||||
});
|
||||
|
||||
function setup() {
|
||||
const qc = new QueryClient({
|
||||
// Freeze the seeded cache so no refetch clobbers the optimistic writes under test.
|
||||
defaultOptions: { queries: { retry: false, staleTime: Infinity, refetchOnMount: false } },
|
||||
});
|
||||
qc.setQueryData(qk.tagSuggestions(), seed());
|
||||
vi.spyOn(api, "tagSuggestions").mockResolvedValue(seed());
|
||||
const toggleTag = vi.fn();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useTagSuggestions({ tags, toggleTag }), { wrapper });
|
||||
return { qc, toggleTag, result };
|
||||
}
|
||||
|
||||
describe("useTagSuggestions", () => {
|
||||
it("resolves tag ids to Tags and drops suggestions whose tag was deleted", () => {
|
||||
const { result } = setup();
|
||||
const forA = result.current.suggestionsFor("UC_a");
|
||||
expect(forA.map((s) => s.tag.id)).toEqual([docu.id, history.id]);
|
||||
// The suggestion for the missing tag id 999 is dropped, not rendered as a broken chip.
|
||||
expect(result.current.suggestionsFor("UC_ghost")).toEqual([]);
|
||||
});
|
||||
|
||||
it("derives visible and high-confidence suggestions, excluding the deleted-tag one", () => {
|
||||
const { result } = setup();
|
||||
// UC_a(docu 0.95) + UC_a(history 0.6) + UC_b(history 0.7) = 3; UC_ghost excluded.
|
||||
expect(result.current.visibleSuggestions).toHaveLength(3);
|
||||
// Only the ≥0.9 one (UC_a/docu) is high-confidence.
|
||||
expect(result.current.highConfidence).toEqual([
|
||||
{ cid: "UC_a", tagId: docu.id, confidence: 0.95 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("accept optimistically removes the whole channel from the cache and toggles the tag on", () => {
|
||||
const { qc, toggleTag, result } = setup();
|
||||
act(() => result.current.acceptSuggestion("UC_a", docu.id));
|
||||
// The real tag is attached (hasTag=false → attach).
|
||||
expect(toggleTag).toHaveBeenCalledWith("UC_a", docu.id, false);
|
||||
// The whole channel is dropped from the suggestions cache (attached lane XOR ghost lane), so its
|
||||
// OTHER ghost chip (history) goes too — not just the accepted one.
|
||||
const cache = qc.getQueryData<TagSuggestions>(qk.tagSuggestions());
|
||||
expect(cache?.UC_a).toBeUndefined();
|
||||
expect(cache?.UC_b).toBeDefined(); // other channels untouched
|
||||
});
|
||||
|
||||
it("dismiss hides only that one (channel, tag) for the session", () => {
|
||||
const { result } = setup();
|
||||
act(() => result.current.dismissSuggestion("UC_a", history.id));
|
||||
// UC_a loses only the history suggestion; docu stays.
|
||||
expect(result.current.suggestionsFor("UC_a").map((s) => s.tag.id)).toEqual([docu.id]);
|
||||
// The same tag on a DIFFERENT channel is not dismissed (dismiss is scoped to the pair).
|
||||
expect(result.current.suggestionsFor("UC_b").map((s) => s.tag.id)).toEqual([history.id]);
|
||||
});
|
||||
|
||||
it("bulk-accept applies ONLY the high-confidence suggestions after a confirm", async () => {
|
||||
confirmState.result = true;
|
||||
const { toggleTag, result } = setup();
|
||||
await act(async () => {
|
||||
await result.current.acceptAllHighConfidence();
|
||||
});
|
||||
// Only UC_a/docu (0.95 ≥ 0.9) is accepted — not history (0.6) or UC_b/history (0.7).
|
||||
expect(toggleTag).toHaveBeenCalledTimes(1);
|
||||
expect(toggleTag).toHaveBeenCalledWith("UC_a", docu.id, false);
|
||||
});
|
||||
|
||||
it("bulk-accept does nothing when the confirm is cancelled", async () => {
|
||||
confirmState.result = false;
|
||||
const { toggleTag, result } = setup();
|
||||
await act(async () => {
|
||||
await result.current.acceptAllHighConfidence();
|
||||
});
|
||||
expect(toggleTag).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type Tag, type TagSuggestions } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import type { SuggestedTag } from "./ChannelTags";
|
||||
|
||||
// A tag suggestion at/above this score is "high confidence" — the only ones the header's bulk
|
||||
// "accept all" acts on. Mirrors AUTOAPPLY_THRESHOLD in backend app/sync/tagsuggest.py (kept in
|
||||
// lockstep by intent: the two aren't imported across the stack, so a change must touch both).
|
||||
const HIGH_CONFIDENCE = 0.9;
|
||||
|
||||
type VisibleSuggestion = { cid: string; tagId: number; confidence: number };
|
||||
|
||||
/** Suggested-tag state for the Channel Manager: the /tags/suggestions query, a session-only
|
||||
* dismiss set, and the accept / dismiss / bulk-accept actions. Suggestions are learned from the
|
||||
* user's own manual-tag pattern and shown as ghost chips on UNTAGGED channels. Extracted as a hook
|
||||
* (like useChannelTagActions) so the optimistic-cache and dismiss logic is unit-testable apart from
|
||||
* the large Channels component. */
|
||||
export function useTagSuggestions({
|
||||
tags,
|
||||
toggleTag,
|
||||
}: {
|
||||
/** The full tag catalog, to resolve a suggestion's tag_id → Tag (and drop tags deleted since compute). */
|
||||
tags: Tag[];
|
||||
/** The shared attach/detach toggle. Its onError must also refetch suggestions (see Channels) so a
|
||||
* failed accept restores the ghost chips this hook optimistically removed. */
|
||||
toggleTag: (channelId: string, tagId: number, hasTag: boolean) => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const suggestionsQuery = useQuery({ queryKey: qk.tagSuggestions(), queryFn: api.tagSuggestions });
|
||||
const suggestions: TagSuggestions = suggestionsQuery.data ?? {};
|
||||
const tagById = useMemo(() => new Map(tags.map((tg) => [tg.id, tg])), [tags]);
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(() => new Set());
|
||||
const sKey = (cid: string, tagId: number) => `${cid}:${tagId}`;
|
||||
|
||||
// Resolve a channel's raw {tag_id, confidence} list to renderable {tag, confidence}, dropping any
|
||||
// that were dismissed this session or whose tag no longer exists (deleted since the last compute).
|
||||
const suggestionsFor = (cid: string): SuggestedTag[] =>
|
||||
(suggestions[cid] ?? []).flatMap((s) => {
|
||||
const tag = tagById.get(s.tag_id);
|
||||
return tag && !dismissed.has(sKey(cid, s.tag_id)) ? [{ tag, confidence: s.confidence }] : [];
|
||||
});
|
||||
|
||||
const dismissSuggestion = (cid: string, tagId: number) =>
|
||||
setDismissed((prev) => new Set(prev).add(sKey(cid, tagId)));
|
||||
|
||||
const acceptSuggestion = (cid: string, tagId: number) => {
|
||||
// Optimistically drop the WHOLE channel from the suggestions cache: attaching any tag makes it
|
||||
// "tagged", which the server excludes from suggestions entirely — so its other ghost chips would
|
||||
// vanish on the next refetch anyway. Removing them now keeps the ghost lane and the attached lane
|
||||
// mutually exclusive (no transient of both showing). toggleTag attaches the real tag; on success
|
||||
// its onSuccess re-invalidates suggestions, and on failure its onError refetches them — so a
|
||||
// failed accept restores these ghost chips rather than leaving an empty cell (see Channels' onError).
|
||||
qc.setQueryData<TagSuggestions>(qk.tagSuggestions(), (old) => {
|
||||
if (!old?.[cid]) return old;
|
||||
const next = { ...old };
|
||||
delete next[cid];
|
||||
return next;
|
||||
});
|
||||
toggleTag(cid, tagId, false);
|
||||
};
|
||||
|
||||
// Every currently-visible suggestion (all channels, minus dismissed) and the high-confidence subset
|
||||
// the bulk action accepts.
|
||||
const visibleSuggestions: VisibleSuggestion[] = Object.keys(suggestions).flatMap((cid) =>
|
||||
suggestionsFor(cid).map((s) => ({ cid, tagId: s.tag.id, confidence: s.confidence })),
|
||||
);
|
||||
const highConfidence = visibleSuggestions.filter((s) => s.confidence >= HIGH_CONFIDENCE);
|
||||
const acceptAllHighConfidence = async () => {
|
||||
const ok = await confirm({
|
||||
title: t("channels.tags.acceptAllTitle"),
|
||||
message: t("channels.tags.acceptAllConfirm", { count: highConfidence.length }),
|
||||
confirmLabel: t("channels.tags.acceptAllConfirmCta"),
|
||||
});
|
||||
if (!ok) return;
|
||||
highConfidence.forEach(({ cid, tagId }) => acceptSuggestion(cid, tagId));
|
||||
};
|
||||
|
||||
return {
|
||||
suggestionsFor,
|
||||
dismissSuggestion,
|
||||
acceptSuggestion,
|
||||
visibleSuggestions,
|
||||
highConfidence,
|
||||
acceptAllHighConfidence,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user