mirror of
https://github.com/captbaritone/webamp.git
synced 2026-07-17 16:46:04 +00:00
Scroll search
This commit is contained in:
parent
f74d7a6cdf
commit
04c396ed1f
5 changed files with 167 additions and 46 deletions
|
|
@ -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 "{inputValue}"
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
18
packages/skin-database/app/(modern)/scroll/algoliaClient.ts
Normal file
18
packages/skin-database/app/(modern)/scroll/algoliaClient.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -18,7 +18,7 @@ export default function Layout({ children }: LayoutProps) {
|
|||
}}
|
||||
>
|
||||
{children}
|
||||
<ViewTransition name="footer">
|
||||
<ViewTransition>
|
||||
<BottomMenuBar />
|
||||
</ViewTransition>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue