feat(managers): paginate the card layout + its top scroll-fade

UAT round 3 feedback on the card layout:
- Add a pager + "Cards per page" to the card view (it infinitely-scrolled before).
  Extracted the pager cluster out of DataTable into a reusable Pager component
  (the table now renders it too — no behaviour change); the card view slices the
  sorted rows and drives its own per-account page size (subscribed + discovery).
- Restore the top edge-fade in the card layout. The channels page registers
  fadeTop:false for its sticky-header table; the card layout has no sticky header,
  so App now re-enables the top fade when the channel layout is cards.
This commit is contained in:
2026-07-19 23:28:43 +02:00
parent 9c4dd0163a
commit dff4152522
8 changed files with 209 additions and 88 deletions
+7 -1
View File
@@ -27,6 +27,7 @@ import {
} from "./lib/storage";
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
import { useChannels } from "./components/ChannelsProvider";
import { useFeedView, useFeedViewActions } from "./components/FeedViewProvider";
import { moduleDef } from "./lib/modules";
import { PAGE_CONTENT, PAGE_RAIL } from "./components/moduleRegistry";
@@ -62,6 +63,9 @@ export default function App() {
// handlers that mutate the feed view (onShowInFeed, tag-click, the feed scrollKey).
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
// The channel manager turns its top fade OFF for the table (its sticky header sits at the scroller
// top) but wants it ON for the card layout (no sticky header) — so the fade depends on the layout.
const { layout: channelLayout } = useChannels();
// The feed's view mode (card/row/tile density) lives in FeedViewProvider — server-synced, shared
// by the feed page and the channel page. App reads it only to hand to <Feed>/<ChannelPage>.
const view = useFeedView();
@@ -387,7 +391,9 @@ export default function App() {
) : (
<PageShell
scrollKey={normalScrollKey}
fadeTop={fadeTop ?? true}
// Channels: the registry disables the top fade for its sticky-header table, but the card
// layout has no sticky header, so re-enable it there.
fadeTop={activePage === "channels" ? channelLayout === "cards" : fadeTop ?? true}
header={<Header me={meQuery.data!} onYtSearch={enterYtSearch} />}
banners={
<>
+30 -2
View File
@@ -15,6 +15,7 @@ import AboutCell from "./AboutCell";
import DataTable, { type Column } from "./DataTable";
import ChannelLayoutGrid from "./ChannelLayoutGrid";
import ColumnSortControl from "./ColumnSortControl";
import Pager from "./Pager";
import { PageToolbar } from "./PageShell";
import { useConfirm } from "./ConfirmProvider";
import { useChannels } from "./ChannelsProvider";
@@ -47,6 +48,9 @@ export default function ChannelDiscovery({
setSortState(s);
writeAccount(LS.channelDiscoverySort, s);
};
// Card layout pagination (its own; the table keeps its rows-per-page inside DataTable).
const [cardSize, setCardSize] = useState(() => readAccount<number>(LS.channelDiscoveryCardSize, 25));
const [cardPage, setCardPage] = useState(0);
const query = useQuery({
queryKey: ["discovered-channels"],
@@ -180,14 +184,37 @@ export default function ChannelDiscovery({
},
];
// Card layout paginates like the table (slice the sorted rows; the Pager reports changes).
const sortedRows = sortRows(rows, columns, sort);
const cardTotalPages = cardSize <= 0 ? 1 : Math.max(1, Math.ceil(sortedRows.length / cardSize));
const cardSafePage = Math.min(cardPage, cardTotalPages - 1);
const pagedRows =
cardSize <= 0
? sortedRows
: sortedRows.slice(cardSafePage * cardSize, cardSafePage * cardSize + cardSize);
return (
<>
{/* Intro + the table controls are fixed chrome; only the table rows scroll (sticky header). */}
<PageToolbar>
<div className="px-4 pt-3 max-w-[96rem] mx-auto flex items-start justify-between gap-4">
<p className="text-xs text-muted leading-relaxed">{t("channels.discovery.intro")}</p>
{/* Cards has no column headers, so a dropdown drives the sort (table uses the headers). */}
{/* 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
page={cardSafePage}
size={cardSize}
total={sortedRows.length}
pageSizeOptions={[10, 25, 50, 100]}
onPageChange={setCardPage}
onSizeChange={(s) => {
setCardSize(s);
writeAccount(LS.channelDiscoveryCardSize, s);
setCardPage(0);
}}
sizeLabel={t("channels.cardsPerPage")}
/>
<ColumnSortControl
columns={columns}
sort={sort}
@@ -197,6 +224,7 @@ export default function ChannelDiscovery({
ascLabel={t("channels.sort.asc")}
descLabel={t("channels.sort.desc")}
/>
</div>
)}
</div>
</PageToolbar>
@@ -218,7 +246,7 @@ export default function ChannelDiscovery({
/>
) : (
<ChannelLayoutGrid
rows={sortRows(rows, columns, sort)}
rows={pagedRows}
columns={columns}
rowKey={(c) => c.id}
aboutKey="about"
+42 -6
View File
@@ -19,7 +19,7 @@ import {
} from "lucide-react";
import { api, type ManagedChannel, type Tag } from "../lib/api";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage";
import { accountKey, LS, readAccount, writeAccount } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
import { formatEta, formatTotalHours, relativeTime } from "../lib/format";
@@ -30,6 +30,7 @@ import DataTable, { type Column } from "./DataTable";
import ChannelLayoutGrid from "./ChannelLayoutGrid";
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 { PageToolbar } from "./PageShell";
@@ -60,6 +61,10 @@ const LAYOUT_ICON: Record<ChannelLayout, typeof Table> = {
cards: LayoutGrid,
};
// Cards-per-page options (mirrors the DataTable default); the card grid gets a pager like the table.
const CARD_PAGE_SIZES = [10, 25, 50, 100];
const CARD_SIZE_DEFAULT = 25;
export default function Channels() {
const me = useMe();
const canWrite = me.can_write;
@@ -210,6 +215,10 @@ export default function Channels() {
// Channel-name search (from the shared top SearchBar, via props) + tag-chip filtering, applied
// client-side over the status-filtered list.
const [tagFilter, setTagFilter] = useState<number[]>([]);
// Card layout pagination (the table keeps its own inside DataTable). Size persists per account;
// the page is transient.
const [cardSize, setCardSize] = useState(() => readAccount<number>(LS.channelCardSize, CARD_SIZE_DEFAULT));
const [cardPage, setCardPage] = useState(0);
// A focus-channel intent (header "without full history" / tag manager) seeds the search box.
useEffect(() => {
if (focusChannelName) setSearch(focusChannelName);
@@ -513,6 +522,30 @@ export default function Channels() {
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.
const sortedChannels = sortRows(visibleChannels, columns, sort);
const cardTotalPages = cardSize <= 0 ? 1 : Math.max(1, Math.ceil(sortedChannels.length / cardSize));
const cardSafePage = Math.min(cardPage, cardTotalPages - 1);
const pagedCards =
cardSize <= 0
? sortedChannels
: sortedChannels.slice(cardSafePage * cardSize, cardSafePage * cardSize + cardSize);
const cardPager = (
<Pager
page={cardSafePage}
size={cardSize}
total={sortedChannels.length}
pageSizeOptions={CARD_PAGE_SIZES}
onPageChange={setCardPage}
onSizeChange={(s) => {
setCardSize(s);
writeAccount(LS.channelCardSize, s);
setCardPage(0);
}}
sizeLabel={t("channels.cardsPerPage")}
/>
);
const tabs = (
<div className="px-4 pt-4 max-w-[96rem] mx-auto flex items-center justify-between gap-4 flex-wrap">
@@ -596,10 +629,13 @@ export default function Channels() {
</div>
</div>
)}
{/* In table mode the status chips ride in DataTable's own in-band controls row (beside the
pager). The card/row layouts have no such row, so surface the same chips here. */}
{view === "subscribed" && layout !== "table" && (
<div className="px-4 pb-2 max-w-[96rem] mx-auto">{statusChips}</div>
{/* In table mode the status chips + pager ride in DataTable's own in-band controls row. The card
layout has no such row, so surface the same status chips (left) and its pager (right) here. */}
{view === "subscribed" && layout === "cards" && (
<div className="px-4 pb-2 max-w-[96rem] mx-auto flex items-center justify-between gap-3 flex-wrap">
{statusChips}
{cardPager}
</div>
)}
</PageToolbar>
{tagManagerOpen && (
@@ -675,7 +711,7 @@ export default function Channels() {
/>
) : (
<ChannelLayoutGrid
rows={sortRows(visibleChannels, columns, sort)}
rows={pagedCards}
columns={columns}
rowKey={(c) => c.id}
leadKey="priority"
+14 -68
View File
@@ -1,9 +1,10 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ListFilter, X } from "lucide-react";
import { ArrowDown, ArrowUp, ListFilter, X } from "lucide-react";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
import { sortRows, cycleSort, type SortState } from "../lib/columnSort";
import Pager from "./Pager";
import { PageToolbar } from "./PageShell";
// A reusable, client-side data table: per-column sort + filter (in the header) + pagination,
@@ -132,8 +133,6 @@ export default function DataTable<T>({
const [page, setPage] = useState(initial.page);
const [size, setSize] = useState(initial.size ?? pageSize);
const [openFilter, setOpenFilter] = useState<string | null>(null);
// Local text for the editable "jump to page" box (committed on Enter/blur).
const [pageInput, setPageInput] = useState("1");
const popRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
@@ -181,13 +180,6 @@ export default function DataTable<T>({
const safePage = Math.min(page, totalPages - 1);
const paged = allRows ? sorted : sorted.slice(safePage * size, safePage * size + size);
useEffect(() => setPageInput(String(safePage + 1)), [safePage]);
function commitPageInput() {
const n = parseInt(pageInput, 10);
if (!isNaN(n)) setPage(Math.min(totalPages - 1, Math.max(0, n - 1)));
else setPageInput(String(safePage + 1));
}
function toggleSort(key: string) {
const next = cycleSort(sort, key);
if (controlledSort) onSortChange!(next);
@@ -291,66 +283,20 @@ 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.
const pagerNode =
totalPages > 1 || sorted.length > 0 ? (
<div className="flex items-center gap-3 flex-wrap">
{totalPages > 1 && (
<div className="flex items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={safePage === 0}
className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition"
>
<ChevronLeft className="w-4 h-4" />
{t("datatable.pager.prev")}
</button>
<span className="text-muted flex items-center gap-1.5">
{t("datatable.pager.pageLabel")}
<input
type="number"
min={1}
max={totalPages}
value={pageInput}
onChange={(e) => setPageInput(e.target.value)}
onBlur={commitPageInput}
onKeyDown={(e) => e.key === "Enter" && commitPageInput()}
aria-label={t("datatable.pager.pageLabel")}
className="w-14 bg-card border border-border rounded-md px-1.5 py-1 text-xs text-center tabular-nums outline-none focus:border-accent"
/>
{t("datatable.pager.ofTotal", { total: totalPages })}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={safePage >= totalPages - 1}
className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition"
>
{t("datatable.pager.next")}
<ChevronRight className="w-4 h-4" />
</button>
</div>
)}
{sorted.length > 0 && (
<label className="flex items-center gap-2 text-muted">
{t("datatable.rowsPerPage")}
<select
value={size}
onChange={(e) => {
setSize(Number(e.target.value));
const pagerNode = (
<Pager
page={safePage}
size={size}
total={sorted.length}
pageSizeOptions={pageSizeOptions}
onPageChange={setPage}
onSizeChange={(s) => {
setSize(s);
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>
) : null;
sizeLabel={t("datatable.rowsPerPage")}
/>
);
const controls =
controlsLeading != null || pagerNode ? (
+100
View File
@@ -0,0 +1,100 @@
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.
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 allRows = size <= 0;
const totalPages = allRows ? 1 : Math.max(1, Math.ceil(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]);
function commitPageInput() {
const n = parseInt(pageInput, 10);
if (!isNaN(n)) onPageChange(Math.min(totalPages - 1, Math.max(0, n - 1)));
else setPageInput(String(safePage + 1));
}
if (!(totalPages > 1 || total > 0)) return null;
return (
<div className="flex items-center gap-3 flex-wrap">
{totalPages > 1 && (
<div className="flex items-center gap-2">
<button
onClick={() => onPageChange(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={() => onPageChange(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))}
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>
);
}
@@ -19,6 +19,7 @@
"asc": "Ascending",
"desc": "Descending"
},
"cardsPerPage": "Cards per page",
"discovery": {
"intro": "Channels that appear in your playlists but that you don't subscribe to. Subscribe to follow them — their new uploads will start showing in your feed (subscribing costs a little YouTube quota; existing videos aren't re-fetched).",
"loading": "Finding channels…",
@@ -19,6 +19,7 @@
"asc": "Növekvő",
"desc": "Csökkenő"
},
"cardsPerPage": "Kártya / oldal",
"discovery": {
"intro": "Csatornák, amelyek megjelennek a lejátszási listáidban, de még nem iratkoztál fel rájuk. Iratkozz fel, hogy kövesd őket — az új feltöltéseik megjelennek a hírfolyamodban (a feliratkozás kevés YouTube-kvótát használ; a meglévő videókat nem tölti le újra).",
"loading": "Csatornák keresése…",
+3
View File
@@ -29,6 +29,9 @@ export const LS = {
// The manager's sort, lifted out of the table so the card layout shares it (per-account, per-tab).
channelSort: "siftlode.channelSort",
channelDiscoverySort: "siftlode.channelDiscoverySort",
// Cards-per-page for the card layout (the table keeps its own rows-per-page in its persist blob).
channelCardSize: "siftlode.channelCardSize",
channelDiscoveryCardSize: "siftlode.channelDiscoveryCardSize",
channelsTable: "siftlode.channelsTable",
channelDiscoveryTable: "siftlode.channelDiscoveryTable",
settingsTab: "siftlode.settingsTab",