feat : server side
CI / Verify (push) Successful in 1m33s
CI / Build immutable images and deploy (push) Successful in 2m16s

This commit is contained in:
2026-09-23 21:45:46 +07:00 Unverified
parent bd84d49108
commit e0b25500bc
10 changed files with 1126 additions and 611 deletions
+104 -26
View File
@@ -4,7 +4,8 @@
import Image from "next/image";
import Link from "next/link";
import { ArrowUpRightIcon, SearchIcon, XIcon } from "lucide-react";
import { useMemo, useState, type ReactNode } from "react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { GuideImageExport } from "@/components/public/guide-image-export";
import { ExportText } from "@/components/public/export-language";
@@ -19,7 +20,7 @@ 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 type { PublicTeamCardData, PublicTeamConditionGroup } from "@/lib/guides/public-filters";
import {
searchTeams,
sortTeamsByDps,
@@ -28,7 +29,7 @@ import {
} from "@/lib/guides/team-search";
import { cn } from "@/lib/utils";
type Team = PublicGuide["teams"][number];
type Team = PublicTeamCardData;
type TeamArtifact = {
key: string;
name: string;
@@ -379,32 +380,109 @@ function TeamCard({
);
}
export function TeamSearch({ groups }: { groups: PublicGuide["teamGroups"] }) {
export function TeamSearch({
groups,
serverFilters,
}: {
groups: PublicTeamConditionGroup[];
serverFilters?: {
conditionIndex: number;
query: string;
field: TeamSearchTarget;
sort: DpsOrder;
};
}) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const searchString = searchParams.toString();
const [activeCondition, setActiveCondition] = useState("0");
const activeGroup = groups[Number(activeCondition)] ?? groups[0];
const activeConditionIndex = serverFilters?.conditionIndex ?? Number(activeCondition);
const activeGroup = groups[activeConditionIndex] ?? groups[0];
const teams = useMemo(() => activeGroup?.teams ?? [], [activeGroup]);
const [query, setQuery] = useState("");
const [query, setQuery] = useState(serverFilters?.query ?? "");
const [searchTarget, setSearchTarget] = useState<TeamSearchTarget>("all");
const [dpsOrder, setDpsOrder] = useState<DpsOrder>(null);
const search = useMemo(
() => searchTeams(teams, (team) => ({
team: [team.title],
character: team.members.flatMap((member) => [member.character.name, ...member.character.aliases]),
weapon: team.members.flatMap((member) => member.weapon ? [member.weapon.name, ...member.weapon.aliases] : []),
artifact: team.members.flatMap((member) => [member.artifact, member.secondArtifact].flatMap((artifact) => artifact ? [artifact.name, ...artifact.aliases] : [])),
}), query, searchTarget),
[query, searchTarget, teams],
);
const currentQuery = serverFilters?.query ?? "";
const currentField = serverFilters?.field ?? searchTarget;
const currentSort = serverFilters?.sort ?? dpsOrder;
const filteredTeams = sortTeamsByDps(search.items, dpsOrder);
useEffect(() => {
if (!serverFilters) return;
const nextQuery = query.trim();
if (nextQuery === currentQuery) return;
const timeout = window.setTimeout(() => {
const nextParams = new URLSearchParams(searchString);
if (nextQuery) nextParams.set("q", nextQuery);
else nextParams.delete("q");
const queryString = nextParams.toString();
router.replace(`${pathname}${queryString ? `?${queryString}` : ""}`, { scroll: false });
}, 300);
return () => window.clearTimeout(timeout);
}, [currentQuery, pathname, query, router, searchString, serverFilters]);
const localSearch = useMemo(
() => serverFilters ? null : searchTeams(teams, (team) => ({
team: [team.title],
character: team.members.flatMap((member) => [member.character.name, ...(member.character.aliases ?? [])]),
weapon: team.members.flatMap((member) => member.weapon ? [member.weapon.name, ...(member.weapon.aliases ?? [])] : []),
artifact: team.members.flatMap((member) => [member.artifact, member.secondArtifact].flatMap((artifact) => artifact ? [artifact.name, ...(artifact.aliases ?? [])] : [])),
}), query, currentField),
[currentField, query, serverFilters, teams],
);
const filteredTeams = serverFilters
? teams
: sortTeamsByDps(localSearch?.items ?? [], currentSort);
function updateUrl(name: string, value: string | null) {
const nextParams = new URLSearchParams(searchString);
const nextQuery = query.trim();
if (nextQuery) nextParams.set("q", nextQuery);
else nextParams.delete("q");
if (value === null) nextParams.delete(name);
else nextParams.set(name, value);
const queryString = nextParams.toString();
router.push(`${pathname}${queryString ? `?${queryString}` : ""}`, { scroll: false });
}
function selectCondition(index: number) {
if (!serverFilters) {
setActiveCondition(String(index));
return;
}
updateUrl("condition", index === 0 ? null : String(index + 1));
}
function selectField(field: TeamSearchTarget) {
if (!serverFilters) {
setSearchTarget(field);
return;
}
updateUrl("field", field === "all" ? null : field);
}
function selectSort(order: Exclude<DpsOrder, null>) {
if (!serverFilters) {
setDpsOrder(order);
return;
}
updateUrl("sort", order);
}
function clearSort() {
if (!serverFilters) {
setDpsOrder(null);
return;
}
updateUrl("sort", null);
}
return (
<div className="flex min-w-0 flex-col gap-4">
{groups.length > 1 ? (
<ScrollableNav ariaLabel="เลือกเงื่อนไขทีม" className="w-full min-w-0">
<ToggleGroup aria-label="เลือกเงื่อนไขทีม" variant="outline" value={[activeCondition]} onValueChange={(values) => {
if (values[0]) setActiveCondition(values[0]);
<ToggleGroup aria-label="เลือกเงื่อนไขทีม" variant="outline" value={[String(activeConditionIndex)]} onValueChange={(values) => {
if (values[0]) selectCondition(Number(values[0]));
}}>
{groups.map((group, index) => <ToggleGroupItem key={group.id} value={String(index)} className="aria-pressed:bg-primary aria-pressed:text-primary-foreground">{group.name || `เงื่อนไข ${index + 1}`}</ToggleGroupItem>)}
</ToggleGroup>
@@ -426,10 +504,10 @@ export function TeamSearch({ groups }: { groups: PublicGuide["teamGroups"] }) {
className="max-w-full flex-wrap justify-start"
variant="outline"
size="sm"
value={[searchTarget]}
value={[currentField]}
onValueChange={(value) => {
const target = value[0] as TeamSearchTarget | undefined;
if (target) setSearchTarget(target);
const field = value[0] as TeamSearchTarget | undefined;
if (field) selectField(field);
}}
>
<ToggleGroupItem value="all">ทั้งหมด</ToggleGroupItem>
@@ -443,10 +521,10 @@ export function TeamSearch({ groups }: { groups: PublicGuide["teamGroups"] }) {
aria-label="เรียงทีมตาม DPS"
variant="outline"
size="sm"
value={dpsOrder ? [dpsOrder] : []}
value={currentSort ? [currentSort] : []}
onValueChange={(value) => {
const order = value[0] as Exclude<DpsOrder, null> | undefined;
if (order) setDpsOrder(order);
if (order) selectSort(order);
}}
>
<ToggleGroupItem value="best">Best DPS</ToggleGroupItem>
@@ -455,8 +533,8 @@ export function TeamSearch({ groups }: { groups: PublicGuide["teamGroups"] }) {
<Button
variant="ghost"
size="sm"
disabled={!dpsOrder}
onClick={() => setDpsOrder(null)}
disabled={!currentSort}
onClick={clearSort}
>
<XIcon data-icon="inline-start" />
Clear