More stuff for the new layout

This commit is contained in:
Jordan Eldredge 2025-11-07 21:38:21 -08:00
parent c778464c42
commit 8efe121f3c
11 changed files with 534 additions and 76 deletions

View file

@ -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 && (
<div
ref={menuRef}
style={{
position: "absolute",
bottom: "4.5rem",
left: 0,
left: "50%",
transform: "translateX(-50%)",
width: "100%",
maxWidth: MOBILE_MAX_WIDTH,
backgroundColor: "rgba(26, 26, 26, 0.98)",
backdropFilter: "blur(10px)",
borderTop: "1px solid rgba(255, 255, 255, 0.1)",
zIndex: 999,
}}
>
<HamburgerMenuItem
href="/about"
icon={<Info size={20} />}
label="About"
onClick={() => {
setIsHamburgerOpen(false);
}}
/>
<HamburgerMenuItem
href="/upload"
icon={<Upload size={20} />}
label="Upload"
onClick={() => {
setIsHamburgerOpen(false);
}}
/>{" "}
<HamburgerMenuItem
href="https://github.com/captbaritone/webamp/issues"
icon={<MessageSquare size={20} />}
label="Feedback"
onClick={() => {
setIsHamburgerOpen(false);
}}
external
/>
<HamburgerMenuItem
href="https://github.com/captbaritone/webamp/"
icon={<Github size={20} />}
label="GitHub"
onClick={() => {
setIsHamburgerOpen(false);
}}
external
/>
<div ref={menuRef}>
<HamburgerMenuItem
href="/about"
icon={<Info size={20} />}
label="About"
onClick={() => {
setIsHamburgerOpen(false);
}}
/>
<HamburgerMenuItem
href="/upload"
icon={<Upload size={20} />}
label="Upload"
onClick={() => {
setIsHamburgerOpen(false);
}}
/>{" "}
<HamburgerMenuItem
href="https://github.com/captbaritone/webamp/issues"
icon={<MessageSquare size={20} />}
label="Feedback"
onClick={() => {
setIsHamburgerOpen(false);
}}
external
/>
<HamburgerMenuItem
href="https://github.com/captbaritone/webamp/"
icon={<Github size={20} />}
label="GitHub"
onClick={() => {
setIsHamburgerOpen(false);
}}
external
/>
</div>
</div>
)}
@ -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,
}}
>
<MenuButton
href="/scroll"
icon={<Smartphone size={24} />}
label="Feed"
isActive={pathname === "/scroll"}
/>
<MenuButton
href="/"
icon={<Grid3x3 size={24} />}
label="Grid"
isActive={pathname === "/"}
/>
<MenuButton
href="/"
icon={<Search size={24} />}
label="Search"
isActive={false}
/>
<MenuButton
icon={<Menu size={24} />}
label="Menu"
onClick={toggleHamburger}
isButton
isActive={false}
/>
<div
style={{
width: "100%",
maxWidth: MOBILE_MAX_WIDTH, // Match the scroll page max width
display: "flex",
justifyContent: "space-evenly",
alignItems: "center",
}}
>
<MenuButton
href="/scroll"
icon={<Smartphone size={24} />}
label="Feed"
isActive={
pathname === "/scroll" || pathname.startsWith("/scroll/skin")
}
/>
<MenuButton
href="/scroll/grid"
icon={<Grid3x3 size={24} />}
label="Grid"
isActive={pathname === "/scroll/grid"}
/>
<MenuButton
href="/"
icon={<Search size={24} />}
label="Search"
isActive={false}
/>
<MenuButton
icon={<Menu size={24} />}
label="Menu"
onClick={toggleHamburger}
isButton
isActive={false}
/>
</div>
</div>
</>
);
@ -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}
</button>

View file

@ -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) {
}}
>
<div style={{ position: "relative", flexShrink: 0 }}>
<img
src={skin.screenshotUrl}
alt={skin.fileName}
style={{
width: "100%",
aspectRatio: "275 / 348",
imageRendering: "pixelated",
}}
/>
<ViewTransition name={`skin-${skin.md5}`}>
<img
src={skin.screenshotUrl}
alt={skin.fileName}
style={{
width: "100%",
aspectRatio: "275 / 348",
imageRendering: "pixelated",
}}
/>
</ViewTransition>
<SkinActionIcons skin={skin} sessionId={sessionId} />
</div>

View file

@ -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({
<div
ref={setContainerRef}
style={{
maxWidth: MOBILE_MAX_WIDTH, // 9:16 aspect ratio for scroll, full width for grid
margin: "0 auto",
height: "100vh",
width: "100%",
// width: "100%",
overflowY: "scroll",
scrollSnapType: "y mandatory",
scrollbarWidth: "none", // Firefox

View file

@ -0,0 +1,186 @@
"use client";
import React, {
useState,
useMemo,
useCallback,
useRef,
unstable_ViewTransition as ViewTransition,
} from "react";
import Link from "next/link";
import { FixedSizeGrid as Grid } from "react-window";
import { useWindowSize } from "../../../../legacy-client/src/hooks";
import {
SCREENSHOT_WIDTH,
SKIN_RATIO,
} from "../../../../legacy-client/src/constants";
import { getMuseumPageSkins, GridSkin } from "./getMuseumPageSkins";
type CellData = {
skins: GridSkin[];
columnCount: number;
width: number;
height: number;
loadMoreSkins: (startIndex: number) => Promise<void>;
};
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 (
<div style={style}>
<div style={{ width, height, position: "relative" }}>
<Link href={`/scroll/skin/${skin.md5}`}>
<ViewTransition name={`skin-${skin.md5}`}>
<img
src={skin.screenshotUrl}
alt={skin.fileName}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
</ViewTransition>
</Link>
{skin.nsfw && (
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(0,0,0,0.7)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontSize: "14px",
}}
>
NSFW
</div>
)}
</div>
</div>
);
}
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<GridSkin[]>(initialSkins);
const [loadedPages, setLoadedPages] = useState<Set<number>>(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<any>();
const itemRef = React.useRef<number>(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 (
<div id="infinite-skins">
<Grid
ref={gridRef}
itemKey={itemKey}
itemData={itemData}
columnCount={columnCount}
columnWidth={columnWidth}
height={windowHeight}
rowCount={Math.ceil(initialTotal / columnCount)}
rowHeight={rowHeight}
width={windowWidth}
overscanRowsCount={5}
onScroll={onScroll}
style={{ overflowY: "scroll" }}
>
{Cell}
</Grid>
</div>
);
}

View file

@ -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<ClientSkin[]>;
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 <div style={style} />;
}
return (
<div
style={{
...style,
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
backgroundColor: "#1a1a1a",
padding: "2px",
boxSizing: "border-box",
cursor: "pointer",
}}
onClick={() => {
router.push(`/scroll/skin/${skin.md5}`);
}}
>
<div style={{ width: "100%", height: "100%", position: "relative" }}>
<img
src={skin.screenshotUrl}
alt={skin.fileName}
style={{
width: "100%",
height: "100%",
display: "block",
imageRendering: "pixelated",
objectFit: "cover",
}}
/>
</div>
</div>
);
});
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<ClientSkin[]>(initialSkins);
const [fetching, setFetching] = useState(false);
const [windowWidth, setWindowWidth] = useState(0);
const [windowHeight, setWindowHeight] = useState(0);
const gridRef = useRef<Grid>(null);
const requestedIndicesRef = useRef<Set<number>>(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 (
<div style={{ backgroundColor: "#1a1a1a" }}>
<Grid
ref={gridRef}
columnCount={columnCount}
columnWidth={columnWidth}
height={windowHeight}
rowCount={rowCount}
rowHeight={rowHeight}
width={windowWidth}
itemData={itemData}
overscanRowCount={2}
style={{
scrollbarWidth: "none", // Firefox
msOverflowStyle: "none", // IE and Edge
}}
className="hide-scrollbar"
>
{GridCell}
</Grid>
</div>
);
}

View file

@ -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<GridSkin[]> {
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;
}

View file

@ -0,0 +1,9 @@
import { ReactNode } from "react";
type LayoutProps = {
children: ReactNode;
};
export default function Layout({ children }: LayoutProps) {
return <div style={{ width: "100vw" }}>{children}</div>;
}

View file

@ -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 <Grid initialSkins={initialSkins} initialTotal={skinCount} />;
}

View file

@ -8,14 +8,11 @@ type LayoutProps = {
children: ReactNode;
};
export default function LayoutWrapper({ children }: LayoutProps) {
export default function Layout({ children }: LayoutProps) {
return (
<div
style={{
position: "relative",
width: "100vw",
maxWidth: "56.25vh", // 9:16 aspect ratio (100vh * 9/16)
margin: "0 auto",
height: "100vh",
}}
>

View file

@ -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";

View file

@ -1,6 +1,9 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
serverExternalPackages: ["knex", "imagemin-optipng", "discord.js"],
experimental: {
viewTransition: true,
},
};
module.exports = nextConfig;