Files
webamp/packages/skin-database/app/(modern)/scroll/Grid.tsx
T
Jordan EldredgeandClaude Opus 5.5 9999094dde Make the new UI's grid and feed two views of the same list
The feed was its own shuffled list (museum favorites plus random skins).
Now it continues through whatever the grid shows, the museum or a search:
tapping a skin opens the feed on it with the list before and after it,
and going back lands the grid on the skin you were on. Feed URLs are
plain permalinks; the list lives in the tab (listPosition.ts), and a
permalink opened fresh is followed by the museum. /feed redirects to the
museum's first skin.

- Event logging v3: one session per tab visit spanning both views, with
  view_switch events and each feed view's list, list index and depth.
  Older events are still accepted.
- The feed loads readmes and like counts on demand.
- Keyboard paging steps from the skin actually on screen, so it can't get
  stuck after a scroll is interrupted.
- Going back to the grid animates the skin into its tile. React's
  <ViewTransition> can't animate Back with Next's router, so it's done
  by hand.
- Grid tiles and view transition snapshots render pixelated.
- Download saves the skin under its file name, not its md5.
- A feed URL for a missing skin returns a 404 status.
- The list position is kept apart from the server actions, so pages with
  only the menu bar don't each compile another copy of the server code;
  this kept the build within the server's memory.
- Declare reselect (used by legacy-client) and give knexfile.ts named
  exports.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QzNErwA6wVTLaUTNMARPQt
2026-10-15 10:05:25 -07:00

547 lines
18 KiB
TypeScript

"use client";
import React, {
useState,
useMemo,
useCallback,
useRef,
// @ts-expect-error - unstable_ViewTransition is not yet in @types/react
unstable_ViewTransition as ViewTransition,
useEffect,
useSyncExternalStore,
} from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { FixedSizeGrid as Grid } from "react-window";
import {
SCREENSHOT_WIDTH,
SKIN_RATIO,
MOBILE_MAX_WIDTH,
} from "../../../legacy-client/src/constants";
import { GridSkin } from "./gridSkin";
import { enterView, setNextViewVia, useEventLogger } from "./eventLogger";
import { getSearchDebounceMs } from "../../../legacy-client/src/searchConfig";
import { gridPath, skinPath } from "./paths";
import {
cacheSearch,
getCachedSearch,
loadMuseumRange,
loadSearch,
seedMuseum,
useMuseum,
} from "./skinList";
import { getListPosition, setListPosition } from "./listPosition";
import { corsScreenshotUrl } from "./skinTint";
import HintPill, { markHintDone } from "./HintPill";
import { EyeOff, Pointer } from "lucide-react";
import { rememberSkinNsfw, useShowNsfw } from "./nsfwConsent";
// Remembered once the visitor has tapped a skin (see HintPill).
const GRID_HINT_KEY = "grid-hint-dismissed";
// Where each visit to the grid was scrolled to (its first visible skin), by
// history entry (see useHistoryEntryKey), so coming Back to it returns there
// if the feed hasn't moved you on since (see restoreIndex). An index rather
// than pixels, so it survives a change in column count. In memory only: a
// reload starts at the top.
const scrollPositions = new Map<string, number>();
type CellData = {
// Sparse: pages are written at their offset as they arrive.
skins: ReadonlyArray<GridSkin | undefined>;
columnCount: number;
width: number;
height: number;
onSkinClick: (skin: GridSkin, position: number) => void;
};
function Cell({
columnIndex,
rowIndex,
style,
data,
}: {
columnIndex: number;
rowIndex: number;
style: React.CSSProperties;
data: CellData;
}) {
const { skins, width, height, columnCount } = data;
const showNsfw = useShowNsfw();
const index = rowIndex * columnCount + columnIndex;
const skin = skins[index];
if (!skin) {
return null;
}
return (
<div style={style}>
<div
className="grid-tile"
style={{ width, height, position: "relative" }}
>
<Link
href={skinPath(skin.md5, skin.fileName)}
onClick={() => {
rememberSkinNsfw(skin.md5, skin.nsfw);
data.onSkinClick(skin, index);
}}
>
<ViewTransition name={`skin-${skin.md5}`}>
<img
// The same URL and request mode as the feed's screenshots
// (which read their colors), so the browser caches one copy.
crossOrigin="anonymous"
src={corsScreenshotUrl(skin.screenshotUrl)}
alt={skin.fileName}
// Opening it shows the choice to see it (see NsfwCover).
className={skin.nsfw && !showNsfw ? "nsfw-blurred" : undefined}
/>
</ViewTransition>
</Link>
<div className="grid-tile-name" aria-hidden>
{skin.fileName}
</div>
{skin.nsfw && !showNsfw && (
<div className="grid-tile-nsfw" aria-hidden>
<EyeOff size={14} />
NSFW
</div>
)}
</div>
</div>
);
}
type SkinTableProps = {
initialSkins: GridSkin[];
initialTotal: number;
// The search in the URL (?query=), if any.
initialQuery: string;
// That search's results, if the server ran it (initialSkins is then empty).
initialSearchSkins: GridSkin[] | null;
};
// The window's size, or null while hydrating. The grid is laid out to fit
// the window, so it isn't rendered on the server (or the first client render,
// which has to match it). Navigating to the grid renders it straight away, so
// a view transition into it sees its tiles.
type WindowSize = { width: number; height: number };
let windowSize: WindowSize | null = null;
function getWindowSize(): WindowSize {
const width = document.documentElement.clientWidth;
const height = document.documentElement.clientHeight;
if (windowSize?.width !== width || windowSize.height !== height) {
windowSize = { width, height };
}
return windowSize;
}
function subscribeToResize(onChange: () => void): () => void {
window.addEventListener("resize", onChange);
return () => window.removeEventListener("resize", onChange);
}
function useMountedWindowSize(): WindowSize | null {
return useSyncExternalStore(subscribeToResize, getWindowSize, () => null);
}
export default function SkinTable({
initialSkins,
initialTotal,
initialQuery,
initialSearchSkins,
}: SkinTableProps) {
const windowSize = useMountedWindowSize();
const windowWidth = windowSize?.width ?? 0;
const windowHeight = windowSize?.height ?? 0;
const logger = useEventLogger();
// The search box's text. From the live URL rather than only the server's render: coming Back to a
// search, the page can be the one first rendered without it.
const searchParams = useSearchParams();
const [inputValue, setInputValue] = useState(
() => searchParams.get("query") ?? initialQuery
);
// Results for the search we're starting with, if we have them: the
// server's, or ones from earlier in this visit.
const [initialResults] = useState(() => {
const query = inputValue.trim();
const skins =
initialSearchSkins != null && query === initialQuery.trim()
? initialSearchSkins
: getCachedSearch(query);
if (query === "" || skins == null) return null;
// Not a new search if we're coming back to it.
const isNew = getCachedSearch(query) == null;
cacheSearch(query, skins);
return { query, skins, isNew };
});
// Browsing: the museum, as far as it's loaded (see skinList.ts).
useState(() => seedMuseum(0, initialSkins, initialTotal));
const browseSkins = useMuseum().skins;
// State for search mode
const [searchSkins, setSearchSkins] = useState<GridSkin[]>(
initialResults?.skins ?? []
);
const [searchError, setSearchError] = useState<string | null>(null);
// The query that `searchSkins`/`searchError` are the outcome of. Until it
// matches the input, a search is pending (debouncing or in flight).
const [resultsQuery, setResultsQuery] = useState<string | null>(
initialResults?.query ?? null
);
const resultsQueryRef = useRef(resultsQuery);
resultsQueryRef.current = resultsQuery;
// Determine which mode we're in based on actual search query, not input
const isSearchMode = inputValue.trim().length > 0;
const searchPending = isSearchMode && resultsQuery !== inputValue.trim();
// While a search is pending we leave whatever was already on screen mounted:
// the previous query's results, or the browse grid if this is the first
// query. So we show search results once any query has resolved.
const showSearchResults = isSearchMode && resultsQuery !== null;
const skins = showSearchResults ? searchSkins : browseSkins;
const total = showSearchResults ? searchSkins.length : initialTotal;
// The list on screen.
const listQuery = showSearchResults ? resultsQuery! : "";
useEffect(() => {
const query = inputValue.trim();
const urlQuery = new URLSearchParams(location.search).get("query") ?? "";
if (urlQuery !== inputValue) {
replaceHistoryEntry({}, query === "" ? "/" : gridPath(inputValue));
}
// Already showing this search's results (the server's, or cached).
if (query.length > 0 && query === resultsQueryRef.current) {
return;
}
// If query is empty, clear results immediately
if (query.length === 0) {
setSearchSkins([]);
setSearchError(null);
setResultsQuery(null);
return;
}
// Debounce, and ignore responses that arrive after a newer query.
let cancelled = false;
const timer = setTimeout(async () => {
try {
const skins = await loadSearch(query);
if (cancelled) return;
setSearchSkins(skins);
setSearchError(null);
setResultsQuery(query);
logger.log({ type: "grid_search", query, resultCount: skins.length });
} catch (err) {
if (cancelled) return;
console.error("Search failed:", err);
setSearchError("Search failed. Please try again.");
setSearchSkins([]);
setResultsQuery(query);
}
}, getSearchDebounceMs());
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [inputValue]);
const columnCount = Math.max(
1,
Math.round(windowWidth / (SCREENSHOT_WIDTH * 0.9))
);
const columnWidth = windowWidth / columnCount;
const rowHeight = columnWidth * SKIN_RATIO;
// The rows the grid last rendered (including overscan).
const renderedRowsRef = useRef<[number, number]>([0, 0]);
const loadPagesForRows = useCallback(
(firstRow: number, lastRow: number) => {
loadMuseumRange(firstRow * columnCount, (lastRow + 1) * columnCount - 1);
},
[columnCount]
);
const onItemsRendered = useCallback(
({
overscanRowStartIndex,
overscanRowStopIndex,
}: {
overscanRowStartIndex: number;
overscanRowStopIndex: number;
}) => {
renderedRowsRef.current = [overscanRowStartIndex, overscanRowStopIndex];
if (showSearchResults) {
return;
}
loadPagesForRows(overscanRowStartIndex, overscanRowStopIndex);
},
[showSearchResults, loadPagesForRows]
);
// Clearing a search shows the browse grid in the rows that were already on
// screen, so onItemsRendered isn't called again; load their pages here.
useEffect(() => {
if (!showSearchResults) {
loadPagesForRows(...renderedRowsRef.current);
}
}, [showSearchResults, loadPagesForRows]);
const itemKey = useCallback(
({ columnIndex, rowIndex }: { columnIndex: number; rowIndex: number }) => {
const index = rowIndex * columnCount + columnIndex;
const skin = skins[index];
return skin ? skin.md5 : `empty-cell-${columnIndex}-${rowIndex}`;
},
[columnCount, skins]
);
// Coming back to the grid (e.g. with the feed's back arrow) returns to the
// skin you were on: the one in view in the feed, if you were in the feed
// showing this list, otherwise where this visit to the grid was scrolled
// to. The grid is first rendered already scrolled there, so the skin you
// came back from is on screen for the view transition to land on.
const scrollKey = useHistoryEntryKey();
const [restoreIndex] = useState(() => {
if (typeof window === "undefined") return undefined;
const position = getListPosition();
const startQuery = initialResults?.query ?? "";
if (position != null && position.query === startQuery) {
return position.index;
}
return scrollPositions.get(window.history.state?.gridEntryKey);
});
const initialScrollTop =
restoreIndex == null
? 0
: Math.floor(restoreIndex / columnCount) * rowHeight;
const onScroll = useCallback(
({
scrollTop,
scrollUpdateWasRequested,
}: {
scrollTop: number;
scrollUpdateWasRequested: boolean;
}) => {
if (rowHeight === 0) return;
const index = Math.round(scrollTop / rowHeight) * columnCount;
if (scrollKey != null) scrollPositions.set(scrollKey, index);
// Only the visitor's own scrolling moves them on from the skin they
// came back to (not where the grid starts, or scrolls itself to).
if (scrollUpdateWasRequested || scrollTop === initialScrollTop) return;
const skin = skins[index];
if (skin != null) setListPosition({ query: listQuery, index, skin });
},
[columnCount, rowHeight, scrollKey, skins, listQuery, initialScrollTop]
);
// Strict Mode runs this effect twice in development; log one arrival.
const enteredRef = useRef(false);
useEffect(() => {
if (enteredRef.current) return;
enteredRef.current = true;
const position = getListPosition();
const cameBackTo =
restoreIndex != null && position?.index === restoreIndex
? position
: null;
enterView("grid", {
query: inputValue.trim() || null,
skinMd5: cameBackTo?.skin.md5 ?? null,
listIndex: cameBackTo?.index ?? null,
});
if (initialResults?.isNew) {
logger.log({
type: "grid_search",
query: initialResults.query,
resultCount: initialResults.skins.length,
});
}
// Only on mount: the search the grid was opened with.
}, [logger]);
// Keyboard shortcuts to jump to the search box: "/" (when not already typing
// somewhere) and Cmd/Ctrl+K (from anywhere).
const searchInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
const input = searchInputRef.current;
if (input == null || e.defaultPrevented) return;
const isCommandK =
(e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === "k";
const isSlash =
e.key === "/" &&
!e.metaKey &&
!e.ctrlKey &&
!e.altKey &&
!(e.target as HTMLElement | null)?.closest(
"input, textarea, select, [contenteditable]"
);
if (!isCommandK && !isSlash) return;
e.preventDefault();
input.focus();
input.select();
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
const onSkinClick = useCallback(
(skin: GridSkin, position: number) => {
logger.log({
type: "grid_skin_click",
skinMd5: skin.md5,
position,
query: listQuery || null,
});
// The feed continues through this list from here.
setListPosition({ query: listQuery, index: position, skin });
setNextViewVia("grid");
markHintDone(GRID_HINT_KEY);
},
[logger, listQuery]
);
const itemData: CellData = useMemo(
() => ({
skins,
columnCount,
width: columnWidth,
height: rowHeight,
onSkinClick,
}),
[skins, columnCount, columnWidth, rowHeight, onSkinClick]
);
return (
<div id="infinite-skins">
<HintPill
storageKey={GRID_HINT_KEY}
done={false}
onShown={() => logger.log({ type: "grid_hint_shown" })}
icon={<Pointer size={16} />}
className="hint-pill-grid"
>
Tap a skin to try it in Webamp
</HintPill>
<div className="grid-search" style={{ maxWidth: MOBILE_MAX_WIDTH }}>
<div style={{ position: "relative" }}>
<input
className={
searchPending ? "grid-search-input pending" : "grid-search-input"
}
type="search"
enterKeyHint="search"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
ref={searchInputRef}
onKeyDown={(e) => {
// Search runs as you type; Enter just dismisses the keyboard,
// and Escape leaves the search box.
if (e.key === "Enter" || e.key === "Escape") {
e.currentTarget.blur();
}
}}
placeholder="Search skins..."
aria-label="Search skins"
aria-keyshortcuts="/ Meta+K Control+K"
title="Search skins (/ or ⌘K)"
aria-busy={searchPending}
/>
{searchPending && (
<div
className="search-spinner"
role="status"
aria-label="Searching"
/>
)}
</div>
</div>
{showSearchResults && searchError && (
<div className="grid-message grid-message-error">{searchError}</div>
)}
{showSearchResults && !searchError && skins.length === 0 && (
<div className="grid-message">
No results found for &quot;{resultsQuery}&quot;
</div>
)}
{/* Grid - show when browsing or when we have results (even while pending) */}
{windowSize != null &&
(!showSearchResults || (!searchError && skins.length > 0)) && (
<Grid
itemKey={itemKey}
itemData={itemData}
columnCount={columnCount}
columnWidth={columnWidth}
height={windowHeight}
rowCount={Math.ceil(total / columnCount)}
rowHeight={rowHeight}
width={windowWidth}
overscanRowCount={5}
onScroll={onScroll}
initialScrollTop={initialScrollTop}
onItemsRendered={onItemsRendered}
style={{ overflowY: "scroll" }}
>
{Cell}
</Grid>
)}
</div>
);
}
// A key for the current history entry, kept in its state, so a grid can
// remember things about this visit to it and find them again on Back.
function useHistoryEntryKey(): string | null {
const [key, setKey] = useState<string | null>(null);
useEffect(() => {
let entryKey: string | undefined = window.history.state?.gridEntryKey;
if (entryKey == null) {
entryKey = crypto.randomUUID();
replaceHistoryEntry({ gridEntryKey: entryKey });
}
setKey(entryKey);
}, []);
return key;
}
/**
* Update the current history entry's state (keeping our key), and its URL if
* given.
*
* With a new URL, only our own state is passed: Next.js adds its own back, and
* given its keys it would assume the update was its own and not learn the new
* URL, then put its old one back on the entry (so Back would lose the search).
* Without one, Next's keys are kept: there's nothing for it to learn, and this
* can run before Next is listening (child effects run first), when it
* couldn't add them back.
*/
function replaceHistoryEntry(
state: { gridEntryKey?: string },
url?: string
): void {
const current = window.history.state;
if (url == null) {
window.history.replaceState({ ...current, ...state }, "");
} else {
window.history.replaceState(
{ gridEntryKey: current?.gridEntryKey, ...state },
"",
url
);
}
}