mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-21 10:47:04 +02:00
feat: search functionality
This commit is contained in:
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,
|
||||
});
|
||||
Reference in new issue
Block a user