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

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