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
+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"