548 lines
21 KiB
TypeScript
548 lines
21 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 { ExportText } from "@/components/public/export-language";
|
|
import { ScrollableNav } from "@/components/public/scrollable-nav";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Kbd } from "@/components/ui/kbd";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
|
import { elementBackgroundColor } from "@/lib/catalog/element";
|
|
import { characterRarityGradientClass, rarityGradientClass } from "@/lib/catalog/rarity";
|
|
import { isCustomTravelerKey } from "@/lib/catalog/travelers";
|
|
import type { PublicGuide } from "@/lib/guides/queries";
|
|
import { teamConstellationLabel, teamRefinementLabel } from "@/lib/guides/team-tools";
|
|
import {
|
|
searchTeams,
|
|
sortTeamsByDps,
|
|
type DpsOrder,
|
|
type TeamSearchTarget,
|
|
} from "@/lib/guides/team-search";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export interface TeamExportData {
|
|
title: string;
|
|
rotationSeconds: string | number;
|
|
combo: string;
|
|
members: Array<{
|
|
position: number;
|
|
constellation: string;
|
|
refinement: string;
|
|
damage: string | number;
|
|
character: {
|
|
key: string;
|
|
name: string;
|
|
imageUrl: string;
|
|
element: string | null;
|
|
rarity: number | null;
|
|
};
|
|
weapon: {
|
|
key: string;
|
|
name: string;
|
|
imageUrl: string;
|
|
rarity: number | null;
|
|
} | null;
|
|
artifact: TeamArtifact | null;
|
|
secondArtifact: TeamArtifact | null;
|
|
}>;
|
|
}
|
|
|
|
type Team = PublicGuide["teams"][number];
|
|
const teamSearchTargetLabels: Record<TeamSearchTarget, string> = {
|
|
all: "ทั้งหมด",
|
|
team: "ชื่อทีม",
|
|
character: "ตัวละคร",
|
|
weapon: "อาวุธ",
|
|
artifact: "อาร์ติแฟกต์",
|
|
};
|
|
|
|
type TeamArtifact = {
|
|
key: string;
|
|
name: string;
|
|
imageUrl: string;
|
|
rarity: number | null;
|
|
};
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
export function TeamExportImage({ team }: { team: TeamExportData }) {
|
|
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]">
|
|
<ExportText
|
|
th={`ระยะเวลาโรเทชั่น: ${seconds.toLocaleString("en-US", { maximumFractionDigits: 2 })} วินาที`}
|
|
en={`Rotation duration: ${seconds.toLocaleString("en-US", { maximumFractionDigits: 2 })} seconds`}
|
|
/>
|
|
</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", characterRarityGradientClass(member.character.key, isCustomTravelerKey(member.character.key) ? 5 : member.character.rarity))}>
|
|
<Image src={member.character.imageUrl} alt="" fill loading="eager" sizes="140px" className="object-contain" />
|
|
</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" /></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="flex min-w-0 items-baseline gap-3 text-[36px] font-semibold">
|
|
<span className="truncate">{member.character.name}</span>
|
|
<span className="shrink-0 text-[28px] text-[#f0c95f]">{teamConstellationLabel(member.constellation)}{teamRefinementLabel(member.refinement)}</span>
|
|
</span>
|
|
<span className="shrink-0 text-[36px]">{damage.toLocaleString("en-US")}</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 data-export-allow-wrap 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("en-US", { 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",
|
|
characterRarityGradientClass(
|
|
member.character.key,
|
|
isCustomTravelerKey(member.character.key)
|
|
? 5
|
|
: member.character.rarity,
|
|
),
|
|
)}
|
|
>
|
|
<Image
|
|
src={member.character.imageUrl}
|
|
alt={member.character.name}
|
|
fill
|
|
loading={exporting ? "eager" : undefined}
|
|
sizes="(min-width: 1280px) 184px, (min-width: 640px) 16vw, 25vw"
|
|
className="object-contain"
|
|
/>
|
|
</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}
|
|
sizes="(min-width: 1280px) 86px, (min-width: 640px) 8vw, 12.5vw"
|
|
className="object-contain p-1"
|
|
/>
|
|
<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="order-first sm:order-0 sm:col-span-2"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="mt-2 flex items-center justify-center gap-1">
|
|
<Badge variant="secondary">{teamConstellationLabel(member.constellation)}</Badge>
|
|
<Badge variant="secondary">{teamRefinementLabel(member.refinement)}</Badge>
|
|
</div>
|
|
<p className="mt-1 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({ groups }: { groups: PublicGuide["teamGroups"] }) {
|
|
const [activeCondition, setActiveCondition] = useState("0");
|
|
const activeGroup = groups[Number(activeCondition)] ?? groups[0];
|
|
const teams = useMemo(() => activeGroup?.teams ?? [], [activeGroup]);
|
|
const [query, setQuery] = useState("");
|
|
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 filteredTeams = sortTeamsByDps(search.items, dpsOrder);
|
|
|
|
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]);
|
|
}}>
|
|
{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>
|
|
</ScrollableNav>
|
|
) : null}
|
|
{activeGroup?.note ? <p className="whitespace-pre-wrap text-muted-foreground">{activeGroup.note}</p> : null}
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<div className="relative min-w-0 flex-1">
|
|
<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>
|
|
<Select value={searchTarget} onValueChange={(value) => {
|
|
if (value) setSearchTarget(value as TeamSearchTarget);
|
|
}}>
|
|
<SelectTrigger
|
|
aria-label="เลือกประเภทข้อมูลที่ต้องการค้นหา"
|
|
className="w-32 shrink-0"
|
|
>
|
|
<SelectValue>{teamSearchTargetLabels[searchTarget]}</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
<SelectItem value="all">ทั้งหมด</SelectItem>
|
|
<SelectItem value="team">ชื่อทีม</SelectItem>
|
|
<SelectItem value="character">ตัวละคร</SelectItem>
|
|
<SelectItem value="weapon">อาวุธ</SelectItem>
|
|
<SelectItem value="artifact">อาร์ติแฟกต์</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<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"
|
|
allowWithoutNotes={false}
|
|
mobileIconOnly
|
|
showLanguageSelector={false}
|
|
promptForLanguage
|
|
previewPosition="right"
|
|
compactDialog
|
|
>
|
|
<TeamExportImage team={team} />
|
|
</GuideImageExport>
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<Card>
|
|
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
|
ไม่พบทีมที่ตรงกับคำค้นหา
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|