Merge: E4 managers overhaul — bottom pager, About column, table/cards layouts (S1–S3)
This commit is contained in:
@@ -214,6 +214,7 @@ def _channel_summary(ch: Channel) -> dict:
|
|||||||
"thumbnail_url": ch.thumbnail_url,
|
"thumbnail_url": ch.thumbnail_url,
|
||||||
"subscriber_count": ch.subscriber_count,
|
"subscriber_count": ch.subscriber_count,
|
||||||
"video_count": ch.video_count,
|
"video_count": ch.video_count,
|
||||||
|
"description": ch.description,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Generated
+7
@@ -12,6 +12,7 @@
|
|||||||
"@tanstack/react-query": "^5.51.0",
|
"@tanstack/react-query": "^5.51.0",
|
||||||
"@tanstack/react-virtual": "^3.14.3",
|
"@tanstack/react-virtual": "^3.14.3",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"flag-icons": "^7.5.0",
|
||||||
"hls.js": "^1.6.16",
|
"hls.js": "^1.6.16",
|
||||||
"i18next": "^23.11.5",
|
"i18next": "^23.11.5",
|
||||||
"lucide-react": "^0.408.0",
|
"lucide-react": "^0.408.0",
|
||||||
@@ -1809,6 +1810,12 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/fraction.js": {
|
||||||
"version": "5.3.4",
|
"version": "5.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"@tanstack/react-query": "^5.51.0",
|
"@tanstack/react-query": "^5.51.0",
|
||||||
"@tanstack/react-virtual": "^3.14.3",
|
"@tanstack/react-virtual": "^3.14.3",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"flag-icons": "^7.5.0",
|
||||||
"hls.js": "^1.6.16",
|
"hls.js": "^1.6.16",
|
||||||
"i18next": "^23.11.5",
|
"i18next": "^23.11.5",
|
||||||
"lucide-react": "^0.408.0",
|
"lucide-react": "^0.408.0",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
} from "./lib/storage";
|
} from "./lib/storage";
|
||||||
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
|
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
|
||||||
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
|
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
|
||||||
|
import { useChannelLayout } from "./components/ChannelsProvider";
|
||||||
import { useFeedView, useFeedViewActions } from "./components/FeedViewProvider";
|
import { useFeedView, useFeedViewActions } from "./components/FeedViewProvider";
|
||||||
import { moduleDef } from "./lib/modules";
|
import { moduleDef } from "./lib/modules";
|
||||||
import { PAGE_CONTENT, PAGE_RAIL } from "./components/moduleRegistry";
|
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).
|
// handlers that mutate the feed view (onShowInFeed, tag-click, the feed scrollKey).
|
||||||
const filters = useFeedFilters();
|
const filters = useFeedFilters();
|
||||||
const { setFilters } = useFeedFiltersActions();
|
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
|
// 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>.
|
// by the feed page and the channel page. App reads it only to hand to <Feed>/<ChannelPage>.
|
||||||
const view = useFeedView();
|
const view = useFeedView();
|
||||||
@@ -387,7 +393,9 @@ export default function App() {
|
|||||||
) : (
|
) : (
|
||||||
<PageShell
|
<PageShell
|
||||||
scrollKey={normalScrollKey}
|
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} />}
|
header={<Header me={meQuery.data!} onYtSearch={enterYtSearch} />}
|
||||||
banners={
|
banners={
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import ChannelAboutContent from "./ChannelAbout";
|
||||||
|
|
||||||
|
const MARGIN = 8;
|
||||||
|
const POP_W = 480; // the overlay's fixed width (px)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A channel-description cell: one truncated line in the table, revealing the FULL channel "About"
|
||||||
|
* (the same content as the detail page's About tab — description, links, country, language, topics,
|
||||||
|
* keywords) in a portalled glass overlay. The detail is fetched ON DEMAND on hover/focus, sharing
|
||||||
|
* the ["channel", id] react-query cache with the channel page, so the list stays light. The overlay
|
||||||
|
* is interactive (scrollable, links clickable) via a small hover-bridge; it closes on page scroll
|
||||||
|
* (but not when scrolling inside itself) or resize. Shared by the subscribed + discovery tables.
|
||||||
|
*/
|
||||||
|
export default function AboutCell({ text, channelId }: { text: string | null; channelId: string }) {
|
||||||
|
const preview = (text ?? "").trim();
|
||||||
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
const popRef = useRef<HTMLDivElement>(null);
|
||||||
|
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [coords, setCoords] = useState<{ left: number; top: number } | null>(null);
|
||||||
|
|
||||||
|
const { data: ch } = useQuery({
|
||||||
|
queryKey: ["channel", channelId],
|
||||||
|
queryFn: () => api.channelDetail(channelId),
|
||||||
|
enabled: open && !!channelId,
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
function place() {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
setCoords({
|
||||||
|
left: Math.min(Math.max(r.left, MARGIN), window.innerWidth - POP_W - MARGIN),
|
||||||
|
top: r.bottom + 6,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function show() {
|
||||||
|
if (hideTimer.current) {
|
||||||
|
clearTimeout(hideTimer.current);
|
||||||
|
hideTimer.current = null;
|
||||||
|
}
|
||||||
|
place();
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
// Small grace period so the cursor can travel the gap from the cell to the overlay without it
|
||||||
|
// vanishing — the overlay's own mouseenter cancels it.
|
||||||
|
function scheduleHide() {
|
||||||
|
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||||
|
hideTimer.current = setTimeout(() => setOpen(false), 160);
|
||||||
|
}
|
||||||
|
function cancelHide() {
|
||||||
|
if (hideTimer.current) {
|
||||||
|
clearTimeout(hideTimer.current);
|
||||||
|
hideTimer.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Close on page scroll (the fixed overlay would strand at a stale anchor) or resize — but NOT
|
||||||
|
// when the scroll happens inside the overlay itself (it is scrollable).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onScroll = (e: Event) => {
|
||||||
|
if (popRef.current && e.target instanceof Node && popRef.current.contains(e.target)) return;
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
const onResize = () => setOpen(false);
|
||||||
|
window.addEventListener("scroll", onScroll, true);
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", onScroll, true);
|
||||||
|
window.removeEventListener("resize", onResize);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Flip above the anchor if the overlay would run off the bottom of the viewport.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const pop = popRef.current;
|
||||||
|
const el = ref.current;
|
||||||
|
if (!open || !coords || !pop || !el) return;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
const h = pop.offsetHeight;
|
||||||
|
if (r.bottom + 6 + h > window.innerHeight - MARGIN && r.top - 6 - h > MARGIN) {
|
||||||
|
const top = r.top - 6 - h;
|
||||||
|
if (Math.abs(top - coords.top) > 0.5) setCoords((c) => (c ? { ...c, top } : c));
|
||||||
|
}
|
||||||
|
}, [open, coords, ch]);
|
||||||
|
|
||||||
|
if (!preview) return <span className="text-muted">—</span>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
ref={ref}
|
||||||
|
onMouseEnter={show}
|
||||||
|
onMouseLeave={scheduleHide}
|
||||||
|
onFocus={show}
|
||||||
|
onBlur={scheduleHide}
|
||||||
|
tabIndex={0}
|
||||||
|
className="block max-w-[22rem] truncate text-muted cursor-help outline-none"
|
||||||
|
>
|
||||||
|
{preview}
|
||||||
|
{open &&
|
||||||
|
coords &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
ref={popRef}
|
||||||
|
onMouseEnter={cancelHide}
|
||||||
|
onMouseLeave={scheduleHide}
|
||||||
|
style={{ position: "fixed", left: coords.left, top: coords.top, width: POP_W }}
|
||||||
|
className="glass z-tooltip max-h-[28rem] overflow-y-auto px-3.5 py-3 rounded-lg animate-[fadeIn_0.12s_ease]"
|
||||||
|
>
|
||||||
|
{/* Full About once the detail loads; until then the list's truncated description. */}
|
||||||
|
{ch ? (
|
||||||
|
<ChannelAboutContent ch={ch} />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{preview}</p>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ExternalLink } from "lucide-react";
|
||||||
|
import type { ChannelDetail } from "../lib/api";
|
||||||
|
import { linkify } from "../lib/linkify";
|
||||||
|
|
||||||
|
// --- About-tab metadata helpers (shared by the channel page and the manager's About overlay) ---
|
||||||
|
function displayName(code: string, lang: string, type: "region" | "language"): string {
|
||||||
|
try {
|
||||||
|
return new Intl.DisplayNames([lang], { type }).of(type === "region" ? code.toUpperCase() : code) ?? code;
|
||||||
|
} catch {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// YouTube keywords are ONE space-separated string with multi-word tags quoted: gaming "let's play".
|
||||||
|
function parseKeywords(s: string): string[] {
|
||||||
|
return (s.match(/"[^"]+"|\S+/g) ?? []).map((k) => k.replace(/^"|"$/g, "")).filter(Boolean);
|
||||||
|
}
|
||||||
|
// topicCategories are Wikipedia URLs (…/wiki/Music) → a readable label, de-duplicated.
|
||||||
|
function topicLabels(urls: string[]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const url of urls) {
|
||||||
|
const seg = url.split("/wiki/")[1] ?? url.split("/").pop() ?? url;
|
||||||
|
let label = seg;
|
||||||
|
try {
|
||||||
|
label = decodeURIComponent(seg);
|
||||||
|
} catch {
|
||||||
|
/* keep raw */
|
||||||
|
}
|
||||||
|
label = label.replace(/_/g, " ").trim();
|
||||||
|
if (label) seen.add(label);
|
||||||
|
}
|
||||||
|
return [...seen];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The channel "About" content — description, external links, and the enriched metadata
|
||||||
|
* (country, language, topics, keywords). Shared by the channel detail page's About tab and the
|
||||||
|
* Channel manager's About-column hover overlay, so both show exactly the same thing.
|
||||||
|
*/
|
||||||
|
export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const topics = ch.topic_categories?.length ? topicLabels(ch.topic_categories) : [];
|
||||||
|
const keywords = ch.keywords ? parseKeywords(ch.keywords) : [];
|
||||||
|
const hasAboutDetails =
|
||||||
|
!!ch.country || !!ch.default_language || topics.length > 0 || keywords.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{ch.description ? (
|
||||||
|
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{linkify(ch.description)}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted">{t("channel.noDescription")}</p>
|
||||||
|
)}
|
||||||
|
{ch.external_links && ch.external_links.length > 0 && (
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{ch.external_links.map((l, i) => (
|
||||||
|
<a
|
||||||
|
key={i}
|
||||||
|
href={l.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-accent hover:underline"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
{l.title || l.url}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasAboutDetails && (
|
||||||
|
<div className="mt-5 space-y-2.5 text-sm border-t border-border pt-4">
|
||||||
|
{ch.country && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="text-muted w-24 shrink-0">{t("channel.country")}</span>
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
{/^[A-Za-z]{2}$/.test(ch.country) && (
|
||||||
|
// Real SVG flag (flag-icons) — Windows/Chrome don't render flag emoji.
|
||||||
|
<span className={`fi fi-${ch.country.toLowerCase()} rounded-sm shrink-0`} aria-hidden />
|
||||||
|
)}
|
||||||
|
{displayName(ch.country, i18n.language, "region")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ch.default_language && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="text-muted w-24 shrink-0">{t("channel.language")}</span>
|
||||||
|
<span>{displayName(ch.default_language, i18n.language, "language")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{topics.length > 0 && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="text-muted w-24 shrink-0 pt-0.5">{t("channel.topics")}</span>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{/* Future search epic: these topic chips become clickable filters. */}
|
||||||
|
{topics.map((tp) => (
|
||||||
|
<span key={tp} className="glass-card px-2 py-0.5 rounded-full text-xs">
|
||||||
|
{tp}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{keywords.length > 0 && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="text-muted w-24 shrink-0 pt-0.5">{t("channel.keywords")}</span>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{keywords.map((kw, i) => (
|
||||||
|
<span key={i} className="glass-card px-2 py-0.5 rounded-full text-xs">
|
||||||
|
{kw}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,25 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { UserPlus } from "lucide-react";
|
import { UserPlus } from "lucide-react";
|
||||||
import { api, type DiscoveredChannel } from "../lib/api";
|
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 { formatCountOrDash } from "../lib/format";
|
||||||
|
import { parseSortState, sortRows, type SortState } from "../lib/columnSort";
|
||||||
|
import { useCardPager } from "../lib/useCardPager";
|
||||||
import { subsColumn } from "./channelColumns";
|
import { subsColumn } from "./channelColumns";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
import ChannelLink from "./ChannelLink";
|
import ChannelLink from "./ChannelLink";
|
||||||
|
import AboutCell from "./AboutCell";
|
||||||
import DataTable, { type Column } from "./DataTable";
|
import DataTable, { type Column } from "./DataTable";
|
||||||
|
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||||
|
import ColumnSortControl from "./ColumnSortControl";
|
||||||
|
import Pager from "./Pager";
|
||||||
import { PageToolbar } from "./PageShell";
|
import { PageToolbar } from "./PageShell";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
|
import { useChannelLayout } from "./ChannelsProvider";
|
||||||
|
|
||||||
// The Channel manager's "Discovery" tab: channels that turn up in the user's playlists but
|
// 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
|
// 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 { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const confirm = useConfirm();
|
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({
|
const query = useQuery({
|
||||||
queryKey: ["discovered-channels"],
|
queryKey: ["discovered-channels"],
|
||||||
@@ -77,6 +95,34 @@ export default function ChannelDiscovery({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const columns: Column<DiscoveredChannel>[] = [
|
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",
|
key: "channel",
|
||||||
header: t("channels.cols.channel"),
|
header: t("channels.cols.channel"),
|
||||||
@@ -84,16 +130,26 @@ export default function ChannelDiscovery({
|
|||||||
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
|
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
|
||||||
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
|
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
|
||||||
cardPrimary: true,
|
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) => (
|
render: (c) => (
|
||||||
<ChannelLink
|
<div className="max-w-[18rem]">
|
||||||
id={c.id}
|
<ChannelLink
|
||||||
title={c.title}
|
id={c.id}
|
||||||
handle={c.handle}
|
title={c.title}
|
||||||
thumbnailUrl={c.thumbnail_url}
|
handle={c.handle}
|
||||||
onView={() => onViewChannel(c.id, c.title ?? c.id)}
|
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),
|
subsColumn<DiscoveredChannel>(t),
|
||||||
{
|
{
|
||||||
key: "videos",
|
key: "videos",
|
||||||
@@ -126,54 +182,71 @@ export default function ChannelDiscovery({
|
|||||||
</Tooltip>
|
</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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Intro + the table controls are fixed chrome; only the table rows scroll (sticky header). */}
|
{/* Intro + the table controls are fixed chrome; only the table rows scroll (sticky header). */}
|
||||||
<PageToolbar>
|
<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>
|
<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>
|
</div>
|
||||||
</PageToolbar>
|
</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 ? (
|
{query.isLoading ? (
|
||||||
<div className="text-muted py-8">{t("channels.discovery.loading")}</div>
|
<div className="text-muted py-8">{t("channels.discovery.loading")}</div>
|
||||||
) : (
|
) : layout === "table" ? (
|
||||||
<DataTable
|
<DataTable
|
||||||
rows={rows}
|
rows={rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowKey={(c) => c.id}
|
rowKey={(c) => c.id}
|
||||||
persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined}
|
persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined}
|
||||||
controlsPosition="top"
|
controlsPosition="both"
|
||||||
controlsInBand
|
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")}
|
emptyText={t("channels.discovery.empty")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { Fragment } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { type Column } from "./DataTable";
|
||||||
|
import VirtualGrid from "./VirtualGrid";
|
||||||
|
import { CHANNEL_LAYOUT_SPEC } from "../lib/channelLayout";
|
||||||
|
|
||||||
|
// The channel manager's `cards` layout (E4 S3), built by reusing the SAME `Column<T>` render closures
|
||||||
|
// the DataTable draws — so the subscribed and discovery tables (which have different columns) both get
|
||||||
|
// the card view for free, and a cell's behaviour (priority bump, tag toggle, sync, actions) is defined
|
||||||
|
// once. The `table` layout keeps DataTable; this renders only when the manager's layout is `cards`.
|
||||||
|
//
|
||||||
|
// Named slots place the columns that need bespoke positions; everything else flows in the meta line.
|
||||||
|
// Unlike DataTable's cramped narrow-screen card fallback, the desktop card is roomy, so it shows the
|
||||||
|
// `hideInCard` columns too (About, priority) — the user wants those affordances in every layout.
|
||||||
|
|
||||||
|
interface ChannelLayoutGridProps<T> {
|
||||||
|
rows: T[];
|
||||||
|
columns: Column<T>[];
|
||||||
|
rowKey: (row: T) => string;
|
||||||
|
/** Column key docked at the top-right of the card (the priority stepper). */
|
||||||
|
leadKey?: string;
|
||||||
|
/** Column key rendered as a full-width description line (the About cell + its hover overlay). */
|
||||||
|
aboutKey?: string;
|
||||||
|
/** Column key rendered as the action cluster, pinned to the card's bottom edge. */
|
||||||
|
actionsKey?: string;
|
||||||
|
rowClassName?: (row: T) => string;
|
||||||
|
emptyText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChannelLayoutGrid<T>({
|
||||||
|
rows,
|
||||||
|
columns,
|
||||||
|
rowKey,
|
||||||
|
leadKey,
|
||||||
|
aboutKey,
|
||||||
|
actionsKey,
|
||||||
|
rowClassName,
|
||||||
|
emptyText,
|
||||||
|
}: ChannelLayoutGridProps<T>) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const spec = CHANNEL_LAYOUT_SPEC.cards;
|
||||||
|
|
||||||
|
const slotted = new Set([leadKey, aboutKey, actionsKey].filter(Boolean) as string[]);
|
||||||
|
const primary = columns.filter((c) => c.cardPrimary);
|
||||||
|
const lead = leadKey ? columns.find((c) => c.key === leadKey) : undefined;
|
||||||
|
const about = aboutKey ? columns.find((c) => c.key === aboutKey) : undefined;
|
||||||
|
const actions = actionsKey ? columns.find((c) => c.key === actionsKey) : undefined;
|
||||||
|
// Everything else flows in the meta line: not the heading, not a slotted column.
|
||||||
|
const meta = columns.filter((c) => !c.cardPrimary && !slotted.has(c.key));
|
||||||
|
|
||||||
|
const renderCard = (row: T) => (
|
||||||
|
<div className={`glass-card rounded-xl p-3 h-full flex flex-col gap-2 ${rowClassName?.(row) ?? ""}`}>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1 font-medium">
|
||||||
|
{primary.map((col) => (
|
||||||
|
<Fragment key={col.key}>{col.render(row)}</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{lead && <div className="shrink-0">{lead.render(row)}</div>}
|
||||||
|
</div>
|
||||||
|
{about && <div className="min-w-0 text-sm">{about.render(row)}</div>}
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-sm min-w-0">
|
||||||
|
{meta.map((col) => (
|
||||||
|
<span key={col.key} className="inline-flex items-center gap-1">
|
||||||
|
{col.cardLabel !== false && <span className="text-muted text-xs">{col.header}</span>}
|
||||||
|
{col.render(row)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="mt-auto pt-1">{actions.render(row)}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-muted py-8 text-center text-sm">{emptyText ?? t("datatable.empty")}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VirtualGrid
|
||||||
|
items={rows}
|
||||||
|
getKey={rowKey}
|
||||||
|
minCol={spec.minCol}
|
||||||
|
maxCol={null}
|
||||||
|
rowEst={spec.rowEst}
|
||||||
|
renderItem={renderCard}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { useNavigationActions } from "./NavigationProvider";
|
|||||||
import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw, SlidersHorizontal } from "lucide-react";
|
import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw, SlidersHorizontal } from "lucide-react";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import Feed from "./Feed";
|
import Feed from "./Feed";
|
||||||
|
import ChannelAboutContent from "./ChannelAbout";
|
||||||
import { PageToolbar, PageToolbarSlot } from "./PageShell";
|
import { PageToolbar, PageToolbarSlot } from "./PageShell";
|
||||||
import { useFeedFilters } from "./FeedFiltersProvider";
|
import { useFeedFilters } from "./FeedFiltersProvider";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
@@ -14,39 +15,6 @@ import { api, type FeedFilters, type Me } from "../lib/api";
|
|||||||
import type { FeedView } from "../lib/feedView";
|
import type { FeedView } from "../lib/feedView";
|
||||||
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
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
|
// 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
|
// 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,
|
ch?.total_view_count != null ? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) }) : null,
|
||||||
joined ? t("channel.joined", { date: joined }) : null,
|
joined ? t("channel.joined", { date: joined }) : null,
|
||||||
].filter(Boolean) as string[];
|
].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) =>
|
const tabClass = (active: boolean) =>
|
||||||
active
|
active
|
||||||
? "pb-2 text-sm font-medium border-b-2 border-fg text-fg"
|
? "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">
|
<div className="px-4 sm:px-6 py-4 max-w-3xl">
|
||||||
{ch?.description ? (
|
{ch ? (
|
||||||
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{ch.description}</p>
|
<ChannelAboutContent ch={ch} />
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted">{t("channel.noDescription")}</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>
|
|
||||||
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</PageToolbarSlot.Provider>
|
</PageToolbarSlot.Provider>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
@@ -9,9 +9,11 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
History,
|
History,
|
||||||
|
LayoutGrid,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Table,
|
||||||
UserMinus,
|
UserMinus,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -25,12 +27,21 @@ import { subsColumn } from "./channelColumns";
|
|||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
import DataTable, { type Column } from "./DataTable";
|
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 { PageToolbar } from "./PageShell";
|
||||||
import ChannelLink from "./ChannelLink";
|
import ChannelLink from "./ChannelLink";
|
||||||
|
import AboutCell from "./AboutCell";
|
||||||
import ChannelDiscovery from "./ChannelDiscovery";
|
import ChannelDiscovery from "./ChannelDiscovery";
|
||||||
import TagManager from "./TagManager";
|
import TagManager from "./TagManager";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import { useChannels, useChannelsActions } from "./ChannelsProvider";
|
import { useChannelLayout, useChannels, useChannelsActions } from "./ChannelsProvider";
|
||||||
import { useNavigationActions } from "./NavigationProvider";
|
import { useNavigationActions } from "./NavigationProvider";
|
||||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||||
import { useWizard } from "./WizardProvider";
|
import { useWizard } from "./WizardProvider";
|
||||||
@@ -47,6 +58,11 @@ const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
|
|||||||
{ id: "hidden", labelKey: "channels.filters.hidden" },
|
{ id: "hidden", labelKey: "channels.filters.hidden" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const LAYOUT_ICON: Record<ChannelLayout, typeof Table> = {
|
||||||
|
table: Table,
|
||||||
|
cards: LayoutGrid,
|
||||||
|
};
|
||||||
|
|
||||||
export default function Channels() {
|
export default function Channels() {
|
||||||
const me = useMe();
|
const me = useMe();
|
||||||
const canWrite = me.can_write;
|
const canWrite = me.can_write;
|
||||||
@@ -60,11 +76,14 @@ export default function Channels() {
|
|||||||
search,
|
search,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
view,
|
view,
|
||||||
|
sort,
|
||||||
filtersResetToken,
|
filtersResetToken,
|
||||||
focusName: focusChannelName,
|
focusName: focusChannelName,
|
||||||
focusToken: focusChannelToken,
|
focusToken: focusChannelToken,
|
||||||
} = useChannels();
|
} = 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 { openChannel: onViewChannel, setPage } = useNavigationActions();
|
||||||
const feedFilters = useFeedFilters();
|
const feedFilters = useFeedFilters();
|
||||||
const { setFilters: setFeedFilters } = useFeedFiltersActions();
|
const { setFilters: setFeedFilters } = useFeedFiltersActions();
|
||||||
@@ -248,6 +267,11 @@ export default function Channels() {
|
|||||||
if (ok) resetBackfill.mutate({ id: c.id, name: c.title ?? c.id });
|
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>[] = [
|
const columns: Column<ManagedChannel>[] = [
|
||||||
{
|
{
|
||||||
key: "priority",
|
key: "priority",
|
||||||
@@ -257,24 +281,56 @@ export default function Channels() {
|
|||||||
sortable: true,
|
sortable: true,
|
||||||
hideInCard: true,
|
hideInCard: true,
|
||||||
sortValue: (c) => c.priority,
|
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)} />,
|
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",
|
key: "channel",
|
||||||
header: t("channels.cols.channel"),
|
header: t("channels.cols.channel"),
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
|
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
|
||||||
|
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
|
||||||
cardPrimary: true,
|
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) => (
|
render: (c) => (
|
||||||
<ChannelLink
|
<div className="max-w-[18rem]">
|
||||||
id={c.id}
|
<ChannelLink
|
||||||
title={c.title}
|
id={c.id}
|
||||||
handle={c.handle}
|
title={c.title}
|
||||||
thumbnailUrl={c.thumbnail_url}
|
handle={c.handle}
|
||||||
onView={() => onView(c)}
|
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",
|
key: "stored",
|
||||||
header: t("channels.cols.stored"),
|
header: t("channels.cols.stored"),
|
||||||
@@ -319,12 +375,25 @@ export default function Channels() {
|
|||||||
header: t("channels.cols.sync"),
|
header: t("channels.cols.sync"),
|
||||||
nowrap: true,
|
nowrap: true,
|
||||||
cardLabel: false,
|
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} />,
|
render: (c) => <SyncCell c={c} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "tags",
|
key: "tags",
|
||||||
header: t("channels.cols.tags"),
|
header: t("channels.cols.tags"),
|
||||||
cardLabel: false,
|
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) => (
|
render: (c) => (
|
||||||
<TagsCell
|
<TagsCell
|
||||||
c={c}
|
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
|
// 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>
|
</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 = (
|
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">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
{(["subscribed", "discovery", "blocked"] as ChannelsView[]).map((v) => (
|
{(["subscribed", "discovery", "blocked"] as ChannelsView[]).map((v) => (
|
||||||
<button
|
<button
|
||||||
@@ -392,6 +554,11 @@ export default function Channels() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{view === "subscribed" && statsBlock}
|
||||||
|
{view === "subscribed" && layout === "cards" && sortControl}
|
||||||
|
{hasLayout && layoutSwitcher}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -403,85 +570,11 @@ export default function Channels() {
|
|||||||
<PageToolbar>
|
<PageToolbar>
|
||||||
{tabs}
|
{tabs}
|
||||||
{view === "subscribed" && (
|
{view === "subscribed" && (
|
||||||
<div className="px-4 pt-3 pb-2 max-w-7xl mx-auto">
|
<div className="px-4 pt-2 pb-2 max-w-[96rem] mx-auto">
|
||||||
{/* Per-user sync status + catalog-wide actions on one row (search/tags filtering
|
{/* Your tags (left) share one row with the two catalog-wide actions (right); the channel-name
|
||||||
now lives in the table headers). */}
|
search lives in the shared top SearchBar, tag create/delete in the manager. */}
|
||||||
<div className="flex items-start justify-between gap-4 flex-wrap mb-4">
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 text-sm text-muted">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
{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">
|
|
||||||
<Tooltip hint={t("channels.tags.yourTagsHint")}>
|
<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">
|
<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")}
|
{t("channels.tags.yourTags")}
|
||||||
@@ -522,8 +615,18 @@ export default function Channels() {
|
|||||||
{t("channels.tags.manage")}
|
{t("channels.tags.manage")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{actionButtons}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* In table mode the status chips + pager ride in DataTable's own in-band controls row. The card
|
||||||
|
layout has no such row, so surface the same status chips (left) and its pager (right) here. */}
|
||||||
|
{view === "subscribed" && layout === "cards" && (
|
||||||
|
<div className="px-4 pb-2 max-w-[96rem] mx-auto flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
{statusChips}
|
||||||
|
{cardPager}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</PageToolbar>
|
</PageToolbar>
|
||||||
{tagManagerOpen && (
|
{tagManagerOpen && (
|
||||||
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
|
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
|
||||||
@@ -538,7 +641,7 @@ export default function Channels() {
|
|||||||
/>
|
/>
|
||||||
) : view === "blocked" ? (
|
) : view === "blocked" ? (
|
||||||
/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
|
/* 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 ? (
|
{(blockedQuery.data?.length ?? 0) === 0 ? (
|
||||||
<p className="text-sm text-muted py-8">{t("channels.blocked.empty")}</p>
|
<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
|
/* 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
|
`position: sticky` engages on the first scroll (the band's controls row above gives the
|
||||||
visual gap). */
|
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
|
{/* 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 ? (
|
{channelsQuery.isLoading ? (
|
||||||
<div className="text-muted py-8">{t("channels.loading")}</div>
|
<div className="text-muted py-8">{t("channels.loading")}</div>
|
||||||
) : (
|
) : layout === "table" ? (
|
||||||
<DataTable
|
<DataTable
|
||||||
rows={visibleChannels}
|
rows={visibleChannels}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowKey={(c) => c.id}
|
rowKey={(c) => c.id}
|
||||||
persistKey={accountKey(LS.channelsTable) ?? undefined}
|
persistKey={accountKey(LS.channelsTable) ?? undefined}
|
||||||
controlsPosition="top"
|
controlsPosition="both"
|
||||||
controlsLeading={statusChips}
|
controlsLeading={statusChips}
|
||||||
controlsInBand
|
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" : "")}
|
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
|
||||||
emptyText={t("channels.empty")}
|
emptyText={t("channels.empty")}
|
||||||
/>
|
/>
|
||||||
@@ -692,21 +810,107 @@ function TagsCell({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
// Open the picker upward when there isn't room below (rows near the viewport bottom would
|
// Anchor for the portalled menu. It renders through <Overlay> (i.e. at <body>) rather than
|
||||||
// otherwise clip it against the scroll container), as long as there's room above.
|
// `absolute` inside the cell, because in the CARD layout every VirtualGrid row carries a
|
||||||
const [openUp, setOpenUp] = useState(false);
|
// `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 fade = useScrollFade();
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
const popRef = useRef<HTMLDivElement | null>(null);
|
||||||
const btnRef = useRef<HTMLButtonElement | 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 = () => {
|
const toggle = () => {
|
||||||
if (!open) {
|
if (open) setOpen(false);
|
||||||
const r = btnRef.current?.getBoundingClientRect();
|
else if (place()) setOpen(true);
|
||||||
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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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;
|
if (userTags.length === 0) return null;
|
||||||
// Show only the tags actually attached to this channel; the “+” opens a per-channel
|
// 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
|
// 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" />
|
<Plus className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && coords && (
|
||||||
|
<Overlay>
|
||||||
<div
|
<div
|
||||||
ref={fade.ref}
|
ref={setPopRef}
|
||||||
style={fade.style}
|
style={{ ...fade.style, position: "fixed", left: coords.left, top: coords.top, bottom: coords.bottom }}
|
||||||
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] ${
|
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]"
|
||||||
openUp ? "bottom-full mb-1" : "top-full mt-1"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{userTags.map((tg) => {
|
{userTags.map((tg) => {
|
||||||
const on = c.tag_ids.includes(tg.id);
|
const on = c.tag_ids.includes(tg.id);
|
||||||
@@ -763,6 +966,7 @@ function TagsCell({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</Overlay>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import {
|
|||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} 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 { useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||||
import type { ChannelStatusFilter, ChannelsView } from "./Channels";
|
import type { ChannelStatusFilter, ChannelsView } from "./Channels";
|
||||||
|
|
||||||
@@ -41,6 +44,8 @@ type ChannelsState = {
|
|||||||
search: string;
|
search: string;
|
||||||
statusFilter: ChannelStatusFilter;
|
statusFilter: ChannelStatusFilter;
|
||||||
view: ChannelsView;
|
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
|
// 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).
|
// specific set (the header's "without full history" link).
|
||||||
filtersResetToken: number;
|
filtersResetToken: number;
|
||||||
@@ -54,6 +59,8 @@ interface ChannelsActions {
|
|||||||
setSearch: (q: string) => void;
|
setSearch: (q: string) => void;
|
||||||
setStatusFilter: (f: ChannelStatusFilter) => void;
|
setStatusFilter: (f: ChannelStatusFilter) => void;
|
||||||
setView: (v: ChannelsView) => 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
|
// "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.
|
// filter so the channel is isolated. Called from the feed sidebar and the notifications inbox.
|
||||||
focusChannel: (name: string) => void;
|
focusChannel: (name: string) => void;
|
||||||
@@ -65,14 +72,21 @@ const StateContext = createContext<ChannelsState>({
|
|||||||
search: "",
|
search: "",
|
||||||
statusFilter: "all",
|
statusFilter: "all",
|
||||||
view: "subscribed",
|
view: "subscribed",
|
||||||
|
sort: null,
|
||||||
filtersResetToken: 0,
|
filtersResetToken: 0,
|
||||||
focusName: null,
|
focusName: null,
|
||||||
focusToken: 0,
|
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>({
|
const ActionsContext = createContext<ChannelsActions>({
|
||||||
setSearch: () => {},
|
setSearch: () => {},
|
||||||
setStatusFilter: () => {},
|
setStatusFilter: () => {},
|
||||||
setView: () => {},
|
setView: () => {},
|
||||||
|
setLayout: () => {},
|
||||||
|
setSort: () => {},
|
||||||
focusChannel: () => {},
|
focusChannel: () => {},
|
||||||
goToFullHistory: () => {},
|
goToFullHistory: () => {},
|
||||||
});
|
});
|
||||||
@@ -80,6 +94,11 @@ const ActionsContext = createContext<ChannelsActions>({
|
|||||||
export function useChannels(): ChannelsState {
|
export function useChannels(): ChannelsState {
|
||||||
return useContext(StateContext);
|
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 {
|
export function useChannelsActions(): ChannelsActions {
|
||||||
return useContext(ActionsContext);
|
return useContext(ActionsContext);
|
||||||
}
|
}
|
||||||
@@ -95,18 +114,52 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
|||||||
const [view, setViewState] = useState<ChannelsView>(() =>
|
const [view, setViewState] = useState<ChannelsView>(() =>
|
||||||
clampView(getAccountRaw(LS.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 [filtersResetToken, setFiltersResetToken] = useState(0);
|
||||||
const [focusName, setFocusName] = useState<string | null>(null);
|
const [focusName, setFocusName] = useState<string | null>(null);
|
||||||
const [focusToken, setFocusToken] = useState(0);
|
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 setSearch = useCallback((q: string) => setSearchState(q), []);
|
||||||
const setStatusFilter = useCallback((f: ChannelStatusFilter) => {
|
const setStatusFilter = useCallback((f: ChannelStatusFilter) => {
|
||||||
setStatusFilterState(f);
|
setStatusFilterState(f);
|
||||||
setAccountRaw(LS.channelFilter, 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) => {
|
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);
|
setViewState(v);
|
||||||
setAccountRaw(LS.channelsView, 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(
|
const focusChannel = useCallback(
|
||||||
@@ -130,18 +183,41 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
|||||||
if (page !== "channels") setFocusName(null);
|
if (page !== "channels") setFocusName(null);
|
||||||
}, [page]);
|
}, [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>(
|
const state = useMemo<ChannelsState>(
|
||||||
() => ({ search, statusFilter, view, filtersResetToken, focusName, focusToken }),
|
() => ({ search, statusFilter, view, sort, filtersResetToken, focusName, focusToken }),
|
||||||
[search, statusFilter, view, filtersResetToken, focusName, focusToken],
|
[search, statusFilter, view, sort, filtersResetToken, focusName, focusToken],
|
||||||
);
|
);
|
||||||
const actions = useMemo<ChannelsActions>(
|
const actions = useMemo<ChannelsActions>(
|
||||||
() => ({ setSearch, setStatusFilter, setView, focusChannel, goToFullHistory }),
|
() => ({ setSearch, setStatusFilter, setView, setLayout, setSort, focusChannel, goToFullHistory }),
|
||||||
[setSearch, setStatusFilter, setView, focusChannel, goToFullHistory],
|
[setSearch, setStatusFilter, setView, setLayout, setSort, focusChannel, goToFullHistory],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionsContext.Provider value={actions}>
|
<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>
|
</ActionsContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||||
|
import { type Column } from "./DataTable";
|
||||||
|
import { type SortState } from "../lib/columnSort";
|
||||||
|
|
||||||
|
// A sort control for views without clickable column headers (the channel manager's card layout):
|
||||||
|
// a dropdown of the sortable columns + an asc/desc toggle, mirroring the feed toolbar's sort. It's
|
||||||
|
// driven by the SAME `SortState` the DataTable header uses, so table and cards stay in sync. The
|
||||||
|
// dropdown's options come straight from the columns' `sortable`/`sortValue` metadata — no new list.
|
||||||
|
export default function ColumnSortControl<T>({
|
||||||
|
columns,
|
||||||
|
sort,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
defaultLabel,
|
||||||
|
ascLabel,
|
||||||
|
descLabel,
|
||||||
|
}: {
|
||||||
|
columns: Column<T>[];
|
||||||
|
sort: SortState;
|
||||||
|
onChange: (next: SortState) => void;
|
||||||
|
/** Accessible name for the dropdown (e.g. "Sort"). */
|
||||||
|
label: string;
|
||||||
|
/** The "no sort / original order" option's text. */
|
||||||
|
defaultLabel: string;
|
||||||
|
ascLabel: string;
|
||||||
|
descLabel: string;
|
||||||
|
}) {
|
||||||
|
const sortable = columns.filter((c) => c.sortable && c.sortValue);
|
||||||
|
// A stored sort can name a column this table no longer offers (renamed, or `sortable` dropped).
|
||||||
|
// `sortRows` already leaves the rows unsorted then, so treat it as no sort here too — otherwise the
|
||||||
|
// select falls back to showing "default" while a direction arrow claims a sort that isn't applied.
|
||||||
|
const active = sortable.some((c) => c.key === sort?.key) ? sort : null;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<select
|
||||||
|
value={active?.key ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const key = e.target.value;
|
||||||
|
// Keep the direction when re-picking the same column; a new column starts ascending.
|
||||||
|
onChange(key ? { key, dir: active?.key === key ? active.dir : "asc" } : null);
|
||||||
|
}}
|
||||||
|
aria-label={label}
|
||||||
|
className="bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="">{defaultLabel}</option>
|
||||||
|
{sortable.map((c) => (
|
||||||
|
<option key={c.key} value={c.key}>
|
||||||
|
{c.header}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{active && (
|
||||||
|
<button
|
||||||
|
onClick={() => onChange({ key: active.key, dir: active.dir === "asc" ? "desc" : "asc" })}
|
||||||
|
title={active.dir === "asc" ? ascLabel : descLabel}
|
||||||
|
aria-label={active.dir === "asc" ? ascLabel : descLabel}
|
||||||
|
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
|
||||||
|
>
|
||||||
|
{active.dir === "asc" ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { useDismiss } from "../lib/useDismiss";
|
||||||
import { useScrollFade } from "../lib/useScrollFade";
|
import { useScrollFade } from "../lib/useScrollFade";
|
||||||
|
import { sortRows, cycleSort, type SortState } from "../lib/columnSort";
|
||||||
|
import Pager, { hasPagerContent } from "./Pager";
|
||||||
import { PageToolbar } from "./PageShell";
|
import { PageToolbar } from "./PageShell";
|
||||||
|
|
||||||
// A reusable, client-side data table: per-column sort + filter (in the header) + pagination,
|
// A reusable, client-side data table: per-column sort + filter (in the header) + pagination,
|
||||||
@@ -50,7 +52,6 @@ export interface Column<T> {
|
|||||||
cardLabel?: boolean;
|
cardLabel?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortState = { key: string; dir: "asc" | "desc" } | null;
|
|
||||||
type FilterMap = Record<string, string | string[]>;
|
type FilterMap = Record<string, string | string[]>;
|
||||||
interface Persisted {
|
interface Persisted {
|
||||||
sort: SortState;
|
sort: SortState;
|
||||||
@@ -89,6 +90,8 @@ export default function DataTable<T>({
|
|||||||
controlsBandClassName,
|
controlsBandClassName,
|
||||||
externalFilter,
|
externalFilter,
|
||||||
resetFiltersToken,
|
resetFiltersToken,
|
||||||
|
sort: sortProp,
|
||||||
|
onSortChange,
|
||||||
}: {
|
}: {
|
||||||
rows: T[];
|
rows: T[];
|
||||||
columns: Column<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
|
// 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.
|
// hide the rows the user was just sent to see.
|
||||||
resetFiltersToken?: number;
|
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 { t } = useTranslation();
|
||||||
const initial = loadPersist(persistKey);
|
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 [filters, setFilters] = useState<FilterMap>(initial.filters);
|
||||||
const [page, setPage] = useState(initial.page);
|
const [page, setPage] = useState(initial.page);
|
||||||
const [size, setSize] = useState(initial.size ?? pageSize);
|
const [size, setSize] = useState(initial.size ?? pageSize);
|
||||||
const [openFilter, setOpenFilter] = useState<string | null>(null);
|
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);
|
const popRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -161,19 +172,7 @@ export default function DataTable<T>({
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const sortCol = sort ? columns.find((c) => c.key === sort.key) : undefined;
|
const sorted = sortRows(filtered, columns, sort);
|
||||||
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;
|
|
||||||
|
|
||||||
// size <= 0 means "All" — one page with every row.
|
// size <= 0 means "All" — one page with every row.
|
||||||
const allRows = size <= 0;
|
const allRows = size <= 0;
|
||||||
@@ -181,17 +180,10 @@ export default function DataTable<T>({
|
|||||||
const safePage = Math.min(page, totalPages - 1);
|
const safePage = Math.min(page, totalPages - 1);
|
||||||
const paged = allRows ? sorted : sorted.slice(safePage * size, safePage * size + size);
|
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) {
|
function toggleSort(key: string) {
|
||||||
setSort((s) =>
|
const next = cycleSort(sort, key);
|
||||||
!s || s.key !== key ? { key, dir: "asc" } : s.dir === "asc" ? { key, dir: "desc" } : null
|
if (controlledSort) onSortChange!(next);
|
||||||
);
|
else setInternalSort(next);
|
||||||
setPage(0);
|
setPage(0);
|
||||||
}
|
}
|
||||||
function applyFilter(key: string, value: string | string[]) {
|
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 =
|
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 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">{controlsLeading}</div>
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
{pagerNode}
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : 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 =
|
const topControls =
|
||||||
controlsInBand && controls ? (
|
controlsInBand && controls ? (
|
||||||
<PageToolbar>
|
<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>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
|
// The Prev / page-input / Next + "N per page" cluster, lifted out of DataTable so a non-table view
|
||||||
|
// (the channel manager's card layout) can paginate the same way. Controlled: the caller owns page +
|
||||||
|
// size and slices its own rows; this only renders the controls and reports changes. `sizeLabel` lets
|
||||||
|
// the caller say "Rows per page" or "Cards per page". Returns null when there's nothing to page.
|
||||||
|
|
||||||
|
/** How many pages `total` rows make at `size` (`size <= 0` = "All" = one page). */
|
||||||
|
function pageCount(total: number, size: number): number {
|
||||||
|
return size <= 0 ? 1 : Math.max(1, Math.ceil(total / size));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a pager would render anything — the ONE owner of that rule. Callers that wrap the pager
|
||||||
|
* in their own chrome gate on this instead of re-deriving it: a drifted second copy is exactly what
|
||||||
|
* left empty control bands behind on tables with nothing to page. */
|
||||||
|
export function hasPagerContent(total: number, size: number): boolean {
|
||||||
|
return pageCount(total, size) > 1 || total > 0;
|
||||||
|
}
|
||||||
|
export default function Pager({
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
total,
|
||||||
|
pageSizeOptions,
|
||||||
|
onPageChange,
|
||||||
|
onSizeChange,
|
||||||
|
sizeLabel,
|
||||||
|
}: {
|
||||||
|
/** Current page (0-based). */
|
||||||
|
page: number;
|
||||||
|
/** Page size; `<= 0` means "All" (one page). */
|
||||||
|
size: number;
|
||||||
|
/** Total item count across all pages. */
|
||||||
|
total: number;
|
||||||
|
pageSizeOptions: number[];
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onSizeChange: (size: number) => void;
|
||||||
|
sizeLabel: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const totalPages = pageCount(total, size);
|
||||||
|
const safePage = Math.min(page, totalPages - 1);
|
||||||
|
|
||||||
|
// Local text for the editable "jump to page" box (committed on Enter/blur).
|
||||||
|
const [pageInput, setPageInput] = useState(String(safePage + 1));
|
||||||
|
useEffect(() => setPageInput(String(safePage + 1)), [safePage]);
|
||||||
|
|
||||||
|
// Turning the page — or resizing it, which re-slices from a different first item — swaps the whole
|
||||||
|
// list, so put the reader at its top; landing mid-list in a page you haven't seen is disorienting.
|
||||||
|
// The page scroller is the app's <main> (PageScroller documents itself as the ONE scroller; the
|
||||||
|
// same implicit contract BackToTop and VirtualGrid's getScrollParent already rely on).
|
||||||
|
function scrollListToTop() {
|
||||||
|
document.querySelector("main")?.scrollTo({ top: 0 });
|
||||||
|
}
|
||||||
|
function goToPage(p: number) {
|
||||||
|
onPageChange(p);
|
||||||
|
scrollListToTop();
|
||||||
|
}
|
||||||
|
function commitPageInput() {
|
||||||
|
const n = parseInt(pageInput, 10);
|
||||||
|
if (!isNaN(n)) goToPage(Math.min(totalPages - 1, Math.max(0, n - 1)));
|
||||||
|
else setPageInput(String(safePage + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPagerContent(total, size)) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => goToPage(Math.max(0, safePage - 1))}
|
||||||
|
disabled={safePage === 0}
|
||||||
|
className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
{t("datatable.pager.prev")}
|
||||||
|
</button>
|
||||||
|
<span className="text-muted flex items-center gap-1.5">
|
||||||
|
{t("datatable.pager.pageLabel")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={totalPages}
|
||||||
|
value={pageInput}
|
||||||
|
onChange={(e) => setPageInput(e.target.value)}
|
||||||
|
onBlur={commitPageInput}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && commitPageInput()}
|
||||||
|
aria-label={t("datatable.pager.pageLabel")}
|
||||||
|
className="w-14 bg-card border border-border rounded-md px-1.5 py-1 text-xs text-center tabular-nums outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
{t("datatable.pager.ofTotal", { total: totalPages })}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => goToPage(Math.min(totalPages - 1, safePage + 1))}
|
||||||
|
disabled={safePage >= totalPages - 1}
|
||||||
|
className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition"
|
||||||
|
>
|
||||||
|
{t("datatable.pager.next")}
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{total > 0 && (
|
||||||
|
<label className="flex items-center gap-2 text-muted">
|
||||||
|
{sizeLabel}
|
||||||
|
<select
|
||||||
|
value={size}
|
||||||
|
onChange={(e) => {
|
||||||
|
onSizeChange(Number(e.target.value));
|
||||||
|
scrollListToTop();
|
||||||
|
}}
|
||||||
|
className="bg-card border border-border rounded-md px-1.5 py-1 text-xs outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
{pageSizeOptions.map((n) => (
|
||||||
|
<option key={n} value={n}>
|
||||||
|
{n}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value={0}>{t("datatable.all")}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,8 @@
|
|||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
||||||
import type { Video } from "../lib/api";
|
import type { Video } from "../lib/api";
|
||||||
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
|
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
|
||||||
import VideoCard from "./VideoCard";
|
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
|
// 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
|
// 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.
|
// 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,
|
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 {
|
export interface VirtualFeedProps {
|
||||||
items: Video[];
|
items: Video[];
|
||||||
view: FeedView;
|
view: FeedView;
|
||||||
@@ -65,6 +30,9 @@ export interface VirtualFeedProps {
|
|||||||
fetchNextPage: () => void;
|
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({
|
export default function VirtualFeed({
|
||||||
items,
|
items,
|
||||||
view,
|
view,
|
||||||
@@ -76,123 +44,31 @@ export default function VirtualFeed({
|
|||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
}: VirtualFeedProps) {
|
}: VirtualFeedProps) {
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const { minCol, maxCol, thumb } = FEED_VIEW_SPEC[view];
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={listRef} style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
<VirtualGrid
|
||||||
{virtualRows.map((vr) => {
|
items={items}
|
||||||
const row = rows[vr.index];
|
getKey={(v) => v.id}
|
||||||
if (!row) return null;
|
minCol={minCol}
|
||||||
return (
|
maxCol={maxCol}
|
||||||
<div
|
rowEst={ROW_EST[view]}
|
||||||
key={vr.key}
|
hasNextPage={hasNextPage}
|
||||||
data-index={vr.index}
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
ref={virtualizer.measureElement}
|
fetchNextPage={fetchNextPage}
|
||||||
style={{
|
renderItem={(v, itemWidth) => (
|
||||||
position: "absolute",
|
<VideoCard
|
||||||
top: 0,
|
video={v}
|
||||||
left: 0,
|
view={view}
|
||||||
width: "100%",
|
// Only the one-line row (thumb:false) reads its own width — it stands columns down as it
|
||||||
transform: `translateY(${vr.start - virtualizer.options.scrollMargin}px)`,
|
// 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}
|
||||||
{/* One branch for both shapes: the single-column views just chunk to colCount 1 and
|
onState={onState}
|
||||||
get their reading-width cap instead of grid columns. */}
|
onToggleSave={onToggleSave}
|
||||||
<div
|
onResetState={onResetState}
|
||||||
className={singleCol ? "mx-auto pb-1" : "grid gap-4"}
|
onOpen={onOpen}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
|
|
||||||
|
// The virtualization engine, lifted out of VirtualFeed so any long client- or server-side list can
|
||||||
|
// reuse it (the feed's video grid, the channel manager's card/row views). It owns ONLY the layout
|
||||||
|
// mechanics — measure the container, derive the column count, chunk into rows, virtualize, and place
|
||||||
|
// each row absolutely — and knows nothing about what an item is. The caller supplies `renderItem`.
|
||||||
|
//
|
||||||
|
// ⚠️ Each row is placed with `position:absolute` + `transform` (below), which makes EVERY ROW ITS OWN
|
||||||
|
// STACKING CONTEXT. A z-indexed child therefore cannot escape its row: a menu or popover rendered
|
||||||
|
// inside an item draws UNDER the next row, whatever z-index it carries — the channel manager's tag
|
||||||
|
// picker hit exactly this. Anything floating that an item renders must portal out (see Overlay).
|
||||||
|
//
|
||||||
|
// Column sizing mirrors the CSS the feed used before virtualization
|
||||||
|
// (grid-cols-[repeat(auto-fill,minmax(260px,1fr))] with a gap-4 / 1rem gutter).
|
||||||
|
const GRID_GAP = 16;
|
||||||
|
const LIST_GAP = 4;
|
||||||
|
// Start fetching the next page this many rows before the end scrolls into view
|
||||||
|
// (keeps the old IntersectionObserver's generous prefetch feel).
|
||||||
|
const PREFETCH_ROWS = 4;
|
||||||
|
|
||||||
|
/** Columns that fit `width`, or 1 for the single-column layout (`minCol == null`). */
|
||||||
|
function columnsFor(minCol: number | null, width: number): number {
|
||||||
|
if (minCol == null) return 1;
|
||||||
|
return Math.max(1, Math.floor((width + GRID_GAP) / (minCol + GRID_GAP)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunk<T>(arr: T[], size: number): T[][] {
|
||||||
|
if (size <= 1) return arr.map((x) => [x]);
|
||||||
|
const out: T[][] = [];
|
||||||
|
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The list scrolls inside an <main overflow-y-auto> (the page scroller), not the window, so the
|
||||||
|
// virtualizer needs that element. Walk up to the nearest scrollable ancestor.
|
||||||
|
function getScrollParent(node: HTMLElement | null): HTMLElement {
|
||||||
|
let el = node?.parentElement ?? null;
|
||||||
|
while (el) {
|
||||||
|
const oy = getComputedStyle(el).overflowY;
|
||||||
|
if (oy === "auto" || oy === "scroll") return el;
|
||||||
|
el = el.parentElement;
|
||||||
|
}
|
||||||
|
return document.documentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VirtualGridProps<T> {
|
||||||
|
items: T[];
|
||||||
|
getKey: (item: T) => string;
|
||||||
|
/** Narrowest grid column in px, or null for ONE full-width column. The column count is derived
|
||||||
|
* from this and the container width, so every multi-column layout reflows for free. */
|
||||||
|
minCol: number | null;
|
||||||
|
/** Single-column layout only: the reading-width cap in px (null = fill). */
|
||||||
|
maxCol?: number | null;
|
||||||
|
/** Estimated row height before measurement; real heights are measured per-row, so this only
|
||||||
|
* affects the very first paint and the scrollbar's initial guess. Measure it, don't guess — a
|
||||||
|
* bad estimate makes the scrollbar jump as you scroll in. */
|
||||||
|
rowEst: number;
|
||||||
|
/** Renders one item. `itemWidth` is the measured px width of one cell — for width-derived
|
||||||
|
* layouts (e.g. a one-line row that drops columns as it narrows); ignore it otherwise, and to
|
||||||
|
* protect a memo'd item from re-rendering on every resize frame, pass a stable value down. */
|
||||||
|
renderItem: (item: T, itemWidth: number) => ReactNode;
|
||||||
|
/** Infinite scroll — omit all three for a finite client-side list. */
|
||||||
|
hasNextPage?: boolean;
|
||||||
|
isFetchingNextPage?: boolean;
|
||||||
|
fetchNextPage?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VirtualGrid<T>({
|
||||||
|
items,
|
||||||
|
getKey,
|
||||||
|
minCol,
|
||||||
|
maxCol = null,
|
||||||
|
rowEst,
|
||||||
|
renderItem,
|
||||||
|
hasNextPage = false,
|
||||||
|
isFetchingNextPage = false,
|
||||||
|
fetchNextPage,
|
||||||
|
}: VirtualGridProps<T>) {
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [scrollEl, setScrollEl] = useState<HTMLElement | null>(null);
|
||||||
|
const singleCol = minCol == null;
|
||||||
|
const [colCount, setColCount] = useState(singleCol ? 1 : 4);
|
||||||
|
// Distance from the scroll element's content top to where this list starts (a toolbar sits above
|
||||||
|
// it inside the same scroll area).
|
||||||
|
const [scrollMargin, setScrollMargin] = useState(0);
|
||||||
|
// How wide ONE row/card actually renders. It has to be the real container width, not the
|
||||||
|
// viewport: side rails/panels move it independently of the window, and a viewport breakpoint got
|
||||||
|
// this wrong before. This is measured here anyway, for colCount.
|
||||||
|
const [itemWidth, setItemWidth] = useState(0);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (listRef.current) setScrollEl(getScrollParent(listRef.current));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Re-measure the column count and start offset on container/scroll-area resize.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const node = listRef.current;
|
||||||
|
if (!node) return;
|
||||||
|
const measure = () => {
|
||||||
|
if (scrollEl) {
|
||||||
|
const m =
|
||||||
|
node.getBoundingClientRect().top -
|
||||||
|
scrollEl.getBoundingClientRect().top +
|
||||||
|
scrollEl.scrollTop;
|
||||||
|
setScrollMargin(m);
|
||||||
|
}
|
||||||
|
const w = node.clientWidth;
|
||||||
|
const cols = columnsFor(minCol, w);
|
||||||
|
setColCount(cols);
|
||||||
|
setItemWidth(
|
||||||
|
minCol == null ? Math.min(w, maxCol ?? w) : Math.floor((w - GRID_GAP * (cols - 1)) / cols)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(node);
|
||||||
|
if (scrollEl) ro.observe(scrollEl);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [scrollEl, minCol, maxCol]);
|
||||||
|
|
||||||
|
const rows = useMemo(() => chunk(items, colCount), [items, colCount]);
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: rows.length,
|
||||||
|
getScrollElement: () => scrollEl,
|
||||||
|
estimateSize: () => rowEst,
|
||||||
|
overscan: 4,
|
||||||
|
gap: singleCol ? LIST_GAP : GRID_GAP,
|
||||||
|
scrollMargin,
|
||||||
|
});
|
||||||
|
|
||||||
|
const virtualRows = virtualizer.getVirtualItems();
|
||||||
|
|
||||||
|
// Infinite-scroll trigger: once the last rendered row is within PREFETCH_ROWS of the end, pull
|
||||||
|
// the next page. A no-op for finite lists (no fetchNextPage / hasNextPage false).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fetchNextPage) return;
|
||||||
|
const last = virtualRows[virtualRows.length - 1];
|
||||||
|
if (!last) return;
|
||||||
|
if (last.index >= rows.length - 1 - PREFETCH_ROWS && hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}, [virtualRows, rows.length, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={listRef} style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
||||||
|
{virtualRows.map((vr) => {
|
||||||
|
const row = rows[vr.index];
|
||||||
|
if (!row) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={vr.key}
|
||||||
|
data-index={vr.index}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: "100%",
|
||||||
|
transform: `translateY(${vr.start - virtualizer.options.scrollMargin}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* One branch for both shapes: the single-column layout chunks to colCount 1 and gets
|
||||||
|
its reading-width cap instead of grid columns. */}
|
||||||
|
<div
|
||||||
|
className={singleCol ? "mx-auto pb-1" : "grid gap-4"}
|
||||||
|
style={
|
||||||
|
singleCol
|
||||||
|
? { maxWidth: maxCol ?? undefined }
|
||||||
|
: { gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))` }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* A transparent Fragment carries React's key so the rendered item stays the DIRECT
|
||||||
|
grid/column child — some cards rely on that (e.g. `h-full` stretch, actions pinned
|
||||||
|
to the bottom edge), which a wrapper element would silently break. */}
|
||||||
|
{row.map((item) => (
|
||||||
|
<Fragment key={getKey(item)}>{renderItem(item, itemWidth)}</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"intro": "Set a channel's <0>priority</0> to push its videos up when you sort by “Channel priority”, attach your own <1>tags</1> to filter the feed, or <2>hide</2> a channel to drop it from the feed without unsubscribing.",
|
|
||||||
"syncSubscriptions": "Read subscriptions from YouTube",
|
"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.",
|
"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",
|
"backfillEverything": "Backfill everything",
|
||||||
@@ -9,6 +8,18 @@
|
|||||||
"discovery": "Discover from playlists",
|
"discovery": "Discover from playlists",
|
||||||
"blocked": "Blocked"
|
"blocked": "Blocked"
|
||||||
},
|
},
|
||||||
|
"layoutLabel": "Layout",
|
||||||
|
"layout": {
|
||||||
|
"table": "Table",
|
||||||
|
"cards": "Cards"
|
||||||
|
},
|
||||||
|
"sort": {
|
||||||
|
"label": "Sort",
|
||||||
|
"default": "Default order",
|
||||||
|
"asc": "Ascending",
|
||||||
|
"desc": "Descending"
|
||||||
|
},
|
||||||
|
"cardsPerPage": "Cards per page",
|
||||||
"discovery": {
|
"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).",
|
"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…",
|
"loading": "Finding channels…",
|
||||||
@@ -47,6 +58,7 @@
|
|||||||
"cols": {
|
"cols": {
|
||||||
"priority": "Prio",
|
"priority": "Prio",
|
||||||
"channel": "Channel",
|
"channel": "Channel",
|
||||||
|
"about": "About",
|
||||||
"stored": "Stored",
|
"stored": "Stored",
|
||||||
"subs": "Subscribers",
|
"subs": "Subscribers",
|
||||||
"lastUpload": "Last upload",
|
"lastUpload": "Last upload",
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"intro": "Állíts be egy csatornának <0>prioritást</0>, hogy a videói előrébb kerüljenek, amikor „Csatorna prioritás” szerint rendezel, csatolj saját <1>címkéket</1> a hírfolyam szűréséhez, vagy <2>rejts el</2> egy csatornát, hogy leiratkozás nélkül kivedd a hírfolyamból.",
|
|
||||||
"syncSubscriptions": "Feliratkozások beolvasása a YouTube-ról",
|
"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.",
|
"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",
|
"backfillEverything": "Minden letöltése",
|
||||||
@@ -9,6 +8,18 @@
|
|||||||
"discovery": "Felfedezés a lejátszási listákból",
|
"discovery": "Felfedezés a lejátszási listákból",
|
||||||
"blocked": "Letiltott"
|
"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": {
|
"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).",
|
"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…",
|
"loading": "Csatornák keresése…",
|
||||||
@@ -47,6 +58,7 @@
|
|||||||
"cols": {
|
"cols": {
|
||||||
"priority": "Prio",
|
"priority": "Prio",
|
||||||
"channel": "Csatorna",
|
"channel": "Csatorna",
|
||||||
|
"about": "Leírás",
|
||||||
"stored": "Tárolt",
|
"stored": "Tárolt",
|
||||||
"subs": "Feliratkozók",
|
"subs": "Feliratkozók",
|
||||||
"lastUpload": "Utolsó feltöltés",
|
"lastUpload": "Utolsó feltöltés",
|
||||||
|
|||||||
@@ -510,6 +510,7 @@ export interface ManagedChannel {
|
|||||||
thumbnail_url: string | null;
|
thumbnail_url: string | null;
|
||||||
subscriber_count: number | null;
|
subscriber_count: number | null;
|
||||||
video_count: number | null;
|
video_count: number | null;
|
||||||
|
description: string | null;
|
||||||
stored_videos: number;
|
stored_videos: number;
|
||||||
last_video_at: string | null;
|
last_video_at: string | null;
|
||||||
total_duration_seconds: number;
|
total_duration_seconds: number;
|
||||||
@@ -535,6 +536,7 @@ export interface DiscoveredChannel {
|
|||||||
thumbnail_url: string | null;
|
thumbnail_url: string | null;
|
||||||
subscriber_count: number | null;
|
subscriber_count: number | null;
|
||||||
video_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_video_count: number; // how many of the user's playlist videos are from here
|
||||||
playlist_count: number; // across how many of their playlists
|
playlist_count: number; // across how many of their playlists
|
||||||
details_synced: boolean;
|
details_synced: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// The channel manager's layout vocabulary (E4 S3). Named `ChannelLayout` — NOT `ChannelView` — on
|
||||||
|
// purpose: the manager already has a `ChannelsView` (the subscribed/discovery/blocked TAB), and a
|
||||||
|
// one-letter-apart type would be a footgun. This is the table-vs-card density switch, riding the
|
||||||
|
// same generic `ViewSwitcher` as the feed. Two layouts: the DataTable, and a virtualized card grid.
|
||||||
|
// (A `rows` variant existed briefly but was retired — it barely differed from the table.)
|
||||||
|
export type ChannelLayout = "table" | "cards";
|
||||||
|
|
||||||
|
export const CHANNEL_LAYOUTS: readonly ChannelLayout[] = ["table", "cards"] as const;
|
||||||
|
|
||||||
|
const CHANNEL_LAYOUT_DEFAULT: ChannelLayout = "table";
|
||||||
|
|
||||||
|
/** The `cards` layout's grid spec, consumed by VirtualGrid. `table` has no entry — it keeps the
|
||||||
|
* numbered-pager DataTable, not the virtualized grid. */
|
||||||
|
export interface ChannelLayoutSpec {
|
||||||
|
/** Narrowest grid column in px. */
|
||||||
|
minCol: number;
|
||||||
|
/** First-paint row-height estimate; real heights are measured per-row after. Measured in the
|
||||||
|
* browser (channel heading + About line + meta + tags + actions), not guessed. */
|
||||||
|
rowEst: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CHANNEL_LAYOUT_SPEC: Record<Exclude<ChannelLayout, "table">, ChannelLayoutSpec> = {
|
||||||
|
cards: { minCol: 320, rowEst: 232 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Coerce a stored value to a layout; anything unknown (corrupt, a rolled-back future layout, or the
|
||||||
|
* retired "rows") falls back to the default rather than rendering nothing. */
|
||||||
|
export function parseChannelLayout(raw: unknown): ChannelLayout {
|
||||||
|
return (CHANNEL_LAYOUTS as readonly string[]).includes(raw as string)
|
||||||
|
? (raw as ChannelLayout)
|
||||||
|
: CHANNEL_LAYOUT_DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cards-per-page choices for the card layout's pager (0 = "All"), shared by both manager tabs. */
|
||||||
|
export const CARD_PAGE_SIZES = [10, 25, 50, 100];
|
||||||
|
const CARD_SIZE_DEFAULT = 25;
|
||||||
|
|
||||||
|
/** Coerce a stored cards-per-page value to one the pager actually OFFERS (the sizes above, or 0 =
|
||||||
|
* "All"). Clamped against the allowed set — like `clampView`/`clampStatus` — not merely type-checked:
|
||||||
|
* a corrupt entry would make the page count NaN and slice the grid down to nothing, and an
|
||||||
|
* off-menu number (a hand-edited 7, or an option a future build dropped) would page by a value the
|
||||||
|
* `<select>` cannot show, so the control and the behaviour would silently disagree. */
|
||||||
|
export function parseCardSize(raw: unknown): number {
|
||||||
|
return typeof raw === "number" && (raw === 0 || CARD_PAGE_SIZES.includes(raw))
|
||||||
|
? raw
|
||||||
|
: CARD_SIZE_DEFAULT;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// Shared column-sort primitives, lifted out of DataTable so a NON-table renderer (the channel
|
||||||
|
// manager's card layout) can sort the same rows the same way — one comparator, one source of truth.
|
||||||
|
// Kept structural (no `Column` import) so it has zero coupling to DataTable.
|
||||||
|
|
||||||
|
export type SortState = { key: string; dir: "asc" | "desc" } | null;
|
||||||
|
|
||||||
|
interface Sortable<T> {
|
||||||
|
key: string;
|
||||||
|
sortValue?: (row: T) => string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sort a copy of `rows` by the sortable column named in `sort` (numeric diff or locale compare).
|
||||||
|
* Returns `rows` unchanged when there's no sort or the column has no `sortValue`. */
|
||||||
|
export function sortRows<T>(rows: T[], columns: Sortable<T>[], sort: SortState): T[] {
|
||||||
|
if (!sort) return rows;
|
||||||
|
const get = columns.find((c) => c.key === sort.key)?.sortValue;
|
||||||
|
if (!get) return rows;
|
||||||
|
return [...rows].sort((a, b) => {
|
||||||
|
const av = get(a);
|
||||||
|
const bv = get(b);
|
||||||
|
const cmp =
|
||||||
|
typeof av === "number" && typeof bv === "number"
|
||||||
|
? av - bv
|
||||||
|
: String(av).localeCompare(String(bv));
|
||||||
|
return sort.dir === "asc" ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Coerce a stored value to a SortState. Anything malformed (corrupt storage, a shape from an older
|
||||||
|
* build) becomes "no sort" rather than flowing a junk `dir` into the comparator. */
|
||||||
|
export function parseSortState(raw: unknown): SortState {
|
||||||
|
if (!raw || typeof raw !== "object") return null;
|
||||||
|
const { key, dir } = raw as { key?: unknown; dir?: unknown };
|
||||||
|
return typeof key === "string" && (dir === "asc" || dir === "desc") ? { key, dir } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The header-click cycle: unsorted → ascending → descending → unsorted. */
|
||||||
|
export function cycleSort(current: SortState, key: string): SortState {
|
||||||
|
if (!current || current.key !== key) return { key, dir: "asc" };
|
||||||
|
if (current.dir === "asc") return { key, dir: "desc" };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { Fragment, type ReactNode } from "react";
|
||||||
|
|
||||||
|
// Known platform → profile base URL, for "<platform>: @handle" mentions in descriptions.
|
||||||
|
const PLATFORMS: Record<string, string> = {
|
||||||
|
x: "https://x.com/",
|
||||||
|
twitter: "https://x.com/",
|
||||||
|
instagram: "https://instagram.com/",
|
||||||
|
insta: "https://instagram.com/",
|
||||||
|
ig: "https://instagram.com/",
|
||||||
|
facebook: "https://facebook.com/",
|
||||||
|
fb: "https://facebook.com/",
|
||||||
|
tiktok: "https://www.tiktok.com/@",
|
||||||
|
youtube: "https://www.youtube.com/@",
|
||||||
|
yt: "https://www.youtube.com/@",
|
||||||
|
twitch: "https://www.twitch.tv/",
|
||||||
|
threads: "https://www.threads.net/@",
|
||||||
|
telegram: "https://t.me/",
|
||||||
|
soundcloud: "https://soundcloud.com/",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Two link shapes, combined into one scanner:
|
||||||
|
// URL — explicit http(s):// URLs, www.* hosts, or domain.tld/path bare URLs (required path +
|
||||||
|
// letters-only TLD keep version numbers / file names out). Non-http gets an https:// href.
|
||||||
|
// MENTION — "<platform>: @handle" where <platform> is one of the known keys above. The colon/arrow
|
||||||
|
// separator is REQUIRED, so a stray "x" in prose won't match. Only the @handle is linked
|
||||||
|
// (to the platform's profile URL); the "<platform>: " label stays as text. Bare @handles
|
||||||
|
// with no platform, and #hashtags, are left plain.
|
||||||
|
const URL_SOURCE =
|
||||||
|
'(?:https?://|www\\.)[^\\s<]+[^\\s<.,!?;:)\\]}"\']|[a-z0-9-]+(?:\\.[a-z0-9-]+)*\\.[a-z]{2,}/[^\\s<]*[^\\s<.,!?;:)\\]}"\']';
|
||||||
|
const MENTION_SOURCE =
|
||||||
|
"\\b(" +
|
||||||
|
Object.keys(PLATFORMS).join("|") +
|
||||||
|
")\\b[ \\t]*[::►▶▸]+[ \\t]*@([a-z0-9_](?:[a-z0-9_.]{0,28}[a-z0-9_])?)";
|
||||||
|
const TOKEN_RE = new RegExp("(" + URL_SOURCE + ")|" + MENTION_SOURCE, "gi");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn links inside a plain-text string into clickable <a>s that open in a new tab (safe rel):
|
||||||
|
* bare URLs (http(s)/www/domain.tld/path) and "<platform>: @handle" social mentions. Everything
|
||||||
|
* else is returned as text, so it composes with `whitespace-pre-wrap` (line breaks preserved).
|
||||||
|
*/
|
||||||
|
export function linkify(text: string): ReactNode[] {
|
||||||
|
const out: ReactNode[] = [];
|
||||||
|
let last = 0;
|
||||||
|
let key = 0;
|
||||||
|
TOKEN_RE.lastIndex = 0;
|
||||||
|
const link = (href: string, label: string) => (
|
||||||
|
<a
|
||||||
|
key={key++}
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-accent hover:underline break-all"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = TOKEN_RE.exec(text)) !== null) {
|
||||||
|
if (m.index > last) out.push(<Fragment key={key++}>{text.slice(last, m.index)}</Fragment>);
|
||||||
|
if (m[1]) {
|
||||||
|
// Plain URL.
|
||||||
|
const raw = m[1];
|
||||||
|
out.push(link(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`, raw));
|
||||||
|
} else {
|
||||||
|
// "<platform>: @handle" — keep the "<platform>: " prefix as text, link only the handle.
|
||||||
|
const handle = m[3];
|
||||||
|
out.push(<Fragment key={key++}>{m[0].slice(0, m[0].length - handle.length - 1)}</Fragment>);
|
||||||
|
out.push(link(PLATFORMS[m[2].toLowerCase()] + handle, "@" + handle));
|
||||||
|
}
|
||||||
|
last = m.index + m[0].length;
|
||||||
|
}
|
||||||
|
if (last < text.length) out.push(<Fragment key={key++}>{text.slice(last)}</Fragment>);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -24,6 +24,14 @@ export const LS = {
|
|||||||
feedView: "siftlode.feedView",
|
feedView: "siftlode.feedView",
|
||||||
channelFilter: "siftlode.channelFilter",
|
channelFilter: "siftlode.channelFilter",
|
||||||
channelsView: "siftlode.channelsView",
|
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",
|
channelsTable: "siftlode.channelsTable",
|
||||||
channelDiscoveryTable: "siftlode.channelDiscoveryTable",
|
channelDiscoveryTable: "siftlode.channelDiscoveryTable",
|
||||||
settingsTab: "siftlode.settingsTab",
|
settingsTab: "siftlode.settingsTab",
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { CARD_PAGE_SIZES, parseCardSize } from "./channelLayout";
|
||||||
|
import { readAccount, writeAccount } from "./storage";
|
||||||
|
|
||||||
|
// Client-side pagination for the channel manager's card layout, owned once instead of twice. Both
|
||||||
|
// manager tabs page an in-memory list the same way, and keeping two copies meant every fix landed
|
||||||
|
// twice (and the day one copy was missed, the tabs would page differently). The caller keeps only
|
||||||
|
// what genuinely differs: which rows, which storage key, and when to jump back to page one.
|
||||||
|
//
|
||||||
|
// The page size persists per account; the page itself is transient. `enabled` is the card layout
|
||||||
|
// flag — in table mode DataTable does its own paging, so this does no work and holds still.
|
||||||
|
export function useCardPager<T>(rows: T[], sizeKey: string, enabled: boolean) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [size, setSize] = useState(() => parseCardSize(readAccount(sizeKey, null)));
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
|
||||||
|
const totalPages = size <= 0 ? 1 : Math.max(1, Math.ceil(rows.length / size));
|
||||||
|
const safePage = Math.min(page, totalPages - 1);
|
||||||
|
|
||||||
|
// A list that shrank under you (an unsubscribe, a refetch) leaves `page` out of range: the clamp
|
||||||
|
// above only HIDES that, so commit it or it springs back the moment the list grows again. Only
|
||||||
|
// fires when the page you are on stopped existing, so it never costs you your place otherwise.
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled && page > totalPages - 1) setPage(totalPages - 1);
|
||||||
|
}, [enabled, page, totalPages]);
|
||||||
|
|
||||||
|
const paged =
|
||||||
|
!enabled || size <= 0 ? rows : rows.slice(safePage * size, safePage * size + size);
|
||||||
|
|
||||||
|
return {
|
||||||
|
paged,
|
||||||
|
/** Spread onto <Pager/>. */
|
||||||
|
pagerProps: {
|
||||||
|
page: safePage,
|
||||||
|
size,
|
||||||
|
total: rows.length,
|
||||||
|
pageSizeOptions: CARD_PAGE_SIZES,
|
||||||
|
onPageChange: setPage,
|
||||||
|
onSizeChange: (s: number) => {
|
||||||
|
setSize(s);
|
||||||
|
writeAccount(sizeKey, s);
|
||||||
|
setPage(0);
|
||||||
|
},
|
||||||
|
sizeLabel: t("channels.cardsPerPage"),
|
||||||
|
},
|
||||||
|
/** Call when the caller's own filters change — a different list starts at page one. */
|
||||||
|
resetPage: useCallback(() => setPage(0), []),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { PrefsProvider } from "./components/PrefsProvider";
|
|||||||
import { WizardProvider } from "./components/WizardProvider";
|
import { WizardProvider } from "./components/WizardProvider";
|
||||||
import "./i18n";
|
import "./i18n";
|
||||||
import "./index.css";
|
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
|
// 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.
|
// legal page never downloads the authenticated app bundle, and the app never carries them either.
|
||||||
|
|||||||
Reference in New Issue
Block a user