feat: search functionality

This commit is contained in:
TrèsAbhi committed 2026-01-15 14:30:40 -06:00
1 parent 63ce327458
commit 610b59f4c6
9 files changed
+133 -81

No files matched your search

@@ -0,0 +1,7 @@
.avatar-controls {
display: flex;
.search {
flex: 1;
}
}
@@ -1,14 +1,32 @@
import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
import { useRef } from "react";
import { useStrings } from "../../hooks/useStrings";
import { Avatars } from "../../stores/avatars";
import { TextField } from "../TextField";
import "./index.css";
export function AvatarControls() {
const strings = useStrings();
const search = useRef<HTMLInputElement>(null);
return (
<div>
<TextField placeholder={strings.avatars.search}>
<div className="avatar-controls">
<TextField
ref={search}
className="search"
placeholder={strings.avatars.search}
onChange={(event) => {
const trimmed = event.target.value.trim();
Avatars.mutate((draft) => {
if (trimmed.length === 0) {
draft.search = null;
} else {
draft.search = trimmed;
}
});
}}
>
<MagnifyingGlassIcon />
</TextField>
</div>
@@ -0,0 +1,5 @@
.avatars-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--space-7), 1fr));
gap: var(--space-5) var(--space-4);
}
@@ -0,0 +1,85 @@
import fuzzysort from "fuzzysort";
import { useMemo } from "react";
import { api } from "../../core/api/dynamic";
import { useAwait } from "../../hooks/useAwait";
import { useGameStrings } from "../../hooks/useGameStrings";
import { useLocale } from "../../hooks/useLocale";
import type { Avatar } from "../../protos/avatar";
import { Avatars } from "../../stores/avatars";
import { AvatarCard } from "../AvatarCard";
import { IncrementalLoader } from "../IncrementalLoader";
import "./index.css";
const { go } = fuzzysort;
const indexingPattern = /.+_(\d+)$/;
export function AvatarsList() {
const locale = useLocale();
const search = Avatars.use((state) => state.search);
const { avatars } = useAwait(() => api.avatars(), "avatars");
const profileAvatarEntityStrings = useGameStrings("ProfileAvatarEntity");
const groups = useMemo(() => {
const groups = new Map<string, Avatar[]>();
for (const avatar of avatars) {
const name =
profileAvatarEntityStrings[avatar.stuff_ui!.display_name] ??
avatar.stuff_ui!.display_name;
if (groups.has(name)) {
groups.get(name)!.push(avatar);
} else {
groups.set(name, [avatar]);
}
}
return groups;
}, []);
const ordered = useMemo(() => {
const array = Array.from(groups.entries());
return array
.sort((a, b) => a[0].localeCompare(b[0], locale))
.map(([name, avatars]) => ({
key: name,
name,
avatars: avatars.sort((a, b) => {
const gradeA = a.stuff_ui!.grade;
const gradeB = b.stuff_ui!.grade;
let diff = gradeA - gradeB;
if (diff === 0) {
const matchesA = a.name.match(indexingPattern);
const matchesB = b.name.match(indexingPattern);
if (matchesA && matchesB) {
const indexA = Number(matchesA[1]);
const indexB = Number(matchesB[1]);
diff = indexA - indexB;
}
}
return diff;
}),
}));
}, []);
const filtered = useMemo(() => {
if (search === null) return ordered;
const searched = go(search, ordered, { keys: ["name"] });
return searched.map((result) => result.obj);
}, [search]);
return (
<div className="avatars-list">
<IncrementalLoader
initial={40}
intermediate={10}
data={filtered}
Component={AvatarCard}
/>
</div>
);
}
@@ -3,17 +3,17 @@ import { useEffect, useRef, useState, type ReactNode } from "react";
import type { SkeletonProps } from "../../types/SkeletonProps";
import "./index.css";
export interface IncrementalLoaderProps<Props> {
export interface IncrementalLoaderProps<Props extends { key: string }> {
initial: number;
intermediate: number;
rate?: number;
skeleton?: boolean;
data: Props[];
Component: (props: SkeletonProps<Props>) => ReactNode;
Component: (props: SkeletonProps<Omit<Props, "key">>) => ReactNode;
}
export function IncrementalLoader<Props>({
export function IncrementalLoader<Props extends { key: string }>({
initial,
rate = 2,
data,
@@ -27,7 +27,7 @@ export function IncrementalLoader<Props>({
return (
<>
{!skeleton &&
sliced.map((item, index) => <Component key={index} {...item} />)}
sliced.map(({ key, ...props }) => <Component key={key} {...props} />)}
{times(
skeleton
@@ -4,7 +4,6 @@
box-sizing: border-box;
gap: var(--space-3);
margin: 0 var(--space-4);
border-radius: var(--max-size);
@@ -1,13 +1,7 @@
import { useMemo } from "react";
import { AvatarCard } from "../../../components/AvatarCard";
import { AvatarControls } from "../../../components/AvatarControls";
import { IncrementalLoader } from "../../../components/IncrementalLoader";
import { AvatarsList } from "../../../components/AvatarsList";
import { Section } from "../../../components/Section";
import { api } from "../../../core/api/dynamic";
import { useAwait } from "../../../hooks/useAwait";
import { useGameStrings } from "../../../hooks/useGameStrings";
import { LocaleProvider, useLocale } from "../../../hooks/useLocale";
import type { Avatar } from "../../../protos/avatar";
import { LocaleProvider } from "../../../hooks/useLocale";
interface PageProps extends ContentProps {
locale: string;
@@ -25,57 +19,7 @@ interface ContentProps {
skeleton?: boolean;
}
const indexingPattern = /.+_(\d+)$/;
function Content({}: ContentProps) {
const locale = useLocale();
const { avatars } = useAwait(() => api.avatars(), "avatars");
const profileAvatarEntityStrings = useGameStrings("ProfileAvatarEntity");
const groups = useMemo(() => {
const groups = new Map<string, Avatar[]>();
for (const avatar of avatars) {
const name =
profileAvatarEntityStrings[avatar.stuff_ui!.display_name] ??
avatar.stuff_ui!.display_name;
if (groups.has(name)) {
groups.get(name)!.push(avatar);
} else {
groups.set(name, [avatar]);
}
}
return groups;
}, []);
const ordered = useMemo(() => {
const array = Array.from(groups.entries());
return array
.sort((a, b) => a[0].localeCompare(b[0], locale))
.map(([name, avatars]) => ({
name,
avatars: avatars.sort((a, b) => {
const gradeA = a.stuff_ui!.grade;
const gradeB = b.stuff_ui!.grade;
let diff = gradeA - gradeB;
if (diff === 0) {
const matchesA = a.name.match(indexingPattern);
const matchesB = b.name.match(indexingPattern);
if (matchesA && matchesB) {
const indexA = Number(matchesA[1]);
const indexB = Number(matchesB[1]);
diff = indexA - indexB;
}
}
return diff;
}),
}));
}, []);
return (
<>
<Section>
@@ -83,14 +27,7 @@ function Content({}: ContentProps) {
</Section>
<Section>
<div className="avatars">
<IncrementalLoader
initial={40}
intermediate={10}
data={ordered}
Component={AvatarCard}
/>
</div>
<AvatarsList />
</Section>
</>
);
@@ -9,14 +9,6 @@ export { getStaticPaths } from "../index.astro";
const { locale } = Astro.props;
---
<style is:global>
.avatars {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--space-7), 1fr));
gap: var(--space-5) var(--space-4);
}
</style>
<BaseLayout {locale} accent="gold">
<ToolBanner {locale} tool={tools.avatars} slot="pre-app" />
@@ -0,0 +1,9 @@
import { Varuna } from "varuna";
interface Avatars {
search: string | null;
}
export const Avatars = new Varuna<Avatars>({
search: null,
});