Files
buzz-sheet/components/public/team-search.tsx
T
2026-09-06 00:31:51 +07:00

454 lines
18 KiB
TypeScript

"use client";
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 { GuideImageExport } from "@/components/public/guide-image-export";
import { Button } from "@/components/ui/button";
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
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,
sortTeamsByDps,
type DpsOrder,
type TeamSearchTarget,
} from "@/lib/guides/team-search";
import { cn } from "@/lib/utils";
type Team = PublicGuide["teams"][number];
type TeamArtifact = NonNullable<Team["members"][number]["artifact"]>;
function ArtifactHalf({
artifact,
side,
eager,
linked,
sizes,
}: {
artifact: TeamArtifact;
side: "left" | "right";
eager: boolean;
linked: boolean;
sizes: string;
}) {
const content = (
<span className="relative block size-full">
<Image
src={artifact.imageUrl}
alt={linked ? artifact.name : ""}
fill
loading={eager ? "eager" : undefined}
sizes={sizes}
className="object-contain p-1"
/>
</span>
);
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 ? (
<Link
href={`https://lunaris.moe/artifact/${encodeURIComponent(artifact.key)}`}
target="_blank"
rel="noreferrer"
aria-label={`เปิดข้อมูลอาร์ติแฟกต์ ${artifact.name} บน Lunaris`}
className={className}
>
{content}
</Link>
) : (
<span className={className}>{content}</span>
);
}
export 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 <div className={cn("aspect-square rounded-md border bg-muted", className)} />;
}
if (!secondary) {
const content = (
<>
<Image
src={primary.imageUrl}
alt={linked ? primary.name : ""}
fill
loading={eager ? "eager" : undefined}
sizes={sizes}
className="object-contain p-1"
/>
{linked ? (
<Kbd className="absolute top-0 right-0 h-3 min-w-3 bg-transparent p-0 text-white">
<ArrowUpRightIcon aria-hidden="true" />
</Kbd>
) : null}
</>
);
const artifactClassName = cn(
"relative aspect-square scale-110 overflow-hidden rounded-md bg-muted",
rarityGradientClass(primary.rarity),
className,
);
return linked ? (
<Link
href={`https://lunaris.moe/artifact/${encodeURIComponent(primary.key)}`}
target="_blank"
rel="noreferrer"
aria-label={`เปิดข้อมูลอาร์ติแฟกต์ ${primary.name} บน Lunaris`}
className={artifactClassName}
>
{content}
</Link>
) : (
<div className={artifactClassName}>{content}</div>
);
}
return (
<div className={cn("relative aspect-square scale-110 overflow-hidden rounded-md", className)}>
<ArtifactHalf artifact={primary} side="left" eager={eager} linked={linked} sizes={sizes} />
<ArtifactHalf artifact={secondary} side="right" eager={eager} linked={linked} sizes={sizes} />
<span
aria-hidden="true"
className="pointer-events-none absolute top-1/2 left-1/2 h-px w-[142%] -translate-x-1/2 -translate-y-1/2 rotate-45 bg-white/30"
/>
{linked ? (
<Kbd className="pointer-events-none absolute top-0 right-0 h-3 min-w-3 bg-transparent p-0 text-white">
<ArrowUpRightIcon aria-hidden="true" />
</Kbd>
) : null}
</div>
);
}
function TeamExportImage({ team }: { team: Team }) {
const total = team.members.reduce((sum, member) => sum + Number(member.damage), 0);
const seconds = Number(team.rotationSeconds);
return (
<div className="flex min-h-337.5 w-270 flex-col bg-[#05060a] p-[48px_52px] font-heading text-[white]">
<header className="flex flex-col items-center text-center">
<h2 className="text-[64px] leading-tight font-bold">{team.title}</h2>
<p className="text-[32px] text-[#b9b9c2]">ระยะเวลาโรเทชั่น {seconds.toLocaleString(undefined, { maximumFractionDigits: 2 })} วิ</p>
</header>
<div className="mt-5 flex flex-col gap-4">
{team.members.map((member) => {
const damage = Number(member.damage);
const percent = total > 0 ? damage / total * 100 : 0;
return (
<div key={member.position} className="flex h-43.5 items-center gap-6 rounded-3xl p-3.5" style={{ backgroundColor: elementBackgroundColor(member.character.element) }}>
<div className={cn("relative size-35 shrink-0 overflow-hidden rounded-3xl border-[3px] border-white/20", rarityGradientClass(isCustomTravelerKey(member.character.key) ? 5 : member.character.rarity))}>
<Image src={member.character.imageUrl} alt="" fill loading="eager" sizes="140px" className="object-contain" />
<span className="absolute top-1.5 left-1.5 text-xl font-bold">C{member.constellation}</span>
</div>
<div className="grid w-44 shrink-0 grid-cols-2 gap-3">
{member.weapon ? <div className={cn("relative aspect-square min-w-0 scale-110 overflow-hidden rounded-2xl", rarityGradientClass(member.weapon.rarity))}><Image src={member.weapon.imageUrl} alt="" fill loading="eager" sizes="82px" className="object-contain p-1" /><span className="absolute top-1 left-1 text-base font-bold">R{member.refinement}</span></div> : null}
<TeamArtifactIcon
first={member.artifact}
second={member.secondArtifact}
eager
sizes="82px"
className="min-w-0 rounded-2xl"
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-3.5">
<div className="flex items-center justify-between gap-4"><span className="truncate text-[36px] font-semibold">{member.character.name}</span><span className="text-[36px]">{damage.toLocaleString()}</span></div>
<div className="flex items-center gap-5"><div className="h-11 flex-1 overflow-hidden rounded-xl bg-[#272730]"><div className="h-full rounded-xl bg-linear-to-r from-[#b7791f] to-[#f0b72f]" style={{ width: `${Math.max(2, Math.min(100, percent))}%` }} /></div><span className="w-28 text-right text-[34px] font-bold">{percent.toFixed(1)}%</span></div>
</div>
</div>
);
})}
</div>
<div className="my-3 flex flex-1 items-center justify-center rounded-2xl bg-[#272730] p-4 text-center text-[28px] leading-[1.4]"><p className="whitespace-pre-wrap">{team.combo}</p></div>
<div className="flex shrink-0 justify-center border-t-2 border-[#34343d] pt-6 text-[64px] font-bold text-[#f0c95f]">DPS: {seconds > 0 ? (total / seconds).toLocaleString(undefined, { maximumFractionDigits: 0 }) : "0"}</div>
<div className="mt-5 flex shrink-0 justify-center text-[40px] text-[#74747f]">By YT เกนชินไม่ใช่เกมมือถือ</div>
</div>
);
}
function TeamCard({
team,
action,
exporting = false,
}: {
team: Team;
action?: ReactNode;
exporting?: boolean;
}) {
const total = team.members.reduce(
(sum, member) => sum + Number(member.damage),
0,
);
const seconds = Number(team.rotationSeconds);
return (
<Card>
<CardHeader>
<CardTitle>{team.title}</CardTitle>
<CardDescription>โรเทชั่น {seconds} วิ</CardDescription>
{action ? <CardAction>{action}</CardAction> : null}
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div
className={cn(
"grid grid-cols-4",
exporting ? "gap-3" : "gap-1 sm:gap-3",
)}
>
{team.members.map((member) => (
<div
key={member.position}
className={cn(
"min-w-0 rounded-lg text-center",
exporting ? "p-2" : "p-1 sm:p-2",
)}
style={{
backgroundColor: elementBackgroundColor(
member.character.element,
),
}}
>
<div
className={cn(
"gap-2",
exporting
? "grid grid-cols-3"
: "flex flex-col sm:grid sm:grid-cols-3",
)}
>
<div
className={cn(
"relative aspect-square min-h-0 overflow-hidden rounded-lg bg-muted",
exporting
? "col-span-2 row-span-2"
: "sm:col-span-2 sm:row-span-2",
rarityGradientClass(
isCustomTravelerKey(member.character.key)
? 5
: member.character.rarity,
),
)}
>
<Image
src={member.character.imageUrl}
alt={member.character.name}
fill
loading={exporting ? "eager" : undefined}
className="object-contain"
/>
<Kbd className="absolute top-0 left-0 bg-transparent text-white">
C{member.constellation}
</Kbd>
</div>
<div
className={cn(
"min-w-0 grid grid-cols-2 gap-2",
exporting
? "row-span-2"
: "sm:row-span-2",
)}
>
{member.weapon ? (
<a
href={`https://lunaris.moe/weapon/${encodeURIComponent(member.weapon.key)}`}
target="_blank"
rel="noreferrer"
tabIndex={exporting ? -1 : undefined}
aria-label={`เปิดข้อมูลอาวุธ ${member.weapon.name} บน Lunaris`}
className={cn(
"relative aspect-square scale-110 overflow-hidden rounded-md bg-muted outline-none sm:col-span-2",
rarityGradientClass(member.weapon.rarity),
)}
>
<Image
src={member.weapon.imageUrl}
alt={member.weapon.name}
fill
loading={exporting ? "eager" : undefined}
className="object-contain p-1"
/>
<Kbd className="absolute top-0 left-0 h-4 min-w-4 whitespace-nowrap bg-transparent px-0.5 text-[9px] text-white">
R{member.refinement}
</Kbd>
<Kbd className="absolute top-0 right-0 h-3 min-w-3 bg-transparent p-0 text-white">
<ArrowUpRightIcon aria-hidden="true" />
</Kbd>
</a>
) : (
<div className="aspect-square rounded-md border bg-muted sm:col-span-2" />
)}
<TeamArtifactIcon
first={member.artifact}
second={member.secondArtifact}
eager={exporting}
linked={!exporting}
sizes="64px"
className="sm:col-span-2"
/>
</div>
</div>
<p className="mt-2 truncate text-sm font-medium">
{member.character.name}
</p>
<p className="text-sm font-semibold">
{Number(member.damage).toLocaleString(undefined, {
maximumFractionDigits: 2,
})}
</p>
<p className="text-xs text-muted-foreground">
{total
? ((Number(member.damage) / total) * 100).toFixed(2)
: "0.00"}
%
</p>
</div>
))}
</div>
<Separator />
<div className="flex items-end justify-between">
<div>
<p className="text-sm text-muted-foreground">ดาเมจรวม</p>
<p className="font-semibold">{total.toLocaleString()}</p>
</div>
<div className="text-right">
<p className="text-sm text-muted-foreground">DPS</p>
<p className="text-3xl font-semibold text-primary">
{seconds > 0
? (total / seconds).toLocaleString(undefined, {
maximumFractionDigits: 0,
})
: "0"}
</p>
</div>
</div>
<p className="rounded-lg bg-muted p-3 text-sm leading-6">
{team.combo}
</p>
</CardContent>
</Card>
);
}
export function TeamSearch({ teams }: { teams: PublicGuide["teams"] }) {
const [query, setQuery] = useState("");
const [searchTarget, setSearchTarget] = useState<TeamSearchTarget>("all");
const [dpsOrder, setDpsOrder] = useState<DpsOrder>(null);
const filteredTeams = useMemo(
() => sortTeamsByDps(teams.filter((team) => matchesTeamSearch({
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)), dpsOrder),
[dpsOrder, query, searchTarget, teams],
);
return (
<div className="flex flex-col gap-4">
<div className="relative">
<SearchIcon aria-hidden="true" className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
aria-label="ค้นหาทีม ตัวละคร อาวุธ หรืออาร์ติแฟกต์"
className="pl-9"
placeholder="ค้นหาทีม ตัวละคร อาวุธ หรืออาร์ติแฟกต์"
value={query}
onValueChange={setQuery}
/>
</div>
<ToggleGroup
aria-label="เลือกประเภทข้อมูลที่ต้องการค้นหา"
className="max-w-full flex-wrap justify-start"
variant="outline"
size="sm"
value={[searchTarget]}
onValueChange={(value) => {
const target = value[0] as TeamSearchTarget | undefined;
if (target) setSearchTarget(target);
}}
>
<ToggleGroupItem value="all">ทั้งหมด</ToggleGroupItem>
<ToggleGroupItem value="team">ชื่อทีม</ToggleGroupItem>
<ToggleGroupItem value="character">ตัวละคร</ToggleGroupItem>
<ToggleGroupItem value="weapon">อาวุธ</ToggleGroupItem>
<ToggleGroupItem value="artifact">อาร์ติแฟกต์</ToggleGroupItem>
</ToggleGroup>
<div className="flex items-center gap-2">
<ToggleGroup
aria-label="เรียงทีมตาม DPS"
variant="outline"
size="sm"
value={dpsOrder ? [dpsOrder] : []}
onValueChange={(value) => {
const order = value[0] as Exclude<DpsOrder, null> | undefined;
if (order) setDpsOrder(order);
}}
>
<ToggleGroupItem value="best">Best DPS</ToggleGroupItem>
<ToggleGroupItem value="worst">Worst DPS</ToggleGroupItem>
</ToggleGroup>
<Button
variant="ghost"
size="sm"
disabled={!dpsOrder}
onClick={() => setDpsOrder(null)}
>
<XIcon data-icon="inline-start" />
Clear
</Button>
</div>
{filteredTeams.length ? (
<div className="grid gap-5 xl:grid-cols-2">
{filteredTeams.map((team) => (
<TeamCard
key={team.id}
team={team}
action={
<GuideImageExport fileName={`${team.title}.png`} width={1080} captureClassName="p-0">
<TeamExportImage team={team} />
</GuideImageExport>
}
/>
))}
</div>
) : (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
ไม่พบทีมที่ตรงกับคำค้นหา
</CardContent>
</Card>
)}
</div>
);
}