From dff4152522e7447068f4de71dd27de777267d647 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 19 Jul 2026 23:28:43 +0200 Subject: [PATCH] feat(managers): paginate the card layout + its top scroll-fade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/App.tsx | 8 +- frontend/src/components/ChannelDiscovery.tsx | 50 ++++++++-- frontend/src/components/Channels.tsx | 48 +++++++-- frontend/src/components/DataTable.tsx | 86 +++------------- frontend/src/components/Pager.tsx | 100 +++++++++++++++++++ frontend/src/i18n/locales/en/channels.json | 1 + frontend/src/i18n/locales/hu/channels.json | 1 + frontend/src/lib/storage.ts | 3 + 8 files changed, 209 insertions(+), 88 deletions(-) create mode 100644 frontend/src/components/Pager.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 93f003b9..94892efd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 /. const view = useFeedView(); @@ -387,7 +391,9 @@ export default function App() { ) : ( } banners={ <> diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index d3183567..3bf291a1 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -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(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). */}

{t("channels.discovery.intro")}

- {/* 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" && ( - +
+ { + setCardSize(s); + writeAccount(LS.channelDiscoveryCardSize, s); + setCardPage(0); + }} + sizeLabel={t("channels.cardsPerPage")} + /> + +
)}
@@ -218,7 +246,7 @@ export default function ChannelDiscovery({ /> ) : ( c.id} aboutKey="about" diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index f2277382..690d86f0 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -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 = { 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([]); + // Card layout pagination (the table keeps its own inside DataTable). Size persists per account; + // the page is transient. + const [cardSize, setCardSize] = useState(() => readAccount(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 = ( + { + setCardSize(s); + writeAccount(LS.channelCardSize, s); + setCardPage(0); + }} + sizeLabel={t("channels.cardsPerPage")} + /> + ); const tabs = (
@@ -596,10 +629,13 @@ export default function Channels() {
)} - {/* 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" && ( -
{statusChips}
+ {/* 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" && ( +
+ {statusChips} + {cardPager} +
)} {tagManagerOpen && ( @@ -675,7 +711,7 @@ export default function Channels() { /> ) : ( c.id} leadKey="priority" diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx index 40130214..b91b0483 100644 --- a/frontend/src/components/DataTable.tsx +++ b/frontend/src/components/DataTable.tsx @@ -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({ const [page, setPage] = useState(initial.page); const [size, setSize] = useState(initial.size ?? pageSize); const [openFilter, setOpenFilter] = useState(null); - // Local text for the editable "jump to page" box (committed on Enter/blur). - const [pageInput, setPageInput] = useState("1"); const popRef = useRef(null); useEffect(() => { @@ -181,13 +180,6 @@ export default function DataTable({ 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({ // 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 ? ( -
- {totalPages > 1 && ( -
- - - {t("datatable.pager.pageLabel")} - 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 })} - - -
- )} - {sorted.length > 0 && ( - - )} -
- ) : null; + const pagerNode = ( + { + setSize(s); + setPage(0); + }} + sizeLabel={t("datatable.rowsPerPage")} + /> + ); const controls = controlsLeading != null || pagerNode ? ( diff --git a/frontend/src/components/Pager.tsx b/frontend/src/components/Pager.tsx new file mode 100644 index 00000000..68a96347 --- /dev/null +++ b/frontend/src/components/Pager.tsx @@ -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 ( +
+ {totalPages > 1 && ( +
+ + + {t("datatable.pager.pageLabel")} + 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 })} + + +
+ )} + {total > 0 && ( + + )} +
+ ); +} diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index fb6cb8fe..bfedd362 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -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…", diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index 6e4cca52..5a236e6e 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -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…", diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index c8f8e2c9..77d44025 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -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",