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:
@@ -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={
|
||||
<>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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));
|
||||
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;
|
||||
const pagerNode = (
|
||||
<Pager
|
||||
page={safePage}
|
||||
size={size}
|
||||
total={sorted.length}
|
||||
pageSizeOptions={pageSizeOptions}
|
||||
onPageChange={setPage}
|
||||
onSizeChange={(s) => {
|
||||
setSize(s);
|
||||
setPage(0);
|
||||
}}
|
||||
sizeLabel={t("datatable.rowsPerPage")}
|
||||
/>
|
||||
);
|
||||
|
||||
const controls =
|
||||
controlsLeading != null || pagerNode ? (
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user