Merge: promote dev to prod
This commit is contained in:
@@ -214,6 +214,7 @@ def _channel_summary(ch: Channel) -> dict:
|
||||
"thumbnail_url": ch.thumbnail_url,
|
||||
"subscriber_count": ch.subscriber_count,
|
||||
"video_count": ch.video_count,
|
||||
"description": ch.description,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Generated
+7
@@ -12,6 +12,7 @@
|
||||
"@tanstack/react-query": "^5.51.0",
|
||||
"@tanstack/react-virtual": "^3.14.3",
|
||||
"clsx": "^2.1.1",
|
||||
"flag-icons": "^7.5.0",
|
||||
"hls.js": "^1.6.16",
|
||||
"i18next": "^23.11.5",
|
||||
"lucide-react": "^0.408.0",
|
||||
@@ -1809,6 +1810,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/flag-icons": {
|
||||
"version": "7.5.0",
|
||||
"resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz",
|
||||
"integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"@tanstack/react-query": "^5.51.0",
|
||||
"@tanstack/react-virtual": "^3.14.3",
|
||||
"clsx": "^2.1.1",
|
||||
"flag-icons": "^7.5.0",
|
||||
"hls.js": "^1.6.16",
|
||||
"i18next": "^23.11.5",
|
||||
"lucide-react": "^0.408.0",
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "./lib/storage";
|
||||
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
|
||||
import { useChannelLayout } from "./components/ChannelsProvider";
|
||||
import { useFeedView, useFeedViewActions } from "./components/FeedViewProvider";
|
||||
import { moduleDef } from "./lib/modules";
|
||||
import { PAGE_CONTENT, PAGE_RAIL } from "./components/moduleRegistry";
|
||||
@@ -62,6 +63,11 @@ export default function App() {
|
||||
// handlers that mutate the feed view (onShowInFeed, tag-click, the feed scrollKey).
|
||||
const filters = useFeedFilters();
|
||||
const { setFilters } = useFeedFiltersActions();
|
||||
// The channel manager turns its top fade OFF for the table (its sticky header sits at the scroller
|
||||
// top) but wants it ON for the card layout (no sticky header) — so the fade depends on the layout.
|
||||
// Deliberately the NARROW layout context: subscribing to the manager's whole state here would
|
||||
// re-render the app root on every keystroke in its search box.
|
||||
const channelLayout = useChannelLayout();
|
||||
// The feed's view mode (card/row/tile density) lives in FeedViewProvider — server-synced, shared
|
||||
// by the feed page and the channel page. App reads it only to hand to <Feed>/<ChannelPage>.
|
||||
const view = useFeedView();
|
||||
@@ -387,7 +393,9 @@ export default function App() {
|
||||
) : (
|
||||
<PageShell
|
||||
scrollKey={normalScrollKey}
|
||||
fadeTop={fadeTop ?? true}
|
||||
// Channels: the registry disables the top fade for its sticky-header table, but the card
|
||||
// layout has no sticky header, so re-enable it there.
|
||||
fadeTop={activePage === "channels" ? channelLayout === "cards" : fadeTop ?? true}
|
||||
header={<Header me={meQuery.data!} onYtSearch={enterYtSearch} />}
|
||||
banners={
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "../lib/api";
|
||||
import ChannelAboutContent from "./ChannelAbout";
|
||||
|
||||
const MARGIN = 8;
|
||||
const POP_W = 480; // the overlay's fixed width (px)
|
||||
|
||||
/**
|
||||
* 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 portalled glass overlay. 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 overlay
|
||||
* is interactive (scrollable, links clickable) via a small hover-bridge; it closes on page scroll
|
||||
* (but not when scrolling inside itself) or resize. Shared by the subscribed + discovery tables.
|
||||
*/
|
||||
export default function AboutCell({ text, channelId }: { text: string | null; channelId: string }) {
|
||||
const preview = (text ?? "").trim();
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [coords, setCoords] = useState<{ left: number; top: number } | null>(null);
|
||||
|
||||
const { data: ch } = useQuery({
|
||||
queryKey: ["channel", channelId],
|
||||
queryFn: () => api.channelDetail(channelId),
|
||||
enabled: open && !!channelId,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
function place() {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
setCoords({
|
||||
left: Math.min(Math.max(r.left, MARGIN), window.innerWidth - POP_W - MARGIN),
|
||||
top: r.bottom + 6,
|
||||
});
|
||||
}
|
||||
function show() {
|
||||
if (hideTimer.current) {
|
||||
clearTimeout(hideTimer.current);
|
||||
hideTimer.current = null;
|
||||
}
|
||||
place();
|
||||
setOpen(true);
|
||||
}
|
||||
// Small grace period so the cursor can travel the gap from the cell to the overlay without it
|
||||
// vanishing — the overlay's own mouseenter cancels it.
|
||||
function scheduleHide() {
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
hideTimer.current = setTimeout(() => setOpen(false), 160);
|
||||
}
|
||||
function cancelHide() {
|
||||
if (hideTimer.current) {
|
||||
clearTimeout(hideTimer.current);
|
||||
hideTimer.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Close on page scroll (the fixed overlay would strand at a stale anchor) or resize — but NOT
|
||||
// when the scroll happens inside the overlay itself (it is scrollable).
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onScroll = (e: Event) => {
|
||||
if (popRef.current && e.target instanceof Node && popRef.current.contains(e.target)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
const onResize = () => setOpen(false);
|
||||
window.addEventListener("scroll", onScroll, true);
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", onScroll, true);
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Flip above the anchor if the overlay would run off the bottom of the viewport.
|
||||
useLayoutEffect(() => {
|
||||
const pop = popRef.current;
|
||||
const el = ref.current;
|
||||
if (!open || !coords || !pop || !el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
const h = pop.offsetHeight;
|
||||
if (r.bottom + 6 + h > window.innerHeight - MARGIN && r.top - 6 - h > MARGIN) {
|
||||
const top = r.top - 6 - h;
|
||||
if (Math.abs(top - coords.top) > 0.5) setCoords((c) => (c ? { ...c, top } : c));
|
||||
}
|
||||
}, [open, coords, ch]);
|
||||
|
||||
if (!preview) return <span className="text-muted">—</span>;
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
onMouseEnter={show}
|
||||
onMouseLeave={scheduleHide}
|
||||
onFocus={show}
|
||||
onBlur={scheduleHide}
|
||||
tabIndex={0}
|
||||
className="block max-w-[22rem] truncate text-muted cursor-help outline-none"
|
||||
>
|
||||
{preview}
|
||||
{open &&
|
||||
coords &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={popRef}
|
||||
onMouseEnter={cancelHide}
|
||||
onMouseLeave={scheduleHide}
|
||||
style={{ position: "fixed", left: coords.left, top: coords.top, width: POP_W }}
|
||||
className="glass z-tooltip max-h-[28rem] overflow-y-auto px-3.5 py-3 rounded-lg animate-[fadeIn_0.12s_ease]"
|
||||
>
|
||||
{/* Full About once the detail loads; until then the list's truncated description. */}
|
||||
{ch ? (
|
||||
<ChannelAboutContent ch={ch} />
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{preview}</p>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import type { ChannelDetail } from "../lib/api";
|
||||
import { linkify } from "../lib/linkify";
|
||||
|
||||
// --- About-tab metadata helpers (shared by the channel page and the manager's About overlay) ---
|
||||
function displayName(code: string, lang: string, type: "region" | "language"): string {
|
||||
try {
|
||||
return new Intl.DisplayNames([lang], { type }).of(type === "region" ? code.toUpperCase() : code) ?? code;
|
||||
} catch {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
// 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) → a readable label, de-duplicated.
|
||||
function topicLabels(urls: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
for (const url of urls) {
|
||||
const seg = url.split("/wiki/")[1] ?? url.split("/").pop() ?? url;
|
||||
let label = seg;
|
||||
try {
|
||||
label = decodeURIComponent(seg);
|
||||
} catch {
|
||||
/* keep raw */
|
||||
}
|
||||
label = label.replace(/_/g, " ").trim();
|
||||
if (label) seen.add(label);
|
||||
}
|
||||
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 ? topicLabels(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="text-sm whitespace-pre-wrap break-words 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-sm shrink-0`} aria-hidden />
|
||||
)}
|
||||
{displayName(ch.country, i18n.language, "region")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{ch.default_language && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted w-24 shrink-0">{t("channel.language")}</span>
|
||||
<span>{displayName(ch.default_language, i18n.language, "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) => (
|
||||
<span key={tp} className="glass-card px-2 py-0.5 rounded-full text-xs">
|
||||
{tp}
|
||||
</span>
|
||||
))}
|
||||
</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) => (
|
||||
<span key={i} className="glass-card px-2 py-0.5 rounded-full text-xs">
|
||||
{kw}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,25 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { UserPlus } from "lucide-react";
|
||||
import { api, type DiscoveredChannel } from "../lib/api";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
import { accountKey, LS, readAccount, writeAccount } from "../lib/storage";
|
||||
import { formatCountOrDash } from "../lib/format";
|
||||
import { parseSortState, sortRows, type SortState } from "../lib/columnSort";
|
||||
import { useCardPager } from "../lib/useCardPager";
|
||||
import { subsColumn } from "./channelColumns";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import Tooltip from "./Tooltip";
|
||||
import ChannelLink from "./ChannelLink";
|
||||
import AboutCell from "./AboutCell";
|
||||
import DataTable, { type Column } from "./DataTable";
|
||||
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||
import ColumnSortControl from "./ColumnSortControl";
|
||||
import Pager from "./Pager";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { useChannelLayout } from "./ChannelsProvider";
|
||||
|
||||
// The Channel manager's "Discovery" tab: channels that turn up in the user's playlists but
|
||||
// that they don't subscribe to. Subscribing here only creates the subscription + enriches
|
||||
@@ -31,6 +39,16 @@ export default function ChannelDiscovery({
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
// The table/cards density is shared with the Subscriptions tab (ChannelsProvider). The sort is
|
||||
// this tab's own (its columns differ), lifted out of the table so the card layout shares it.
|
||||
const layout = useChannelLayout();
|
||||
const [sort, setSortState] = useState<SortState>(() =>
|
||||
parseSortState(readAccount(LS.channelDiscoverySort, null)),
|
||||
);
|
||||
const setSort = (s: SortState) => {
|
||||
setSortState(s);
|
||||
writeAccount(LS.channelDiscoverySort, s);
|
||||
};
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["discovered-channels"],
|
||||
@@ -77,6 +95,34 @@ export default function ChannelDiscovery({
|
||||
);
|
||||
|
||||
const columns: Column<DiscoveredChannel>[] = [
|
||||
// Actions first, mirroring the Subscriptions table (where it sits right after Prio) so the two
|
||||
// tabs' action clusters line up in the same place.
|
||||
{
|
||||
key: "actions",
|
||||
header: t("channels.cols.actions"),
|
||||
align: "left",
|
||||
nowrap: true,
|
||||
width: "120px",
|
||||
cardLabel: false,
|
||||
render: (c) => (
|
||||
<Tooltip
|
||||
hint={
|
||||
canWrite
|
||||
? t("channels.discovery.subscribeHint")
|
||||
: t("channels.discovery.needWriteHint")
|
||||
}
|
||||
>
|
||||
<button
|
||||
onClick={() => onSubscribe(c)}
|
||||
disabled={!canWrite || subscribe.isPending}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-50 transition"
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" />
|
||||
{t("channels.discovery.subscribe")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "channel",
|
||||
header: t("channels.cols.channel"),
|
||||
@@ -84,16 +130,26 @@ export default function ChannelDiscovery({
|
||||
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) => (
|
||||
<ChannelLink
|
||||
id={c.id}
|
||||
title={c.title}
|
||||
handle={c.handle}
|
||||
thumbnailUrl={c.thumbnail_url}
|
||||
onView={() => onViewChannel(c.id, c.title ?? c.id)}
|
||||
/>
|
||||
<div className="max-w-[18rem]">
|
||||
<ChannelLink
|
||||
id={c.id}
|
||||
title={c.title}
|
||||
handle={c.handle}
|
||||
thumbnailUrl={c.thumbnail_url}
|
||||
onView={() => onViewChannel(c.id, c.title ?? c.id)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "about",
|
||||
header: t("channels.cols.about"),
|
||||
hideInCard: true,
|
||||
render: (c) => <AboutCell text={c.description} channelId={c.id} />,
|
||||
},
|
||||
subsColumn<DiscoveredChannel>(t),
|
||||
{
|
||||
key: "videos",
|
||||
@@ -126,54 +182,71 @@ export default function ChannelDiscovery({
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: t("channels.cols.actions"),
|
||||
align: "right",
|
||||
nowrap: true,
|
||||
width: "120px",
|
||||
cardLabel: false,
|
||||
render: (c) => (
|
||||
<Tooltip
|
||||
hint={
|
||||
canWrite
|
||||
? t("channels.discovery.subscribeHint")
|
||||
: t("channels.discovery.needWriteHint")
|
||||
}
|
||||
>
|
||||
<button
|
||||
onClick={() => onSubscribe(c)}
|
||||
disabled={!canWrite || subscribe.isPending}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-50 transition"
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" />
|
||||
{t("channels.discovery.subscribe")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Card layout paginates like the table (slice the sorted rows; the Pager reports changes). Only
|
||||
// computed for cards — DataTable sorts and slices internally, so doing it in table mode too would
|
||||
// copy+sort the whole list every render for a result nothing reads.
|
||||
const isCards = layout === "cards";
|
||||
const sortedRows = isCards ? sortRows(rows, columns, sort) : rows;
|
||||
const { paged: pagedRows, pagerProps, resetPage } = useCardPager(
|
||||
sortedRows,
|
||||
LS.channelDiscoveryCardSize,
|
||||
isCards,
|
||||
);
|
||||
// Changing what you're looking at starts over at the first page. NOT keyed on the row count:
|
||||
// subscribing drops a row from this tab, and being thrown to page 1 for it would punish the very
|
||||
// action you just took — a list that shrank past your page is handled by the hook's clamp.
|
||||
useEffect(() => {
|
||||
resetPage();
|
||||
}, [search, sort, resetPage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Intro + the table controls are fixed chrome; only the table rows scroll (sticky header). */}
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-3 max-w-7xl mx-auto">
|
||||
<div className="px-4 pt-3 max-w-[96rem] mx-auto flex items-start justify-between gap-4">
|
||||
<p className="text-xs text-muted leading-relaxed">{t("channels.discovery.intro")}</p>
|
||||
{/* Cards has no column headers, so a dropdown drives the sort + a pager (table uses its own). */}
|
||||
{layout === "cards" && (
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<Pager {...pagerProps} />
|
||||
<ColumnSortControl
|
||||
columns={columns}
|
||||
sort={sort}
|
||||
onChange={setSort}
|
||||
label={t("channels.sort.label")}
|
||||
defaultLabel={t("channels.sort.default")}
|
||||
ascLabel={t("channels.sort.asc")}
|
||||
descLabel={t("channels.sort.desc")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageToolbar>
|
||||
<div className="px-4 pb-4 max-w-7xl mx-auto">
|
||||
<div className="px-4 pb-4 max-w-[96rem] mx-auto">
|
||||
{query.isLoading ? (
|
||||
<div className="text-muted py-8">{t("channels.discovery.loading")}</div>
|
||||
) : (
|
||||
) : layout === "table" ? (
|
||||
<DataTable
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
rowKey={(c) => c.id}
|
||||
persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined}
|
||||
controlsPosition="top"
|
||||
controlsPosition="both"
|
||||
controlsInBand
|
||||
controlsBandClassName="px-4 max-w-7xl mx-auto"
|
||||
controlsBandClassName="px-4 max-w-[96rem] mx-auto"
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
emptyText={t("channels.discovery.empty")}
|
||||
/>
|
||||
) : (
|
||||
<ChannelLayoutGrid
|
||||
rows={pagedRows}
|
||||
columns={columns}
|
||||
rowKey={(c) => c.id}
|
||||
aboutKey="about"
|
||||
actionsKey="actions"
|
||||
emptyText={t("channels.discovery.empty")}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Column } from "./DataTable";
|
||||
import VirtualGrid from "./VirtualGrid";
|
||||
import { CHANNEL_LAYOUT_SPEC } from "../lib/channelLayout";
|
||||
|
||||
// The channel manager's `cards` layout (E4 S3), 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.
|
||||
|
||||
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;
|
||||
rowClassName?: (row: T) => string;
|
||||
emptyText?: string;
|
||||
}
|
||||
|
||||
export default function ChannelLayoutGrid<T>({
|
||||
rows,
|
||||
columns,
|
||||
rowKey,
|
||||
leadKey,
|
||||
aboutKey,
|
||||
actionsKey,
|
||||
rowClassName,
|
||||
emptyText,
|
||||
}: ChannelLayoutGridProps<T>) {
|
||||
const { t } = useTranslation();
|
||||
const spec = CHANNEL_LAYOUT_SPEC.cards;
|
||||
|
||||
const slotted = new Set([leadKey, aboutKey, actionsKey].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;
|
||||
// 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) => (
|
||||
<div className={`glass-card rounded-xl 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>}
|
||||
</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}
|
||||
maxCol={null}
|
||||
rowEst={spec.rowEst}
|
||||
renderItem={renderCard}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useNavigationActions } from "./NavigationProvider";
|
||||
import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw, SlidersHorizontal } from "lucide-react";
|
||||
import Avatar from "./Avatar";
|
||||
import Feed from "./Feed";
|
||||
import ChannelAboutContent from "./ChannelAbout";
|
||||
import { PageToolbar, PageToolbarSlot } from "./PageShell";
|
||||
import { useFeedFilters } from "./FeedFiltersProvider";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
@@ -14,39 +15,6 @@ import { api, type FeedFilters, type Me } from "../lib/api";
|
||||
import type { FeedView } from "../lib/feedView";
|
||||
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
||||
|
||||
// --- About-tab metadata helpers ---
|
||||
// ISO 3166-1 alpha-2 code → 🇺🇸 regional-indicator flag emoji.
|
||||
function countryFlag(code: string): string {
|
||||
if (!/^[A-Za-z]{2}$/.test(code)) return "";
|
||||
return String.fromCodePoint(...[...code.toUpperCase()].map((c) => 0x1f1e6 + c.charCodeAt(0) - 65));
|
||||
}
|
||||
function displayName(code: string, lang: string, type: "region" | "language"): string {
|
||||
try {
|
||||
return new Intl.DisplayNames([lang], { type }).of(type === "region" ? code.toUpperCase() : code) ?? code;
|
||||
} catch {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
// 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) → a readable label, de-duplicated.
|
||||
function topicLabels(urls: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
for (const url of urls) {
|
||||
const seg = url.split("/wiki/")[1] ?? url.split("/").pop() ?? url;
|
||||
let label = seg;
|
||||
try {
|
||||
label = decodeURIComponent(seg);
|
||||
} catch {
|
||||
/* keep raw */
|
||||
}
|
||||
label = label.replace(/_/g, " ").trim();
|
||||
if (label) seen.add(label);
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
|
||||
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
|
||||
@@ -209,10 +177,6 @@ export default function ChannelPage({
|
||||
ch?.total_view_count != null ? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) }) : null,
|
||||
joined ? t("channel.joined", { date: joined }) : null,
|
||||
].filter(Boolean) as string[];
|
||||
const topics = ch?.topic_categories?.length ? topicLabels(ch.topic_categories) : [];
|
||||
const keywords = ch?.keywords ? parseKeywords(ch.keywords) : [];
|
||||
const hasAboutDetails =
|
||||
!!ch?.country || !!ch?.default_language || topics.length > 0 || keywords.length > 0;
|
||||
const tabClass = (active: boolean) =>
|
||||
active
|
||||
? "pb-2 text-sm font-medium border-b-2 border-fg text-fg"
|
||||
@@ -422,69 +386,11 @@ export default function ChannelPage({
|
||||
</>
|
||||
) : (
|
||||
<div className="px-4 sm:px-6 py-4 max-w-3xl">
|
||||
{ch?.description ? (
|
||||
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{ch.description}</p>
|
||||
{ch ? (
|
||||
<ChannelAboutContent ch={ch} />
|
||||
) : (
|
||||
<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>
|
||||
{countryFlag(ch.country)} {displayName(ch.country, i18n.language, "region")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{ch?.default_language && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted w-24 shrink-0">{t("channel.language")}</span>
|
||||
<span>{displayName(ch.default_language, i18n.language, "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">
|
||||
{topics.map((tp) => (
|
||||
<span key={tp} className="glass-card px-2 py-0.5 rounded-full text-xs">
|
||||
{tp}
|
||||
</span>
|
||||
))}
|
||||
</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) => (
|
||||
<span key={i} className="glass-card px-2 py-0.5 rounded-full text-xs">
|
||||
{kw}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PageToolbarSlot.Provider>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowDown,
|
||||
@@ -9,9 +9,11 @@ import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
History,
|
||||
LayoutGrid,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Table,
|
||||
UserMinus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
@@ -25,12 +27,21 @@ import { subsColumn } from "./channelColumns";
|
||||
import { notify } from "../lib/notifications";
|
||||
import Tooltip from "./Tooltip";
|
||||
import DataTable, { type Column } from "./DataTable";
|
||||
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||
import Overlay from "./Overlay";
|
||||
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||
import ColumnSortControl from "./ColumnSortControl";
|
||||
import Pager from "./Pager";
|
||||
import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout";
|
||||
import { sortRows } from "../lib/columnSort";
|
||||
import { useCardPager } from "../lib/useCardPager";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import ChannelLink from "./ChannelLink";
|
||||
import AboutCell from "./AboutCell";
|
||||
import ChannelDiscovery from "./ChannelDiscovery";
|
||||
import TagManager from "./TagManager";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { useChannels, useChannelsActions } from "./ChannelsProvider";
|
||||
import { useChannelLayout, useChannels, useChannelsActions } from "./ChannelsProvider";
|
||||
import { useNavigationActions } from "./NavigationProvider";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||
import { useWizard } from "./WizardProvider";
|
||||
@@ -47,6 +58,11 @@ const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
|
||||
{ id: "hidden", labelKey: "channels.filters.hidden" },
|
||||
];
|
||||
|
||||
const LAYOUT_ICON: Record<ChannelLayout, typeof Table> = {
|
||||
table: Table,
|
||||
cards: LayoutGrid,
|
||||
};
|
||||
|
||||
export default function Channels() {
|
||||
const me = useMe();
|
||||
const canWrite = me.can_write;
|
||||
@@ -60,11 +76,14 @@ export default function Channels() {
|
||||
search,
|
||||
statusFilter,
|
||||
view,
|
||||
sort,
|
||||
filtersResetToken,
|
||||
focusName: focusChannelName,
|
||||
focusToken: focusChannelToken,
|
||||
} = useChannels();
|
||||
const { setSearch, setStatusFilter, setView, focusChannel: onFocusChannel } = useChannelsActions();
|
||||
const layout = useChannelLayout();
|
||||
const { setSearch, setStatusFilter, setView, setLayout, setSort, focusChannel: onFocusChannel } =
|
||||
useChannelsActions();
|
||||
const { openChannel: onViewChannel, setPage } = useNavigationActions();
|
||||
const feedFilters = useFeedFilters();
|
||||
const { setFilters: setFeedFilters } = useFeedFiltersActions();
|
||||
@@ -248,6 +267,11 @@ export default function Channels() {
|
||||
if (ok) resetBackfill.mutate({ id: c.id, name: c.title ?? c.id });
|
||||
};
|
||||
|
||||
// Distinct priority levels present in the catalog → the priority column's filter options.
|
||||
const prioOptions = [...new Set((channelsQuery.data ?? []).map((c) => c.priority))]
|
||||
.sort((a, b) => b - a)
|
||||
.map((p) => ({ value: String(p), label: String(p) }));
|
||||
|
||||
const columns: Column<ManagedChannel>[] = [
|
||||
{
|
||||
key: "priority",
|
||||
@@ -257,24 +281,56 @@ export default function Channels() {
|
||||
sortable: true,
|
||||
hideInCard: true,
|
||||
sortValue: (c) => c.priority,
|
||||
filter: { kind: "select", options: prioOptions, test: (c, v) => String(c.priority) === v },
|
||||
render: (c) => <PriorityCell c={c} onPriority={(d) => bumpPriority(c.id, c.priority, d)} />,
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: t("channels.cols.actions"),
|
||||
align: "left",
|
||||
nowrap: true,
|
||||
width: "104px",
|
||||
cardLabel: false,
|
||||
render: (c) => (
|
||||
<ActionsCell
|
||||
c={c}
|
||||
isAdmin={isAdmin}
|
||||
canWrite={canWrite}
|
||||
onHide={() => patch.mutate({ id: c.id, body: { hidden: !c.hidden } })}
|
||||
onDeep={() => patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } })}
|
||||
onReset={() => onReset(c)}
|
||||
onUnsubscribe={() => onUnsub(c)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 so a long-titled channel can't stretch the column (table-layout:auto ignores a
|
||||
// cell max-width, but a max-w element INSIDE the cell bounds it — same trick AboutCell uses).
|
||||
// Without this a single long name blows the column ~289→429px and overflows the centered table.
|
||||
render: (c) => (
|
||||
<ChannelLink
|
||||
id={c.id}
|
||||
title={c.title}
|
||||
handle={c.handle}
|
||||
thumbnailUrl={c.thumbnail_url}
|
||||
onView={() => onView(c)}
|
||||
/>
|
||||
<div className="max-w-[18rem]">
|
||||
<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,
|
||||
render: (c) => <AboutCell text={c.description} channelId={c.id} />,
|
||||
},
|
||||
{
|
||||
key: "stored",
|
||||
header: t("channels.cols.stored"),
|
||||
@@ -319,12 +375,25 @@ export default function Channels() {
|
||||
header: t("channels.cols.sync"),
|
||||
nowrap: true,
|
||||
cardLabel: false,
|
||||
filter: {
|
||||
kind: "select",
|
||||
options: [
|
||||
{ value: "fully", label: t("channels.filters.fullySynced") },
|
||||
{ value: "needsFull", label: t("channels.filters.needsFull") },
|
||||
],
|
||||
test: (c, v) => (v === "fully" ? c.recent_synced && c.backfill_done : v === "needsFull" ? !c.backfill_done : true),
|
||||
},
|
||||
render: (c) => <SyncCell c={c} />,
|
||||
},
|
||||
{
|
||||
key: "tags",
|
||||
header: t("channels.cols.tags"),
|
||||
cardLabel: false,
|
||||
filter: {
|
||||
kind: "multi",
|
||||
options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })),
|
||||
test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))),
|
||||
},
|
||||
render: (c) => (
|
||||
<TagsCell
|
||||
c={c}
|
||||
@@ -334,25 +403,6 @@ export default function Channels() {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: t("channels.cols.actions"),
|
||||
align: "right",
|
||||
nowrap: true,
|
||||
width: "104px",
|
||||
cardLabel: false,
|
||||
render: (c) => (
|
||||
<ActionsCell
|
||||
c={c}
|
||||
isAdmin={isAdmin}
|
||||
canWrite={canWrite}
|
||||
onHide={() => patch.mutate({ id: c.id, body: { hidden: !c.hidden } })}
|
||||
onDeep={() => patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } })}
|
||||
onReset={() => onReset(c)}
|
||||
onUnsubscribe={() => onUnsub(c)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Status chips sit in the table's controls row (next to paging) — compact and close to
|
||||
@@ -375,8 +425,120 @@ export default function Channels() {
|
||||
</div>
|
||||
);
|
||||
|
||||
// The catalog-wide stats ride on the right of the tab row (subscribed only), leaving room for
|
||||
// future tabs on the left.
|
||||
const statsBlock = (
|
||||
<div className="flex flex-wrap items-center justify-end gap-x-5 gap-y-1 text-sm text-muted">
|
||||
{s && (
|
||||
<>
|
||||
<Stat label={t("channels.stats.channels")} value={s.channels_total} hint={t("channels.stats.channelsHint")} />
|
||||
<Stat
|
||||
label={t("channels.stats.recentSynced")}
|
||||
value={`${s.channels_recent_synced}/${s.channels_total}`}
|
||||
hint={t("channels.stats.recentSyncedHint")}
|
||||
/>
|
||||
<Stat
|
||||
label={t("channels.stats.fullHistory")}
|
||||
value={`${s.channels_deep_done}/${s.channels_deep_requested}`}
|
||||
hint={t("channels.stats.fullHistoryHint")}
|
||||
/>
|
||||
{s.deep_pending_count > 0 && (
|
||||
<Stat
|
||||
label={t("channels.stats.left")}
|
||||
value={formatEta(s.deep_eta_seconds)}
|
||||
hint={t("channels.stats.leftHint", { count: s.deep_pending_count })}
|
||||
/>
|
||||
)}
|
||||
<Stat label={t("channels.stats.myVideos")} value={s.my_videos.toLocaleString()} hint={t("channels.stats.myVideosHint")} />
|
||||
<Stat
|
||||
label={t("channels.stats.quotaLeft")}
|
||||
value={s.quota_remaining_today.toLocaleString()}
|
||||
hint={t("channels.stats.quotaLeftHint")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Sync + backfill — now share the tags row (right side).
|
||||
const actionButtons = (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Tooltip side="bottom" hint={t("channels.syncSubscriptionsHint")}>
|
||||
<button
|
||||
onClick={() => syncSubs.mutate()}
|
||||
disabled={syncSubs.isPending}
|
||||
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
|
||||
{t("channels.syncSubscriptions")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip side="bottom" hint={t("channels.backfillEverythingHint")}>
|
||||
<button
|
||||
onClick={() => deepAll.mutate()}
|
||||
disabled={deepAll.isPending}
|
||||
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||
>
|
||||
<History className={`w-4 h-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />
|
||||
{t("channels.backfillEverything")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 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";
|
||||
const sortedChannels = isCards ? sortRows(visibleChannels, columns, sort) : visibleChannels;
|
||||
const { paged: pagedCards, pagerProps, resetPage } = useCardPager(
|
||||
sortedChannels,
|
||||
LS.channelCardSize,
|
||||
isCards,
|
||||
);
|
||||
// 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 ["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();
|
||||
}, [statusFilter, search, tagFilter, sort, resetPage]);
|
||||
const cardPager = <Pager {...pagerProps} />;
|
||||
|
||||
const tabs = (
|
||||
<div className="px-4 pt-4 max-w-7xl mx-auto">
|
||||
<div className="px-4 pt-4 max-w-[96rem] mx-auto flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{(["subscribed", "discovery", "blocked"] as ChannelsView[]).map((v) => (
|
||||
<button
|
||||
@@ -392,6 +554,11 @@ export default function Channels() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{view === "subscribed" && statsBlock}
|
||||
{view === "subscribed" && layout === "cards" && sortControl}
|
||||
{hasLayout && layoutSwitcher}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -403,85 +570,11 @@ export default function Channels() {
|
||||
<PageToolbar>
|
||||
{tabs}
|
||||
{view === "subscribed" && (
|
||||
<div className="px-4 pt-3 pb-2 max-w-7xl mx-auto">
|
||||
{/* Per-user sync status + catalog-wide actions on one row (search/tags filtering
|
||||
now lives in the table headers). */}
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap mb-4">
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 text-sm text-muted">
|
||||
{s && (
|
||||
<>
|
||||
<Stat label={t("channels.stats.channels")} value={s.channels_total} hint={t("channels.stats.channelsHint")} />
|
||||
<Stat
|
||||
label={t("channels.stats.recentSynced")}
|
||||
value={`${s.channels_recent_synced}/${s.channels_total}`}
|
||||
hint={t("channels.stats.recentSyncedHint")}
|
||||
/>
|
||||
<Stat
|
||||
label={t("channels.stats.fullHistory")}
|
||||
value={`${s.channels_deep_done}/${s.channels_deep_requested}`}
|
||||
hint={t("channels.stats.fullHistoryHint")}
|
||||
/>
|
||||
{s.deep_pending_count > 0 && (
|
||||
<Stat
|
||||
label={t("channels.stats.left")}
|
||||
value={formatEta(s.deep_eta_seconds)}
|
||||
hint={t("channels.stats.leftHint", { count: s.deep_pending_count })}
|
||||
/>
|
||||
)}
|
||||
<Stat label={t("channels.stats.myVideos")} value={s.my_videos.toLocaleString()} hint={t("channels.stats.myVideosHint")} />
|
||||
<Stat
|
||||
label={t("channels.stats.quotaLeft")}
|
||||
value={s.quota_remaining_today.toLocaleString()}
|
||||
hint={t("channels.stats.quotaLeftHint")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Tooltip
|
||||
side="bottom"
|
||||
hint={t("channels.syncSubscriptionsHint")}
|
||||
>
|
||||
<button
|
||||
onClick={() => syncSubs.mutate()}
|
||||
disabled={syncSubs.isPending}
|
||||
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
|
||||
{t("channels.syncSubscriptions")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
side="bottom"
|
||||
hint={t("channels.backfillEverythingHint")}
|
||||
>
|
||||
<button
|
||||
onClick={() => deepAll.mutate()}
|
||||
disabled={deepAll.isPending}
|
||||
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||
>
|
||||
<History className={`w-4 h-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />
|
||||
{t("channels.backfillEverything")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted mb-4 leading-relaxed">
|
||||
<Trans
|
||||
i18nKey="channels.intro"
|
||||
components={[
|
||||
<b className="text-fg/80" />,
|
||||
<b className="text-fg/80" />,
|
||||
<b className="text-fg/80" />,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
|
||||
{/* The channel-name search now lives in the shared top SearchBar (driven via props). */}
|
||||
|
||||
{/* Your tags — click a chip to filter the table by it (add/rename/delete live in the manager). */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 mb-4">
|
||||
<div className="px-4 pt-2 pb-2 max-w-[96rem] mx-auto">
|
||||
{/* Your tags (left) share one row with the two catalog-wide actions (right); the channel-name
|
||||
search lives in the shared top SearchBar, tag create/delete in the manager. */}
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Tooltip hint={t("channels.tags.yourTagsHint")}>
|
||||
<span className="text-xs uppercase tracking-wide text-muted mr-1 cursor-help underline decoration-dotted decoration-muted/40 underline-offset-4">
|
||||
{t("channels.tags.yourTags")}
|
||||
@@ -522,8 +615,18 @@ export default function Channels() {
|
||||
{t("channels.tags.manage")}
|
||||
</button>
|
||||
</div>
|
||||
{actionButtons}
|
||||
</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" && (
|
||||
<div className="px-4 pb-2 max-w-[96rem] mx-auto flex items-center justify-between gap-3 flex-wrap">
|
||||
{statusChips}
|
||||
{cardPager}
|
||||
</div>
|
||||
)}
|
||||
</PageToolbar>
|
||||
{tagManagerOpen && (
|
||||
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
|
||||
@@ -538,7 +641,7 @@ export default function Channels() {
|
||||
/>
|
||||
) : 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-7xl mx-auto">
|
||||
<div className="px-4 pb-4 pt-3 max-w-[96rem] mx-auto">
|
||||
{(blockedQuery.data?.length ?? 0) === 0 ? (
|
||||
<p className="text-sm text-muted py-8">{t("channels.blocked.empty")}</p>
|
||||
) : (
|
||||
@@ -575,21 +678,36 @@ export default function Channels() {
|
||||
/* 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 className="px-4 pb-4 max-w-7xl mx-auto">
|
||||
<div className="px-4 pb-4 max-w-[96rem] 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). */}
|
||||
(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.isLoading ? (
|
||||
<div className="text-muted py-8">{t("channels.loading")}</div>
|
||||
) : (
|
||||
) : layout === "table" ? (
|
||||
<DataTable
|
||||
rows={visibleChannels}
|
||||
columns={columns}
|
||||
rowKey={(c) => c.id}
|
||||
persistKey={accountKey(LS.channelsTable) ?? undefined}
|
||||
controlsPosition="top"
|
||||
controlsPosition="both"
|
||||
controlsLeading={statusChips}
|
||||
controlsInBand
|
||||
controlsBandClassName="px-4 max-w-7xl mx-auto"
|
||||
controlsBandClassName="px-4 max-w-[96rem] mx-auto"
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
|
||||
emptyText={t("channels.empty")}
|
||||
/>
|
||||
) : (
|
||||
<ChannelLayoutGrid
|
||||
rows={pagedCards}
|
||||
columns={columns}
|
||||
rowKey={(c) => c.id}
|
||||
leadKey="priority"
|
||||
aboutKey="about"
|
||||
actionsKey="actions"
|
||||
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
|
||||
emptyText={t("channels.empty")}
|
||||
/>
|
||||
@@ -692,21 +810,107 @@ function TagsCell({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
// Open the picker upward when there isn't room below (rows near the viewport bottom would
|
||||
// otherwise clip it against the scroll container), as long as there's room above.
|
||||
const [openUp, setOpenUp] = useState(false);
|
||||
// Anchor for the portalled menu. It renders through <Overlay> (i.e. at <body>) rather than
|
||||
// `absolute` inside the cell, because in the CARD layout every VirtualGrid row carries a
|
||||
// `transform` — which makes it a stacking context and traps a z-indexed child, so the menu drew
|
||||
// UNDER the next card. Same hazard, same blessed fix as the players in E3.
|
||||
const [coords, setCoords] = useState<{ left: number; top?: number; bottom?: number } | null>(null);
|
||||
const fade = useScrollFade();
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const popRef = useRef<HTMLDivElement | null>(null);
|
||||
const btnRef = useRef<HTMLButtonElement | null>(null);
|
||||
useDismiss(open, () => setOpen(false), ref);
|
||||
// The menu is no longer a DOM descendant of `ref`, so dismiss has to watch both.
|
||||
useDismiss(open, () => setOpen(false), [ref, popRef]);
|
||||
// Keep the scroll-fade hook's callback ref working alongside our own.
|
||||
const setPopRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
popRef.current = node;
|
||||
fade.ref(node);
|
||||
},
|
||||
[fade.ref],
|
||||
);
|
||||
|
||||
// ONE placement rule, so the position the menu opens at and the one it keeps while scrolling can't
|
||||
// drift apart. `flip` keeps it anchored above the trigger once it has been flipped; the horizontal
|
||||
// clamp uses the MEASURED width (`w-44` is rem — it grows with the text-size slider, so a px guess
|
||||
// reads short at 1.3). Before first paint the menu isn't measurable yet; the layout effect below
|
||||
// re-runs this once it is.
|
||||
const coordsFrom = useCallback((r: DOMRect, flip: boolean) => {
|
||||
const w = popRef.current?.offsetWidth ?? 0;
|
||||
const left =
|
||||
w && r.left + w > window.innerWidth - 8 ? Math.max(8, window.innerWidth - 8 - w) : r.left;
|
||||
return flip
|
||||
? { left, bottom: window.innerHeight - r.top + 4 }
|
||||
: { left, top: r.bottom + 4 };
|
||||
}, []);
|
||||
|
||||
const place = useCallback(() => {
|
||||
const r = btnRef.current?.getBoundingClientRect();
|
||||
if (!r) return false;
|
||||
setCoords(coordsFrom(r, false));
|
||||
return true;
|
||||
}, [coordsFrom]);
|
||||
const toggle = () => {
|
||||
if (!open) {
|
||||
const r = btnRef.current?.getBoundingClientRect();
|
||||
const est = Math.min(userTags.length * 30 + 12, 264); // matches the max-height cap below
|
||||
setOpenUp(!!r && r.bottom + est > window.innerHeight - 8 && r.top - est > 8);
|
||||
}
|
||||
setOpen((o) => !o);
|
||||
if (open) setOpen(false);
|
||||
else if (place()) setOpen(true);
|
||||
};
|
||||
|
||||
// Correct the placement against the MEASURED box: flip above when it would run off the bottom (and
|
||||
// there is room above), and apply the width clamp now that the menu can actually be measured.
|
||||
useLayoutEffect(() => {
|
||||
const pop = popRef.current;
|
||||
const r = btnRef.current?.getBoundingClientRect();
|
||||
if (!open || !pop || !coords || !r) return;
|
||||
const h = pop.offsetHeight; // the width is read inside coordsFrom, off the same node
|
||||
const fitsBelow = r.bottom + 4 + h <= window.innerHeight - 8;
|
||||
const fitsAbove = r.top - 4 - h >= 8;
|
||||
// Decide BOTH ways, every time. Prefer the side it is already on (no flapping while scrolling),
|
||||
// but leave it as soon as that side stops fitting and the other one does — a one-way flip would
|
||||
// keep an above-anchored menu above a row scrolling toward the top and push it off the screen.
|
||||
const above = coords.bottom !== undefined;
|
||||
const flip = above ? fitsAbove || !fitsBelow : !fitsBelow && fitsAbove;
|
||||
const next = coordsFrom(r, flip);
|
||||
if (next.left !== coords.left || next.top !== coords.top || next.bottom !== coords.bottom) {
|
||||
setCoords(next);
|
||||
}
|
||||
}, [open, coords, coordsFrom]);
|
||||
|
||||
// A fixed menu doesn't travel with its row, so FOLLOW the trigger on scroll rather than closing:
|
||||
// in the table the menu used to scroll with the row, and closing on every wheel tick would make
|
||||
// attaching several tags a reopen-per-tag chore. Closes only once the trigger leaves the SCROLLER
|
||||
// (not the window — the page scroller starts below ~230px of fixed chrome, so a row hidden behind
|
||||
// the toolbar is still "on screen" by window coords and the menu would float over the chrome).
|
||||
// Scrolling INSIDE the menu must not move it.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let raf = 0;
|
||||
const reposition = () => {
|
||||
raf = 0;
|
||||
const r = btnRef.current?.getBoundingClientRect();
|
||||
const scroller = document.querySelector("main")?.getBoundingClientRect();
|
||||
const topEdge = scroller ? scroller.top : 0;
|
||||
const bottomEdge = scroller ? scroller.bottom : window.innerHeight;
|
||||
if (!r || r.bottom < topEdge || r.top > bottomEdge) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
// Keep whichever side it is anchored to, so the layout effect has nothing left to correct —
|
||||
// resetting to "below" every frame would make it flip back and forth, two renders per frame.
|
||||
setCoords((cur) => (cur ? coordsFrom(r, cur.bottom !== undefined) : cur));
|
||||
};
|
||||
const onScroll = (e: Event) => {
|
||||
if (popRef.current && e.target instanceof Node && popRef.current.contains(e.target)) return;
|
||||
if (!raf) raf = requestAnimationFrame(reposition); // coalesce a scroll burst into one move
|
||||
};
|
||||
const onResize = () => setOpen(false);
|
||||
window.addEventListener("scroll", onScroll, true);
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
window.removeEventListener("scroll", onScroll, true);
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
}, [open, coordsFrom]);
|
||||
if (userTags.length === 0) return null;
|
||||
// Show only the tags actually attached to this channel; the “+” opens a per-channel
|
||||
// picker to attach/detach (kept the convenient "kirakás" without listing every tag
|
||||
@@ -733,13 +937,12 @@ function TagsCell({
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</button>
|
||||
{open && (
|
||||
{open && coords && (
|
||||
<Overlay>
|
||||
<div
|
||||
ref={fade.ref}
|
||||
style={fade.style}
|
||||
className={`glass-menu absolute left-0 z-chrome w-44 max-h-[264px] overflow-y-auto no-scrollbar rounded-xl p-1.5 animate-[popIn_0.16s_ease] ${
|
||||
openUp ? "bottom-full mb-1" : "top-full mt-1"
|
||||
}`}
|
||||
ref={setPopRef}
|
||||
style={{ ...fade.style, position: "fixed", left: coords.left, top: coords.top, bottom: coords.bottom }}
|
||||
className="glass-menu z-popover w-44 max-h-[264px] overflow-y-auto no-scrollbar rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{userTags.map((tg) => {
|
||||
const on = c.tag_ids.includes(tg.id);
|
||||
@@ -763,6 +966,7 @@ function TagsCell({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Overlay>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,10 +4,13 @@ import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||
import { getAccountRaw, LS, readAccount, setAccountRaw, writeAccount } from "../lib/storage";
|
||||
import { parseChannelLayout, type ChannelLayout } from "../lib/channelLayout";
|
||||
import { parseSortState, type SortState } from "../lib/columnSort";
|
||||
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||
import type { ChannelStatusFilter, ChannelsView } from "./Channels";
|
||||
|
||||
@@ -41,6 +44,8 @@ type ChannelsState = {
|
||||
search: string;
|
||||
statusFilter: ChannelStatusFilter;
|
||||
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;
|
||||
@@ -54,6 +59,8 @@ interface ChannelsActions {
|
||||
setSearch: (q: string) => void;
|
||||
setStatusFilter: (f: ChannelStatusFilter) => 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;
|
||||
@@ -65,14 +72,21 @@ const StateContext = createContext<ChannelsState>({
|
||||
search: "",
|
||||
statusFilter: "all",
|
||||
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.
|
||||
const LayoutContext = createContext<ChannelLayout>("table");
|
||||
const ActionsContext = createContext<ChannelsActions>({
|
||||
setSearch: () => {},
|
||||
setStatusFilter: () => {},
|
||||
setView: () => {},
|
||||
setLayout: () => {},
|
||||
setSort: () => {},
|
||||
focusChannel: () => {},
|
||||
goToFullHistory: () => {},
|
||||
});
|
||||
@@ -80,6 +94,11 @@ const ActionsContext = createContext<ChannelsActions>({
|
||||
export function useChannels(): ChannelsState {
|
||||
return useContext(StateContext);
|
||||
}
|
||||
/** 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(LayoutContext);
|
||||
}
|
||||
export function useChannelsActions(): ChannelsActions {
|
||||
return useContext(ActionsContext);
|
||||
}
|
||||
@@ -95,18 +114,52 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
const [view, setViewState] = useState<ChannelsView>(() =>
|
||||
clampView(getAccountRaw(LS.channelsView)),
|
||||
);
|
||||
const [layout, setLayoutState] = useState<ChannelLayout>(() =>
|
||||
parseChannelLayout(getAccountRaw(LS.channelLayout)),
|
||||
);
|
||||
const [sort, setSortState] = useState<SortState>(() =>
|
||||
parseSortState(readAccount(LS.channelSort, null)),
|
||||
);
|
||||
const [filtersResetToken, setFiltersResetToken] = useState(0);
|
||||
const [focusName, setFocusName] = useState<string | null>(null);
|
||||
const [focusToken, setFocusToken] = useState(0);
|
||||
|
||||
// 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).
|
||||
// Written during render — the sanctioned pattern NavigationProvider uses for `pageRef`.
|
||||
const viewRef = useRef(view);
|
||||
viewRef.current = view;
|
||||
|
||||
const setSearch = useCallback((q: string) => setSearchState(q), []);
|
||||
const setStatusFilter = useCallback((f: ChannelStatusFilter) => {
|
||||
setStatusFilterState(f);
|
||||
setAccountRaw(LS.channelFilter, f);
|
||||
}, []);
|
||||
// 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;
|
||||
setViewState(v);
|
||||
setAccountRaw(LS.channelsView, v);
|
||||
// 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.
|
||||
window.history.pushState({ sfPage: "channels", _chTab: v }, "");
|
||||
}
|
||||
}, []);
|
||||
const setLayout = useCallback((l: ChannelLayout) => {
|
||||
setLayoutState(l);
|
||||
setAccountRaw(LS.channelLayout, l);
|
||||
}, []);
|
||||
const setSort = useCallback((s: SortState) => {
|
||||
setSortState(s);
|
||||
writeAccount(LS.channelSort, s);
|
||||
}, []);
|
||||
|
||||
const focusChannel = useCallback(
|
||||
@@ -130,18 +183,41 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
if (page !== "channels") setFocusName(null);
|
||||
}, [page]);
|
||||
|
||||
// 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
|
||||
const next = clampView(tab as string);
|
||||
setViewState(next);
|
||||
setAccountRaw(LS.channelsView, next);
|
||||
}
|
||||
window.addEventListener("popstate", onPop);
|
||||
return () => window.removeEventListener("popstate", onPop);
|
||||
}, []);
|
||||
|
||||
const state = useMemo<ChannelsState>(
|
||||
() => ({ search, statusFilter, view, filtersResetToken, focusName, focusToken }),
|
||||
[search, statusFilter, view, filtersResetToken, focusName, focusToken],
|
||||
() => ({ search, statusFilter, view, sort, filtersResetToken, focusName, focusToken }),
|
||||
[search, statusFilter, view, sort, filtersResetToken, focusName, focusToken],
|
||||
);
|
||||
const actions = useMemo<ChannelsActions>(
|
||||
() => ({ setSearch, setStatusFilter, setView, focusChannel, goToFullHistory }),
|
||||
[setSearch, setStatusFilter, setView, focusChannel, goToFullHistory],
|
||||
() => ({ setSearch, setStatusFilter, setView, setLayout, setSort, focusChannel, goToFullHistory }),
|
||||
[setSearch, setStatusFilter, setView, setLayout, setSort, focusChannel, goToFullHistory],
|
||||
);
|
||||
|
||||
return (
|
||||
<ActionsContext.Provider value={actions}>
|
||||
<StateContext.Provider value={state}>{children}</StateContext.Provider>
|
||||
<LayoutContext.Provider value={layout}>
|
||||
<StateContext.Provider value={state}>{children}</StateContext.Provider>
|
||||
</LayoutContext.Provider>
|
||||
</ActionsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||
import { type Column } from "./DataTable";
|
||||
import { type SortState } from "../lib/columnSort";
|
||||
|
||||
// A sort control for views without clickable column headers (the channel manager's card layout):
|
||||
// a dropdown of the sortable columns + an asc/desc toggle, mirroring the feed toolbar's sort. It's
|
||||
// driven by the SAME `SortState` the DataTable header uses, so table and cards stay in sync. The
|
||||
// dropdown's options come straight from the columns' `sortable`/`sortValue` metadata — no new list.
|
||||
export default function ColumnSortControl<T>({
|
||||
columns,
|
||||
sort,
|
||||
onChange,
|
||||
label,
|
||||
defaultLabel,
|
||||
ascLabel,
|
||||
descLabel,
|
||||
}: {
|
||||
columns: Column<T>[];
|
||||
sort: SortState;
|
||||
onChange: (next: SortState) => void;
|
||||
/** Accessible name for the dropdown (e.g. "Sort"). */
|
||||
label: string;
|
||||
/** The "no sort / original order" option's text. */
|
||||
defaultLabel: string;
|
||||
ascLabel: string;
|
||||
descLabel: string;
|
||||
}) {
|
||||
const sortable = columns.filter((c) => c.sortable && c.sortValue);
|
||||
// A stored sort can name a column this table no longer offers (renamed, or `sortable` dropped).
|
||||
// `sortRows` already leaves the rows unsorted then, so treat it as no sort here too — otherwise the
|
||||
// select falls back to showing "default" while a direction arrow claims a sort that isn't applied.
|
||||
const active = sortable.some((c) => c.key === sort?.key) ? sort : null;
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<select
|
||||
value={active?.key ?? ""}
|
||||
onChange={(e) => {
|
||||
const key = e.target.value;
|
||||
// Keep the direction when re-picking the same column; a new column starts ascending.
|
||||
onChange(key ? { key, dir: active?.key === key ? active.dir : "asc" } : null);
|
||||
}}
|
||||
aria-label={label}
|
||||
className="bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">{defaultLabel}</option>
|
||||
{sortable.map((c) => (
|
||||
<option key={c.key} value={c.key}>
|
||||
{c.header}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{active && (
|
||||
<button
|
||||
onClick={() => onChange({ key: active.key, dir: active.dir === "asc" ? "desc" : "asc" })}
|
||||
title={active.dir === "asc" ? ascLabel : descLabel}
|
||||
aria-label={active.dir === "asc" ? ascLabel : descLabel}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
|
||||
>
|
||||
{active.dir === "asc" ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ListFilter, X } from "lucide-react";
|
||||
import { ArrowDown, ArrowUp, ListFilter, X } from "lucide-react";
|
||||
import { useDismiss } from "../lib/useDismiss";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { sortRows, cycleSort, type SortState } from "../lib/columnSort";
|
||||
import Pager, { hasPagerContent } from "./Pager";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
|
||||
// A reusable, client-side data table: per-column sort + filter (in the header) + pagination,
|
||||
@@ -50,7 +52,6 @@ export interface Column<T> {
|
||||
cardLabel?: boolean;
|
||||
}
|
||||
|
||||
type SortState = { key: string; dir: "asc" | "desc" } | null;
|
||||
type FilterMap = Record<string, string | string[]>;
|
||||
interface Persisted {
|
||||
sort: SortState;
|
||||
@@ -89,6 +90,8 @@ export default function DataTable<T>({
|
||||
controlsBandClassName,
|
||||
externalFilter,
|
||||
resetFiltersToken,
|
||||
sort: sortProp,
|
||||
onSortChange,
|
||||
}: {
|
||||
rows: T[];
|
||||
columns: Column<T>[];
|
||||
@@ -112,16 +115,24 @@ export default function DataTable<T>({
|
||||
// set" (e.g. the header's "without full history" link), so a stale persisted filter can't
|
||||
// hide the rows the user was just sent to see.
|
||||
resetFiltersToken?: number;
|
||||
// CONTROLLED sort (optional): when `onSortChange` is given, the caller owns the sort — the table
|
||||
// reflects `sort` and reports header clicks instead of keeping its own — so another view of the
|
||||
// same rows (the channel manager's cards) can share one sort. Omit both for the default internal,
|
||||
// persistKey-backed sort.
|
||||
sort?: SortState;
|
||||
onSortChange?: (next: SortState) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const initial = loadPersist(persistKey);
|
||||
const [sort, setSort] = useState<SortState>(initial.sort);
|
||||
// Sort is controlled when the caller passes onSortChange (it then owns/persists it); otherwise
|
||||
// the table keeps its own, seeded from and saved to persistKey.
|
||||
const controlledSort = onSortChange !== undefined;
|
||||
const [internalSort, setInternalSort] = useState<SortState>(initial.sort);
|
||||
const sort = controlledSort ? sortProp ?? null : internalSort;
|
||||
const [filters, setFilters] = useState<FilterMap>(initial.filters);
|
||||
const [page, setPage] = useState(initial.page);
|
||||
const [size, setSize] = useState(initial.size ?? pageSize);
|
||||
const [openFilter, setOpenFilter] = useState<string | null>(null);
|
||||
// Local text for the editable "jump to page" box (committed on Enter/blur).
|
||||
const [pageInput, setPageInput] = useState("1");
|
||||
const popRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -161,19 +172,7 @@ export default function DataTable<T>({
|
||||
})
|
||||
);
|
||||
|
||||
const sortCol = sort ? columns.find((c) => c.key === sort.key) : undefined;
|
||||
const sorted =
|
||||
sort && sortCol?.sortValue
|
||||
? [...filtered].sort((a, b) => {
|
||||
const av = sortCol.sortValue!(a);
|
||||
const bv = sortCol.sortValue!(b);
|
||||
const cmp =
|
||||
typeof av === "number" && typeof bv === "number"
|
||||
? av - bv
|
||||
: String(av).localeCompare(String(bv));
|
||||
return sort.dir === "asc" ? cmp : -cmp;
|
||||
})
|
||||
: filtered;
|
||||
const sorted = sortRows(filtered, columns, sort);
|
||||
|
||||
// size <= 0 means "All" — one page with every row.
|
||||
const allRows = size <= 0;
|
||||
@@ -181,17 +180,10 @@ export default function DataTable<T>({
|
||||
const safePage = Math.min(page, totalPages - 1);
|
||||
const paged = allRows ? sorted : sorted.slice(safePage * size, safePage * size + size);
|
||||
|
||||
useEffect(() => setPageInput(String(safePage + 1)), [safePage]);
|
||||
function commitPageInput() {
|
||||
const n = parseInt(pageInput, 10);
|
||||
if (!isNaN(n)) setPage(Math.min(totalPages - 1, Math.max(0, n - 1)));
|
||||
else setPageInput(String(safePage + 1));
|
||||
}
|
||||
|
||||
function toggleSort(key: string) {
|
||||
setSort((s) =>
|
||||
!s || s.key !== key ? { key, dir: "asc" } : s.dir === "asc" ? { key, dir: "desc" } : null
|
||||
);
|
||||
const next = cycleSort(sort, key);
|
||||
if (controlledSort) onSortChange!(next);
|
||||
else setInternalSort(next);
|
||||
setPage(0);
|
||||
}
|
||||
function applyFilter(key: string, value: string | string[]) {
|
||||
@@ -288,70 +280,40 @@ export default function DataTable<T>({
|
||||
);
|
||||
}
|
||||
|
||||
// The pager + rows-per-page cluster on its own, so it can be rendered a second time at the bottom
|
||||
// (controlsPosition "both") WITHOUT repeating the leading controls (e.g. the status chips) — those
|
||||
// are header controls and belong only at the top.
|
||||
// Stays NULLABLE: the wrappers below decide whether to render a controls row at all, and an always
|
||||
// -truthy element would leave an empty `my-3` band (and an empty in-band PageToolbar) on a table
|
||||
// with nothing to page. The emptiness rule itself lives in Pager, so the two can't drift apart.
|
||||
const pagerNode = hasPagerContent(sorted.length, size) ? (
|
||||
<Pager
|
||||
page={safePage}
|
||||
size={size}
|
||||
total={sorted.length}
|
||||
pageSizeOptions={pageSizeOptions}
|
||||
onPageChange={setPage}
|
||||
onSizeChange={(s) => {
|
||||
setSize(s);
|
||||
setPage(0);
|
||||
}}
|
||||
sizeLabel={t("datatable.rowsPerPage")}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const controls =
|
||||
controlsLeading != null || sorted.length > 0 ? (
|
||||
controlsLeading != null || pagerNode ? (
|
||||
<div className="flex items-center justify-between gap-3 my-3 text-sm flex-wrap">
|
||||
<div className="flex items-center gap-3 flex-wrap">{controlsLeading}</div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={safePage === 0}
|
||||
className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
{t("datatable.pager.prev")}
|
||||
</button>
|
||||
<span className="text-muted flex items-center gap-1.5">
|
||||
{t("datatable.pager.pageLabel")}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={totalPages}
|
||||
value={pageInput}
|
||||
onChange={(e) => setPageInput(e.target.value)}
|
||||
onBlur={commitPageInput}
|
||||
onKeyDown={(e) => e.key === "Enter" && commitPageInput()}
|
||||
aria-label={t("datatable.pager.pageLabel")}
|
||||
className="w-14 bg-card border border-border rounded-md px-1.5 py-1 text-xs text-center tabular-nums outline-none focus:border-accent"
|
||||
/>
|
||||
{t("datatable.pager.ofTotal", { total: totalPages })}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={safePage >= totalPages - 1}
|
||||
className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition"
|
||||
>
|
||||
{t("datatable.pager.next")}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{sorted.length > 0 && (
|
||||
<label className="flex items-center gap-2 text-muted">
|
||||
{t("datatable.rowsPerPage")}
|
||||
<select
|
||||
value={size}
|
||||
onChange={(e) => {
|
||||
setSize(Number(e.target.value));
|
||||
setPage(0);
|
||||
}}
|
||||
className="bg-card border border-border rounded-md px-1.5 py-1 text-xs outline-none focus:border-accent"
|
||||
>
|
||||
{pageSizeOptions.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
<option value={0}>{t("datatable.all")}</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{pagerNode}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
// Bottom row for "both": just the pager, right-aligned (the leading controls stay at the top).
|
||||
const bottomControls = pagerNode ? (
|
||||
<div className="flex items-center justify-end gap-3 my-3 text-sm flex-wrap">{pagerNode}</div>
|
||||
) : null;
|
||||
|
||||
const topControls =
|
||||
controlsInBand && controls ? (
|
||||
<PageToolbar>
|
||||
@@ -476,7 +438,8 @@ export default function DataTable<T>({
|
||||
<div className="text-muted py-8 text-center text-sm">{emptyText ?? t("datatable.empty")}</div>
|
||||
)}
|
||||
|
||||
{(controlsPosition === "bottom" || controlsPosition === "both") && controls}
|
||||
{controlsPosition === "bottom" && controls}
|
||||
{controlsPosition === "both" && bottomControls}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
// The Prev / page-input / Next + "N per page" cluster, lifted out of DataTable so a non-table view
|
||||
// (the channel manager's card layout) can paginate the same way. Controlled: the caller owns page +
|
||||
// size and slices its own rows; this only renders the controls and reports changes. `sizeLabel` lets
|
||||
// the caller say "Rows per page" or "Cards per page". Returns null when there's nothing to page.
|
||||
|
||||
/** How many pages `total` rows make at `size` (`size <= 0` = "All" = one page). */
|
||||
function pageCount(total: number, size: number): number {
|
||||
return size <= 0 ? 1 : Math.max(1, Math.ceil(total / size));
|
||||
}
|
||||
|
||||
/** Whether a pager would render anything — the ONE owner of that rule. Callers that wrap the pager
|
||||
* in their own chrome gate on this instead of re-deriving it: a drifted second copy is exactly what
|
||||
* left empty control bands behind on tables with nothing to page. */
|
||||
export function hasPagerContent(total: number, size: number): boolean {
|
||||
return pageCount(total, size) > 1 || total > 0;
|
||||
}
|
||||
export default function Pager({
|
||||
page,
|
||||
size,
|
||||
total,
|
||||
pageSizeOptions,
|
||||
onPageChange,
|
||||
onSizeChange,
|
||||
sizeLabel,
|
||||
}: {
|
||||
/** Current page (0-based). */
|
||||
page: number;
|
||||
/** Page size; `<= 0` means "All" (one page). */
|
||||
size: number;
|
||||
/** Total item count across all pages. */
|
||||
total: number;
|
||||
pageSizeOptions: number[];
|
||||
onPageChange: (page: number) => void;
|
||||
onSizeChange: (size: number) => void;
|
||||
sizeLabel: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const totalPages = pageCount(total, size);
|
||||
const safePage = Math.min(page, totalPages - 1);
|
||||
|
||||
// Local text for the editable "jump to page" box (committed on Enter/blur).
|
||||
const [pageInput, setPageInput] = useState(String(safePage + 1));
|
||||
useEffect(() => setPageInput(String(safePage + 1)), [safePage]);
|
||||
|
||||
// Turning the page — or resizing it, which re-slices from a different first item — swaps the whole
|
||||
// list, so put the reader at its top; landing mid-list in a page you haven't seen is disorienting.
|
||||
// The page scroller is the app's <main> (PageScroller documents itself as the ONE scroller; the
|
||||
// same implicit contract BackToTop and VirtualGrid's getScrollParent already rely on).
|
||||
function scrollListToTop() {
|
||||
document.querySelector("main")?.scrollTo({ top: 0 });
|
||||
}
|
||||
function goToPage(p: number) {
|
||||
onPageChange(p);
|
||||
scrollListToTop();
|
||||
}
|
||||
function commitPageInput() {
|
||||
const n = parseInt(pageInput, 10);
|
||||
if (!isNaN(n)) goToPage(Math.min(totalPages - 1, Math.max(0, n - 1)));
|
||||
else setPageInput(String(safePage + 1));
|
||||
}
|
||||
|
||||
if (!hasPagerContent(total, size)) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => goToPage(Math.max(0, safePage - 1))}
|
||||
disabled={safePage === 0}
|
||||
className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
{t("datatable.pager.prev")}
|
||||
</button>
|
||||
<span className="text-muted flex items-center gap-1.5">
|
||||
{t("datatable.pager.pageLabel")}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={totalPages}
|
||||
value={pageInput}
|
||||
onChange={(e) => setPageInput(e.target.value)}
|
||||
onBlur={commitPageInput}
|
||||
onKeyDown={(e) => e.key === "Enter" && commitPageInput()}
|
||||
aria-label={t("datatable.pager.pageLabel")}
|
||||
className="w-14 bg-card border border-border rounded-md px-1.5 py-1 text-xs text-center tabular-nums outline-none focus:border-accent"
|
||||
/>
|
||||
{t("datatable.pager.ofTotal", { total: totalPages })}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => goToPage(Math.min(totalPages - 1, safePage + 1))}
|
||||
disabled={safePage >= totalPages - 1}
|
||||
className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition"
|
||||
>
|
||||
{t("datatable.pager.next")}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{total > 0 && (
|
||||
<label className="flex items-center gap-2 text-muted">
|
||||
{sizeLabel}
|
||||
<select
|
||||
value={size}
|
||||
onChange={(e) => {
|
||||
onSizeChange(Number(e.target.value));
|
||||
scrollListToTop();
|
||||
}}
|
||||
className="bg-card border border-border rounded-md px-1.5 py-1 text-xs outline-none focus:border-accent"
|
||||
>
|
||||
{pageSizeOptions.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
<option value={0}>{t("datatable.all")}</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,8 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import type { Video } from "../lib/api";
|
||||
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
|
||||
import VideoCard from "./VideoCard";
|
||||
import VirtualGrid from "./VirtualGrid";
|
||||
|
||||
// Column sizing mirrors the CSS the feed used before virtualization
|
||||
// (grid-cols-[repeat(auto-fill,minmax(260px,1fr))] with a gap-4 / 1rem gutter). The per-view
|
||||
// minimum column width lives with the rest of the view matrix in lib/feedView.ts.
|
||||
const GRID_GAP = 16;
|
||||
const LIST_GAP = 4;
|
||||
// Estimated row heights before measurement; real heights are measured per-row so these only affect
|
||||
// the very first paint and the scrollbar's initial guess. Taken from measuring each mode in the
|
||||
// browser rather than guessed — a bad estimate makes the scrollbar jump as you scroll in.
|
||||
@@ -24,35 +18,6 @@ const ROW_EST: Record<FeedView, number> = {
|
||||
tiles: 132,
|
||||
};
|
||||
|
||||
/** Columns that fit `width`, or 1 for the single-column views. */
|
||||
function columnsFor(view: FeedView, width: number): number {
|
||||
const { minCol } = FEED_VIEW_SPEC[view];
|
||||
if (minCol == null) return 1;
|
||||
return Math.max(1, Math.floor((width + GRID_GAP) / (minCol + GRID_GAP)));
|
||||
}
|
||||
// Start fetching the next page this many rows before the end scrolls into view
|
||||
// (keeps the old IntersectionObserver's generous prefetch feel).
|
||||
const PREFETCH_ROWS = 4;
|
||||
|
||||
function chunk<T>(arr: T[], size: number): T[][] {
|
||||
if (size <= 1) return arr.map((x) => [x]);
|
||||
const out: T[][] = [];
|
||||
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
|
||||
return out;
|
||||
}
|
||||
|
||||
// The feed scrolls inside the app's <main overflow-y-auto>, not the window, so the
|
||||
// virtualizer needs that element. Walk up to the nearest scrollable ancestor.
|
||||
function getScrollParent(node: HTMLElement | null): HTMLElement {
|
||||
let el = node?.parentElement ?? null;
|
||||
while (el) {
|
||||
const oy = getComputedStyle(el).overflowY;
|
||||
if (oy === "auto" || oy === "scroll") return el;
|
||||
el = el.parentElement;
|
||||
}
|
||||
return document.documentElement;
|
||||
}
|
||||
|
||||
export interface VirtualFeedProps {
|
||||
items: Video[];
|
||||
view: FeedView;
|
||||
@@ -65,6 +30,9 @@ export interface VirtualFeedProps {
|
||||
fetchNextPage: () => void;
|
||||
}
|
||||
|
||||
// The feed's videos on the shared VirtualGrid engine — this wrapper only maps a FeedView to the
|
||||
// grid's layout spec and renders a VideoCard per item. All the virtualization mechanics (measure,
|
||||
// chunk, place, infinite-scroll) live in VirtualGrid.
|
||||
export default function VirtualFeed({
|
||||
items,
|
||||
view,
|
||||
@@ -76,123 +44,31 @@ export default function VirtualFeed({
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
}: VirtualFeedProps) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const [scrollEl, setScrollEl] = useState<HTMLElement | null>(null);
|
||||
const { minCol, maxCol } = FEED_VIEW_SPEC[view];
|
||||
const singleCol = minCol == null;
|
||||
const [colCount, setColCount] = useState(singleCol ? 1 : 4);
|
||||
// Distance from the scroll element's content top to where this list starts (the
|
||||
// feed toolbar sits above it inside the same scroll area).
|
||||
const [scrollMargin, setScrollMargin] = useState(0);
|
||||
// How wide ONE row/card actually renders. The one-line row drops columns as this shrinks, and it
|
||||
// has to be the real width, not the viewport: the nav rail (56 slim / ~230 pinned) and the filter
|
||||
// panel (~80 tab / ~256 pinned) move it by ~430px between them. A viewport breakpoint got this
|
||||
// wrong — at 1280 with both pinned every column still showed while the row had 762px for 766px of
|
||||
// them, which squeezed the title to nothing. This is measured here anyway, for colCount.
|
||||
const [itemWidth, setItemWidth] = useState(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (listRef.current) setScrollEl(getScrollParent(listRef.current));
|
||||
}, []);
|
||||
|
||||
// Re-measure the column count and start offset on container/scroll-area resize.
|
||||
useLayoutEffect(() => {
|
||||
const node = listRef.current;
|
||||
if (!node) return;
|
||||
const measure = () => {
|
||||
if (scrollEl) {
|
||||
const m =
|
||||
node.getBoundingClientRect().top -
|
||||
scrollEl.getBoundingClientRect().top +
|
||||
scrollEl.scrollTop;
|
||||
setScrollMargin(m);
|
||||
}
|
||||
const w = node.clientWidth;
|
||||
const cols = columnsFor(view, w);
|
||||
setColCount(cols);
|
||||
const { minCol: min, maxCol: max } = FEED_VIEW_SPEC[view];
|
||||
setItemWidth(
|
||||
min == null ? Math.min(w, max ?? w) : Math.floor((w - GRID_GAP * (cols - 1)) / cols)
|
||||
);
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(node);
|
||||
if (scrollEl) ro.observe(scrollEl);
|
||||
return () => ro.disconnect();
|
||||
}, [scrollEl, view]);
|
||||
|
||||
const rows = useMemo(() => chunk(items, colCount), [items, colCount]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => scrollEl,
|
||||
estimateSize: () => ROW_EST[view],
|
||||
overscan: 4,
|
||||
gap: singleCol ? LIST_GAP : GRID_GAP,
|
||||
scrollMargin,
|
||||
});
|
||||
|
||||
const virtualRows = virtualizer.getVirtualItems();
|
||||
|
||||
// Infinite-scroll trigger: once the last rendered row is within PREFETCH_ROWS of
|
||||
// the end, pull the next keyset page.
|
||||
useEffect(() => {
|
||||
const last = virtualRows[virtualRows.length - 1];
|
||||
if (!last) return;
|
||||
if (last.index >= rows.length - 1 - PREFETCH_ROWS && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [virtualRows, rows.length, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
const { minCol, maxCol, thumb } = FEED_VIEW_SPEC[view];
|
||||
return (
|
||||
<div ref={listRef} style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
||||
{virtualRows.map((vr) => {
|
||||
const row = rows[vr.index];
|
||||
if (!row) return null;
|
||||
return (
|
||||
<div
|
||||
key={vr.key}
|
||||
data-index={vr.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${vr.start - virtualizer.options.scrollMargin}px)`,
|
||||
}}
|
||||
>
|
||||
{/* One branch for both shapes: the single-column views just chunk to colCount 1 and
|
||||
get their reading-width cap instead of grid columns. */}
|
||||
<div
|
||||
className={singleCol ? "mx-auto pb-1" : "grid gap-4"}
|
||||
style={
|
||||
singleCol
|
||||
? { maxWidth: maxCol ?? undefined }
|
||||
: { gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))` }
|
||||
}
|
||||
>
|
||||
{row.map((v) => (
|
||||
<VideoCard
|
||||
key={v.id}
|
||||
video={v}
|
||||
view={view}
|
||||
// Only the one-line row reads its own width (it stands columns down as it
|
||||
// narrows). Everything else takes a stable 0, so a resize — which changes
|
||||
// itemWidth every frame — doesn't blow past VideoCard's memo for views that
|
||||
// wouldn't do anything with it.
|
||||
width={FEED_VIEW_SPEC[view].thumb ? 0 : itemWidth}
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onResetState={onResetState}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<VirtualGrid
|
||||
items={items}
|
||||
getKey={(v) => v.id}
|
||||
minCol={minCol}
|
||||
maxCol={maxCol}
|
||||
rowEst={ROW_EST[view]}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
renderItem={(v, itemWidth) => (
|
||||
<VideoCard
|
||||
video={v}
|
||||
view={view}
|
||||
// Only the one-line row (thumb:false) reads its own width — it stands columns down as it
|
||||
// narrows. Everything else takes a stable 0, so a resize (which changes itemWidth every
|
||||
// frame) doesn't blow past VideoCard's memo for views that wouldn't do anything with it.
|
||||
width={thumb ? 0 : itemWidth}
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onResetState={onResetState}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
// The virtualization engine, lifted out of VirtualFeed so any long client- or server-side list can
|
||||
// reuse it (the feed's video grid, the channel manager's card/row views). It owns ONLY the layout
|
||||
// mechanics — measure the container, derive the column count, chunk into rows, virtualize, and place
|
||||
// each row absolutely — and knows nothing about what an item is. The caller supplies `renderItem`.
|
||||
//
|
||||
// ⚠️ Each row is placed with `position:absolute` + `transform` (below), which makes EVERY ROW ITS OWN
|
||||
// STACKING CONTEXT. A z-indexed child therefore cannot escape its row: a menu or popover rendered
|
||||
// inside an item draws UNDER the next row, whatever z-index it carries — the channel manager's tag
|
||||
// picker hit exactly this. Anything floating that an item renders must portal out (see Overlay).
|
||||
//
|
||||
// Column sizing mirrors the CSS the feed used before virtualization
|
||||
// (grid-cols-[repeat(auto-fill,minmax(260px,1fr))] with a gap-4 / 1rem gutter).
|
||||
const GRID_GAP = 16;
|
||||
const LIST_GAP = 4;
|
||||
// Start fetching the next page this many rows before the end scrolls into view
|
||||
// (keeps the old IntersectionObserver's generous prefetch feel).
|
||||
const PREFETCH_ROWS = 4;
|
||||
|
||||
/** Columns that fit `width`, or 1 for the single-column layout (`minCol == null`). */
|
||||
function columnsFor(minCol: number | null, width: number): number {
|
||||
if (minCol == null) return 1;
|
||||
return Math.max(1, Math.floor((width + GRID_GAP) / (minCol + GRID_GAP)));
|
||||
}
|
||||
|
||||
function chunk<T>(arr: T[], size: number): T[][] {
|
||||
if (size <= 1) return arr.map((x) => [x]);
|
||||
const out: T[][] = [];
|
||||
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
|
||||
return out;
|
||||
}
|
||||
|
||||
// The list scrolls inside an <main overflow-y-auto> (the page scroller), not the window, so the
|
||||
// virtualizer needs that element. Walk up to the nearest scrollable ancestor.
|
||||
function getScrollParent(node: HTMLElement | null): HTMLElement {
|
||||
let el = node?.parentElement ?? null;
|
||||
while (el) {
|
||||
const oy = getComputedStyle(el).overflowY;
|
||||
if (oy === "auto" || oy === "scroll") return el;
|
||||
el = el.parentElement;
|
||||
}
|
||||
return document.documentElement;
|
||||
}
|
||||
|
||||
export interface VirtualGridProps<T> {
|
||||
items: T[];
|
||||
getKey: (item: T) => string;
|
||||
/** Narrowest grid column in px, or null for ONE full-width column. The column count is derived
|
||||
* from this and the container width, so every multi-column layout reflows for free. */
|
||||
minCol: number | null;
|
||||
/** Single-column layout only: the reading-width cap in px (null = fill). */
|
||||
maxCol?: number | null;
|
||||
/** Estimated row height before measurement; real heights are measured per-row, so this only
|
||||
* affects the very first paint and the scrollbar's initial guess. Measure it, don't guess — a
|
||||
* bad estimate makes the scrollbar jump as you scroll in. */
|
||||
rowEst: number;
|
||||
/** Renders one item. `itemWidth` is the measured px width of one cell — for width-derived
|
||||
* layouts (e.g. a one-line row that drops columns as it narrows); ignore it otherwise, and to
|
||||
* protect a memo'd item from re-rendering on every resize frame, pass a stable value down. */
|
||||
renderItem: (item: T, itemWidth: number) => ReactNode;
|
||||
/** Infinite scroll — omit all three for a finite client-side list. */
|
||||
hasNextPage?: boolean;
|
||||
isFetchingNextPage?: boolean;
|
||||
fetchNextPage?: () => void;
|
||||
}
|
||||
|
||||
export default function VirtualGrid<T>({
|
||||
items,
|
||||
getKey,
|
||||
minCol,
|
||||
maxCol = null,
|
||||
rowEst,
|
||||
renderItem,
|
||||
hasNextPage = false,
|
||||
isFetchingNextPage = false,
|
||||
fetchNextPage,
|
||||
}: VirtualGridProps<T>) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const [scrollEl, setScrollEl] = useState<HTMLElement | null>(null);
|
||||
const singleCol = minCol == null;
|
||||
const [colCount, setColCount] = useState(singleCol ? 1 : 4);
|
||||
// Distance from the scroll element's content top to where this list starts (a toolbar sits above
|
||||
// it inside the same scroll area).
|
||||
const [scrollMargin, setScrollMargin] = useState(0);
|
||||
// How wide ONE row/card actually renders. It has to be the real container width, not the
|
||||
// viewport: side rails/panels move it independently of the window, and a viewport breakpoint got
|
||||
// this wrong before. This is measured here anyway, for colCount.
|
||||
const [itemWidth, setItemWidth] = useState(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (listRef.current) setScrollEl(getScrollParent(listRef.current));
|
||||
}, []);
|
||||
|
||||
// Re-measure the column count and start offset on container/scroll-area resize.
|
||||
useLayoutEffect(() => {
|
||||
const node = listRef.current;
|
||||
if (!node) return;
|
||||
const measure = () => {
|
||||
if (scrollEl) {
|
||||
const m =
|
||||
node.getBoundingClientRect().top -
|
||||
scrollEl.getBoundingClientRect().top +
|
||||
scrollEl.scrollTop;
|
||||
setScrollMargin(m);
|
||||
}
|
||||
const w = node.clientWidth;
|
||||
const cols = columnsFor(minCol, w);
|
||||
setColCount(cols);
|
||||
setItemWidth(
|
||||
minCol == null ? Math.min(w, maxCol ?? w) : Math.floor((w - GRID_GAP * (cols - 1)) / cols)
|
||||
);
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(node);
|
||||
if (scrollEl) ro.observe(scrollEl);
|
||||
return () => ro.disconnect();
|
||||
}, [scrollEl, minCol, maxCol]);
|
||||
|
||||
const rows = useMemo(() => chunk(items, colCount), [items, colCount]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => scrollEl,
|
||||
estimateSize: () => rowEst,
|
||||
overscan: 4,
|
||||
gap: singleCol ? LIST_GAP : GRID_GAP,
|
||||
scrollMargin,
|
||||
});
|
||||
|
||||
const virtualRows = virtualizer.getVirtualItems();
|
||||
|
||||
// Infinite-scroll trigger: once the last rendered row is within PREFETCH_ROWS of the end, pull
|
||||
// the next page. A no-op for finite lists (no fetchNextPage / hasNextPage false).
|
||||
useEffect(() => {
|
||||
if (!fetchNextPage) return;
|
||||
const last = virtualRows[virtualRows.length - 1];
|
||||
if (!last) return;
|
||||
if (last.index >= rows.length - 1 - PREFETCH_ROWS && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [virtualRows, rows.length, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
return (
|
||||
<div ref={listRef} style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
||||
{virtualRows.map((vr) => {
|
||||
const row = rows[vr.index];
|
||||
if (!row) return null;
|
||||
return (
|
||||
<div
|
||||
key={vr.key}
|
||||
data-index={vr.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${vr.start - virtualizer.options.scrollMargin}px)`,
|
||||
}}
|
||||
>
|
||||
{/* One branch for both shapes: the single-column layout chunks to colCount 1 and gets
|
||||
its reading-width cap instead of grid columns. */}
|
||||
<div
|
||||
className={singleCol ? "mx-auto pb-1" : "grid gap-4"}
|
||||
style={
|
||||
singleCol
|
||||
? { maxWidth: maxCol ?? undefined }
|
||||
: { gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))` }
|
||||
}
|
||||
>
|
||||
{/* A transparent Fragment carries React's key so the rendered item stays the DIRECT
|
||||
grid/column child — some cards rely on that (e.g. `h-full` stretch, actions pinned
|
||||
to the bottom edge), which a wrapper element would silently break. */}
|
||||
{row.map((item) => (
|
||||
<Fragment key={getKey(item)}>{renderItem(item, itemWidth)}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"intro": "Set a channel's <0>priority</0> to push its videos up when you sort by “Channel priority”, attach your own <1>tags</1> to filter the feed, or <2>hide</2> a channel to drop it from the feed without unsubscribing.",
|
||||
"syncSubscriptions": "Read subscriptions from YouTube",
|
||||
"syncSubscriptionsHint": "Re-import your subscription list from YouTube — adds channels you've newly followed and drops ones you've unfollowed. The videos themselves keep syncing automatically in the background; this does not re-fetch them.",
|
||||
"backfillEverything": "Backfill everything",
|
||||
@@ -9,6 +8,18 @@
|
||||
"discovery": "Discover from playlists",
|
||||
"blocked": "Blocked"
|
||||
},
|
||||
"layoutLabel": "Layout",
|
||||
"layout": {
|
||||
"table": "Table",
|
||||
"cards": "Cards"
|
||||
},
|
||||
"sort": {
|
||||
"label": "Sort",
|
||||
"default": "Default order",
|
||||
"asc": "Ascending",
|
||||
"desc": "Descending"
|
||||
},
|
||||
"cardsPerPage": "Cards per page",
|
||||
"discovery": {
|
||||
"intro": "Channels that appear in your playlists but that you don't subscribe to. Subscribe to follow them — their new uploads will start showing in your feed (subscribing costs a little YouTube quota; existing videos aren't re-fetched).",
|
||||
"loading": "Finding channels…",
|
||||
@@ -47,6 +58,7 @@
|
||||
"cols": {
|
||||
"priority": "Prio",
|
||||
"channel": "Channel",
|
||||
"about": "About",
|
||||
"stored": "Stored",
|
||||
"subs": "Subscribers",
|
||||
"lastUpload": "Last upload",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"intro": "Állíts be egy csatornának <0>prioritást</0>, hogy a videói előrébb kerüljenek, amikor „Csatorna prioritás” szerint rendezel, csatolj saját <1>címkéket</1> a hírfolyam szűréséhez, vagy <2>rejts el</2> egy csatornát, hogy leiratkozás nélkül kivedd a hírfolyamból.",
|
||||
"syncSubscriptions": "Feliratkozások beolvasása a YouTube-ról",
|
||||
"syncSubscriptionsHint": "Újraimportálja a feliratkozási listádat a YouTube-ról — hozzáadja az újonnan követett csatornákat, és eltávolítja azokat, amelyekről leiratkoztál. Maguk a videók a háttérben automatikusan tovább szinkronizálódnak; ez nem tölti le őket újra.",
|
||||
"backfillEverything": "Minden letöltése",
|
||||
@@ -9,6 +8,18 @@
|
||||
"discovery": "Felfedezés a lejátszási listákból",
|
||||
"blocked": "Letiltott"
|
||||
},
|
||||
"layoutLabel": "Nézet",
|
||||
"layout": {
|
||||
"table": "Táblázat",
|
||||
"cards": "Kártya"
|
||||
},
|
||||
"sort": {
|
||||
"label": "Rendezés",
|
||||
"default": "Alap sorrend",
|
||||
"asc": "Növekvő",
|
||||
"desc": "Csökkenő"
|
||||
},
|
||||
"cardsPerPage": "Kártya / oldal",
|
||||
"discovery": {
|
||||
"intro": "Csatornák, amelyek megjelennek a lejátszási listáidban, de még nem iratkoztál fel rájuk. Iratkozz fel, hogy kövesd őket — az új feltöltéseik megjelennek a hírfolyamodban (a feliratkozás kevés YouTube-kvótát használ; a meglévő videókat nem tölti le újra).",
|
||||
"loading": "Csatornák keresése…",
|
||||
@@ -47,6 +58,7 @@
|
||||
"cols": {
|
||||
"priority": "Prio",
|
||||
"channel": "Csatorna",
|
||||
"about": "Leírás",
|
||||
"stored": "Tárolt",
|
||||
"subs": "Feliratkozók",
|
||||
"lastUpload": "Utolsó feltöltés",
|
||||
|
||||
@@ -510,6 +510,7 @@ export interface ManagedChannel {
|
||||
thumbnail_url: string | null;
|
||||
subscriber_count: number | null;
|
||||
video_count: number | null;
|
||||
description: string | null;
|
||||
stored_videos: number;
|
||||
last_video_at: string | null;
|
||||
total_duration_seconds: number;
|
||||
@@ -535,6 +536,7 @@ export interface DiscoveredChannel {
|
||||
thumbnail_url: string | null;
|
||||
subscriber_count: number | null;
|
||||
video_count: number | null;
|
||||
description: string | null;
|
||||
playlist_video_count: number; // how many of the user's playlist videos are from here
|
||||
playlist_count: number; // across how many of their playlists
|
||||
details_synced: boolean;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// The channel manager's layout vocabulary (E4 S3). Named `ChannelLayout` — NOT `ChannelView` — on
|
||||
// purpose: the manager already has a `ChannelsView` (the subscribed/discovery/blocked TAB), and a
|
||||
// one-letter-apart type would be a footgun. This is the table-vs-card density switch, riding the
|
||||
// same generic `ViewSwitcher` as the feed. Two layouts: the DataTable, and a virtualized card grid.
|
||||
// (A `rows` variant existed briefly but was retired — it barely differed from the table.)
|
||||
export type ChannelLayout = "table" | "cards";
|
||||
|
||||
export const CHANNEL_LAYOUTS: readonly ChannelLayout[] = ["table", "cards"] as const;
|
||||
|
||||
const CHANNEL_LAYOUT_DEFAULT: ChannelLayout = "table";
|
||||
|
||||
/** The `cards` layout's grid spec, consumed by VirtualGrid. `table` has no entry — it keeps the
|
||||
* numbered-pager DataTable, not the virtualized grid. */
|
||||
export interface ChannelLayoutSpec {
|
||||
/** Narrowest grid column in px. */
|
||||
minCol: number;
|
||||
/** First-paint row-height estimate; real heights are measured per-row after. Measured in the
|
||||
* browser (channel heading + About line + meta + tags + actions), not guessed. */
|
||||
rowEst: number;
|
||||
}
|
||||
|
||||
export const CHANNEL_LAYOUT_SPEC: Record<Exclude<ChannelLayout, "table">, ChannelLayoutSpec> = {
|
||||
cards: { minCol: 320, rowEst: 232 },
|
||||
};
|
||||
|
||||
/** Coerce a stored value to a layout; anything unknown (corrupt, a rolled-back future layout, or the
|
||||
* retired "rows") falls back to the default rather than rendering nothing. */
|
||||
export function parseChannelLayout(raw: unknown): ChannelLayout {
|
||||
return (CHANNEL_LAYOUTS as readonly string[]).includes(raw as string)
|
||||
? (raw as ChannelLayout)
|
||||
: CHANNEL_LAYOUT_DEFAULT;
|
||||
}
|
||||
|
||||
/** Cards-per-page choices for the card layout's pager (0 = "All"), shared by both manager tabs. */
|
||||
export const CARD_PAGE_SIZES = [10, 25, 50, 100];
|
||||
const CARD_SIZE_DEFAULT = 25;
|
||||
|
||||
/** Coerce a stored cards-per-page value to one the pager actually OFFERS (the sizes above, or 0 =
|
||||
* "All"). Clamped against the allowed set — like `clampView`/`clampStatus` — not merely type-checked:
|
||||
* a corrupt entry would make the page count NaN and slice the grid down to nothing, and an
|
||||
* off-menu number (a hand-edited 7, or an option a future build dropped) would page by a value the
|
||||
* `<select>` cannot show, so the control and the behaviour would silently disagree. */
|
||||
export function parseCardSize(raw: unknown): number {
|
||||
return typeof raw === "number" && (raw === 0 || CARD_PAGE_SIZES.includes(raw))
|
||||
? raw
|
||||
: CARD_SIZE_DEFAULT;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Shared column-sort primitives, lifted out of DataTable so a NON-table renderer (the channel
|
||||
// manager's card layout) can sort the same rows the same way — one comparator, one source of truth.
|
||||
// Kept structural (no `Column` import) so it has zero coupling to DataTable.
|
||||
|
||||
export type SortState = { key: string; dir: "asc" | "desc" } | null;
|
||||
|
||||
interface Sortable<T> {
|
||||
key: string;
|
||||
sortValue?: (row: T) => string | number;
|
||||
}
|
||||
|
||||
/** Sort a copy of `rows` by the sortable column named in `sort` (numeric diff or locale compare).
|
||||
* Returns `rows` unchanged when there's no sort or the column has no `sortValue`. */
|
||||
export function sortRows<T>(rows: T[], columns: Sortable<T>[], sort: SortState): T[] {
|
||||
if (!sort) return rows;
|
||||
const get = columns.find((c) => c.key === sort.key)?.sortValue;
|
||||
if (!get) return rows;
|
||||
return [...rows].sort((a, b) => {
|
||||
const av = get(a);
|
||||
const bv = get(b);
|
||||
const cmp =
|
||||
typeof av === "number" && typeof bv === "number"
|
||||
? av - bv
|
||||
: String(av).localeCompare(String(bv));
|
||||
return sort.dir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
}
|
||||
|
||||
/** Coerce a stored value to a SortState. Anything malformed (corrupt storage, a shape from an older
|
||||
* build) becomes "no sort" rather than flowing a junk `dir` into the comparator. */
|
||||
export function parseSortState(raw: unknown): SortState {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const { key, dir } = raw as { key?: unknown; dir?: unknown };
|
||||
return typeof key === "string" && (dir === "asc" || dir === "desc") ? { key, dir } : null;
|
||||
}
|
||||
|
||||
/** The header-click cycle: unsorted → ascending → descending → unsorted. */
|
||||
export function cycleSort(current: SortState, key: string): SortState {
|
||||
if (!current || current.key !== key) return { key, dir: "asc" };
|
||||
if (current.dir === "asc") return { key, dir: "desc" };
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Fragment, type ReactNode } from "react";
|
||||
|
||||
// Known platform → profile base URL, for "<platform>: @handle" mentions in descriptions.
|
||||
const PLATFORMS: Record<string, string> = {
|
||||
x: "https://x.com/",
|
||||
twitter: "https://x.com/",
|
||||
instagram: "https://instagram.com/",
|
||||
insta: "https://instagram.com/",
|
||||
ig: "https://instagram.com/",
|
||||
facebook: "https://facebook.com/",
|
||||
fb: "https://facebook.com/",
|
||||
tiktok: "https://www.tiktok.com/@",
|
||||
youtube: "https://www.youtube.com/@",
|
||||
yt: "https://www.youtube.com/@",
|
||||
twitch: "https://www.twitch.tv/",
|
||||
threads: "https://www.threads.net/@",
|
||||
telegram: "https://t.me/",
|
||||
soundcloud: "https://soundcloud.com/",
|
||||
};
|
||||
|
||||
// Two link shapes, combined into one scanner:
|
||||
// URL — explicit http(s):// URLs, www.* hosts, or domain.tld/path bare URLs (required path +
|
||||
// letters-only TLD keep version numbers / file names out). Non-http gets an https:// href.
|
||||
// MENTION — "<platform>: @handle" where <platform> is one of the known keys above. The colon/arrow
|
||||
// separator is REQUIRED, so a stray "x" in prose won't match. Only the @handle is linked
|
||||
// (to the platform's profile URL); the "<platform>: " label stays as text. Bare @handles
|
||||
// with no platform, and #hashtags, are left plain.
|
||||
const URL_SOURCE =
|
||||
'(?:https?://|www\\.)[^\\s<]+[^\\s<.,!?;:)\\]}"\']|[a-z0-9-]+(?:\\.[a-z0-9-]+)*\\.[a-z]{2,}/[^\\s<]*[^\\s<.,!?;:)\\]}"\']';
|
||||
const MENTION_SOURCE =
|
||||
"\\b(" +
|
||||
Object.keys(PLATFORMS).join("|") +
|
||||
")\\b[ \\t]*[::►▶▸]+[ \\t]*@([a-z0-9_](?:[a-z0-9_.]{0,28}[a-z0-9_])?)";
|
||||
const TOKEN_RE = new RegExp("(" + URL_SOURCE + ")|" + MENTION_SOURCE, "gi");
|
||||
|
||||
/**
|
||||
* Turn links inside a plain-text string into clickable <a>s that open in a new tab (safe rel):
|
||||
* bare URLs (http(s)/www/domain.tld/path) and "<platform>: @handle" social mentions. Everything
|
||||
* else is returned as text, so it composes with `whitespace-pre-wrap` (line breaks preserved).
|
||||
*/
|
||||
export function linkify(text: string): ReactNode[] {
|
||||
const out: ReactNode[] = [];
|
||||
let last = 0;
|
||||
let key = 0;
|
||||
TOKEN_RE.lastIndex = 0;
|
||||
const link = (href: string, label: string) => (
|
||||
<a
|
||||
key={key++}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline break-all"
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = TOKEN_RE.exec(text)) !== null) {
|
||||
if (m.index > last) out.push(<Fragment key={key++}>{text.slice(last, m.index)}</Fragment>);
|
||||
if (m[1]) {
|
||||
// Plain URL.
|
||||
const raw = m[1];
|
||||
out.push(link(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`, raw));
|
||||
} else {
|
||||
// "<platform>: @handle" — keep the "<platform>: " prefix as text, link only the handle.
|
||||
const handle = m[3];
|
||||
out.push(<Fragment key={key++}>{m[0].slice(0, m[0].length - handle.length - 1)}</Fragment>);
|
||||
out.push(link(PLATFORMS[m[2].toLowerCase()] + handle, "@" + handle));
|
||||
}
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (last < text.length) out.push(<Fragment key={key++}>{text.slice(last)}</Fragment>);
|
||||
return out;
|
||||
}
|
||||
@@ -14,6 +14,24 @@ export interface ReleaseEntry {
|
||||
}
|
||||
|
||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.46.0",
|
||||
date: "2026-07-20",
|
||||
summary:
|
||||
"The channel manager, overhauled — read it as a table or as cards, with sorting, paging and each channel's full description at hand.",
|
||||
features: [
|
||||
"The channel manager has a card view alongside the familiar table. Pick it from the switcher next to the tabs; your choice sticks, and both Subscriptions and Discover follow it.",
|
||||
"A new About column shows each channel's description, and hovering it opens the full profile — description, links, country, language, topics and keywords — without leaving the list. Links in descriptions are clickable, and social handles resolve to their profiles.",
|
||||
"Sorting is shared between the two views: sort by clicking a column header in the table or from the dropdown in cards, and your choice carries over when you switch.",
|
||||
"Both views page through your channels — \"Rows per page\" in the table, \"Cards per page\" in cards — and the table now has a pager at the bottom as well as the top. Turning a page takes you back to the top of the list.",
|
||||
"Filter the table column by column: funnels on priority, channel, sync state and tags narrow it without disturbing your top-level filters.",
|
||||
"Moving between the manager's tabs now goes into your browser history, so Back steps back through the tabs you visited instead of leaving the manager altogether.",
|
||||
],
|
||||
fixes: [
|
||||
"The channel table stays centred with \"Rows per page\" set to All. A single long channel name used to stretch its column and push the whole table off to one side.",
|
||||
"The tag editor no longer opens behind the card below it.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.45.1",
|
||||
date: "2026-07-19",
|
||||
|
||||
@@ -24,6 +24,14 @@ export const LS = {
|
||||
feedView: "siftlode.feedView",
|
||||
channelFilter: "siftlode.channelFilter",
|
||||
channelsView: "siftlode.channelsView",
|
||||
// The channel manager's layout (table / cards — see lib/channelLayout.ts), per-account.
|
||||
channelLayout: "siftlode.channelLayout",
|
||||
// The manager's sort, lifted out of the table so the card layout shares it (per-account, per-tab).
|
||||
channelSort: "siftlode.channelSort",
|
||||
channelDiscoverySort: "siftlode.channelDiscoverySort",
|
||||
// Cards-per-page for the card layout (the table keeps its own rows-per-page in its persist blob).
|
||||
channelCardSize: "siftlode.channelCardSize",
|
||||
channelDiscoveryCardSize: "siftlode.channelDiscoveryCardSize",
|
||||
channelsTable: "siftlode.channelsTable",
|
||||
channelDiscoveryTable: "siftlode.channelDiscoveryTable",
|
||||
settingsTab: "siftlode.settingsTab",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CARD_PAGE_SIZES, parseCardSize } from "./channelLayout";
|
||||
import { readAccount, writeAccount } from "./storage";
|
||||
|
||||
// Client-side pagination for the channel manager's card layout, owned once instead of twice. Both
|
||||
// manager tabs page an in-memory list the same way, and keeping two copies meant every fix landed
|
||||
// twice (and the day one copy was missed, the tabs would page differently). The caller keeps only
|
||||
// what genuinely differs: which rows, which storage key, and when to jump back to page one.
|
||||
//
|
||||
// The page size persists per account; the page itself is transient. `enabled` is the card layout
|
||||
// flag — in table mode DataTable does its own paging, so this does no work and holds still.
|
||||
export function useCardPager<T>(rows: T[], sizeKey: string, enabled: boolean) {
|
||||
const { t } = useTranslation();
|
||||
const [size, setSize] = useState(() => parseCardSize(readAccount(sizeKey, null)));
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const totalPages = size <= 0 ? 1 : Math.max(1, Math.ceil(rows.length / size));
|
||||
const safePage = Math.min(page, totalPages - 1);
|
||||
|
||||
// A list that shrank under you (an unsubscribe, a refetch) leaves `page` out of range: the clamp
|
||||
// above only HIDES that, so commit it or it springs back the moment the list grows again. Only
|
||||
// fires when the page you are on stopped existing, so it never costs you your place otherwise.
|
||||
useEffect(() => {
|
||||
if (enabled && page > totalPages - 1) setPage(totalPages - 1);
|
||||
}, [enabled, page, totalPages]);
|
||||
|
||||
const paged =
|
||||
!enabled || size <= 0 ? rows : rows.slice(safePage * size, safePage * size + size);
|
||||
|
||||
return {
|
||||
paged,
|
||||
/** Spread onto <Pager/>. */
|
||||
pagerProps: {
|
||||
page: safePage,
|
||||
size,
|
||||
total: rows.length,
|
||||
pageSizeOptions: CARD_PAGE_SIZES,
|
||||
onPageChange: setPage,
|
||||
onSizeChange: (s: number) => {
|
||||
setSize(s);
|
||||
writeAccount(sizeKey, s);
|
||||
setPage(0);
|
||||
},
|
||||
sizeLabel: t("channels.cardsPerPage"),
|
||||
},
|
||||
/** Call when the caller's own filters change — a different list starts at page one. */
|
||||
resetPage: useCallback(() => setPage(0), []),
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { PrefsProvider } from "./components/PrefsProvider";
|
||||
import { WizardProvider } from "./components/WizardProvider";
|
||||
import "./i18n";
|
||||
import "./index.css";
|
||||
import "flag-icons/css/flag-icons.min.css";
|
||||
|
||||
// Split by top-level route so each entry point is its own chunk: a public /watch share link or a
|
||||
// legal page never downloads the authenticated app bundle, and the app never carries them either.
|
||||
|
||||
Reference in New Issue
Block a user