Scroll search

This commit is contained in:
Jordan Eldredge 2025-12-29 20:22:39 -08:00
parent f74d7a6cdf
commit 04c396ed1f
5 changed files with 167 additions and 46 deletions

View file

@ -4,6 +4,7 @@ import React, {
useMemo,
useCallback,
useRef,
useTransition,
// @ts-expect-error - unstable_ViewTransition is not yet in @types/react
unstable_ViewTransition as ViewTransition,
} from "react";
@ -17,6 +18,12 @@ import {
MOBILE_MAX_WIDTH,
} from "../../../legacy-client/src/constants";
import { getMuseumPageSkins, GridSkin } from "./getMuseumPageSkins";
import { searchSkins as performAlgoliaSearch } from "./algoliaClient";
// Simple utility to get screenshot URL (avoiding server-side import)
function getScreenshotUrl(md5: string): string {
return `https://r2.webampskins.org/screenshots/${md5}.png`;
}
type CellData = {
skins: GridSkin[];
@ -97,11 +104,67 @@ export default function SkinTable({
}: SkinTableProps) {
const { windowWidth, windowHeight } = useWindowSize();
// Initialize state with server-provided data
const [skins, setSkins] = useState<GridSkin[]>(initialSkins);
// Search input state - separate input value from actual search query
const [inputValue, setInputValue] = useState("");
// State for browsing mode
const [browseSkins, setBrowseSkins] = useState<GridSkin[]>(initialSkins);
const [loadedPages, setLoadedPages] = useState<Set<number>>(new Set([0]));
const isLoadingRef = useRef(false);
// State for search mode
const [searchSkins, setSearchSkins] = useState<GridSkin[]>([]);
const [searchError, setSearchError] = useState<string | null>(null);
const [searchIsPending, setSearchIsPending] = useState(false);
// Debounce timer ref
// Determine which mode we're in based on actual search query, not input
const isSearchMode = inputValue.trim().length > 0;
const skins = isSearchMode ? searchSkins : browseSkins;
const total = isSearchMode ? searchSkins.length : initialTotal;
// Handle search input change
const handleSearchChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const query = e.target.value;
setInputValue(query);
// If query is empty, clear results immediately
if (!query || query.trim().length === 0) {
return;
// setSearchQuery("");
startTransition(() => {
setSearchSkins([]);
setSearchError(null);
});
return;
}
// return;
try {
setSearchIsPending(true);
const result = await performAlgoliaSearch(query);
const hits = result.hits as Array<{
objectID: string;
fileName: string;
nsfw?: boolean;
}>;
const searchResults: GridSkin[] = hits.map((hit) => ({
md5: hit.objectID,
screenshotUrl: getScreenshotUrl(hit.objectID),
fileName: hit.fileName,
nsfw: hit.nsfw ?? false,
}));
setSearchSkins(searchResults);
} catch (err) {
console.error("Search failed:", err);
setSearchError("Search failed. Please try again.");
setSearchSkins([]);
} finally {
setSearchIsPending(false);
}
};
const columnCount = Math.round(windowWidth / (SCREENSHOT_WIDTH * 0.9));
const columnWidth = windowWidth / columnCount;
const rowHeight = columnWidth * SKIN_RATIO;
@ -109,6 +172,11 @@ export default function SkinTable({
const loadMoreSkins = useCallback(
async (startIndex: number) => {
// Don't load more in search mode
if (isSearchMode) {
return;
}
const pageNumber = Math.floor(startIndex / pageSize);
// Don't reload if we already have this page
@ -120,7 +188,7 @@ export default function SkinTable({
try {
const offset = pageNumber * pageSize;
const newSkins = await getMuseumPageSkins(offset, pageSize);
setSkins((prev) => [...prev, ...newSkins]);
setBrowseSkins((prev) => [...prev, ...newSkins]);
setLoadedPages((prev) => new Set([...prev, pageNumber]));
} catch (error) {
console.error("Failed to load skins:", error);
@ -128,20 +196,17 @@ export default function SkinTable({
isLoadingRef.current = false;
}
},
[loadedPages, pageSize]
[loadedPages, pageSize, isSearchMode]
);
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 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]
);
const gridRef = React.useRef<any>(null);
const itemRef = React.useRef<number>(0);
@ -152,7 +217,7 @@ export default function SkinTable({
itemRef.current =
Math.round(scrollData.scrollTop / rowHeight) * columnCount + half;
};
}, [columnCount, rowHeight, loadMoreSkins]);
}, [columnCount, rowHeight]);
const itemData: CellData = useMemo(
() => ({
@ -171,7 +236,7 @@ export default function SkinTable({
<div
style={{
position: "fixed",
bottom: "5rem",
bottom: "4.25rem",
left: "50%",
transform: "translateX(-50%)",
width: "calc(100% - 2rem)",
@ -180,22 +245,18 @@ export default function SkinTable({
zIndex: 998,
}}
>
<form
action="/"
method="GET"
style={{
width: "100%",
}}
>
<div style={{ position: "relative" }}>
<input
type="search"
name="q"
value={inputValue}
onChange={handleSearchChange}
placeholder="Search skins..."
style={{
width: "100%",
padding: "0.75rem 1rem",
paddingRight: "1rem",
fontSize: "1rem",
backgroundColor: "rgba(26, 26, 26, 0.85)",
backgroundColor: "rgba(26, 26, 26, 0.55)",
backdropFilter: "blur(10px)",
border: "1px solid rgba(255, 255, 255, 0.2)",
borderRadius: "9999px",
@ -203,35 +264,69 @@ export default function SkinTable({
outline: "none",
fontFamily: "inherit",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.3)",
transition: "padding-right 0.2s ease",
}}
onFocus={(e) => {
e.currentTarget.style.backgroundColor = "rgba(26, 26, 26, 0.95)";
e.currentTarget.style.backgroundColor = "rgba(26, 26, 26, 0.65)";
e.currentTarget.style.borderColor = "rgba(255, 255, 255, 0.3)";
}}
onBlur={(e) => {
e.currentTarget.style.backgroundColor = "rgba(26, 26, 26, 0.85)";
e.currentTarget.style.backgroundColor = "rgba(26, 26, 26, 0.55)";
e.currentTarget.style.borderColor = "rgba(255, 255, 255, 0.2)";
}}
/>
</form>
</div>
</div>
<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>
{/* Error State */}
{isSearchMode && searchError && (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: windowHeight,
color: "#ff6b6b",
}}
>
{searchError}
</div>
)}
{/* Empty Results */}
{isSearchMode && !searchError && skins.length === 0 && (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: windowHeight,
color: "#ccc",
}}
>
No results found for &quot;{inputValue}&quot;
</div>
)}
{/* Grid - show when browsing or when we have results (even while pending) */}
{(!isSearchMode || (!searchError && skins.length > 0)) && (
<Grid
ref={gridRef}
itemKey={itemKey}
itemData={itemData}
columnCount={columnCount}
columnWidth={columnWidth}
height={windowHeight}
rowCount={Math.ceil(total / columnCount)}
rowHeight={rowHeight}
width={windowWidth}
overscanRowsCount={5}
onScroll={onScroll}
style={{ overflowY: "scroll" }}
>
{Cell}
</Grid>
)}
</div>
);
}

View file

@ -120,6 +120,7 @@ export function Input({
borderRadius: "4px",
color: "#fff",
fontFamily: "inherit",
boxSizing: "border-box",
...style,
}}
{...props}
@ -146,6 +147,7 @@ export function Textarea({
fontFamily: "inherit",
display: "block",
resize: "vertical",
boxSizing: "border-box",
...style,
}}
{...props}

View file

@ -0,0 +1,18 @@
import { algoliasearch } from "algoliasearch";
// Using the legacy hardcoded credentials for client-side search
const client = algoliasearch("HQ9I5Z6IM5", "6466695ec3f624a5fccf46ec49680e51");
export async function searchSkins(query: string) {
const result = await client.searchSingleIndex({
indexName: "Skins",
searchParams: {
query,
attributesToRetrieve: ["objectID", "fileName", "nsfw"],
attributesToHighlight: [],
hitsPerPage: 1000,
typoTolerance: "min",
},
});
return result;
}

View file

@ -18,7 +18,7 @@ export default function Layout({ children }: LayoutProps) {
}}
>
{children}
<ViewTransition name="footer">
<ViewTransition>
<BottomMenuBar />
</ViewTransition>
</div>

View file

@ -2,6 +2,12 @@ body {
margin: 0; /* Remove default margin */
height: 100vh; /* Set body height to viewport height */
background-color: #1a1a1a; /* Dark charcoal instead of pure black */
font-family: "MS Sans Serif", "Segoe UI", sans-serif;
}
input,
button {
font-family: "MS Sans Serif", "Segoe UI", sans-serif;
}
.scroller::-webkit-scrollbar,