mirror of
https://github.com/captbaritone/webamp.git
synced 2026-11-07 10:25:59 -10:00
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
547 lines
18 KiB
TypeScript
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 "{resultsQuery}"
|
|
</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
|
|
);
|
|
}
|
|
}
|