feat : buzz feedback
CI / Verify (push) Successful in 1m5s
CI / Build immutable images and deploy (push) Successful in 1m41s

This commit is contained in:
2026-08-30 15:11:35 +00:00 Unverified
parent b2e9b69619
commit 14a98c55f1
13 changed files with 418 additions and 96 deletions
@@ -7,6 +7,7 @@ import sharp from "sharp";
import { rarityGradient } from "@/lib/catalog/rarity";
import { imageContentDisposition } from "@/lib/guides/export-image";
import { getPublicArtifactExport } from "@/lib/guides/queries";
import { normalizeTargetStat, statPriorityColor, targetStatIconName, targetStatOptions } from "@/lib/guides/stats";
import { normalizeTalentPriorities } from "@/lib/guides/talents";
const anuphanFonts = Promise.all(
@@ -55,9 +56,9 @@ async function pngDataUrl(url: string): Promise<string | null> {
}
}
async function localPngDataUrl(fileName: string): Promise<string> {
const png = await sharp(join(process.cwd(), "public", "icon", fileName))
.resize(96, 96, { fit: "contain" })
async function localPngDataUrl(fileName: string, size: number | null = 96): Promise<string> {
const image = sharp(join(process.cwd(), "public", "icon", fileName));
const png = await (size === null ? image : image.resize(size, size, { fit: "contain" }))
.png()
.toBuffer();
return `data:image/png;base64,${png.toString("base64")}`;
@@ -72,7 +73,7 @@ export async function GET(
if (!guide) return new Response("Guide not found", { status: 404 });
const talents = normalizeTalentPriorities(guide.profile?.talents ?? []);
const [fonts, groupAssets, statIconAssets, talentAssets] = await Promise.all([
const [fonts, groupAssets, statIconAssets, talentAssets, targetStatAssets, watermark] = await Promise.all([
anuphanFonts,
Promise.all(
guide.groups.map((group) =>
@@ -93,13 +94,15 @@ export async function GET(
const catalogTalent = guide.character.talents.find((item) => item.type === talent.type);
return catalogTalent?.iconUrl ? pngDataUrl(catalogTalent.iconUrl) : null;
})),
Promise.all(targetStatOptions.map((stat) => localPngDataUrl(`stats/${targetStatIconName(stat)}.webp`))),
localPngDataUrl("Watermark.png", null),
]);
const profile = guide.profile;
const groupRowHeights = guide.groups.map((group) => group.rows.map((row) => Math.max(76, 76 + (row.note ? Math.ceil(row.note.length / 58) * 22 : 0))));
const leftHeight = guide.groups.reduce((sum, _group, groupIndex) => sum + 68 + groupRowHeights[groupIndex].reduce((groupSum, rowHeight) => groupSum + rowHeight, 0), 0) + Math.max(0, guide.groups.length - 1) * 16;
const detailHeight = profile
? 60 + profile.targets.length * 30 + (profile.talents.length ? 82 : 0) + (profile.note ? Math.max(48, Math.ceil(profile.note.length / 58) * 24) : 0)
? 60 + profile.targets.length * 40 + (profile.talents.length ? 82 : 0) + (profile.note ? Math.max(48, Math.ceil(profile.note.length / 58) * 24) : 0)
: 0;
const rightHeight = profile
? 242 + (profile.substats.length ? 76 : 0) + (detailHeight > 60 ? detailHeight + 16 : 0) + 32
@@ -118,8 +121,11 @@ export async function GET(
background: "#05060a",
padding: "32px 36px",
fontFamily: "Anuphan",
position: "relative",
}}
>
<img src={watermark} alt="" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", opacity: 0.2 }} />
<div style={{ position: "relative", zIndex: 1, display: "flex", flex: 1, flexDirection: "column", gap: 20 }}>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 8 }}>
<div style={{ display: "flex", color: "#f0b72f", fontSize: 16, fontWeight: 700 }}>
อาร์ติแฟกต์และสเตตัสที่แนะนำ
@@ -148,7 +154,7 @@ export async function GET(
</div>
<div style={{ display: "flex", flexDirection: "column", padding: "0 20px 14px" }}>
{group.rows.map((row, rowIndex) => (
<div key={row.id} style={{ display: "flex", minHeight: groupRowHeights[groupIndex][rowIndex], alignItems: "center", gap: 14 }}>
<div key={row.id} style={{ display: "flex", minHeight: groupRowHeights[groupIndex][rowIndex], alignItems: "center", gap: 14 }}>
<div style={{ display: "flex", width: row.second ? 94 : 58 }}>
{groupAssets[groupIndex][rowIndex].first ? (
<div style={{ display: "flex", width: 58, height: 58, overflow: "hidden", border: "1px solid #ffffff2a", borderRadius: 10, background: rarityGradient(row.first.rarity) }}>
@@ -193,8 +199,8 @@ export async function GET(
<img src={stat.icon} alt="" width={48} height={48} style={{ objectFit: "contain" }} />
<div style={{ display: "flex", fontSize: 17, fontWeight: 700 }}>{stat.label}</div>
<div style={{ display: "flex", width: "100%", borderTop: "1px solid #34343d" }} />
<div style={{ display: "flex", fontSize: 14, lineHeight: 1.45 }}>
{stat.values.length ? stat.values.map(statLabel).join(" > ") : "—"}
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 4, fontSize: 14, lineHeight: 1.45 }}>
{stat.values.length ? stat.values.map((value, index) => <span key={`${value}-${index}`} style={{ color: statPriorityColor(index), whiteSpace: "nowrap" }}>{statLabel(value)}</span>) : "—"}
</div>
</div>
))}
@@ -202,8 +208,8 @@ export async function GET(
</div>
{profile.substats.length ? (
<div style={{ display: "flex", justifyContent: "center", border: "1px solid #34343d", borderRadius: 14, background: "#1d1d25", padding: "20px", fontSize: 17, textAlign: "center" }}>
<strong>Stat Priority:&nbsp;</strong>{profile.substats.map(statLabel).join(" > ")}
<div style={{ display: "flex", justifyContent: "center", border: "1px solid #34343d", borderRadius: 14, background: "#1d1d25", padding: "20px", fontSize: 17, textAlign: "center" }}>
<strong>Stat Priority:&nbsp;</strong>{profile.substats.map((stat, index) => <span key={`${stat}-${index}`} style={{ color: statPriorityColor(index) }}>{index ? " > " : ""}{statLabel(stat)}</span>)}
</div>
) : null}
@@ -213,11 +219,14 @@ export async function GET(
{profile.targets.length ? (
<div style={{ display: "flex", flexDirection: "column", gap: 7 }}>
<div style={{ display: "flex", color: "#b9b9c2", fontSize: 14, fontWeight: 700 }}>Target Stats</div>
{profile.targets.map((target) => (
<div key={`${target.stat}-${target.value}`} style={{ display: "flex", justifyContent: "space-between", fontSize: 15 }}>
<span>{target.stat}</span><strong>{target.value}</strong>
{profile.targets.map((target, index) => {
const iconIndex = targetStatOptions.findIndex((stat) => stat === normalizeTargetStat(target.stat));
return (
<div key={`${target.stat}-${target.value}`} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, borderRadius: 6, background: index % 2 === 0 ? "#34343d" : "#272730", padding: "5px 8px", fontSize: 15 }}>
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>{iconIndex >= 0 ? <img src={targetStatAssets[iconIndex]} alt="" width={28} height={28} style={{ objectFit: "contain" }} /> : null}{normalizeTargetStat(target.stat) ?? target.stat}</span><strong>{target.value}</strong>
</div>
))}
);
})}
</div>
) : null}
{talents.length ? (
@@ -245,6 +254,7 @@ export async function GET(
</div>
) : null}
</div>
</div>
</div>,
{
width: 1200,
@@ -1,4 +1,6 @@
/* eslint-disable @next/next/no-img-element */
import { join } from "node:path";
import { ImageResponse } from "next/og";
import sharp from "sharp";
@@ -44,6 +46,14 @@ async function pngDataUrl(url: string): Promise<string | null> {
}
}
async function localPngDataUrl(fileName: string, size: number | null = 96): Promise<string> {
const image = sharp(join(process.cwd(), "public", "icon", fileName));
const png = await (size === null ? image : image.resize(size, size, { fit: "contain" }))
.png()
.toBuffer();
return `data:image/png;base64,${png.toString("base64")}`;
}
export async function GET(
_request: Request,
context: { params: Promise<{ guideId: string }> },
@@ -52,9 +62,10 @@ export async function GET(
const guide = await getPublicWeaponExport(guideId);
if (!guide) return new Response("Guide not found", { status: 404 });
const [fonts, weaponImages] = await Promise.all([
const [fonts, weaponImages, watermark] = await Promise.all([
anuphanFonts,
Promise.all(guide.weapons.map((row) => pngDataUrl(row.weapon.imageUrl))),
localPngDataUrl("Watermark.png", null),
]);
const weaponHeights = guide.weapons.map((row) => Math.max(126, 126 + (row.note ? Math.ceil(row.note.length / 52) * 22 : 0)));
const height = Math.max(360, 190 + weaponHeights.reduce((sum, rowHeight) => sum + rowHeight, 0) + Math.max(0, guide.weapons.length - 1) * 14);
@@ -71,8 +82,11 @@ export async function GET(
background: "#05060a",
padding: "32px 36px",
fontFamily: "Anuphan",
position: "relative",
}}
>
<img src={watermark} alt="" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", opacity: 0.2 }} />
<div style={{ position: "relative", zIndex: 1, display: "flex", flex: 1, flexDirection: "column", gap: 14 }}>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 8, marginBottom: 6 }}>
<div style={{ display: "flex", color: "#f0b72f", fontSize: 16, fontWeight: 700 }}>อาวุธที่แนะนำ</div>
<div style={{ display: "flex", fontSize: 38, fontWeight: 700 }}>Weapons</div>
@@ -128,6 +142,7 @@ export async function GET(
</div>
</div>
))}
</div>
</div>,
{
width: 1000,
+20 -4
View File
@@ -1,5 +1,6 @@
/* eslint-disable @next/next/no-img-element */
import { ImageResponse } from "next/og";
import { join } from "node:path";
import sharp from "sharp";
import { rarityGradient } from "@/lib/catalog/rarity";
@@ -44,6 +45,14 @@ async function pngDataUrl(url: string): Promise<string | null> {
}
}
async function localPngDataUrl(fileName: string, size: number | null = 96): Promise<string> {
const image = sharp(join(process.cwd(), "public", "icon", fileName));
const png = await (size === null ? image : image.resize(size, size, { fit: "contain" }))
.png()
.toBuffer();
return `data:image/png;base64,${png.toString("base64")}`;
}
export async function GET(
_request: Request,
context: RouteContext<"/api/teams/[teamId]/image">,
@@ -58,8 +67,10 @@ export async function GET(
);
const seconds = Number(team.rotationSeconds);
const dps = seconds > 0 ? total / seconds : 0;
const fonts = await anuphanFonts;
const memberAssets = await Promise.all(
const [fonts, watermark, memberAssets] = await Promise.all([
anuphanFonts,
localPngDataUrl("Watermark.png", null),
Promise.all(
team.members.map(async (member) => {
const [character, weapon, artifact] = await Promise.all([
pngDataUrl(member.character.imageUrl),
@@ -68,7 +79,8 @@ export async function GET(
]);
return { character, weapon, artifact };
}),
);
),
]);
return new ImageResponse(
<div
@@ -83,8 +95,11 @@ export async function GET(
border: "2px solid #3aaaa2",
padding: "34px 36px",
fontFamily: "Anuphan",
position: "relative",
}}
>
<img src={watermark} alt="" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", opacity: 0.2 }} />
<div style={{ position: "relative", zIndex: 1, display: "flex", flex: 1, flexDirection: "column" }}>
<div style={{ display: "flex", flexDirection: "column", textAlign: "center" }}>
<div style={{ display: "flex", justifyContent: "center", fontSize: 42, fontWeight: 700 }}>
{team.guideName}
@@ -117,7 +132,7 @@ export async function GET(
{member.weapon && memberAssets[index].weapon ? (
<div style={{ position: "relative", display: "flex", width: 50, height: 50, overflow: "hidden", borderRadius: 10, background: rarityGradient(member.weapon.rarity) }}>
<img src={memberAssets[index].weapon} alt="" width={50} height={50} style={{ objectFit: "contain" }} />
<div style={{ position: "absolute", top: 2, right: 2, display: "flex", borderRadius: 4, background: "#f4fbfa", color: "#06171b", padding: "1px 4px", fontSize: 11, fontWeight: 700 }}>
<div style={{ position: "absolute", top: 2, left: 2, display: "flex", borderRadius: 4, background: "#f4fbfa", color: "#06171b", padding: "1px 4px", fontSize: 11, fontWeight: 700 }}>
R{member.refinement}
</div>
</div>
@@ -167,6 +182,7 @@ export async function GET(
<div style={{ display: "flex", justifyContent: "center", marginTop: 18, color: "#5c9995", fontSize: 14 }}>
By YT เกนชินไม่ใช่เกมมือถือ
</div>
</div>
</div>,
{
width: 550,
+6
View File
@@ -23,6 +23,9 @@
--color-success: var(--success);
--color-warning: var(--warning);
--color-info: var(--info);
--color-tier-gold: var(--tier-gold);
--color-tier-silver: var(--tier-silver);
--color-tier-copper: var(--tier-copper);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
@@ -73,6 +76,9 @@
--success: oklch(0.72 0.15 155);
--warning: oklch(0.8 0.15 82);
--info: oklch(0.72 0.12 235);
--tier-gold: oklch(0.8 0.15 82);
--tier-silver: oklch(0.82 0.02 260);
--tier-copper: oklch(0.65 0.12 55);
--border: oklch(0.96 0.01 88 / 12%);
--input: oklch(0.96 0.01 88 / 16%);
--ring: oklch(0.79 0.14 78);
+96 -14
View File
@@ -1,7 +1,19 @@
"use client";
import Image from "next/image";
import { GripVerticalIcon, PlusIcon, SparklesIcon, Trash2Icon } from "lucide-react";
import {
ChartNoAxesCombinedIcon,
GemIcon,
GripVerticalIcon,
ListOrderedIcon,
PlusIcon,
RefreshCcwIcon,
SparklesIcon,
SwordIcon,
TimerIcon,
Trash2Icon,
UserRoundIcon,
} from "lucide-react";
import { useState, type ReactNode } from "react";
import {
closestCenter,
@@ -31,6 +43,17 @@ import {
import { AutosaveStatus } from "@/components/admin/autosave-status";
import { MediaUploader } from "@/components/admin/media-uploader";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
@@ -60,6 +83,11 @@ import {
teamSectionSchema,
weaponSectionSchema,
} from "@/lib/guides/schemas";
import {
normalizeTargetStat,
targetStatDefaults,
targetStatOptions,
} from "@/lib/guides/stats";
import { supportsWeaponType } from "@/lib/catalog/lunaris";
import { rarityGradientClass } from "@/lib/catalog/rarity";
import {
@@ -98,7 +126,6 @@ export interface GuideSectionEditorProps {
};
}
const DEFAULT_TARGET_STATS = ["ATK", "EM", "ER", "CRIT Rate", "CRIT DMG"] as const;
const SELECTED_TOGGLE_CLASS = "aria-pressed:border-primary aria-pressed:ring-2 aria-pressed:ring-primary/30";
function CatalogPicker({ items, value, onChange, placeholder, rarityBackground = false }: { items: CatalogItem[]; value: string; onChange: (value: string) => void; placeholder: string; rarityBackground?: boolean }) {
@@ -285,9 +312,16 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas
const [circlet, setCirclet] = useState(profile?.circlet ?? []);
const [substats, setSubstats] = useState(profile?.substats ?? []);
const [targets, setTargets] = useState(() => {
const existing = profile?.targets ?? [];
const defaults = DEFAULT_TARGET_STATS.map((stat) => existing.find((target) => target.stat === stat) ?? { stat, value: "" });
return [...defaults, ...existing.filter((target) => !DEFAULT_TARGET_STATS.includes(target.stat as (typeof DEFAULT_TARGET_STATS)[number]))];
const existing = new Map(
(profile?.targets ?? []).flatMap((target) => {
const stat = normalizeTargetStat(target.stat);
return stat ? [[stat, target.value] as const] : [];
}),
);
return targetStatDefaults.map((target) => ({
stat: target.stat,
value: existing.get(target.stat) ?? target.value,
}));
});
const [talents, setTalents] = useState<TalentPriority[]>(() => {
const source: TalentPriority[] = profile?.talents.length
@@ -299,7 +333,7 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas
})));
});
const [note, setNote] = useState(profile?.note ?? "");
const value = { groups: groups.map((group) => ({ ...group, rows: group.rows.map((row) => ({ ...row, percent: Number(row.percent), note: row.note?.trim() || null })) })), sands, goblet, circlet, substats, targets: targets.filter((target) => target.stat.trim() && target.value.trim()), talents, note: note || null };
const value = { groups: groups.map((group) => ({ ...group, rows: group.rows.map((row) => ({ ...row, percent: Number(row.percent), note: row.note?.trim() || null })) })), sands, goblet, circlet, substats, targets: targets.filter((target) => target.value.trim()).map((target) => ({ stat: target.stat, value: target.value.trim() })), talents, note: note || null };
const autosave = useAutosave({ value, valid: artifactSectionSchema.safeParse({ ...value, expectedVersion: guide.version }).success, initialVersion: guide.version, persist: (next, expectedVersion) => saveArtifactSection(guide.id, { ...next, expectedVersion }) });
const orderedTalents = normalizeTalentPriorities(talents);
const handleTalentDragEnd = ({ active, over }: DragEndEvent) => {
@@ -313,7 +347,7 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas
<div className="flex flex-col gap-6">
<Card>
<CardHeader><CardTitle>Artifact tier list</CardTitle><CardDescription>จัดชุด Artifact ในกลุ่ม Late Game และ Early Game ระบบจะบันทึกอัตโนมัติ</CardDescription></CardHeader>
<CardContent className="flex flex-col gap-5">
<CardContent className="grid gap-5 md:grid-cols-2">
{groups.map((group, groupIndex) => <Card key={group.name} size="sm"><CardHeader><CardTitle>{group.name}</CardTitle></CardHeader><CardContent className="flex flex-col gap-3">{group.rows.map((row, rowIndex) => <div key={rowIndex} className="flex flex-col gap-3 rounded-lg border p-3"><div className="grid gap-3 lg:grid-cols-[1fr_1fr_8rem_auto]"><CatalogPicker items={artifacts} value={row.firstArtifactKey} placeholder="ค้นหาชุด Artifact แรก…" rarityBackground onChange={(firstArtifactKey) => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, firstArtifactKey } : candidate) } : item))} /><CatalogPicker items={artifacts} value={row.secondArtifactKey ?? ""} placeholder="ค้นหาชุดที่สอง (ถ้ามี)…" rarityBackground onChange={(secondArtifactKey) => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, secondArtifactKey: secondArtifactKey || null } : candidate) } : item))} /><Input type="number" min="0" step="0.01" aria-label="Artifact percent" value={row.percent} onChange={(event) => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: event.target.value } : candidate) } : item))} /><Button variant="ghost" size="icon" onClick={() => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.filter((_, inner) => inner !== rowIndex) } : item))}><Trash2Icon /><span className="sr-only">ลบ Artifact</span></Button></div><Field><FieldLabel htmlFor={`artifact-note-${groupIndex}-${rowIndex}`}>Note (optional)</FieldLabel><Textarea id={`artifact-note-${groupIndex}-${rowIndex}`} value={row.note ?? ""} onChange={(event) => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, note: event.target.value } : candidate) } : item))} /></Field></div>)}<Button variant="outline" onClick={() => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: [...item.rows, { firstArtifactKey: "", secondArtifactKey: null, percent: "100", note: "" }] } : item))}><PlusIcon data-icon="inline-start" />เพิ่มชุด Artifact</Button></CardContent></Card>)}
</CardContent>
</Card>
@@ -327,8 +361,18 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas
<StatSelect label="Substats" options={substatOptions} value={substats} max={substatOptions.length} onChange={setSubstats} />
<FieldSet>
<FieldLegend>Target Stats</FieldLegend>
{targets.map((target, index) => <div key={`${target.stat}-${index}`} className="grid gap-3 sm:grid-cols-[1fr_1fr_auto]"><Input value={target.stat} placeholder="ATK" aria-label={`Target stat ${index + 1}`} onChange={(event) => setTargets(targets.map((item, itemIndex) => itemIndex === index ? { ...item, stat: event.target.value } : item))} /><Input value={target.value} placeholder={target.stat === "ATK" ? "2200+" : "Recommended value"} aria-label={`${target.stat || "Target"} value`} onChange={(event) => setTargets(targets.map((item, itemIndex) => itemIndex === index ? { ...item, value: event.target.value } : item))} /><Button variant="ghost" size="icon" onClick={() => setTargets(targets.filter((_, itemIndex) => itemIndex !== index))}><Trash2Icon /><span className="sr-only">ลบเป้าหมาย</span></Button></div>)}
<Button variant="outline" onClick={() => setTargets([...targets, { stat: "", value: "" }])}><PlusIcon data-icon="inline-start" />เพิ่ม Target</Button>
<div className="flex flex-col gap-3">
{targets.map((target, index) => (
<div key={`${target.stat}-${index}`} className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<Select value={target.stat} onValueChange={(stat) => { if (stat) setTargets(targets.map((item, itemIndex) => itemIndex === index ? { ...item, stat } : item)); }}>
<SelectTrigger className="w-full" aria-label={`Target stat ${index + 1}`}><SelectValue /></SelectTrigger>
<SelectContent><SelectGroup>{targetStatOptions.map((option) => <SelectItem key={option} value={option} disabled={targets.some((item, itemIndex) => itemIndex !== index && item.stat === option)}>{option}</SelectItem>)}</SelectGroup></SelectContent>
</Select>
<Input value={target.value} placeholder="Value or text" aria-label={`${target.stat} value`} onChange={(event) => setTargets(targets.map((item, itemIndex) => itemIndex === index ? { ...item, value: event.target.value } : item))} />
</div>
))}
</div>
<FieldDescription>เลือกสเตตัสจากรายการ และกรอกค่าเป็นตัวเลข เปอร์เซ็นต์ หรือข้อความ เช่น -%</FieldDescription>
</FieldSet>
<FieldSet>
<FieldLegend>Talent Priority</FieldLegend>
@@ -438,14 +482,52 @@ function TeamEditor({ guide, characters, weapons, artifacts, initial }: EditorBa
</span>
</AccordionTrigger>
{dragHandle}
<Button type="button" variant="ghost" size="icon-sm" onClick={() => setTeams(teams.filter((item) => item.clientId !== team.clientId))}><Trash2Icon /><span className="sr-only">ลบ Team</span></Button>
<AlertDialog>
<AlertDialogTrigger render={<Button type="button" variant="ghost" size="icon-sm" />}>
<Trash2Icon /><span className="sr-only">ลบ Team</span>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>ลบ {team.title || `Team ${teamIndex + 1}`}?</AlertDialogTitle>
<AlertDialogDescription>ข้อมูล Team และตัวละครทั้ง 4 คนจะถูกลบออกจาก Guide</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>ยกเลิก</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={() => setTeams(teams.filter((item) => item.clientId !== team.clientId))}>ยืนยันลบ Team</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<AccordionContent className="pb-4">
<FieldGroup>
<Field><FieldLabel>ชื่อ Team</FieldLabel><Input value={team.title} onChange={(event) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, title: event.target.value } : item))} /></Field>
<div className="grid gap-4 md:grid-cols-2">{team.members.map((member, memberIndex) => { const character = characters.find((item) => item.key === member.characterKey); const compatibleWeapons = weapons.filter((weapon) => supportsWeaponType(character?.weaponType, weapon.weaponType)); return <Card key={memberIndex} size="sm"><CardHeader><CardTitle>ตัวละคร {memberIndex + 1}</CardTitle><CardDescription>{total ? `${((Number(member.damage) || 0) / total * 100).toFixed(2)}% ของ Team` : "0% ของ Team"}</CardDescription>{character ? <CardAction><Avatar size="lg"><AvatarImage src={character.imageUrl} alt={character.name} /><AvatarFallback>{character.name.slice(0, 2).toUpperCase()}</AvatarFallback></Avatar></CardAction> : null}</CardHeader><CardContent><FieldGroup><Field><FieldLabel>ตัวละคร</FieldLabel><CatalogPicker items={characters} value={member.characterKey} placeholder="ค้นหาชื่อตัวละคร…" onChange={(characterKey) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, characterKey, weaponKey: null, artifactKey: null, constellation: 0, refinement: 1 } : candidate) } : item))} /></Field><Field><FieldLabel>Damage</FieldLabel><Input type="number" min="0" step="0.01" value={member.damage} onChange={(event) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, damage: event.target.value } : candidate) } : item))} /></Field><Field><FieldLabel>Weapon</FieldLabel><CatalogPicker items={compatibleWeapons} value={member.weaponKey ?? ""} placeholder="ค้นหาอาวุธที่รองรับ…" rarityBackground onChange={(weaponKey) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, weaponKey: weaponKey || null } : candidate) } : item))} /></Field><Field><FieldLabel>Refinement</FieldLabel><ToggleGroup className="flex-wrap justify-start" variant="outline" value={[`R${member.refinement}`]} onValueChange={(selected) => { const refinement = selected[0]; if (refinement) setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, refinement: Number(refinement.slice(1)) } : candidate) } : item)); }}>{[1, 2, 3, 4, 5].map((refinement) => <ToggleGroupItem className={SELECTED_TOGGLE_CLASS} key={refinement} value={`R${refinement}`}>R{refinement}</ToggleGroupItem>)}</ToggleGroup></Field><Field><FieldLabel>Artifact set</FieldLabel><CatalogPicker items={artifacts} value={member.artifactKey ?? ""} placeholder="ค้นหาชุด Artifact…" rarityBackground onChange={(artifactKey) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, artifactKey: artifactKey || null } : candidate) } : item))} /></Field><Field><FieldLabel>Constellation</FieldLabel><ToggleGroup className="flex-wrap justify-start" variant="outline" value={[`C${member.constellation}`]} onValueChange={(selected) => { const constellation = selected[0]; if (constellation) setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, constellation: Number(constellation.slice(1)) } : candidate) } : item)); }}>{[0, 1, 2, 3, 4, 5, 6].map((constellation) => <ToggleGroupItem className={SELECTED_TOGGLE_CLASS} key={constellation} value={`C${constellation}`}>C{constellation}</ToggleGroupItem>)}</ToggleGroup></Field></FieldGroup></CardContent></Card>; })}</div>
<Field><FieldLabel>Rotation (seconds)</FieldLabel><Input type="number" min="0.01" step="0.01" value={team.rotationSeconds} onChange={(event) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, rotationSeconds: event.target.value } : item))} /></Field>
<Field><FieldLabel>Combo</FieldLabel><Textarea value={team.combo} onChange={(event) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, combo: event.target.value } : item))} /></Field>
<Field><FieldLabel className="flex items-center gap-2"><UserRoundIcon />ชื่อ Team</FieldLabel><Input value={team.title} onChange={(event) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, title: event.target.value } : item))} /></Field>
<div className="grid gap-4 md:grid-cols-2">
{team.members.map((member, memberIndex) => {
const character = characters.find((item) => item.key === member.characterKey);
const compatibleWeapons = weapons.filter((weapon) => supportsWeaponType(character?.weaponType, weapon.weaponType));
return (
<Card key={memberIndex} size="sm">
<CardHeader>
<CardTitle>ตัวละคร {memberIndex + 1}</CardTitle>
<CardDescription>{total ? `${((Number(member.damage) || 0) / total * 100).toFixed(2)}% ของ Team` : "0% ของ Team"}</CardDescription>
{character ? <CardAction><Avatar size="lg"><AvatarImage src={character.imageUrl} alt={character.name} /><AvatarFallback>{character.name.slice(0, 2).toUpperCase()}</AvatarFallback></Avatar></CardAction> : null}
</CardHeader>
<CardContent>
<FieldGroup>
<Field><FieldLabel className="flex items-center gap-2"><UserRoundIcon />ตัวละคร</FieldLabel><CatalogPicker items={characters} value={member.characterKey} placeholder="ค้นหาชื่อตัวละคร…" onChange={(characterKey) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, characterKey, weaponKey: null, artifactKey: null, constellation: 0, refinement: 1 } : candidate) } : item))} /></Field>
<Field><FieldLabel className="flex items-center gap-2"><ChartNoAxesCombinedIcon />Damage</FieldLabel><Input type="number" min="0" step="0.01" value={member.damage} onChange={(event) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, damage: event.target.value } : candidate) } : item))} /></Field>
<Field><FieldLabel className="flex items-center gap-2"><SwordIcon />Weapon</FieldLabel><CatalogPicker items={compatibleWeapons} value={member.weaponKey ?? ""} placeholder="ค้นหาอาวุธที่รองรับ…" rarityBackground onChange={(weaponKey) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, weaponKey: weaponKey || null } : candidate) } : item))} /></Field>
<Field><FieldLabel className="flex items-center gap-2"><RefreshCcwIcon />Refinement</FieldLabel><ToggleGroup className="flex-wrap justify-start" variant="outline" value={[`R${member.refinement}`]} onValueChange={(selected) => { const refinement = selected[0]; if (refinement) setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, refinement: Number(refinement.slice(1)) } : candidate) } : item)); }}>{[1, 2, 3, 4, 5].map((refinement) => <ToggleGroupItem className={SELECTED_TOGGLE_CLASS} key={refinement} value={`R${refinement}`}>R{refinement}</ToggleGroupItem>)}</ToggleGroup></Field>
<Field><FieldLabel className="flex items-center gap-2"><GemIcon />Artifact set</FieldLabel><CatalogPicker items={artifacts} value={member.artifactKey ?? ""} placeholder="ค้นหาชุด Artifact…" rarityBackground onChange={(artifactKey) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, artifactKey: artifactKey || null } : candidate) } : item))} /></Field>
<Field><FieldLabel className="flex items-center gap-2"><SparklesIcon />Constellation</FieldLabel><ToggleGroup className="flex-wrap justify-start" variant="outline" value={[`C${member.constellation}`]} onValueChange={(selected) => { const constellation = selected[0]; if (constellation) setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, constellation: Number(constellation.slice(1)) } : candidate) } : item)); }}>{[0, 1, 2, 3, 4, 5, 6].map((constellation) => <ToggleGroupItem className={SELECTED_TOGGLE_CLASS} key={constellation} value={`C${constellation}`}>C{constellation}</ToggleGroupItem>)}</ToggleGroup></Field>
</FieldGroup>
</CardContent>
</Card>
);
})}
</div>
<Field><FieldLabel className="flex items-center gap-2"><TimerIcon />Rotation (seconds)</FieldLabel><Input type="number" min="0.01" step="0.01" value={team.rotationSeconds} onChange={(event) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, rotationSeconds: event.target.value } : item))} /></Field>
<Field><FieldLabel className="flex items-center gap-2"><ListOrderedIcon />Combo</FieldLabel><Textarea value={team.combo} onChange={(event) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, combo: event.target.value } : item))} /></Field>
</FieldGroup>
</AccordionContent>
</>}</SortableTeamItem>
+35 -29
View File
@@ -1,9 +1,9 @@
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
} from "@/components/ui/pagination";
import Image from "next/image";
import Link from "next/link";
import { ScrollableNav } from "@/components/public/scrollable-nav";
import { navigationIconPath } from "@/lib/navigation";
import { cn } from "@/lib/utils";
const sections = [
{ slug: "weapon", label: "Weapons" },
@@ -21,29 +21,35 @@ export function GuideSectionNav({
current: "overview" | (typeof sections)[number]["slug"];
}) {
return (
<Pagination className="justify-start overflow-x-auto rounded-xl border bg-card p-2">
<PaginationContent className="whitespace-nowrap">
<PaginationItem>
<PaginationLink
href={`/admin/${characterKey}`}
size="default"
isActive={current === "overview"}
<ScrollableNav
ariaLabel="เมนูแก้ไข Guide"
className="w-full rounded-xl border bg-card p-2"
>
<Link
href={`/admin/${characterKey}`}
className={cn(
"flex min-h-8 shrink-0 items-center rounded-md px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
current === "overview" ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
Overview
</Link>
{sections.map((section) => {
const iconPath = navigationIconPath(section.slug);
return (
<Link
key={section.slug}
href={`/admin/${characterKey}/${section.slug}`}
className={cn(
"flex min-h-8 shrink-0 items-center gap-2 rounded-md px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
current === section.slug ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
Overview
</PaginationLink>
</PaginationItem>
{sections.map((section) => (
<PaginationItem key={section.slug}>
<PaginationLink
href={`/admin/${characterKey}/${section.slug}`}
size="default"
isActive={current === section.slug}
>
{section.label}
</PaginationLink>
</PaginationItem>
))}
</PaginationContent>
</Pagination>
{iconPath ? <Image src={iconPath} alt="" width={20} height={20} className="size-5 object-contain" /> : null}
{section.label}
</Link>
);
})}
</ScrollableNav>
);
}
+9 -5
View File
@@ -1,3 +1,4 @@
import Image from "next/image";
import Link from "next/link";
import { ChevronLeftIcon, ScrollTextIcon } from "lucide-react";
@@ -6,6 +7,7 @@ import {
PublicBlockRenderer,
} from "@/components/public/block-renderer";
import { SectionDrawer } from "@/components/public/section-drawer";
import { ScrollableNav } from "@/components/public/scrollable-nav";
import { SiteHeader } from "@/components/public/site-header";
import { LiveRefresh } from "@/components/live-refresh";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
@@ -13,6 +15,7 @@ import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import type { PageSnapshot } from "@/lib/content/types";
import { cn } from "@/lib/utils";
import { navigationIconPath } from "@/lib/navigation";
export function GuideShell({ snapshot }: { snapshot: PageSnapshot }) {
const sections = collectGuideSections(snapshot.blocks);
@@ -46,9 +49,9 @@ export function GuideShell({ snapshot }: { snapshot: PageSnapshot }) {
</Badge>
) : null}
</div>
<nav
aria-label={`หน้าคู่มือ ${snapshot.character.nameTh}`}
className="flex min-h-11 gap-1 overflow-x-auto border-t [scrollbar-width:none]"
<ScrollableNav
ariaLabel={`หน้าคู่มือ ${snapshot.character.nameTh}`}
className="min-h-11 border-t"
>
{snapshot.pages.map((page) => (
<Link
@@ -56,15 +59,16 @@ export function GuideShell({ snapshot }: { snapshot: PageSnapshot }) {
href={`/${snapshot.character.slug}/${page.slug}`}
aria-current={page.id === snapshot.page.id ? "page" : undefined}
className={cn(
"relative flex min-h-11 shrink-0 items-center rounded-md px-3 text-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
"relative flex min-h-11 shrink-0 items-center gap-2 rounded-md px-3 text-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
page.id === snapshot.page.id &&
"font-medium text-foreground after:absolute after:inset-x-3 after:bottom-0 after:h-0.5 after:bg-primary",
)}
>
{navigationIconPath(page.slug) ? <Image src={navigationIconPath(page.slug)!} alt="" width={20} height={20} className="size-5 object-contain" /> : null}
{page.title}
</Link>
))}
</nav>
</ScrollableNav>
</div>
</div>
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function ScrollableNav({
ariaLabel,
children,
className,
}: {
ariaLabel: string;
children: ReactNode;
className?: string;
}) {
const navRef = useRef<HTMLElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const updateScrollState = useCallback(() => {
const nav = navRef.current;
if (!nav) return;
setCanScrollLeft(nav.scrollLeft > 0);
setCanScrollRight(nav.scrollLeft + nav.clientWidth < nav.scrollWidth - 1);
}, []);
useEffect(() => {
const nav = navRef.current;
if (!nav) return;
updateScrollState();
nav.addEventListener("scroll", updateScrollState, { passive: true });
window.addEventListener("resize", updateScrollState);
return () => {
nav.removeEventListener("scroll", updateScrollState);
window.removeEventListener("resize", updateScrollState);
};
}, [updateScrollState]);
const scroll = (direction: -1 | 1) => {
navRef.current?.scrollBy({ left: direction * navRef.current.clientWidth * 0.75, behavior: "smooth" });
};
return (
<div className={cn("flex items-center gap-1", className)}>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0 sm:hidden"
disabled={!canScrollLeft}
onClick={() => scroll(-1)}
aria-label="เลื่อนเมนูไปทางซ้าย"
>
<ChevronLeftIcon />
</Button>
<nav
ref={navRef}
aria-label={ariaLabel}
className="flex min-w-0 flex-1 gap-1 overflow-x-auto [scrollbar-width:none]"
>
{children}
</nav>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0 sm:hidden"
disabled={!canScrollRight}
onClick={() => scroll(1)}
aria-label="เลื่อนเมนูไปทางขวา"
>
<ChevronRightIcon />
</Button>
</div>
);
}
+56 -28
View File
@@ -15,11 +15,14 @@ import {
import { Kbd } from "@/components/ui/kbd";
import { Separator } from "@/components/ui/separator";
import { SiteHeader } from "@/components/public/site-header";
import { ScrollableNav } from "@/components/public/scrollable-nav";
import { ElementIcon } from "@/components/public/element-icon";
import { LiveRefresh } from "@/components/live-refresh";
import { rarityGradientClass, rarityTextClass } from "@/lib/catalog/rarity";
import { isCustomTravelerKey } from "@/lib/catalog/travelers";
import type { PublicGuide } from "@/lib/guides/queries";
import { navigationIconPath } from "@/lib/navigation";
import { normalizeTargetStat, statPriorityColorClass, targetStatIconName } from "@/lib/guides/stats";
import { normalizeTalentPriorities, talentRelationSymbol } from "@/lib/guides/talents";
import { cn } from "@/lib/utils";
@@ -87,7 +90,10 @@ export function StructuredGuideShell({
) : null}
</div>
</div>
<nav className="mx-auto flex min-h-11 w-full max-w-7xl gap-1 overflow-x-auto px-4 sm:px-6">
<ScrollableNav
ariaLabel={`เมนูคู่มือ ${guide.name}`}
className="mx-auto min-h-11 w-full max-w-7xl px-4 sm:px-6"
>
<Link
href={`/${guide.slug}`}
className={cn(
@@ -102,14 +108,15 @@ export function StructuredGuideShell({
key={item.id}
href={`/${guide.slug}/${item.slug}`}
className={cn(
"flex shrink-0 items-center whitespace-nowrap rounded-md px-3 text-sm text-muted-foreground",
"flex min-h-8 shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm text-muted-foreground",
section === item.slug && "bg-muted text-foreground",
)}
>
{navigationIconPath(item.slug) ? <Image src={navigationIconPath(item.slug)!} alt="" width={20} height={20} className="size-5 object-contain" /> : null}
{item.title}
</Link>
))}
</nav>
</ScrollableNav>
</div>
<main className="mx-auto w-full max-w-7xl p-4 sm:p-6 lg:p-10">
{section ? (
@@ -364,7 +371,12 @@ function ArtifactSection({ guide }: { guide: PublicGuide }) {
{profile.substats.length ? (
<Card>
<CardContent className="py-6 text-center text-lg">
<strong>Stat Priority:</strong> {profile.substats.map(statLabel).join(" > ")}
<strong>Stat Priority:</strong>{" "}
{profile.substats.map((stat, index) => (
<span key={`${stat}-${index}`} className={statPriorityColorClass(index)}>
{index ? " > " : ""}{statLabel(stat)}
</span>
))}
</CardContent>
</Card>
) : null}
@@ -374,15 +386,23 @@ function ArtifactSection({ guide }: { guide: PublicGuide }) {
{profile.targets.length ? (
<div>
<p className="mb-2 text-sm font-medium">Target Stats</p>
{profile.targets.map((target) => (
<div className="flex flex-col gap-1">
{profile.targets.map((target, index) => (
<div
key={`${target.stat}-${target.value}`}
className="flex justify-between py-1 text-sm"
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>{target.stat}</span>
<strong>{target.value}</strong>
<span className="flex min-w-0 items-center gap-2">
{targetStatIconName(target.stat) ? <Image src={`/icon/stats/${targetStatIconName(target.stat)}.webp`} alt="" width={28} height={28} className="size-7 shrink-0 object-contain" /> : null}
<span>{normalizeTargetStat(target.stat) ?? target.stat}</span>
</span>
<strong className="shrink-0">{target.value}</strong>
</div>
))}
</div>
</div>
) : null}
{profile.talents.length ? (
@@ -440,7 +460,15 @@ function ArtifactStat({ iconSrc, label, values }: { iconSrc: string; label: stri
<Image src={iconSrc} alt="" width={48} height={48} className="size-12 object-contain" />
<p className="font-semibold">{label}</p>
<Separator />
<p className="text-sm leading-6 sm:text-base">{values.length ? values.map(statLabel).join(" > ") : "—"}</p>
<div className="flex max-w-full flex-col items-center gap-1 text-sm leading-6 sm:text-base">
{values.length
? values.map((value, index) => (
<span key={`${value}-${index}`} className={cn("max-w-full overflow-x-auto whitespace-nowrap", statPriorityColorClass(index))}>
{statLabel(value)}
</span>
))
: <span>—</span>}
</div>
</div>
);
}
@@ -455,24 +483,24 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {
<div className="grid gap-4 md:grid-cols-2">
{guide.constellations.map((row) => (
<Card key={row.level}>
<CardHeader>
<CardTitle className={cn("text-3xl text-white", row.isGreat && "text-warning")}>
{metric(row.cumulativePercent)}
</CardTitle>
<CardDescription className="text-base font-medium text-white">
C{row.level} · {row.name}
</CardDescription>
<CardHeader className="flex flex-row items-center gap-4">
{row.iconUrl ? (
<CardAction>
<Image
src={row.iconUrl}
alt=""
width={48}
height={48}
className="rounded-lg bg-muted"
/>
</CardAction>
<Image
src={row.iconUrl}
alt=""
width={64}
height={64}
className="size-16 shrink-0 rounded-lg bg-muted object-contain"
/>
) : null}
<div className="min-w-0">
<CardTitle className={cn("text-3xl text-white", row.isGreat && "text-warning")}>
{metric(row.cumulativePercent)}
</CardTitle>
<CardDescription className="text-base font-medium text-white">
C{row.level} · {row.name}
</CardDescription>
</div>
</CardHeader>
<CardContent>
<p className="whitespace-pre-line leading-7">{row.detail}</p>
@@ -542,9 +570,9 @@ function TeamSection({ guide }: { guide: PublicGuide }) {
src={member.weapon.imageUrl}
alt={member.weapon.name}
fill
className="object-contain"
className="object-contain p-1"
/>
<Kbd className="absolute top-0 right-0">
<Kbd className="absolute top-0 left-0">
R{member.refinement}
</Kbd>
</div>
@@ -562,7 +590,7 @@ function TeamSection({ guide }: { guide: PublicGuide }) {
src={member.artifact.imageUrl}
alt={member.artifact.name}
fill
className="object-contain"
className="object-contain p-1"
/>
</div>
) : (
+13
View File
@@ -35,6 +35,19 @@ describe("structured guide validation", () => {
expect(artifactSectionSchema.safeParse({ ...base, groups: [{ name: "Late Game", rows: [{ ...row, note: "x".repeat(2_001) }] }, artifactGroups[1]] }).success).toBe(false);
});
it("accepts fixed target stats with text values", () => {
const base = { expectedVersion: 1, groups: artifactGroups, sands: [], goblet: [], circlet: [], substats: [], talents: [], note: null };
expect(artifactSectionSchema.safeParse({
...base,
targets: [
{ stat: "ER", value: "100%" },
{ stat: "CRIT RATE", value: "5%" },
{ stat: "CRIT DAMAGE", value: "-%" },
],
}).success).toBe(true);
expect(artifactSectionSchema.safeParse({ ...base, targets: [{ stat: "Energy Recharge", value: "100%" }] }).success).toBe(false);
});
it("requires the fixed Late Game and Early Game artifact groups", () => {
const base = { expectedVersion: 1, sands: [], goblet: [], circlet: [], substats: [], targets: [], talents: [], note: null };
expect(artifactSectionSchema.safeParse({ ...base, groups: artifactGroups }).success).toBe(true);
+3 -1
View File
@@ -1,5 +1,7 @@
import * as z from "zod";
import { targetStatOptions } from "@/lib/guides/stats";
export const guideSectionKinds = ["weapon", "artifact", "constellations", "team"] as const;
export type GuideSectionKind = (typeof guideSectionKinds)[number];
@@ -62,7 +64,7 @@ export const artifactSectionSchema = z.strictObject({
circlet: z.array(z.enum(mainStatOptions.circlet)).max(2),
substats: z.array(z.enum(substatOptions)).max(substatOptions.length),
targets: z.array(z.strictObject({
stat: z.string().trim().min(1).max(80),
stat: z.enum(targetStatOptions),
value: z.string().trim().min(1).max(80),
})).max(20),
talents: z.array(z.strictObject({
+55
View File
@@ -0,0 +1,55 @@
export const targetStatOptions = [
"HP",
"ATK",
"DEF",
"EM",
"ER",
"CRIT RATE",
"CRIT DAMAGE",
] as const;
export type TargetStat = (typeof targetStatOptions)[number];
export const targetStatDefaults: Array<{ stat: TargetStat; value: string }> = [
{ stat: "HP", value: "" },
{ stat: "ATK", value: "" },
{ stat: "DEF", value: "" },
{ stat: "EM", value: "" },
{ stat: "ER", value: "100%" },
{ stat: "CRIT RATE", value: "5%" },
{ stat: "CRIT DAMAGE", value: "50%" },
];
const legacyTargetStatNames: Record<string, TargetStat> = {
"CRIT Rate": "CRIT RATE",
"CRIT DMG": "CRIT DAMAGE",
};
export function normalizeTargetStat(value: string): TargetStat | null {
const normalized = legacyTargetStatNames[value] ?? value;
return targetStatOptions.includes(normalized as TargetStat)
? (normalized as TargetStat)
: null;
}
export function targetStatIconName(value: string): string | null {
const stat = normalizeTargetStat(value);
if (!stat) return null;
return stat === "CRIT RATE"
? "critrate"
: stat === "CRIT DAMAGE"
? "critdmg"
: stat.toLowerCase();
}
export function statPriorityColorClass(index: number): string {
return index === 0
? "text-tier-gold"
: index === 1
? "text-tier-silver"
: "text-tier-copper";
}
export function statPriorityColor(index: number): string {
return index === 0 ? "#e0b35f" : index === 1 ? "#c7cbd3" : "#bd8055";
}
+7
View File
@@ -0,0 +1,7 @@
export function navigationIconPath(slug: string): string | null {
if (slug === "weapon") return "/icon/nav/Weapon.png";
if (slug === "artifact") return "/icon/nav/Artifact.webp";
if (slug === "constellations") return "/icon/nav/Constellations.webp";
if (slug === "team") return "/icon/nav/Team-calc.webp";
return null;
}