diff --git a/components/admin/guide-section-editor.tsx b/components/admin/guide-section-editor.tsx index 8d7abf1..8b15c46 100644 --- a/components/admin/guide-section-editor.tsx +++ b/components/admin/guide-section-editor.tsx @@ -10,6 +10,7 @@ import { ListOrderedIcon, PlusIcon, RefreshCcwIcon, + SearchIcon, SparklesIcon, SwordIcon, TimerIcon, @@ -17,7 +18,7 @@ import { UserRoundIcon, XIcon, } from "lucide-react"; -import { useEffect, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; import { closestCenter, DndContext, @@ -134,6 +135,10 @@ import { type TeamPreset, type TeamToolValue, } from "@/lib/guides/team-tools"; +import { + matchesTeamSearch, + type TeamSearchTarget, +} from "@/lib/guides/team-search"; import { normalizeTalentPriorities, reorderTalentPriorities, @@ -2069,6 +2074,35 @@ function TeamEditor({ const [presetsHydrated, setPresetsHydrated] = useState(false); const [presetsGuideId, setPresetsGuideId] = useState(null); const [applyFields, setApplyFields] = useState(["loadout"]); + const [searchQuery, setSearchQuery] = useState(""); + const [searchTarget, setSearchTarget] = useState("all"); + const filteredTeams = useMemo(() => { + const characterNames = new Map(characters.map((item) => [item.key, item.name])); + const weaponNames = new Map(weapons.map((item) => [item.key, item.name])); + const artifactNames = new Map(artifacts.map((item) => [item.key, item.name])); + + return teams + .map((team, teamIndex) => ({ team, teamIndex })) + .filter(({ team }) => matchesTeamSearch({ + team: [team.title], + character: team.members.flatMap((member) => { + const name = characterNames.get(member.characterKey); + return name ? [name] : []; + }), + weapon: team.members.flatMap((member) => { + const name = member.weaponKey + ? weaponNames.get(member.weaponKey) + : undefined; + return name ? [name] : []; + }), + artifact: team.members.flatMap((member) => + [member.artifactKey, member.secondArtifactKey].flatMap((key) => { + const name = key ? artifactNames.get(key) : undefined; + return name ? [name] : []; + }), + ), + }, searchQuery, searchTarget)); + }, [artifacts, characters, searchQuery, searchTarget, teams, weapons]); const value = { teams: teams.map((team) => ({ title: team.title, @@ -2213,13 +2247,51 @@ function TeamEditor({ ))} + + + ค้นหา Team +
+
+
+ + ค้นหาใน + { + const target = value[0] as TeamSearchTarget | undefined; + if (target) setSearchTarget(target); + }} + > + ทั้งหมด + ชื่อทีม + ตัวละคร + อาวุธ + อาร์ติแฟกต์ + + +
team.clientId)} + items={filteredTeams.map(({ team }) => team.clientId)} strategy={verticalListSortingStrategy} > - {teams.map((team, teamIndex) => { + {filteredTeams.map(({ team, teamIndex }) => { const total = team.members.reduce( (sum, member) => sum + (Number(member.damage) || 0), 0, @@ -2764,6 +2836,11 @@ function TeamEditor({ ); })} + {filteredTeams.length === 0 ? ( +

+ ไม่พบ Team ที่ตรงกับคำค้นหา +

+ ) : null}
diff --git a/components/public/team-search.tsx b/components/public/team-search.tsx index 90f54db..63ef8bc 100644 --- a/components/public/team-search.tsx +++ b/components/public/team-search.tsx @@ -10,13 +10,152 @@ import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } import { Input } from "@/components/ui/input"; import { Kbd } from "@/components/ui/kbd"; import { Separator } from "@/components/ui/separator"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { elementBackgroundColor } from "@/lib/catalog/element"; import { rarityGradientClass } from "@/lib/catalog/rarity"; import { isCustomTravelerKey } from "@/lib/catalog/travelers"; import type { PublicGuide } from "@/lib/guides/queries"; +import { + matchesTeamSearch, + type TeamSearchTarget, +} from "@/lib/guides/team-search"; import { cn } from "@/lib/utils"; type Team = PublicGuide["teams"][number]; +type TeamArtifact = NonNullable; + +function ArtifactHalf({ + artifact, + side, + eager, + linked, + sizes, +}: { + artifact: TeamArtifact; + side: "left" | "right"; + eager: boolean; + linked: boolean; + sizes: string; +}) { + const content = ( + <> + + {linked + + {linked ? ( + + + ) : null} + + ); + const className = cn( + "absolute inset-0 overflow-hidden", + side === "left" + ? "[clip-path:polygon(0_0,0_100%,100%_100%)]" + : "[clip-path:polygon(0_0,100%_0,100%_100%)]", + rarityGradientClass(artifact.rarity), + ); + + return linked ? ( + + {content} + + ) : ( + {content} + ); +} + +function TeamArtifactIcon({ + first, + second, + eager = false, + linked = false, + className, + sizes, +}: { + first: TeamArtifact | null; + second: TeamArtifact | null; + eager?: boolean; + linked?: boolean; + className?: string; + sizes: string; +}) { + const primary = first ?? second; + const secondary = first ? second : null; + + if (!primary) { + return
; + } + + if (!secondary) { + const content = ( + <> + {linked + {linked ? ( + + + ) : null} + + ); + const artifactClassName = cn( + "relative aspect-square scale-110 overflow-hidden rounded-md bg-muted", + rarityGradientClass(primary.rarity), + className, + ); + + return linked ? ( + + {content} + + ) : ( +
{content}
+ ); + } + + return ( +
+ + +
+ ); +} function TeamExportImage({ team }: { team: Team }) { const total = team.members.reduce((sum, member) => sum + Number(member.damage), 0); @@ -38,8 +177,14 @@ function TeamExportImage({ team }: { team: Team }) { C{member.constellation}
- {member.weapon ?
R{member.refinement}
: null} - {[member.artifact, member.secondArtifact].map((item, index) => item ?
: null)} + {member.weapon ?
R{member.refinement}
: null} +
{member.character.name}{damage.toLocaleString()}
@@ -167,64 +312,14 @@ function TeamCard({ ) : (
)} -
- {member.artifact ? ( - - {member.artifact.name} - - - - ) : ( -
- )} - {member.secondArtifact ? ( - - {member.secondArtifact.name} - - - - ) : null} -
+

@@ -271,22 +366,15 @@ function TeamCard({ export function TeamSearch({ teams }: { teams: PublicGuide["teams"] }) { const [query, setQuery] = useState(""); - const normalizedQuery = query.trim().toLocaleLowerCase(); + const [searchTarget, setSearchTarget] = useState("all"); const filteredTeams = useMemo( - () => teams.filter((team) => { - if (!normalizedQuery) return true; - const searchableNames = [ - team.title, - ...team.members.flatMap((member) => [ - member.character.name, - member.weapon?.name, - member.artifact?.name, - member.secondArtifact?.name, - ]), - ].filter((name): name is string => Boolean(name)); - return searchableNames.some((name) => name.toLocaleLowerCase().includes(normalizedQuery)); - }), - [normalizedQuery, teams], + () => teams.filter((team) => matchesTeamSearch({ + team: [team.title], + character: team.members.map((member) => member.character.name), + weapon: team.members.flatMap((member) => member.weapon ? [member.weapon.name] : []), + artifact: team.members.flatMap((member) => [member.artifact, member.secondArtifact].flatMap((artifact) => artifact ? [artifact.name] : [])), + }, query, searchTarget)), + [query, searchTarget, teams], ); return ( @@ -301,6 +389,23 @@ export function TeamSearch({ teams }: { teams: PublicGuide["teams"] }) { onValueChange={setQuery} />

+ { + const target = value[0] as TeamSearchTarget | undefined; + if (target) setSearchTarget(target); + }} + > + ทั้งหมด + ชื่อทีม + ตัวละคร + อาวุธ + อาร์ติแฟกต์ + {filteredTeams.length ? (
{filteredTeams.map((team) => ( diff --git a/lib/guides/team-search.test.ts b/lib/guides/team-search.test.ts new file mode 100644 index 0000000..44a88e1 --- /dev/null +++ b/lib/guides/team-search.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { matchesTeamSearch } from "./team-search"; + +const team = { + team: ["Vaporize Team"], + character: ["Mualani"], + weapon: ["Surf's Up"], + artifact: ["Obsidian Codex", "Scroll of the Hero"], +}; + +describe("team search", () => { + it("searches every category when the target is all", () => { + expect(matchesTeamSearch(team, "surf", "all")).toBe(true); + expect(matchesTeamSearch(team, "mualani", "all")).toBe(true); + }); + + it("only searches the selected target", () => { + expect(matchesTeamSearch(team, "vaporize", "team")).toBe(true); + expect(matchesTeamSearch(team, "obsidian", "team")).toBe(false); + expect(matchesTeamSearch(team, "obsidian", "artifact")).toBe(true); + }); + + it("ignores case and surrounding whitespace", () => { + expect(matchesTeamSearch(team, " HERO ", "artifact")).toBe(true); + }); + + it("matches every team when the query is empty", () => { + expect(matchesTeamSearch(team, " ", "weapon")).toBe(true); + }); +}); diff --git a/lib/guides/team-search.ts b/lib/guides/team-search.ts new file mode 100644 index 0000000..91caba9 --- /dev/null +++ b/lib/guides/team-search.ts @@ -0,0 +1,30 @@ +export type TeamSearchTarget = + | "all" + | "team" + | "character" + | "weapon" + | "artifact"; + +export interface TeamSearchValues { + team: string[]; + character: string[]; + weapon: string[]; + artifact: string[]; +} + +export function matchesTeamSearch( + values: TeamSearchValues, + query: string, + target: TeamSearchTarget, +): boolean { + const normalizedQuery = query.trim().toLocaleLowerCase(); + if (!normalizedQuery) return true; + + const candidates = target === "all" + ? Object.values(values).flat() + : values[target]; + + return candidates.some((value) => + value.toLocaleLowerCase().includes(normalizedQuery), + ); +}