mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-24 03:59:50 +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 { MagnifyingGlassIcon } from "@radix-ui/react-icons";
|
||||||
|
import { useRef } from "react";
|
||||||
import { useStrings } from "../../hooks/useStrings";
|
import { useStrings } from "../../hooks/useStrings";
|
||||||
|
import { Avatars } from "../../stores/avatars";
|
||||||
import { TextField } from "../TextField";
|
import { TextField } from "../TextField";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
export function AvatarControls() {
|
export function AvatarControls() {
|
||||||
const strings = useStrings();
|
const strings = useStrings();
|
||||||
|
const search = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="avatar-controls">
|
||||||
<TextField placeholder={strings.avatars.search}>
|
<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 />
|
<MagnifyingGlassIcon />
|
||||||
</TextField>
|
</TextField>
|
||||||
</div>
|
</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 type { SkeletonProps } from "../../types/SkeletonProps";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
export interface IncrementalLoaderProps<Props> {
|
export interface IncrementalLoaderProps<Props extends { key: string }> {
|
||||||
initial: number;
|
initial: number;
|
||||||
intermediate: number;
|
intermediate: number;
|
||||||
rate?: number;
|
rate?: number;
|
||||||
|
|
||||||
skeleton?: boolean;
|
skeleton?: boolean;
|
||||||
data: Props[];
|
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,
|
initial,
|
||||||
rate = 2,
|
rate = 2,
|
||||||
data,
|
data,
|
||||||
@@ -27,7 +27,7 @@ export function IncrementalLoader<Props>({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!skeleton &&
|
{!skeleton &&
|
||||||
sliced.map((item, index) => <Component key={index} {...item} />)}
|
sliced.map(({ key, ...props }) => <Component key={key} {...props} />)}
|
||||||
|
|
||||||
{times(
|
{times(
|
||||||
skeleton
|
skeleton
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
margin: 0 var(--space-4);
|
|
||||||
|
|
||||||
border-radius: var(--max-size);
|
border-radius: var(--max-size);
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import { AvatarCard } from "../../../components/AvatarCard";
|
|
||||||
import { AvatarControls } from "../../../components/AvatarControls";
|
import { AvatarControls } from "../../../components/AvatarControls";
|
||||||
import { IncrementalLoader } from "../../../components/IncrementalLoader";
|
import { AvatarsList } from "../../../components/AvatarsList";
|
||||||
import { Section } from "../../../components/Section";
|
import { Section } from "../../../components/Section";
|
||||||
import { api } from "../../../core/api/dynamic";
|
import { LocaleProvider } from "../../../hooks/useLocale";
|
||||||
import { useAwait } from "../../../hooks/useAwait";
|
|
||||||
import { useGameStrings } from "../../../hooks/useGameStrings";
|
|
||||||
import { LocaleProvider, useLocale } from "../../../hooks/useLocale";
|
|
||||||
import type { Avatar } from "../../../protos/avatar";
|
|
||||||
|
|
||||||
interface PageProps extends ContentProps {
|
interface PageProps extends ContentProps {
|
||||||
locale: string;
|
locale: string;
|
||||||
@@ -25,57 +19,7 @@ interface ContentProps {
|
|||||||
skeleton?: boolean;
|
skeleton?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const indexingPattern = /.+_(\d+)$/;
|
|
||||||
|
|
||||||
function Content({}: ContentProps) {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Section>
|
<Section>
|
||||||
@@ -83,14 +27,7 @@ function Content({}: ContentProps) {
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section>
|
<Section>
|
||||||
<div className="avatars">
|
<AvatarsList />
|
||||||
<IncrementalLoader
|
|
||||||
initial={40}
|
|
||||||
intermediate={10}
|
|
||||||
data={ordered}
|
|
||||||
Component={AvatarCard}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Section>
|
</Section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,14 +9,6 @@ export { getStaticPaths } from "../index.astro";
|
|||||||
const { locale } = Astro.props;
|
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">
|
<BaseLayout {locale} accent="gold">
|
||||||
<ToolBanner {locale} tool={tools.avatars} slot="pre-app" />
|
<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