diff --git a/src/components/Discover/DiscoverBrowser.tsx b/src/components/Discover/DiscoverBrowser.tsx index e1dc80a..7d784ae 100644 --- a/src/components/Discover/DiscoverBrowser.tsx +++ b/src/components/Discover/DiscoverBrowser.tsx @@ -15,6 +15,7 @@ import { sortOrderedTags, OrderedTag } from "@/utils/format"; import { getCoverSignedUrls } from "@/app/hack/actions"; import { HackCardAttributes } from "@/components/HackCard"; +const HACKS_PER_PAGE = 9; export default function DiscoverBrowser() { const supabase = createClient(); @@ -28,6 +29,8 @@ export default function DiscoverBrowser() { const [loadingHacks, setLoadingHacks] = React.useState(true); const [loadingTags, setLoadingTags] = React.useState(true); const [onlyReady, setOnlyReady] = React.useState(false); + const [currentPage, setCurrentPage] = React.useState(1); + const listRef = React.useRef(null); const { cached, statuses, countReady } = useBaseRoms(); const readyBaseRomIds = React.useMemo(() => { @@ -43,6 +46,11 @@ export default function DiscoverBrowser() { return set; }, [cached, statuses]); + React.useEffect(() => { + // Reset to first page when filters or sort change + setCurrentPage(1); + }, [query, selectedTags, selectedBaseRoms, onlyReady, sort]); + React.useEffect(() => { const run = async () => { setLoadingHacks(true); @@ -104,19 +112,36 @@ export default function DiscoverBrowser() { tagsBySlug.set(r.hack_slug, arr); }); - let mappedVersions = new Map(); - await Promise.all((rows || []).map(async (r) => { - if (r.current_patch) { - const { data: currentPatch } = await supabase - .from("patches") - .select("version") - .eq("id", r.current_patch) - .maybeSingle(); - mappedVersions.set(r.slug, currentPatch?.version || "Pre-release"); + const patchIds = Array.from( + new Set( + (rows || []) + .map((r: any) => r.current_patch as number | null) + .filter((id): id is number => typeof id === "number") + ) + ); + + const versionsByPatchId = new Map(); + if (patchIds.length > 0) { + const { data: patchRows } = await supabase + .from("patches") + .select("id,version") + .in("id", patchIds); + (patchRows || []).forEach((p: any) => { + if (typeof p.id === "number") { + versionsByPatchId.set(p.id, p.version || "Pre-release"); + } + }); + } + + const mappedVersions = new Map(); + (rows || []).forEach((r: any) => { + if (typeof r.current_patch === "number") { + const version = versionsByPatchId.get(r.current_patch) || "Pre-release"; + mappedVersions.set(r.slug, version); } else { mappedVersions.set(r.slug, r.original_author ? "Archive" : "Pre-release"); } - })); + }); // Fetch all tags with category to build UI groups const { data: allTagRows } = await supabase .from("tags") @@ -197,6 +222,55 @@ export default function DiscoverBrowser() { return out; }, [hacks, query, selectedTags, selectedBaseRoms, onlyReady, readyBaseRomIds]); + const totalPages = React.useMemo( + () => Math.max(1, Math.ceil(filtered.length / HACKS_PER_PAGE)), + [filtered.length] + ); + + React.useEffect(() => { + // Clamp current page if the number of results shrinks + if (currentPage > totalPages) { + setCurrentPage(totalPages); + } + }, [currentPage, totalPages]); + + const paginationRange = React.useMemo(() => { + const startIndex = (currentPage - 1) * HACKS_PER_PAGE; + const endIndex = Math.min(filtered.length, startIndex + HACKS_PER_PAGE); + return { startIndex, endIndex }; + }, [currentPage, filtered.length]); + + const paginated = React.useMemo( + () => filtered.slice(paginationRange.startIndex, paginationRange.endIndex), + [filtered, paginationRange.startIndex, paginationRange.endIndex] + ); + + const scrollToListTopOnMobile = React.useCallback(() => { + if (typeof window === "undefined" || !listRef.current) return; + // Only auto-scroll on small screens so desktop users aren't jolted + if (window.innerWidth < 640) { + const rect = listRef.current.getBoundingClientRect(); + const headerOffset = 72; // approximate navbar with padding + const targetY = Math.max(0, rect.top + window.scrollY - headerOffset); + window.scrollTo({ top: targetY, behavior: "smooth" }); + } + }, []); + + const changePage = React.useCallback( + (nextPage: number) => { + setCurrentPage((prev) => { + const clamped = Math.min(Math.max(1, nextPage), totalPages); + // Only scroll when the page actually changes + if (clamped !== prev) { + // Defer scroll until after React has applied the state update + setTimeout(scrollToListTopOnMobile, 0); + } + return clamped; + }); + }, + [scrollToListTopOnMobile, totalPages] + ); + function toggleTag(name: string) { setSelectedTags((prev) => (prev.includes(name) ? prev.filter((t) => t !== name) : [...prev, name])); } @@ -376,11 +450,70 @@ export default function DiscoverBrowser() { ) : ( -
- {filtered.map((hack) => ( - - ))} -
+ <> +
+ {paginated.map((hack) => ( + + ))} +
+ {totalPages > 1 && ( +
+
+ Showing {paginationRange.startIndex + 1}-{paginationRange.endIndex} of {filtered.length} hacks +
+
+ +
+ {Array.from({ length: totalPages }).map((_, i) => { + const page = i + 1; + const isActive = page === currentPage; + return ( + + ); + })} +
+ +
+
+ )} + )} );