Merge: promote dev to prod

This commit is contained in:
2026-07-20 01:32:45 +02:00
27 changed files with 1618 additions and 518 deletions
+1 -1
View File
@@ -1 +1 @@
0.45.1
0.46.0
+1
View File
@@ -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,
}
+7
View File
@@ -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",
+1
View File
@@ -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",
+9 -1
View File
@@ -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={
<>
+134
View File
@@ -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>
);
}
+119
View File
@@ -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>
)}
</>
);
}
+112 -39
View File
@@ -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}
/>
);
}
+3 -97
View File
@@ -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>
+336 -132
View File
@@ -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>
);
+82 -6
View File
@@ -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>
);
}
+50 -87
View File
@@ -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>
);
}
+126
View File
@@ -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>
);
}
+29 -153
View File
@@ -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}
/>
)}
/>
);
}
+186
View File
@@ -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>
);
}
+13 -1
View File
@@ -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",
+13 -1
View File
@@ -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",
+2
View File
@@ -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;
+47
View File
@@ -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;
}
+42
View File
@@ -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;
}
+74
View File
@@ -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;
}
+18
View File
@@ -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",
+8
View File
@@ -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",
+50
View File
@@ -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), []),
};
}
+1
View File
@@ -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.