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) {
}}
>
-

+
+
+
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.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}`);
+ }}
+ >
+
+

+
+
+ );
+});
+
+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;