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
+39 -11
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,23 +184,47 @@ 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" && (
<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 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}
onChange={setSort}
label={t("channels.sort.label")}
defaultLabel={t("channels.sort.default")}
ascLabel={t("channels.sort.asc")}
descLabel={t("channels.sort.desc")}
/>
</div>
)}
</div>
</PageToolbar>
@@ -218,7 +246,7 @@ export default function ChannelDiscovery({
/>
) : (
<ChannelLayoutGrid
rows={sortRows(rows, columns, sort)}
rows={pagedRows}
columns={columns}
rowKey={(c) => c.id}
aboutKey="about"