From 8efe121f3c6e08423d1caf68c2f521ecb51f2936 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Fri, 7 Nov 2025 21:38:21 -0800 Subject: [PATCH] More stuff for the new layout --- .../app/(modern)/scroll/BottomMenuBar.tsx | 143 +++++++------ .../app/(modern)/scroll/SkinPage.tsx | 21 +- .../app/(modern)/scroll/SkinScroller.tsx | 5 +- .../app/(modern)/scroll/grid/Grid.tsx | 186 ++++++++++++++++ .../scroll/grid/InfiniteScrollGrid.tsx | 198 ++++++++++++++++++ .../scroll/grid/getMuseumPageSkins.ts | 26 +++ .../app/(modern)/scroll/grid/layout.tsx | 9 + .../app/(modern)/scroll/grid/page.tsx | 13 ++ .../app/(modern)/scroll/layout.tsx | 5 +- .../legacy-client/src/constants.js | 1 + packages/skin-database/next.config.js | 3 + 11 files changed, 534 insertions(+), 76 deletions(-) create mode 100644 packages/skin-database/app/(modern)/scroll/grid/Grid.tsx create mode 100644 packages/skin-database/app/(modern)/scroll/grid/InfiniteScrollGrid.tsx create mode 100644 packages/skin-database/app/(modern)/scroll/grid/getMuseumPageSkins.ts create mode 100644 packages/skin-database/app/(modern)/scroll/grid/layout.tsx create mode 100644 packages/skin-database/app/(modern)/scroll/grid/page.tsx diff --git a/packages/skin-database/app/(modern)/scroll/BottomMenuBar.tsx b/packages/skin-database/app/(modern)/scroll/BottomMenuBar.tsx index aea4ac0e..b998c780 100644 --- a/packages/skin-database/app/(modern)/scroll/BottomMenuBar.tsx +++ b/packages/skin-database/app/(modern)/scroll/BottomMenuBar.tsx @@ -13,6 +13,7 @@ import { import Link from "next/link"; import { useState, useEffect, useRef } from "react"; import { usePathname } from "next/navigation"; +import { MOBILE_MAX_WIDTH } from "../../../legacy-client/src/constants"; export default function BottomMenuBar() { const [isHamburgerOpen, setIsHamburgerOpen] = useState(false); @@ -49,52 +50,55 @@ export default function BottomMenuBar() { {/* Hamburger Menu Overlay */} {isHamburgerOpen && (
- } - label="About" - onClick={() => { - setIsHamburgerOpen(false); - }} - /> - } - label="Upload" - onClick={() => { - setIsHamburgerOpen(false); - }} - />{" "} - } - label="Feedback" - onClick={() => { - setIsHamburgerOpen(false); - }} - external - /> - } - label="GitHub" - onClick={() => { - setIsHamburgerOpen(false); - }} - external - /> +
+ } + label="About" + onClick={() => { + setIsHamburgerOpen(false); + }} + /> + } + label="Upload" + onClick={() => { + setIsHamburgerOpen(false); + }} + />{" "} + } + label="Feedback" + onClick={() => { + setIsHamburgerOpen(false); + }} + external + /> + } + label="GitHub" + onClick={() => { + setIsHamburgerOpen(false); + }} + external + /> +
)} @@ -110,36 +114,48 @@ export default function BottomMenuBar() { borderTop: "1px solid rgba(255, 255, 255, 0.1)", padding: "0.75rem 0", display: "flex", - justifyContent: "space-evenly", + justifyContent: "center", alignItems: "center", zIndex: 1000, }} > - } - label="Feed" - isActive={pathname === "/scroll"} - /> - } - label="Grid" - isActive={pathname === "/"} - /> - } - label="Search" - isActive={false} - /> - } - label="Menu" - onClick={toggleHamburger} - isButton - isActive={false} - /> +
+ } + label="Feed" + isActive={ + pathname === "/scroll" || pathname.startsWith("/scroll/skin") + } + /> + } + label="Grid" + isActive={pathname === "/scroll/grid"} + /> + } + label="Search" + isActive={false} + /> + } + label="Menu" + onClick={toggleHamburger} + isButton + isActive={false} + /> +
); @@ -151,6 +167,7 @@ type MenuButtonProps = { label: string; isButton?: boolean; isActive?: boolean; + onClick?: () => void; }; function MenuButton({ @@ -159,6 +176,7 @@ function MenuButton({ label, isButton = false, isActive = false, + onClick, }: MenuButtonProps) { const touchTargetSize = "3.0rem"; @@ -230,6 +248,7 @@ function MenuButton({ style={containerStyle} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} + onClick={onClick} > {content} diff --git a/packages/skin-database/app/(modern)/scroll/SkinPage.tsx b/packages/skin-database/app/(modern)/scroll/SkinPage.tsx index a5267b74..ba6103b4 100644 --- a/packages/skin-database/app/(modern)/scroll/SkinPage.tsx +++ b/packages/skin-database/app/(modern)/scroll/SkinPage.tsx @@ -1,5 +1,6 @@ "use client"; +import { unstable_ViewTransition as ViewTransition } from "react"; import { ClientSkin } from "./SkinScroller"; import SkinActionIcons from "./SkinActionIcons"; @@ -31,15 +32,17 @@ export default function SkinPage({ skin, index, sessionId }: Props) { }} >
- {skin.fileName} + + {skin.fileName} +
diff --git a/packages/skin-database/app/(modern)/scroll/SkinScroller.tsx b/packages/skin-database/app/(modern)/scroll/SkinScroller.tsx index f1a32590..6dde3208 100644 --- a/packages/skin-database/app/(modern)/scroll/SkinScroller.tsx +++ b/packages/skin-database/app/(modern)/scroll/SkinScroller.tsx @@ -4,6 +4,7 @@ import { useState, useLayoutEffect, useEffect } from "react"; import SkinPage from "./SkinPage"; import { logUserEvent } from "./Events"; import { useScrollHint } from "./useScrollHint"; +import { MOBILE_MAX_WIDTH } from "../../../legacy-client/src/constants"; export type ClientSkin = { screenshotUrl: string; @@ -158,8 +159,10 @@ export default function SkinScroller({
Promise; +}; + +function Cell({ + columnIndex, + rowIndex, + style, + data, +}: { + columnIndex: number; + rowIndex: number; + style: React.CSSProperties; + data: CellData; +}) { + const { skins, width, height, columnCount } = data; + const index = rowIndex * columnCount + columnIndex; + data.loadMoreSkins(index); + const skin = skins[index]; + + if (!skin) { + return null; + } + + return ( +
+
+ + + {skin.fileName} + + + {skin.nsfw && ( +
+ NSFW +
+ )} +
+
+ ); +} + +type SkinTableProps = { + initialSkins: GridSkin[]; + initialTotal: number; +}; + +export default function SkinTable({ + initialSkins, + initialTotal, +}: SkinTableProps) { + const { windowWidth, windowHeight } = useWindowSize(); + + // Initialize state with server-provided data + const [skins, setSkins] = useState(initialSkins); + const [loadedPages, setLoadedPages] = useState>(new Set([0])); + const isLoadingRef = useRef(false); + + const columnCount = Math.round(windowWidth / (SCREENSHOT_WIDTH * 0.9)); + const columnWidth = windowWidth / columnCount; + const rowHeight = columnWidth * SKIN_RATIO; + const pageSize = 50; // Number of skins to load per page + + const loadMoreSkins = useCallback( + async (startIndex: number) => { + const pageNumber = Math.floor(startIndex / pageSize); + + // Don't reload if we already have this page + if (loadedPages.has(pageNumber) || isLoadingRef.current) { + return; + } + + isLoadingRef.current = true; + try { + const offset = pageNumber * pageSize; + const newSkins = await getMuseumPageSkins(offset, pageSize); + setSkins((prev) => [...prev, ...newSkins]); + setLoadedPages((prev) => new Set([...prev, pageNumber])); + } catch (error) { + console.error("Failed to load skins:", error); + } finally { + isLoadingRef.current = false; + } + }, + [loadedPages, pageSize] + ); + + function itemKey({ + columnIndex, + rowIndex, + }: { + columnIndex: number; + rowIndex: number; + }) { + const index = rowIndex * columnCount + columnIndex; + const skin = skins[index]; + return skin ? skin.md5 : `empty-cell-${columnIndex}-${rowIndex}`; + } + + const gridRef = React.useRef(); + const itemRef = React.useRef(0); + + const onScroll = useMemo(() => { + const half = Math.round(columnCount / 2); + return (scrollData: { scrollTop: number }) => { + itemRef.current = + Math.round(scrollData.scrollTop / rowHeight) * columnCount + half; + }; + }, [columnCount, rowHeight, loadMoreSkins]); + + const itemData: CellData = useMemo( + () => ({ + skins, + columnCount, + width: columnWidth, + height: rowHeight, + loadMoreSkins, + }), + [skins, columnCount, columnWidth, rowHeight, loadMoreSkins] + ); + + return ( +
+ + {Cell} + +
+ ); +} diff --git a/packages/skin-database/app/(modern)/scroll/grid/InfiniteScrollGrid.tsx b/packages/skin-database/app/(modern)/scroll/grid/InfiniteScrollGrid.tsx new file mode 100644 index 00000000..eef304e9 --- /dev/null +++ b/packages/skin-database/app/(modern)/scroll/grid/InfiniteScrollGrid.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { + useState, + useLayoutEffect, + useEffect, + useRef, + useCallback, + memo, +} from "react"; +import { FixedSizeGrid as Grid } from "react-window"; +import { useRouter } from "next/navigation"; +import { ClientSkin } from "../SkinScroller"; +import { + SCREENSHOT_WIDTH, + SKIN_RATIO, +} from "../../../../legacy-client/src/constants"; + +type Props = { + initialSkins: ClientSkin[]; + getSkins: (sessionId: string, offset: number) => Promise; + sessionId: string; +}; + +type CellProps = { + columnIndex: number; + rowIndex: number; + style: React.CSSProperties; + data: { + skins: ClientSkin[]; + columnCount: number; + requestSkinsIfNeeded: (index: number) => void; + }; +}; + +// Extract Cell as a separate component so we can use hooks +const GridCell = memo(({ columnIndex, rowIndex, style, data }: CellProps) => { + const { skins, columnCount, requestSkinsIfNeeded } = data; + const router = useRouter(); + const index = rowIndex * columnCount + columnIndex; + const skin = skins[index]; + + // Request more skins if this cell needs data + useEffect(() => { + if (!skin) { + requestSkinsIfNeeded(index); + } + }, [skin, index, requestSkinsIfNeeded]); + + if (!skin) { + return
; + } + + return ( +
{ + router.push(`/scroll/skin/${skin.md5}`); + }} + > +
+ {skin.fileName} +
+
+ ); +}); + +GridCell.displayName = "GridCell"; + +// Calculate grid dimensions based on window width +// Skins will be scaled to fill horizontally across multiple columns +function getGridDimensions(windowWidth: number) { + const scale = 1.0; // Can be adjusted for different sizes + const columnCount = Math.max( + 1, + Math.floor(windowWidth / (SCREENSHOT_WIDTH * scale)) + ); + const columnWidth = windowWidth / columnCount; + const rowHeight = columnWidth * SKIN_RATIO; + return { columnWidth, rowHeight, columnCount }; +} + +export default function InfiniteScrollGrid({ + initialSkins, + getSkins, + sessionId, +}: Props) { + const [skins, setSkins] = useState(initialSkins); + const [fetching, setFetching] = useState(false); + const [windowWidth, setWindowWidth] = useState(0); + const [windowHeight, setWindowHeight] = useState(0); + const gridRef = useRef(null); + const requestedIndicesRef = useRef>(new Set()); + + // Track window size + useLayoutEffect(() => { + function updateSize() { + setWindowWidth(window.innerWidth); + setWindowHeight(window.innerHeight); + } + updateSize(); + window.addEventListener("resize", updateSize); + return () => window.removeEventListener("resize", updateSize); + }, []); + + // Scroll to top when window width changes (column count changes) + useEffect(() => { + if (gridRef.current && windowWidth > 0) { + gridRef.current.scrollTo({ scrollLeft: 0, scrollTop: 0 }); + } + }, [windowWidth]); + + // Function to request more skins when a cell needs data + const requestSkinsIfNeeded = useCallback( + (index: number) => { + // Only fetch if this index is beyond our current data + if (index >= skins.length) { + // Calculate which batch this index belongs to + const batchSize = 50; // Fetch in batches + const batchStart = Math.floor(skins.length / batchSize) * batchSize; + + // Only fetch if we haven't already requested this batch + if (!requestedIndicesRef.current.has(batchStart) && !fetching) { + requestedIndicesRef.current.add(batchStart); + setFetching(true); + getSkins(sessionId, batchStart) + .then((newSkins) => { + setSkins((prevSkins) => [...prevSkins, ...newSkins]); + setFetching(false); + }) + .catch(() => { + requestedIndicesRef.current.delete(batchStart); + setFetching(false); + }); + } + } + }, + [skins.length, fetching, sessionId, getSkins] + ); + + const { columnWidth, rowHeight, columnCount } = + getGridDimensions(windowWidth); + + if (windowWidth === 0 || windowHeight === 0) { + return null; // Don't render until we have window dimensions + } + + const rowCount = Math.ceil(skins.length / columnCount); + + const itemData = { + skins, + columnCount, + requestSkinsIfNeeded, + }; + + return ( +
+ + {GridCell} + +
+ ); +} diff --git a/packages/skin-database/app/(modern)/scroll/grid/getMuseumPageSkins.ts b/packages/skin-database/app/(modern)/scroll/grid/getMuseumPageSkins.ts new file mode 100644 index 00000000..4be6b404 --- /dev/null +++ b/packages/skin-database/app/(modern)/scroll/grid/getMuseumPageSkins.ts @@ -0,0 +1,26 @@ +"use server"; + +import { getMuseumPage, getScreenshotUrl } from "../../../../data/skins"; + +export type GridSkin = { + md5: string; + screenshotUrl: string; + fileName: string; + nsfw: boolean; +}; + +export async function getMuseumPageSkins( + offset: number, + limit: number +): Promise { + const page = await getMuseumPage({ offset, first: limit }); + + const skins = page.map((item) => ({ + md5: item.md5, + screenshotUrl: getScreenshotUrl(item.md5), + fileName: item.fileName, + nsfw: item.nsfw, + })); + + return skins; +} diff --git a/packages/skin-database/app/(modern)/scroll/grid/layout.tsx b/packages/skin-database/app/(modern)/scroll/grid/layout.tsx new file mode 100644 index 00000000..22bce96c --- /dev/null +++ b/packages/skin-database/app/(modern)/scroll/grid/layout.tsx @@ -0,0 +1,9 @@ +import { ReactNode } from "react"; + +type LayoutProps = { + children: ReactNode; +}; + +export default function Layout({ children }: LayoutProps) { + return
{children}
; +} diff --git a/packages/skin-database/app/(modern)/scroll/grid/page.tsx b/packages/skin-database/app/(modern)/scroll/grid/page.tsx new file mode 100644 index 00000000..4a2a3dd2 --- /dev/null +++ b/packages/skin-database/app/(modern)/scroll/grid/page.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import Grid from "./Grid"; +import { getMuseumPageSkins } from "./getMuseumPageSkins"; +import * as Skins from "../../../../data/skins"; + +export default async function SkinTable() { + const [initialSkins, skinCount] = await Promise.all([ + getMuseumPageSkins(0, 50), + Skins.getClassicSkinCount(), + ]); + + return ; +} diff --git a/packages/skin-database/app/(modern)/scroll/layout.tsx b/packages/skin-database/app/(modern)/scroll/layout.tsx index c2aaedf3..819a1135 100644 --- a/packages/skin-database/app/(modern)/scroll/layout.tsx +++ b/packages/skin-database/app/(modern)/scroll/layout.tsx @@ -8,14 +8,11 @@ type LayoutProps = { children: ReactNode; }; -export default function LayoutWrapper({ children }: LayoutProps) { +export default function Layout({ children }: LayoutProps) { return (
diff --git a/packages/skin-database/legacy-client/src/constants.js b/packages/skin-database/legacy-client/src/constants.js index d350d24d..c6cdb481 100644 --- a/packages/skin-database/legacy-client/src/constants.js +++ b/packages/skin-database/legacy-client/src/constants.js @@ -1,6 +1,7 @@ export const SCREENSHOT_WIDTH = 275; export const SCREENSHOT_HEIGHT = 348; export const SKIN_RATIO = SCREENSHOT_HEIGHT / SCREENSHOT_WIDTH; +export const MOBILE_MAX_WIDTH = "56.25vh"; // 9:16 aspect ratio (100vh * 9/16) for TikTok-style scroll export const ABOUT_PAGE = "ABOUT_PAGE"; export const UPLOAD_PAGE = "UPLOAD_PAGE"; export const REVIEW_PAGE = "REVIEW_PAGE"; diff --git a/packages/skin-database/next.config.js b/packages/skin-database/next.config.js index 566bf193..84a62cd4 100644 --- a/packages/skin-database/next.config.js +++ b/packages/skin-database/next.config.js @@ -1,6 +1,9 @@ /** @type {import('next').NextConfig} */ const nextConfig = { serverExternalPackages: ["knex", "imagemin-optipng", "discord.js"], + experimental: { + viewTransition: true, + }, }; module.exports = nextConfig;