feat : server side
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
|
||||
import { GuideImageExport } from "@/components/public/guide-image-export";
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { ArtifactSectionContent, type ArtifactSectionGuide } from "@/components/public/artifact-section-content";
|
||||
|
||||
const sectionOptions = [
|
||||
["tierlist", "อันดับอาร์ติแฟกต์"],
|
||||
@@ -27,20 +28,26 @@ const sectionOptions = [
|
||||
type ArtifactExportSection = (typeof sectionOptions)[number][0];
|
||||
|
||||
export function ArtifactImageExport({
|
||||
children,
|
||||
fileName,
|
||||
hasNote,
|
||||
slug,
|
||||
initialData,
|
||||
targetConditionLabels,
|
||||
recommendedConditionLabels,
|
||||
tierConditionLabels,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
fileName: string;
|
||||
hasNote: boolean;
|
||||
slug: string;
|
||||
initialData?: ArtifactSectionGuide;
|
||||
targetConditionLabels: readonly string[];
|
||||
recommendedConditionLabels: readonly string[];
|
||||
tierConditionLabels: readonly string[];
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [exportData, setExportData] = useState<ArtifactSectionGuide | null>(initialData ?? null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [sections, setSections] = useState<Set<ArtifactExportSection>>(
|
||||
() => new Set(sectionOptions.map(([value]) => value)),
|
||||
);
|
||||
@@ -73,8 +80,25 @@ export function ArtifactImageExport({
|
||||
? ".artifact-export-grid{display:block!important}.artifact-export-right{width:560px!important;max-width:100%;margin-left:auto!important;margin-right:auto!important;justify-self:center!important}"
|
||||
: "";
|
||||
|
||||
async function loadExportData() {
|
||||
setLoading(true);
|
||||
setFailed(false);
|
||||
try {
|
||||
const response = await fetch(`/api/public/guides/${encodeURIComponent(slug)}/artifact-export`);
|
||||
if (!response.ok) throw new Error("artifact-export-load-failed");
|
||||
setExportData(await response.json() as ArtifactSectionGuide);
|
||||
} catch {
|
||||
setFailed(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen);
|
||||
if (nextOpen && !exportData && !loading) void loadExportData();
|
||||
}}>
|
||||
<DialogTrigger render={<Button type="button" variant="outline" size="sm" />}>
|
||||
<DownloadIcon data-icon="inline-start" />
|
||||
ส่งออกภาพ
|
||||
@@ -167,17 +191,25 @@ export function ArtifactImageExport({
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
) : null}
|
||||
<GuideImageExport
|
||||
fileName={fileName}
|
||||
width={exportWidth}
|
||||
height={exportHeight}
|
||||
disabled={sections.size === 0}
|
||||
captureClassName="p-0"
|
||||
modeSelection="inline"
|
||||
>
|
||||
<style>{hiddenSections + targetRule + recommendedRule + tierRule + hideEmptyRightColumn + layoutRules}</style>
|
||||
{children}
|
||||
</GuideImageExport>
|
||||
{loading ? <p role="status" className="text-sm text-muted-foreground">กำลังเตรียมข้อมูลส่งออก…</p> : null}
|
||||
{failed ? (
|
||||
<Button type="button" variant="outline" onClick={() => void loadExportData()}>
|
||||
ลองโหลดข้อมูลส่งออกอีกครั้ง
|
||||
</Button>
|
||||
) : null}
|
||||
{exportData ? (
|
||||
<GuideImageExport
|
||||
fileName={fileName}
|
||||
width={exportWidth}
|
||||
height={exportHeight}
|
||||
disabled={sections.size === 0}
|
||||
captureClassName="p-0"
|
||||
modeSelection="inline"
|
||||
>
|
||||
<style>{hiddenSections + targetRule + recommendedRule + tierRule + hideEmptyRightColumn + layoutRules}</style>
|
||||
<ArtifactSectionContent guide={exportData} exporting />
|
||||
</GuideImageExport>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { TeamArtifactIcon } from "@/components/public/team-search";
|
||||
import { elementIconFilter } from "@/lib/catalog/element";
|
||||
import { ExportText } from "@/components/public/export-language";
|
||||
import { GuideExportBranding } from "@/components/public/guide-export-branding";
|
||||
import { ConditionSwitcher } from "@/components/public/condition-switcher";
|
||||
import type { PublicGuide } from "@/lib/guides/queries";
|
||||
import { artifactTierCount } from "@/lib/guides/artifacts";
|
||||
import { normalizeTargetStat, recommendedStatElement, recommendedStatIconName, statPriorityColorClass, targetStatIconName } from "@/lib/guides/stats";
|
||||
import { normalizeTalentPriorities, talentRelationSymbol } from "@/lib/guides/talents";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ArtifactSectionGuide = Pick<PublicGuide, "name" | "coverUrl" | "elementIconUrls" | "artifactProfile" | "artifactGroups"> & {
|
||||
character: Pick<PublicGuide["character"], "splashImageUrl" | "talents">;
|
||||
};
|
||||
|
||||
export interface ArtifactConditionSelection {
|
||||
recommendedIndex: number;
|
||||
setsIndex: number;
|
||||
targetsIndex: number;
|
||||
labels: {
|
||||
recommended: readonly string[];
|
||||
sets: readonly string[];
|
||||
targets: readonly string[];
|
||||
};
|
||||
}
|
||||
|
||||
function metric(value: string | null, locale?: string) {
|
||||
return value === null ? null : `${Number(value).toLocaleString(locale, { maximumFractionDigits: 2 })}%`;
|
||||
}
|
||||
|
||||
function exportLabel(exporting: boolean, th: string, en: string): ReactNode {
|
||||
return exporting ? <ExportText th={th} en={en} /> : th;
|
||||
}
|
||||
|
||||
export function ArtifactSectionContent({
|
||||
guide,
|
||||
action,
|
||||
exporting = false,
|
||||
selection,
|
||||
}: {
|
||||
guide: ArtifactSectionGuide;
|
||||
action?: ReactNode;
|
||||
exporting?: boolean;
|
||||
selection?: ArtifactConditionSelection;
|
||||
}) {
|
||||
const profile = guide.artifactProfile;
|
||||
const recommendedConditions = profile?.recommendedConditions ?? [];
|
||||
const recommendedLabels = selection?.labels.recommended ?? recommendedConditions.map((condition, index) => condition.name || `เงื่อนไข ${index + 1}`);
|
||||
const recommendedCards = recommendedConditions.map((condition, conditionIndex) => (
|
||||
<div key={conditionIndex} data-artifact-export-section="best-stats" data-recommended-condition={conditionIndex} className="flex flex-col gap-5">
|
||||
{exporting && recommendedConditions.length > 1 ? <p className="text-sm font-medium">{condition.name || <ExportText th={`เงื่อนไข ${conditionIndex + 1}`} en={`Condition ${conditionIndex + 1}`} />}</p> : null}
|
||||
{condition.note ? <p data-export-note className="whitespace-pre-wrap text-sm text-muted-foreground">{condition.note}</p> : null}
|
||||
{profile ? (
|
||||
<Card data-artifact-export-section="best-stats" className="artifact-export-main-stats overflow-hidden pt-0">
|
||||
<CardHeader className="bg-primary py-4 text-center text-primary-foreground">
|
||||
<CardTitle className="text-2xl">{exportLabel(exporting, "ออฟหลักที่ดีที่สุด", "Best Main Stats")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-3 p-0">
|
||||
<ArtifactStat
|
||||
iconSrc="/icon/Icon_Sands_of_Eon.webp"
|
||||
label={exportLabel(exporting, "นาฬิกา", "Sands")}
|
||||
values={condition.sands}
|
||||
exporting={exporting}
|
||||
elementIconUrls={guide.elementIconUrls}
|
||||
/>
|
||||
<ArtifactStat
|
||||
iconSrc="/icon/Icon_Goblet_of_Eonothem.webp"
|
||||
label={exportLabel(exporting, "ถ้วย", "Goblet")}
|
||||
values={condition.goblet}
|
||||
exporting={exporting}
|
||||
elementIconUrls={guide.elementIconUrls}
|
||||
/>
|
||||
<ArtifactStat
|
||||
iconSrc="/icon/Icon_Circlet_of_Logos.webp"
|
||||
label={exportLabel(exporting, "หัว", "Circlet")}
|
||||
values={condition.circlet}
|
||||
exporting={exporting}
|
||||
elementIconUrls={guide.elementIconUrls}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
{condition.substats.length ? (
|
||||
<Card data-artifact-export-section="best-stats" className="artifact-export-substats">
|
||||
<CardContent className="flex flex-col items-center gap-2 py-5 text-center text-sm">
|
||||
<strong>{exportLabel(exporting, "ออฟรองที่ดีที่สุด", "Best Substats")}</strong>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{condition.substats.map((stat, index) => (
|
||||
<ArtifactStatValue
|
||||
key={`${stat}-${index}`}
|
||||
value={stat}
|
||||
index={index}
|
||||
exporting={exporting}
|
||||
elementIconUrls={guide.elementIconUrls}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
));
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"flex flex-col gap-6",
|
||||
exporting &&
|
||||
"relative isolate overflow-hidden bg-[#070b20] p-10 text-white",
|
||||
)}
|
||||
>
|
||||
{exporting ? (
|
||||
<GuideExportBranding
|
||||
backgroundUrl={guide.character.splashImageUrl ?? guide.coverUrl}
|
||||
/>
|
||||
) : null}
|
||||
<header className="relative z-10 flex items-center justify-center text-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-sm font-medium text-primary">
|
||||
อาร์ติแฟกต์และสแตทที่แนะนำ
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Image
|
||||
src="/icon/nav/Artifact.webp"
|
||||
alt=""
|
||||
width={56}
|
||||
height={56}
|
||||
loading={exporting ? "eager" : "lazy"}
|
||||
className={cn(
|
||||
"size-14 shrink-0 object-contain",
|
||||
exporting &&
|
||||
"drop-shadow-[0_4px_3px_rgba(0,0,0,0.8)]",
|
||||
)}
|
||||
/>
|
||||
<h1 className="whitespace-nowrap font-heading text-4xl font-semibold">
|
||||
Artifacts & Stats
|
||||
</h1>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
className={cn(
|
||||
"artifact-export-grid relative z-10 grid gap-5",
|
||||
exporting ? "grid-cols-[1.2fr_1fr]" : "lg:grid-cols-[1.2fr_1fr]",
|
||||
)}
|
||||
>
|
||||
{" "}
|
||||
<div className="artifact-export-left flex flex-col gap-5">
|
||||
{exporting ? recommendedCards : (
|
||||
<ConditionSwitcher label="เลือกเงื่อนไขค่าสถานะแนะนำ" labels={recommendedLabels} selectedIndex={selection?.recommendedIndex} queryParam={selection ? "recommended" : undefined}>
|
||||
{recommendedCards}
|
||||
</ConditionSwitcher>
|
||||
)}
|
||||
<div data-artifact-export-section="tierlist" className="order-first">
|
||||
{(() => {
|
||||
const tierCards = guide.artifactGroups.map((condition, conditionIndex) => (
|
||||
<div key={condition.id} data-tier-condition={conditionIndex} className="artifact-export-tierlist flex flex-col gap-5">
|
||||
{exporting && guide.artifactGroups.length > 1 ? <p className="text-sm font-medium">{condition.name || <ExportText th={`เงื่อนไข ${conditionIndex + 1}`} en={`Condition ${conditionIndex + 1}`} />}</p> : null}
|
||||
{condition.note ? <p data-export-note className="whitespace-pre-wrap text-sm text-muted-foreground">{condition.note}</p> : null}
|
||||
{condition.tiers.map((group) => <Card key={group.id}>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{group.name === "Late Game"
|
||||
? exportLabel(exporting, "เลทเกม", "Late Game")
|
||||
: group.name === "Early Game"
|
||||
? exportLabel(exporting, "ต้นเกม", "Early Game")
|
||||
: group.name}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{group.rows.map((row, index) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md px-3 py-2",
|
||||
index % 2 === 0 ? "bg-muted/60" : "bg-muted/20",
|
||||
)}
|
||||
>
|
||||
<TeamArtifactIcon
|
||||
first={row.first}
|
||||
second={row.second}
|
||||
eager={exporting}
|
||||
linked={!exporting}
|
||||
sizes="(min-width: 640px) 4rem, 3.5rem"
|
||||
className={exporting ? "size-16 shrink-0" : "size-14 shrink-0 sm:size-16"}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">
|
||||
{artifactTierCount(row.second?.name ?? null)}{" "}
|
||||
{row.first.name}
|
||||
{row.second ? ` + 2 ${row.second.name}` : ""}
|
||||
</p>
|
||||
{row.note ? (
|
||||
<p data-export-note className="whitespace-pre-wrap text-sm text-muted-foreground">
|
||||
{row.note}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-2xl font-semibold text-primary">
|
||||
{Number(row.percent) === 0 ? "-%" : metric(row.percent, exporting ? "en-US" : undefined)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>)}
|
||||
</div>
|
||||
));
|
||||
const labels = selection?.labels.sets ?? guide.artifactGroups.map((condition, index) => condition.name || `เงื่อนไข ${index + 1}`);
|
||||
return exporting ? tierCards : <ConditionSwitcher label="เลือกเงื่อนไขอันดับอาร์ติแฟกต์" labels={labels} selectedIndex={selection?.setsIndex} queryParam={selection ? "sets" : undefined}>{tierCards}</ConditionSwitcher>;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
{profile ? (
|
||||
<div className="artifact-export-right flex min-w-0 flex-col gap-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-5">
|
||||
{profile.targetConditions.some((group) => group.rows.length) ? (
|
||||
exporting ? (
|
||||
profile.targetConditions.filter((group) => group.rows.length).map((group, groupIndex) => (
|
||||
<div key={groupIndex} data-artifact-export-section="target-stats" data-target-condition={groupIndex}>
|
||||
<p className="mb-2 text-sm font-medium">{exportLabel(exporting, "ค่าสถานะต่ำๆที่ควรจะปั้นไว้", "Recommended Minimum Stats")}</p>
|
||||
{group.note ? <p data-export-note className="mb-2 whitespace-pre-wrap text-sm text-muted-foreground">{group.note}</p> : null}
|
||||
<div className="flex flex-col gap-1">
|
||||
{group.rows.map((target, index) => (
|
||||
<div
|
||||
key={`${target.stat}-${target.value}`}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-4 rounded-md px-3 py-2 text-sm",
|
||||
index % 2 === 0 ? "bg-muted/60" : "bg-muted/20",
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{targetStatIconName(target.stat) ? (
|
||||
<Image
|
||||
src={`/icon/stats/${targetStatIconName(target.stat)}.webp`}
|
||||
alt=""
|
||||
width={14}
|
||||
height={14}
|
||||
loading={exporting ? "eager" : undefined}
|
||||
className="size-3.5 shrink-0 object-contain brightness-0 invert"
|
||||
/>
|
||||
) : null}
|
||||
<span>
|
||||
{statLabel(
|
||||
normalizeTargetStat(target.stat) ?? target.stat, exporting,
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<strong className="shrink-0">{target.value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<ConditionSwitcher label="เลือกเงื่อนไขค่าสถานะเป้าหมาย" labels={selection?.labels.targets ?? profile.targetConditions.filter((group) => group.rows.length).map((group, index) => group.name || `เงื่อนไข ${index + 1}`)} selectedIndex={selection?.targetsIndex} queryParam={selection ? "targets" : undefined}>
|
||||
{profile.targetConditions.filter((group) => group.rows.length).map((group, groupIndex) => (
|
||||
<div key={groupIndex}>
|
||||
<p className="mb-2 text-sm font-medium">{exportLabel(exporting, "ค่าสถานะต่ำๆที่ควรจะปั้นไว้", "Recommended Minimum Stats")}</p>
|
||||
{group.note ? <p data-export-note className="mb-2 whitespace-pre-wrap text-sm text-muted-foreground">{group.note}</p> : null}
|
||||
<div className="flex flex-col gap-1">
|
||||
{group.rows.map((target, index) => (
|
||||
<div key={`${target.stat}-${target.value}`} className={cn("flex items-center justify-between gap-4 rounded-md px-3 py-2 text-sm", index % 2 === 0 ? "bg-muted/60" : "bg-muted/20")}>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{targetStatIconName(target.stat) ? <Image src={`/icon/stats/${targetStatIconName(target.stat)}.webp`} alt="" width={14} height={14} className="size-3.5 shrink-0 object-contain brightness-0 invert" /> : null}
|
||||
<span>{statLabel(normalizeTargetStat(target.stat) ?? target.stat)}</span>
|
||||
</span>
|
||||
<strong className="shrink-0">{target.value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</ConditionSwitcher>
|
||||
)
|
||||
) : null}
|
||||
{profile.talents.length ? (
|
||||
<div data-artifact-export-section="talents">
|
||||
<p className="mb-2 text-sm font-medium">
|
||||
{exportLabel(exporting, "ลำดับการอัพพรสวรรค์", "Talent Priority")}
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start justify-center",
|
||||
exporting ? "gap-3" : "gap-1 sm:gap-3",
|
||||
)}
|
||||
>
|
||||
{normalizeTalentPriorities(profile.talents).map(
|
||||
(talent, index, talents) => {
|
||||
const catalogTalent = guide.character.talents.find(
|
||||
(item) => item.type === talent.type,
|
||||
);
|
||||
const talentName = catalogTalent?.name ?? talent.type;
|
||||
return (
|
||||
<div
|
||||
key={talent.type}
|
||||
className={cn(
|
||||
"flex min-w-0 items-start",
|
||||
exporting ? "gap-3" : "gap-1 sm:gap-3",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-2 text-center",
|
||||
exporting ? "w-28" : "w-16 sm:w-28",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-xl border bg-muted",
|
||||
exporting ? "size-20" : "size-14 sm:size-20",
|
||||
)}
|
||||
>
|
||||
{catalogTalent?.iconUrl ? (
|
||||
<Image
|
||||
src={catalogTalent.iconUrl}
|
||||
alt={talentName}
|
||||
fill
|
||||
loading={exporting ? "eager" : undefined}
|
||||
className="object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="grid size-full place-items-center font-semibold">
|
||||
{talent.type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{talent.level
|
||||
? exportLabel(exporting, `เลเวล ${talent.level}+`, `Level ${talent.level}+`)
|
||||
: "-"}
|
||||
</Badge>
|
||||
<p
|
||||
className={cn(
|
||||
"font-medium",
|
||||
exporting ? "text-sm" : "text-xs sm:text-sm",
|
||||
)}
|
||||
>
|
||||
{talentName}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-muted-foreground",
|
||||
exporting ? "text-xs" : "text-[11px] sm:text-xs",
|
||||
)}
|
||||
>
|
||||
{talent.type === "E"
|
||||
? exportLabel(exporting, "สกิลธาตุ", "Elemental Skill")
|
||||
: talent.type === "Q"
|
||||
? exportLabel(exporting, "ท่าไม้ตาย", "Elemental Burst")
|
||||
: exportLabel(exporting, "ตีปกติ", "Normal Attack")}
|
||||
</p>
|
||||
</div>
|
||||
{index < talents.length - 1 ? (
|
||||
<span
|
||||
className={cn(
|
||||
"font-semibold",
|
||||
exporting
|
||||
? "mt-6 text-3xl"
|
||||
: "mt-4 text-xl sm:mt-6 sm:text-3xl",
|
||||
)}
|
||||
aria-label={
|
||||
talent.relationToNext === "gte"
|
||||
? "มากกว่าหรือเท่ากับ"
|
||||
: talent.relationToNext === "eq"
|
||||
? "เท่ากับ"
|
||||
: "มากกว่า"
|
||||
}
|
||||
>
|
||||
{talentRelationSymbol(talent.relationToNext)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{profile.note ? (
|
||||
<div data-export-note data-artifact-export-section="note" className="pt-3">
|
||||
<p className="mb-2 text-sm font-medium">{exportLabel(exporting, "โน็ต", "Notes")}</p>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
{profile.note}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const thaiStats: Record<string, string> = {
|
||||
HP: "พลังชีวิต",
|
||||
ATK: "พลังโจมตี",
|
||||
DEF: "พลังป้องกัน",
|
||||
EM: "ความชำนาญธาตุ",
|
||||
ER: "ฟื้นฟูพลังงาน",
|
||||
"CRIT RATE": "คริเรท",
|
||||
"CRIT DAMAGE": "คริดาเมท",
|
||||
"ATK%": "พลังโจมตี%",
|
||||
"HP%": "พลังชีวิต%",
|
||||
"DEF%": "พลังป้องกัน%",
|
||||
"CRIT Rate/CRIT DMG": "คริเรท/คริดาเมท",
|
||||
"Pyro DMG%": "โบนัสความเสียหายธาตุไฟ",
|
||||
"Hydro DMG%": "โบนัสความเสียหายธาตุน้ำ",
|
||||
"Cryo DMG%": "โบนัสความเสียหายธาตุน้ำแข็ง",
|
||||
"Electro DMG%": "โบนัสความเสียหายธาตุไฟฟ้า",
|
||||
"Anemo DMG%": "โบนัสความเสียหายธาตุลม",
|
||||
"Geo DMG%": "โบนัสความเสียหายธาตุดิน",
|
||||
"Dendro DMG%": "โบนัสความเสียหายธาตุไม้",
|
||||
"Physical DMG%": "โบนัสความเสียหายกายภาพ",
|
||||
"Healing Bonus%": "โบนัสการรักษา",
|
||||
};
|
||||
|
||||
const englishStats: Record<string, string> = {
|
||||
HP: "HP",
|
||||
ATK: "ATK",
|
||||
DEF: "DEF",
|
||||
EM: "Elemental Mastery",
|
||||
ER: "Energy Recharge",
|
||||
"CRIT RATE": "CRIT Rate",
|
||||
"CRIT DAMAGE": "CRIT DMG",
|
||||
"CRIT Rate/CRIT DMG": "CRIT Rate / CRIT DMG",
|
||||
};
|
||||
|
||||
const statLabelAliases: Record<string, string> = {
|
||||
"CRIT Rate%": "CRIT RATE",
|
||||
"CRIT DMG%": "CRIT DAMAGE",
|
||||
"CRIT Rate%/CRIT DMG%": "CRIT Rate/CRIT DMG",
|
||||
};
|
||||
|
||||
function statLabel(value: string, exporting = false): ReactNode {
|
||||
const labelKey = statLabelAliases[value] ?? value;
|
||||
if (!exporting) return thaiStats[labelKey] ?? value;
|
||||
return (
|
||||
<ExportText
|
||||
th={thaiStats[labelKey] ?? value}
|
||||
en={englishStats[labelKey] ?? value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactStat({
|
||||
iconSrc,
|
||||
label,
|
||||
values,
|
||||
exporting = false,
|
||||
elementIconUrls,
|
||||
}: {
|
||||
iconSrc: string;
|
||||
label: ReactNode;
|
||||
values: string[];
|
||||
exporting?: boolean;
|
||||
elementIconUrls: Record<string, string>;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-col items-center gap-3 border-r text-center last:border-r-0",
|
||||
exporting ? "p-5" : "p-3 sm:p-5",
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src={iconSrc}
|
||||
alt=""
|
||||
width={48}
|
||||
height={48}
|
||||
loading={exporting ? "eager" : undefined}
|
||||
className="size-12 object-contain"
|
||||
/>
|
||||
<p className="font-semibold">{label}</p>
|
||||
<Separator />
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-w-full flex-col items-center gap-1",
|
||||
exporting
|
||||
? "text-sm leading-6"
|
||||
: "text-xs leading-5 sm:text-sm sm:leading-6",
|
||||
)}
|
||||
>
|
||||
{values.length ? (
|
||||
values.map((value, index) => (
|
||||
<ArtifactStatValue
|
||||
key={`${value}-${index}`}
|
||||
value={value}
|
||||
index={index}
|
||||
exporting={exporting}
|
||||
elementIconUrls={elementIconUrls}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<span>-</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactStatValue({
|
||||
value,
|
||||
index,
|
||||
exporting = false,
|
||||
elementIconUrls,
|
||||
}: {
|
||||
value: string;
|
||||
index: number;
|
||||
exporting?: boolean;
|
||||
elementIconUrls: Record<string, string>;
|
||||
}) {
|
||||
const iconName = recommendedStatIconName(value);
|
||||
const element = recommendedStatElement(value);
|
||||
const elementIconUrl = element ? elementIconUrls[element] : null;
|
||||
const hasIcon = Boolean(iconName);
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex max-w-full items-center justify-center gap-1 text-center",
|
||||
statPriorityColorClass(index),
|
||||
)}
|
||||
>
|
||||
{elementIconUrl ? (
|
||||
<Image
|
||||
src={elementIconUrl}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
loading={exporting ? "eager" : undefined}
|
||||
className="size-5 shrink-0 object-contain"
|
||||
style={{ filter: elementIconFilter(element!) }}
|
||||
/>
|
||||
) : hasIcon ? (
|
||||
<Image
|
||||
src={`/icon/stats/${iconName}.${iconName === "elemental-bonus" ? "svg" : "webp"}`}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
loading={exporting ? "eager" : undefined}
|
||||
className="size-5 shrink-0 object-contain brightness-0 invert"
|
||||
/>
|
||||
) : null}
|
||||
<span className="wrap-break-word">{statLabel(value, exporting)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Children, type ReactNode, useState } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { ScrollableNav } from "@/components/public/scrollable-nav";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
@@ -9,34 +10,55 @@ export function ConditionSwitcher({
|
||||
children,
|
||||
label = "เลือก Condition",
|
||||
labels,
|
||||
selectedIndex,
|
||||
queryParam,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
label?: string;
|
||||
labels?: readonly string[];
|
||||
selectedIndex?: number;
|
||||
queryParam?: string;
|
||||
}) {
|
||||
const items = Children.toArray(children);
|
||||
const [active, setActive] = useState("0");
|
||||
const [localActive, setLocalActive] = useState("0");
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const active = selectedIndex === undefined ? localActive : String(selectedIndex);
|
||||
const optionCount = labels?.length ?? items.length;
|
||||
|
||||
if (items.length <= 1) return items[0] ?? null;
|
||||
if (optionCount <= 1) return items[0] ?? null;
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<ScrollableNav ariaLabel={label} className="w-full min-w-0">
|
||||
<ToggleGroup aria-label={label} variant="outline" value={[active]} onValueChange={(values) => {
|
||||
if (values[0]) setActive(values[0]);
|
||||
const nextValue = values[0];
|
||||
if (!nextValue) return;
|
||||
if (selectedIndex === undefined || !queryParam) {
|
||||
setLocalActive(nextValue);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
const nextIndex = Number(nextValue);
|
||||
if (nextIndex === 0) nextParams.delete(queryParam);
|
||||
else nextParams.set(queryParam, String(nextIndex + 1));
|
||||
const query = nextParams.toString();
|
||||
router.push(`${pathname}${query ? `?${query}` : ""}`, { scroll: false });
|
||||
}}>
|
||||
{items.map((_, index) => (
|
||||
{(labels ?? items.map((_, index) => `Condition ${index + 1}`)).map((text, index) => (
|
||||
<ToggleGroupItem
|
||||
key={index}
|
||||
value={String(index)}
|
||||
className="aria-pressed:bg-primary aria-pressed:text-primary-foreground"
|
||||
>
|
||||
{labels?.[index] || `Condition ${index + 1}`}
|
||||
{text}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</ScrollableNav>
|
||||
{items[Number(active)]}
|
||||
{selectedIndex === undefined ? items[Number(active)] : items[0]}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ export function ConstellationImage({
|
||||
guide,
|
||||
group,
|
||||
}: {
|
||||
guide: PublicGuide;
|
||||
guide: Pick<PublicGuide, "coverUrl"> & {
|
||||
character: Pick<PublicGuide["character"], "name" | "splashImageUrl">;
|
||||
};
|
||||
group: PublicGuide["constellationGroups"][number];
|
||||
}) {
|
||||
const rows = constellationChartRows(group.rows);
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -26,12 +25,13 @@ import {
|
||||
import { SiteFooter } from "@/components/public/site-footer";
|
||||
import { SiteHeader } from "@/components/public/site-header";
|
||||
import { ScrollableNav } from "@/components/public/scrollable-nav";
|
||||
import { TeamArtifactIcon, TeamSearch } from "@/components/public/team-search";
|
||||
import { TeamSearch } from "@/components/public/team-search";
|
||||
import { GuideImageExport } from "@/components/public/guide-image-export";
|
||||
import { ExportText } from "@/components/public/export-language";
|
||||
import { WeaponImageExport } from "@/components/public/weapon-image-export";
|
||||
import { WeaponDisplayMode, WeaponDisplayModeProvider } from "@/components/public/weapon-display-mode";
|
||||
import { ArtifactImageExport } from "@/components/public/artifact-image-export";
|
||||
import { ArtifactSectionContent, type ArtifactSectionGuide } from "@/components/public/artifact-section-content";
|
||||
import { GuideExportBranding } from "@/components/public/guide-export-branding";
|
||||
import { ConstellationImage } from "@/components/public/constellation-image";
|
||||
import { ConditionSwitcher } from "@/components/public/condition-switcher";
|
||||
@@ -39,21 +39,9 @@ import { MaterialPoster } from "@/components/public/material-poster";
|
||||
import { RichTextContent } from "@/components/rich-text-content";
|
||||
import { LiveRefresh } from "@/components/live-refresh";
|
||||
import { rarityGradientClass, rarityTextClass } from "@/lib/catalog/rarity";
|
||||
import { elementIconFilter } from "@/lib/catalog/element";
|
||||
import type { PublicGuide } from "@/lib/guides/queries";
|
||||
import { artifactTierCount } from "@/lib/guides/artifacts";
|
||||
import { navigationIconPath } from "@/lib/navigation";
|
||||
import {
|
||||
normalizeTargetStat,
|
||||
recommendedStatElement,
|
||||
recommendedStatIconName,
|
||||
statPriorityColorClass,
|
||||
targetStatIconName,
|
||||
} from "@/lib/guides/stats";
|
||||
import {
|
||||
normalizeTalentPriorities,
|
||||
talentRelationSymbol,
|
||||
} from "@/lib/guides/talents";
|
||||
import { filterPublicTeamGroups, type PublicGuideFilters } from "@/lib/guides/public-filters";
|
||||
import { parseYouTubeUrl } from "@/lib/guides/youtube";
|
||||
import { normalizeExtraRichText } from "@/lib/rich-text";
|
||||
import { publicGuideSections } from "@/lib/guides/public-sections";
|
||||
@@ -75,10 +63,12 @@ export function StructuredGuideShell({
|
||||
guide,
|
||||
section,
|
||||
preview = false,
|
||||
filters,
|
||||
}: {
|
||||
guide: PublicGuide;
|
||||
section?: string;
|
||||
preview?: boolean;
|
||||
filters?: PublicGuideFilters;
|
||||
}) {
|
||||
const sections = publicSections(guide);
|
||||
const baseHref = preview ? `/admin/${guide.characterKey}/preview` : `/${guide.slug}`;
|
||||
@@ -173,7 +163,7 @@ export function StructuredGuideShell({
|
||||
</div>
|
||||
<main className="mx-auto w-full max-w-7xl p-4 sm:p-6 lg:p-10">
|
||||
{section ? (
|
||||
<GuideSection guide={guide} section={section} />
|
||||
<GuideSection guide={guide} section={section} filters={filters} preview={preview} />
|
||||
) : (
|
||||
<GuideOverview guide={guide} sections={sections} />
|
||||
)}
|
||||
@@ -340,15 +330,19 @@ function GuideNavigation({
|
||||
function GuideSection({
|
||||
guide,
|
||||
section,
|
||||
filters,
|
||||
preview,
|
||||
}: {
|
||||
guide: PublicGuide;
|
||||
section: string;
|
||||
filters?: PublicGuideFilters;
|
||||
preview: boolean;
|
||||
}) {
|
||||
if (section === "weapon") return <WeaponSection guide={guide} />;
|
||||
if (section === "artifact") return <ArtifactSection guide={guide} />;
|
||||
if (section === "weapon") return <WeaponSection guide={guide} filters={filters} />;
|
||||
if (section === "artifact") return <ArtifactSection guide={guide} filters={filters} preview={preview} />;
|
||||
if (section === "constellations")
|
||||
return <ConstellationSection guide={guide} />;
|
||||
if (section === "team") return <TeamSection guide={guide} />;
|
||||
return <ConstellationSection guide={guide} filters={filters} />;
|
||||
if (section === "team") return <TeamSection guide={guide} filters={filters} />;
|
||||
const extra = guide.extras.find((item) => item.slug === section);
|
||||
return extra ? <ExtraSection guide={guide} extra={extra} /> : null;
|
||||
}
|
||||
@@ -371,19 +365,21 @@ function lunarisItemUrl(
|
||||
return `https://lunaris.moe/${path}/${encodeURIComponent(key)}`;
|
||||
}
|
||||
|
||||
function WeaponSection({ guide }: { guide: PublicGuide }) {
|
||||
return <WeaponSectionContent guide={guide} showExportActions />;
|
||||
function WeaponSection({ guide, filters }: { guide: PublicGuide; filters?: PublicGuideFilters }) {
|
||||
return <WeaponSectionContent guide={guide} filters={filters} showExportActions />;
|
||||
}
|
||||
|
||||
function WeaponSectionContent({
|
||||
guide,
|
||||
exporting = false,
|
||||
filters,
|
||||
groups: providedGroups,
|
||||
conditionNumber,
|
||||
showExportActions = false,
|
||||
}: {
|
||||
guide: PublicGuide;
|
||||
exporting?: boolean;
|
||||
filters?: PublicGuideFilters;
|
||||
groups?: PublicGuide["weaponGroups"];
|
||||
conditionNumber?: number;
|
||||
showExportActions?: boolean;
|
||||
@@ -391,9 +387,11 @@ function WeaponSectionContent({
|
||||
const note = guide.sections.find(
|
||||
(section) => section.slug === "weapon",
|
||||
)?.note;
|
||||
const weaponGroups = (providedGroups ?? guide.weaponGroups).filter(
|
||||
(group) => group.rows.length > 0,
|
||||
);
|
||||
const weaponGroups = (providedGroups ?? guide.weaponGroups).filter((group) => group.rows.length > 0);
|
||||
const selectedGroup = weaponGroups[filters?.conditionIndex ?? 0];
|
||||
const groupsToRender = filters
|
||||
? selectedGroup ? [[filters.conditionIndex, selectedGroup] as const] : []
|
||||
: weaponGroups.map((group, index) => [index, group] as const);
|
||||
const useCompactExportGrid =
|
||||
exporting &&
|
||||
weaponGroups.reduce((total, group) => total + group.rows.length, 0) > 6;
|
||||
@@ -444,11 +442,13 @@ function WeaponSectionContent({
|
||||
<ConditionSwitcher
|
||||
label="เลือกเงื่อนไขอาวุธ"
|
||||
labels={weaponGroups.map((group, index) => group.name || (exporting ? `Condition ${index + 1}` : `เงื่อนไข ${index + 1}`))}
|
||||
selectedIndex={filters?.conditionIndex}
|
||||
queryParam={filters ? "condition" : undefined}
|
||||
>
|
||||
{weaponGroups.map((group, groupIndex, groups) => (
|
||||
{groupsToRender.map(([groupIndex, group]) => (
|
||||
<div key={group.id} className="relative z-10 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{groups.length > 1 || conditionNumber ? (
|
||||
{weaponGroups.length > 1 || conditionNumber ? (
|
||||
<h2 className="font-heading text-2xl font-semibold">
|
||||
{group.name || (exporting ? `Condition ${conditionNumber ?? groupIndex + 1}` : `เงื่อนไข ${conditionNumber ?? groupIndex + 1}`)}
|
||||
</h2>
|
||||
@@ -611,548 +611,77 @@ function WeaponSectionContent({
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactSection({ guide }: { guide: PublicGuide }) {
|
||||
const targetConditions = guide.artifactProfile?.targetConditions.filter(
|
||||
(group) => group.rows.length,
|
||||
) ?? [];
|
||||
function ArtifactSection({
|
||||
guide,
|
||||
filters,
|
||||
preview,
|
||||
}: {
|
||||
guide: PublicGuide;
|
||||
filters?: PublicGuideFilters;
|
||||
preview: boolean;
|
||||
}) {
|
||||
const profile = guide.artifactProfile;
|
||||
const recommendedConditions = profile?.recommendedConditions ?? [];
|
||||
const targetConditions = profile?.targetConditions.filter((group) => group.rows.length) ?? [];
|
||||
const selectedGuide: ArtifactSectionGuide = filters ? {
|
||||
name: guide.name,
|
||||
coverUrl: guide.coverUrl,
|
||||
character: {
|
||||
splashImageUrl: guide.character.splashImageUrl,
|
||||
talents: guide.character.talents,
|
||||
},
|
||||
elementIconUrls: guide.elementIconUrls,
|
||||
artifactProfile: profile ? {
|
||||
...profile,
|
||||
recommendedConditions: recommendedConditions[filters.recommendedIndex]
|
||||
? [recommendedConditions[filters.recommendedIndex]]
|
||||
: [],
|
||||
targetConditions: targetConditions[filters.targetsIndex]
|
||||
? [targetConditions[filters.targetsIndex]]
|
||||
: [],
|
||||
} : null,
|
||||
artifactGroups: guide.artifactGroups[filters.setsIndex]
|
||||
? [guide.artifactGroups[filters.setsIndex]]
|
||||
: [],
|
||||
} : guide;
|
||||
const selection = filters ? {
|
||||
recommendedIndex: filters.recommendedIndex,
|
||||
setsIndex: filters.setsIndex,
|
||||
targetsIndex: filters.targetsIndex,
|
||||
labels: {
|
||||
recommended: filters.recommendedLabels,
|
||||
sets: filters.setsLabels,
|
||||
targets: filters.targetsLabels,
|
||||
},
|
||||
} : undefined;
|
||||
return (
|
||||
<ArtifactSectionContent
|
||||
guide={guide}
|
||||
guide={selectedGuide}
|
||||
selection={selection}
|
||||
action={
|
||||
<ArtifactImageExport
|
||||
fileName={`${guide.name}-Artifact.png`}
|
||||
slug={guide.slug}
|
||||
hasNote={Boolean(guide.artifactProfile?.note)}
|
||||
recommendedConditionLabels={(guide.artifactProfile?.recommendedConditions ?? []).map((group, index) => group.name || `เงื่อนไข ${index + 1}`)}
|
||||
targetConditionLabels={targetConditions.map((group, index) => group.name || `เงื่อนไข ${index + 1}`)}
|
||||
tierConditionLabels={guide.artifactGroups.map((group, index) => group.name || `เงื่อนไข ${index + 1}`)}
|
||||
>
|
||||
<ArtifactSectionContent guide={guide} exporting />
|
||||
</ArtifactImageExport>
|
||||
initialData={preview ? guide : undefined}
|
||||
recommendedConditionLabels={filters?.recommendedLabels ?? recommendedConditions.map((group, index) => group.name || `เงื่อนไข ${index + 1}`)}
|
||||
targetConditionLabels={filters?.targetsLabels ?? targetConditions.map((group, index) => group.name || `เงื่อนไข ${index + 1}`)}
|
||||
tierConditionLabels={filters?.setsLabels ?? guide.artifactGroups.map((group, index) => group.name || `เงื่อนไข ${index + 1}`)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactSectionContent({
|
||||
guide,
|
||||
action,
|
||||
exporting = false,
|
||||
}: {
|
||||
guide: PublicGuide;
|
||||
action?: ReactNode;
|
||||
exporting?: boolean;
|
||||
}) {
|
||||
const profile = guide.artifactProfile;
|
||||
const recommendedConditions = profile?.recommendedConditions ?? [];
|
||||
const recommendedCards = recommendedConditions.map((condition, conditionIndex) => (
|
||||
<div key={conditionIndex} data-artifact-export-section="best-stats" data-recommended-condition={conditionIndex} className="flex flex-col gap-5">
|
||||
{exporting && recommendedConditions.length > 1 ? <p className="text-sm font-medium">{condition.name || <ExportText th={`เงื่อนไข ${conditionIndex + 1}`} en={`Condition ${conditionIndex + 1}`} />}</p> : null}
|
||||
{condition.note ? <p data-export-note className="whitespace-pre-wrap text-sm text-muted-foreground">{condition.note}</p> : null}
|
||||
{profile ? (
|
||||
<Card data-artifact-export-section="best-stats" className="artifact-export-main-stats overflow-hidden pt-0">
|
||||
<CardHeader className="bg-primary py-4 text-center text-primary-foreground">
|
||||
<CardTitle className="text-2xl">{exportLabel(exporting, "ออฟหลักที่ดีที่สุด", "Best Main Stats")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-3 p-0">
|
||||
<ArtifactStat
|
||||
iconSrc="/icon/Icon_Sands_of_Eon.webp"
|
||||
label={exportLabel(exporting, "นาฬิกา", "Sands")}
|
||||
values={condition.sands}
|
||||
exporting={exporting}
|
||||
elementIconUrls={guide.elementIconUrls}
|
||||
/>
|
||||
<ArtifactStat
|
||||
iconSrc="/icon/Icon_Goblet_of_Eonothem.webp"
|
||||
label={exportLabel(exporting, "ถ้วย", "Goblet")}
|
||||
values={condition.goblet}
|
||||
exporting={exporting}
|
||||
elementIconUrls={guide.elementIconUrls}
|
||||
/>
|
||||
<ArtifactStat
|
||||
iconSrc="/icon/Icon_Circlet_of_Logos.webp"
|
||||
label={exportLabel(exporting, "หัว", "Circlet")}
|
||||
values={condition.circlet}
|
||||
exporting={exporting}
|
||||
elementIconUrls={guide.elementIconUrls}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
{condition.substats.length ? (
|
||||
<Card data-artifact-export-section="best-stats" className="artifact-export-substats">
|
||||
<CardContent className="flex flex-col items-center gap-2 py-5 text-center text-sm">
|
||||
<strong>{exportLabel(exporting, "ออฟรองที่ดีที่สุด", "Best Substats")}</strong>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{condition.substats.map((stat, index) => (
|
||||
<ArtifactStatValue
|
||||
key={`${stat}-${index}`}
|
||||
value={stat}
|
||||
index={index}
|
||||
exporting={exporting}
|
||||
elementIconUrls={guide.elementIconUrls}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
));
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"flex flex-col gap-6",
|
||||
exporting &&
|
||||
"relative isolate overflow-hidden bg-[#070b20] p-10 text-white",
|
||||
)}
|
||||
>
|
||||
{exporting ? (
|
||||
<GuideExportBranding
|
||||
backgroundUrl={guide.character.splashImageUrl ?? guide.coverUrl}
|
||||
/>
|
||||
) : null}
|
||||
<header className="relative z-10 flex items-center justify-center text-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-sm font-medium text-primary">
|
||||
อาร์ติแฟกต์และสแตทที่แนะนำ
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Image
|
||||
src="/icon/nav/Artifact.webp"
|
||||
alt=""
|
||||
width={56}
|
||||
height={56}
|
||||
loading={exporting ? "eager" : "lazy"}
|
||||
className={cn(
|
||||
"size-14 shrink-0 object-contain",
|
||||
exporting &&
|
||||
"drop-shadow-[0_4px_3px_rgba(0,0,0,0.8)]",
|
||||
)}
|
||||
/>
|
||||
<h1 className="whitespace-nowrap font-heading text-4xl font-semibold">
|
||||
Artifacts & Stats
|
||||
</h1>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
className={cn(
|
||||
"artifact-export-grid relative z-10 grid gap-5",
|
||||
exporting ? "grid-cols-[1.2fr_1fr]" : "lg:grid-cols-[1.2fr_1fr]",
|
||||
)}
|
||||
>
|
||||
{" "}
|
||||
<div className="artifact-export-left flex flex-col gap-5">
|
||||
{exporting ? recommendedCards : (
|
||||
<ConditionSwitcher label="เลือกเงื่อนไขค่าสถานะแนะนำ" labels={recommendedConditions.map((condition, index) => condition.name || `เงื่อนไข ${index + 1}`)}>
|
||||
{recommendedCards}
|
||||
</ConditionSwitcher>
|
||||
)}
|
||||
<div data-artifact-export-section="tierlist" className="order-first">
|
||||
{(() => {
|
||||
const tierCards = guide.artifactGroups.map((condition, conditionIndex) => (
|
||||
<div key={condition.id} data-tier-condition={conditionIndex} className="artifact-export-tierlist flex flex-col gap-5">
|
||||
{exporting && guide.artifactGroups.length > 1 ? <p className="text-sm font-medium">{condition.name || <ExportText th={`เงื่อนไข ${conditionIndex + 1}`} en={`Condition ${conditionIndex + 1}`} />}</p> : null}
|
||||
{condition.note ? <p data-export-note className="whitespace-pre-wrap text-sm text-muted-foreground">{condition.note}</p> : null}
|
||||
{condition.tiers.map((group) => <Card key={group.id}>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{group.name === "Late Game"
|
||||
? exportLabel(exporting, "เลทเกม", "Late Game")
|
||||
: group.name === "Early Game"
|
||||
? exportLabel(exporting, "ต้นเกม", "Early Game")
|
||||
: group.name}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{group.rows.map((row, index) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md px-3 py-2",
|
||||
index % 2 === 0 ? "bg-muted/60" : "bg-muted/20",
|
||||
)}
|
||||
>
|
||||
<TeamArtifactIcon
|
||||
first={row.first}
|
||||
second={row.second}
|
||||
eager={exporting}
|
||||
linked={!exporting}
|
||||
sizes="(min-width: 640px) 4rem, 3.5rem"
|
||||
className={exporting ? "size-16 shrink-0" : "size-14 shrink-0 sm:size-16"}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">
|
||||
{artifactTierCount(row.second?.name ?? null)}{" "}
|
||||
{row.first.name}
|
||||
{row.second ? ` + 2 ${row.second.name}` : ""}
|
||||
</p>
|
||||
{row.note ? (
|
||||
<p data-export-note className="whitespace-pre-wrap text-sm text-muted-foreground">
|
||||
{row.note}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-2xl font-semibold text-primary">
|
||||
{Number(row.percent) === 0 ? "-%" : metric(row.percent, exporting ? "en-US" : undefined)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>)}
|
||||
</div>
|
||||
));
|
||||
return exporting ? tierCards : <ConditionSwitcher label="เลือกเงื่อนไขอันดับอาร์ติแฟกต์" labels={guide.artifactGroups.map((condition, index) => condition.name || `เงื่อนไข ${index + 1}`)}>{tierCards}</ConditionSwitcher>;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
{profile ? (
|
||||
<div className="artifact-export-right flex min-w-0 flex-col gap-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-5">
|
||||
{profile.targetConditions.some((group) => group.rows.length) ? (
|
||||
exporting ? (
|
||||
profile.targetConditions.filter((group) => group.rows.length).map((group, groupIndex) => (
|
||||
<div key={groupIndex} data-artifact-export-section="target-stats" data-target-condition={groupIndex}>
|
||||
<p className="mb-2 text-sm font-medium">{exportLabel(exporting, "ค่าสถานะต่ำๆที่ควรจะปั้นไว้", "Recommended Minimum Stats")}</p>
|
||||
{group.note ? <p data-export-note className="mb-2 whitespace-pre-wrap text-sm text-muted-foreground">{group.note}</p> : null}
|
||||
<div className="flex flex-col gap-1">
|
||||
{group.rows.map((target, index) => (
|
||||
<div
|
||||
key={`${target.stat}-${target.value}`}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-4 rounded-md px-3 py-2 text-sm",
|
||||
index % 2 === 0 ? "bg-muted/60" : "bg-muted/20",
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{targetStatIconName(target.stat) ? (
|
||||
<Image
|
||||
src={`/icon/stats/${targetStatIconName(target.stat)}.webp`}
|
||||
alt=""
|
||||
width={14}
|
||||
height={14}
|
||||
loading={exporting ? "eager" : undefined}
|
||||
className="size-3.5 shrink-0 object-contain brightness-0 invert"
|
||||
/>
|
||||
) : null}
|
||||
<span>
|
||||
{statLabel(
|
||||
normalizeTargetStat(target.stat) ?? target.stat, exporting,
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<strong className="shrink-0">{target.value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<ConditionSwitcher label="เลือกเงื่อนไขค่าสถานะเป้าหมาย" labels={profile.targetConditions.filter((group) => group.rows.length).map((group, index) => group.name || `เงื่อนไข ${index + 1}`)}>
|
||||
{profile.targetConditions.filter((group) => group.rows.length).map((group, groupIndex) => (
|
||||
<div key={groupIndex}>
|
||||
<p className="mb-2 text-sm font-medium">{exportLabel(exporting, "ค่าสถานะต่ำๆที่ควรจะปั้นไว้", "Recommended Minimum Stats")}</p>
|
||||
{group.note ? <p data-export-note className="mb-2 whitespace-pre-wrap text-sm text-muted-foreground">{group.note}</p> : null}
|
||||
<div className="flex flex-col gap-1">
|
||||
{group.rows.map((target, index) => (
|
||||
<div key={`${target.stat}-${target.value}`} className={cn("flex items-center justify-between gap-4 rounded-md px-3 py-2 text-sm", index % 2 === 0 ? "bg-muted/60" : "bg-muted/20")}>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{targetStatIconName(target.stat) ? <Image src={`/icon/stats/${targetStatIconName(target.stat)}.webp`} alt="" width={14} height={14} className="size-3.5 shrink-0 object-contain brightness-0 invert" /> : null}
|
||||
<span>{statLabel(normalizeTargetStat(target.stat) ?? target.stat)}</span>
|
||||
</span>
|
||||
<strong className="shrink-0">{target.value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</ConditionSwitcher>
|
||||
)
|
||||
) : null}
|
||||
{profile.talents.length ? (
|
||||
<div data-artifact-export-section="talents">
|
||||
<p className="mb-2 text-sm font-medium">
|
||||
{exportLabel(exporting, "ลำดับการอัพพรสวรรค์", "Talent Priority")}
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start justify-center",
|
||||
exporting ? "gap-3" : "gap-1 sm:gap-3",
|
||||
)}
|
||||
>
|
||||
{normalizeTalentPriorities(profile.talents).map(
|
||||
(talent, index, talents) => {
|
||||
const catalogTalent = guide.character.talents.find(
|
||||
(item) => item.type === talent.type,
|
||||
);
|
||||
const talentName = catalogTalent?.name ?? talent.type;
|
||||
return (
|
||||
<div
|
||||
key={talent.type}
|
||||
className={cn(
|
||||
"flex min-w-0 items-start",
|
||||
exporting ? "gap-3" : "gap-1 sm:gap-3",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-2 text-center",
|
||||
exporting ? "w-28" : "w-16 sm:w-28",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-xl border bg-muted",
|
||||
exporting ? "size-20" : "size-14 sm:size-20",
|
||||
)}
|
||||
>
|
||||
{catalogTalent?.iconUrl ? (
|
||||
<Image
|
||||
src={catalogTalent.iconUrl}
|
||||
alt={talentName}
|
||||
fill
|
||||
loading={exporting ? "eager" : undefined}
|
||||
className="object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="grid size-full place-items-center font-semibold">
|
||||
{talent.type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{talent.level
|
||||
? exportLabel(exporting, `เลเวล ${talent.level}+`, `Level ${talent.level}+`)
|
||||
: "-"}
|
||||
</Badge>
|
||||
<p
|
||||
className={cn(
|
||||
"font-medium",
|
||||
exporting ? "text-sm" : "text-xs sm:text-sm",
|
||||
)}
|
||||
>
|
||||
{talentName}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-muted-foreground",
|
||||
exporting ? "text-xs" : "text-[11px] sm:text-xs",
|
||||
)}
|
||||
>
|
||||
{talent.type === "E"
|
||||
? exportLabel(exporting, "สกิลธาตุ", "Elemental Skill")
|
||||
: talent.type === "Q"
|
||||
? exportLabel(exporting, "ท่าไม้ตาย", "Elemental Burst")
|
||||
: exportLabel(exporting, "ตีปกติ", "Normal Attack")}
|
||||
</p>
|
||||
</div>
|
||||
{index < talents.length - 1 ? (
|
||||
<span
|
||||
className={cn(
|
||||
"font-semibold",
|
||||
exporting
|
||||
? "mt-6 text-3xl"
|
||||
: "mt-4 text-xl sm:mt-6 sm:text-3xl",
|
||||
)}
|
||||
aria-label={
|
||||
talent.relationToNext === "gte"
|
||||
? "มากกว่าหรือเท่ากับ"
|
||||
: talent.relationToNext === "eq"
|
||||
? "เท่ากับ"
|
||||
: "มากกว่า"
|
||||
}
|
||||
>
|
||||
{talentRelationSymbol(talent.relationToNext)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{profile.note ? (
|
||||
<div data-export-note data-artifact-export-section="note" className="pt-3">
|
||||
<p className="mb-2 text-sm font-medium">{exportLabel(exporting, "โน็ต", "Notes")}</p>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
{profile.note}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const thaiStats: Record<string, string> = {
|
||||
HP: "พลังชีวิต",
|
||||
ATK: "พลังโจมตี",
|
||||
DEF: "พลังป้องกัน",
|
||||
EM: "ความชำนาญธาตุ",
|
||||
ER: "ฟื้นฟูพลังงาน",
|
||||
"CRIT RATE": "คริเรท",
|
||||
"CRIT DAMAGE": "คริดาเมท",
|
||||
"ATK%": "พลังโจมตี%",
|
||||
"HP%": "พลังชีวิต%",
|
||||
"DEF%": "พลังป้องกัน%",
|
||||
"CRIT Rate/CRIT DMG": "คริเรท/คริดาเมท",
|
||||
"Pyro DMG%": "โบนัสความเสียหายธาตุไฟ",
|
||||
"Hydro DMG%": "โบนัสความเสียหายธาตุน้ำ",
|
||||
"Cryo DMG%": "โบนัสความเสียหายธาตุน้ำแข็ง",
|
||||
"Electro DMG%": "โบนัสความเสียหายธาตุไฟฟ้า",
|
||||
"Anemo DMG%": "โบนัสความเสียหายธาตุลม",
|
||||
"Geo DMG%": "โบนัสความเสียหายธาตุดิน",
|
||||
"Dendro DMG%": "โบนัสความเสียหายธาตุไม้",
|
||||
"Physical DMG%": "โบนัสความเสียหายกายภาพ",
|
||||
"Healing Bonus%": "โบนัสการรักษา",
|
||||
};
|
||||
|
||||
const englishStats: Record<string, string> = {
|
||||
HP: "HP",
|
||||
ATK: "ATK",
|
||||
DEF: "DEF",
|
||||
EM: "Elemental Mastery",
|
||||
ER: "Energy Recharge",
|
||||
"CRIT RATE": "CRIT Rate",
|
||||
"CRIT DAMAGE": "CRIT DMG",
|
||||
"CRIT Rate/CRIT DMG": "CRIT Rate / CRIT DMG",
|
||||
};
|
||||
|
||||
const statLabelAliases: Record<string, string> = {
|
||||
"CRIT Rate%": "CRIT RATE",
|
||||
"CRIT DMG%": "CRIT DAMAGE",
|
||||
"CRIT Rate%/CRIT DMG%": "CRIT Rate/CRIT DMG",
|
||||
};
|
||||
|
||||
function statLabel(value: string, exporting = false): ReactNode {
|
||||
const labelKey = statLabelAliases[value] ?? value;
|
||||
if (!exporting) return thaiStats[labelKey] ?? value;
|
||||
return (
|
||||
<ExportText
|
||||
th={thaiStats[labelKey] ?? value}
|
||||
en={englishStats[labelKey] ?? value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactStat({
|
||||
iconSrc,
|
||||
label,
|
||||
values,
|
||||
exporting = false,
|
||||
elementIconUrls,
|
||||
}: {
|
||||
iconSrc: string;
|
||||
label: ReactNode;
|
||||
values: string[];
|
||||
exporting?: boolean;
|
||||
elementIconUrls: Record<string, string>;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-col items-center gap-3 border-r text-center last:border-r-0",
|
||||
exporting ? "p-5" : "p-3 sm:p-5",
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src={iconSrc}
|
||||
alt=""
|
||||
width={48}
|
||||
height={48}
|
||||
loading={exporting ? "eager" : undefined}
|
||||
className="size-12 object-contain"
|
||||
/>
|
||||
<p className="font-semibold">{label}</p>
|
||||
<Separator />
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-w-full flex-col items-center gap-1",
|
||||
exporting
|
||||
? "text-sm leading-6"
|
||||
: "text-xs leading-5 sm:text-sm sm:leading-6",
|
||||
)}
|
||||
>
|
||||
{values.length ? (
|
||||
values.map((value, index) => (
|
||||
<ArtifactStatValue
|
||||
key={`${value}-${index}`}
|
||||
value={value}
|
||||
index={index}
|
||||
exporting={exporting}
|
||||
elementIconUrls={elementIconUrls}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<span>-</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactStatValue({
|
||||
value,
|
||||
index,
|
||||
exporting = false,
|
||||
elementIconUrls,
|
||||
}: {
|
||||
value: string;
|
||||
index: number;
|
||||
exporting?: boolean;
|
||||
elementIconUrls: Record<string, string>;
|
||||
}) {
|
||||
const iconName = recommendedStatIconName(value);
|
||||
const element = recommendedStatElement(value);
|
||||
const elementIconUrl = element ? elementIconUrls[element] : null;
|
||||
const hasIcon = Boolean(iconName);
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex max-w-full items-center justify-center gap-1 text-center",
|
||||
statPriorityColorClass(index),
|
||||
)}
|
||||
>
|
||||
{elementIconUrl ? (
|
||||
<Image
|
||||
src={elementIconUrl}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
loading={exporting ? "eager" : undefined}
|
||||
className="size-5 shrink-0 object-contain"
|
||||
style={{ filter: elementIconFilter(element!) }}
|
||||
/>
|
||||
) : hasIcon ? (
|
||||
<Image
|
||||
src={`/icon/stats/${iconName}.${iconName === "elemental-bonus" ? "svg" : "webp"}`}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
loading={exporting ? "eager" : undefined}
|
||||
className="size-5 shrink-0 object-contain brightness-0 invert"
|
||||
/>
|
||||
) : null}
|
||||
<span className="wrap-break-word">{statLabel(value, exporting)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ConstellationSection({ guide }: { guide: PublicGuide }) {
|
||||
function ConstellationSection({ guide, filters }: { guide: PublicGuide; filters?: PublicGuideFilters }) {
|
||||
const note = guide.sections.find(
|
||||
(section) => section.slug === "constellations",
|
||||
)?.note;
|
||||
const groups = guide.constellationGroups.filter((group) => group.rows.length > 0);
|
||||
const selectedGroup = groups[filters?.conditionIndex ?? 0];
|
||||
const groupsToRender = filters
|
||||
? selectedGroup ? [[filters.conditionIndex, selectedGroup] as const] : []
|
||||
: groups.map((group, index) => [index, group] as const);
|
||||
return (
|
||||
<section className="flex flex-col gap-6">
|
||||
<header className="flex flex-col items-center gap-3 text-center">
|
||||
@@ -1178,8 +707,8 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
<ConditionSwitcher label="เลือกเงื่อนไขกลุ่มดาว" labels={guide.constellationGroups.filter((group) => group.rows.length > 0).map((group, index) => group.name || `เงื่อนไข ${index + 1}`)}>
|
||||
{guide.constellationGroups.filter((group) => group.rows.length > 0).map((group, groupIndex, groups) => (
|
||||
<ConditionSwitcher label="เลือกเงื่อนไขกลุ่มดาว" labels={groups.map((group, index) => group.name || `เงื่อนไข ${index + 1}`)} selectedIndex={filters?.conditionIndex} queryParam={filters ? "condition" : undefined}>
|
||||
{groupsToRender.map(([groupIndex, group]) => (
|
||||
<div key={group.id} className="flex flex-col gap-4">
|
||||
<div>
|
||||
<GuideImageExport
|
||||
@@ -1191,7 +720,13 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {
|
||||
promptForLanguage
|
||||
>
|
||||
<ConstellationImage
|
||||
guide={guide}
|
||||
guide={{
|
||||
coverUrl: guide.coverUrl,
|
||||
character: {
|
||||
name: guide.character.name,
|
||||
splashImageUrl: guide.character.splashImageUrl,
|
||||
},
|
||||
}}
|
||||
group={group}
|
||||
/>
|
||||
</GuideImageExport>
|
||||
@@ -1238,14 +773,25 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TeamSection({ guide }: { guide: PublicGuide }) {
|
||||
function TeamSection({ guide, filters }: { guide: PublicGuide; filters?: PublicGuideFilters }) {
|
||||
const groups = filters ? filterPublicTeamGroups(guide, filters) : guide.teamGroups;
|
||||
const serverFilters = filters ? {
|
||||
conditionIndex: filters.conditionIndex,
|
||||
query: filters.query,
|
||||
field: filters.field,
|
||||
sort: filters.sort,
|
||||
} : undefined;
|
||||
return (
|
||||
<section className="flex flex-col gap-6">
|
||||
<header className="text-center">
|
||||
<p className="text-sm font-medium text-primary">คำนวณความเสียหาย</p>
|
||||
<h1 className="font-heading text-4xl font-semibold">Team Calculation</h1>
|
||||
</header>
|
||||
<TeamSearch groups={guide.teamGroups} />
|
||||
<TeamSearch
|
||||
key={serverFilters ? `${serverFilters.conditionIndex}:${serverFilters.query}:${serverFilters.field}:${serverFilters.sort ?? ""}` : "preview"}
|
||||
groups={groups}
|
||||
serverFilters={serverFilters}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user