diff --git a/packages/skin-database/app/(modern)/table/Table.tsx b/packages/skin-database/app/(modern)/table/Table.tsx
new file mode 100644
index 00000000..6e4ef60e
--- /dev/null
+++ b/packages/skin-database/app/(modern)/table/Table.tsx
@@ -0,0 +1,161 @@
+"use client";
+import {
+ HEADING_HEIGHT,
+ SCREENSHOT_WIDTH,
+ SKIN_RATIO,
+} from "../../../legacy-client/src/constants.js";
+import {
+ useScrollbarWidth,
+ useWindowSize,
+} from "../../../legacy-client/src/hooks.js";
+import React, { useEffect, useMemo, useState } from "react";
+import { FixedSizeGrid as Grid } from "react-window";
+
+function ClientOnly({ children }: { children: React.ReactNode }) {
+ const [mounted, setMounted] = useState(false);
+ useEffect(() => setMounted(true), []);
+ return mounted ? <>{children}> : null;
+}
+
+export default function WrappedTable({ initialSkins, skinCount }) {
+ return (
+
+
+
+ );
+}
+
+function Table({ initialSkins, skinCount }) {
+ const [skins, setSkins] = useState(initialSkins);
+
+ const scale = 0.5; // This can be adjusted based on your needs
+ function getSkinData(data: {
+ columnIndex: number;
+ rowIndex: number;
+ columnCount: number;
+ }) {
+ const index = data.rowIndex * columnCount + data.columnIndex;
+ const skin = skins[index];
+ return { requestToken: skin?.md5, skin };
+ }
+ const scrollbarWidth = useScrollbarWidth();
+ const { windowWidth: windowWidthWithScrollabar, windowHeight } =
+ useWindowSize();
+
+ const { columnWidth, rowHeight, columnCount } = getTableDimensions(
+ windowWidthWithScrollabar - scrollbarWidth,
+ scale
+ );
+ function Cell(props) {
+ const index = props.rowIndex * columnCount + props.columnIndex;
+ const skin = skins[index];
+ if (skin == null) {
+ if (index < skinCount) {
+ // Fetch more skins!
+ }
+ return
;
+ }
+ if (skin == null) {
+ return Loading...
;
+ }
+ const imageUrl = `https://r2.webampskins.org/screenshots/${skin.md5}.png`;
+ return (
+
+

+
+ );
+ }
+ return (
+
+
+
+ );
+}
+const getTableDimensions = (windowWidth: number, scale: number) => {
+ const columnCount = Math.round(windowWidth / (SCREENSHOT_WIDTH * scale));
+ const columnWidth = windowWidth / columnCount; // TODO: Consider flooring this to get things aligned to the pixel
+ const rowHeight = columnWidth * SKIN_RATIO;
+ return { columnWidth, rowHeight, columnCount };
+};
+
+function SkinTableUnbound({
+ columnCount,
+ columnWidth,
+ rowHeight,
+ windowHeight,
+ skinCount,
+ windowWidth,
+ getSkinData,
+ Cell,
+}) {
+ function itemKey({ columnIndex, rowIndex }) {
+ const { requestToken, data: skin } = getSkinData({
+ columnIndex,
+ rowIndex,
+ columnCount,
+ });
+ if (skin == null && requestToken == null) {
+ return `empty-cell-${columnIndex}-${rowIndex}`;
+ }
+ return skin ? skin.hash : `unfectched-index-${requestToken}`;
+ }
+ const gridRef = React.useRef();
+ const itemRef = React.useRef();
+ React.useLayoutEffect(() => {
+ if (gridRef.current == null) {
+ return;
+ }
+ gridRef.current.scrollTo({ scrollLeft: 0, scrollTop: 0 });
+ }, [skinCount]);
+
+ React.useLayoutEffect(() => {
+ if (gridRef.current == null) {
+ return;
+ }
+
+ const itemRow = Math.floor(itemRef.current / columnCount);
+
+ gridRef.current.scrollTo({ scrollLeft: 0, scrollTop: rowHeight * itemRow });
+ }, [rowHeight, columnCount]);
+
+ const onScroll = useMemo(() => {
+ const half = Math.round(columnCount / 2);
+ return (scrollData) => {
+ itemRef.current =
+ Math.round(scrollData.scrollTop / rowHeight) * columnCount + half;
+ };
+ }, [columnCount, rowHeight]);
+
+ return (
+
+
+ {Cell}
+
+
+ );
+}
diff --git a/packages/skin-database/app/(modern)/table/page.tsx b/packages/skin-database/app/(modern)/table/page.tsx
new file mode 100644
index 00000000..d6339102
--- /dev/null
+++ b/packages/skin-database/app/(modern)/table/page.tsx
@@ -0,0 +1,17 @@
+import * as Skins from "../../../data/skins";
+import Table from "./Table";
+
+export default async function TablePage() {
+ const skins = await Skins.getMuseumPage({
+ offset: 0,
+ first: 100,
+ });
+
+ const skinCount = await Skins.getClassicSkinCount();
+
+ return (
+
+ );
+}