feat : fix bug
CI / Verify (push) Successful in 1m0s
CI / Build immutable images and deploy (push) Successful in 1m40s

This commit is contained in:
2026-08-30 12:30:12 +00:00 Unverified
parent 811678fd52
commit 4335b54d49
13 changed files with 7161 additions and 49 deletions
+1 -1
View File
@@ -22,5 +22,5 @@ export default async function StructuredEditorPage({ params }: { params: Promise
const [catalog, content] = await Promise.all([getActiveCatalog(), getPublicGuide(guide.slug)]);
if (!catalog || !content) notFound();
const guideCharacter = catalog.characters.find((item) => item.key === guide.characterKey);
return <div className="min-h-svh"><AdminHeader /><main className="mx-auto flex w-full max-w-6xl flex-col gap-6 p-4 sm:p-6 lg:p-8"><header className="flex items-center gap-4"><Button variant="outline" size="icon" render={<Link href={`/admin/${character}`} />} nativeButton={false} aria-label="กลับหน้า Guide"><ArrowLeftIcon /></Button><div className="min-w-0"><p className="text-sm text-muted-foreground">{guide.name}</p><h1 className="whitespace-nowrap font-heading text-3xl font-semibold">{guide.sections.find((section) => section.slug === page)?.title ?? page}</h1></div></header><GuideSectionNav characterKey={guide.characterKey} current={page as (typeof sections)[number]} /><GuideSectionEditor section={page as (typeof sections)[number]} guide={guide} weapons={catalog.weapons} artifacts={catalog.artifacts} characters={catalog.characters} catalogConstellations={guideCharacter?.constellations ?? []} catalogTalents={guideCharacter?.talents ?? []} initial={{ weapons: content.weapons, artifactGroups: content.artifactGroups.map((group) => ({ id: group.id, name: group.name, rows: group.rows.map((row) => ({ firstArtifactKey: row.firstArtifactKey, secondArtifactKey: row.secondArtifactKey, percent: row.percent })) })), artifactProfile: content.artifactProfile, constellations: content.constellations, teams: content.teams.map((team) => ({ title: team.title, rotationSeconds: team.rotationSeconds, combo: team.combo, members: team.members.map((member) => ({ characterKey: member.characterKey, weaponKey: member.weaponKey, artifactKey: member.artifactKey, constellation: member.constellation, refinement: member.refinement, damage: member.damage })) })), extras: content.extras.map((section) => ({ title: section.title, rows: section.rows.map((row) => ({ mediaId: row.mediaId, heading: row.heading, body: row.body, percent: row.percent, imageUrl: row.imageUrl })) })) }} /></main></div>;
return <div className="min-h-svh"><AdminHeader /><main className="mx-auto flex w-full max-w-6xl flex-col gap-6 p-4 sm:p-6 lg:p-8"><header className="flex items-center gap-4"><Button variant="outline" size="icon" render={<Link href={`/admin/${character}`} />} nativeButton={false} aria-label="กลับหน้า Guide"><ArrowLeftIcon /></Button><div className="min-w-0"><p className="text-sm text-muted-foreground">{guide.name}</p><h1 className="whitespace-nowrap font-heading text-3xl font-semibold">{guide.sections.find((section) => section.slug === page)?.title ?? page}</h1></div></header><GuideSectionNav characterKey={guide.characterKey} current={page as (typeof sections)[number]} /><GuideSectionEditor section={page as (typeof sections)[number]} guide={guide} weapons={catalog.weapons} artifacts={catalog.artifacts} characters={catalog.characters} catalogConstellations={guideCharacter?.constellations ?? []} catalogTalents={guideCharacter?.talents ?? []} initial={{ weapons: content.weapons, artifactGroups: content.artifactGroups.map((group) => ({ id: group.id, name: group.name, rows: group.rows.map((row) => ({ firstArtifactKey: row.firstArtifactKey, secondArtifactKey: row.secondArtifactKey, percent: row.percent, note: row.note })) })), artifactProfile: content.artifactProfile, constellations: content.constellations, teams: content.teams.map((team) => ({ id: team.id, title: team.title, rotationSeconds: team.rotationSeconds, combo: team.combo, members: team.members.map((member) => ({ characterKey: member.characterKey, weaponKey: member.weaponKey, artifactKey: member.artifactKey, constellation: member.constellation, refinement: member.refinement, damage: member.damage })) })), extras: content.extras.map((section) => ({ title: section.title, rows: section.rows.map((row) => ({ mediaId: row.mediaId, heading: row.heading, body: row.body, percent: row.percent, imageUrl: row.imageUrl })) })) }} /></main></div>;
}
@@ -70,8 +70,9 @@ export async function GET(
const { guideId } = await context.params;
const guide = await getPublicArtifactExport(guideId);
if (!guide) return new Response("Guide not found", { status: 404 });
const talents = normalizeTalentPriorities(guide.profile?.talents ?? []);
const [fonts, groupAssets, statIconAssets] = await Promise.all([
const [fonts, groupAssets, statIconAssets, talentAssets] = await Promise.all([
anuphanFonts,
Promise.all(
guide.groups.map((group) =>
@@ -88,18 +89,22 @@ export async function GET(
localPngDataUrl("Icon_Goblet_of_Eonothem.webp"),
localPngDataUrl("Icon_Circlet_of_Logos.webp"),
]),
Promise.all(talents.map((talent) => {
const catalogTalent = guide.character.talents.find((item) => item.type === talent.type);
return catalogTalent?.iconUrl ? pngDataUrl(catalogTalent.iconUrl) : null;
})),
]);
const profile = guide.profile;
const leftHeight = guide.groups.reduce((sum, group) => sum + 68 + group.rows.length * 76, 0) + Math.max(0, guide.groups.length - 1) * 16;
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 ? 58 : 0) + (profile.note ? Math.max(48, Math.ceil(profile.note.length / 58) * 24) : 0)
? 60 + profile.targets.length * 30 + (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
: 0;
const height = Math.max(520, 190 + Math.max(leftHeight, rightHeight));
const talents = normalizeTalentPriorities(profile?.talents ?? []);
return new ImageResponse(
<div
@@ -143,7 +148,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: 76, 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) }}>
@@ -156,8 +161,11 @@ export async function GET(
</div>
) : null}
</div>
<div style={{ display: "flex", flex: 1, fontSize: 18, fontWeight: 700 }}>
{rowIndex + 1}. {row.first.name}{row.second ? ` + ${row.second.name}` : ""}
<div style={{ display: "flex", flex: 1, flexDirection: "column", gap: 5 }}>
<div style={{ display: "flex", fontSize: 18, fontWeight: 700 }}>
{rowIndex + 1}. {row.first.name}{row.second ? ` + ${row.second.name}` : ""}
</div>
{row.note ? <div style={{ display: "flex", color: "#b9b9c2", fontSize: 14, lineHeight: 1.55 }}>{row.note}</div> : null}
</div>
<div style={{ display: "flex", fontSize: 26, fontWeight: 700 }}>
{metric(row.percent)}
@@ -215,9 +223,10 @@ export async function GET(
{talents.length ? (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<div style={{ display: "flex", color: "#b9b9c2", fontSize: 14, fontWeight: 700 }}>ลำดับการอัพพรสวรรค์</div>
<div style={{ display: "flex", gap: 10, fontSize: 17, fontWeight: 700 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, fontSize: 17, fontWeight: 700 }}>
{talents.map((talent, index) => (
<div key={talent.type} style={{ display: "flex", gap: 10 }}>
<div key={talent.type} style={{ display: "flex", alignItems: "center", gap: 8 }}>
{talentAssets[index] ? <img src={talentAssets[index]!} alt="" width={42} height={42} style={{ objectFit: "contain" }} /> : null}
<span>{talent.type}{talent.level ? ` Lv.${talent.level}+` : ""}</span>
{index < talents.length - 1 ? <span>{talent.relationToNext === "gte" ? ">=" : ">"}</span> : null}
</div>
@@ -56,7 +56,8 @@ export async function GET(
anuphanFonts,
Promise.all(guide.weapons.map((row) => pngDataUrl(row.weapon.imageUrl))),
]);
const height = Math.max(360, 190 + guide.weapons.length * 126 + Math.max(0, guide.weapons.length - 1) * 14);
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);
return new ImageResponse(
<div
@@ -81,7 +82,7 @@ export async function GET(
key={row.id}
style={{
display: "flex",
minHeight: 112,
minHeight: weaponHeights[index],
overflow: "hidden",
borderRadius: 14,
background: "white",
@@ -104,8 +105,11 @@ export async function GET(
) : (
<div style={{ display: "flex", width: 92, height: 92, alignItems: "center", justifyContent: "center", fontSize: 32 }}>{index + 1}</div>
)}
<div style={{ display: "flex", fontSize: 24, fontWeight: 700 }}>
{index + 1}. {row.weapon.name} R{row.refinement}
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
<div style={{ display: "flex", fontSize: 24, fontWeight: 700 }}>
{index + 1}. {row.weapon.name} R{row.refinement}
</div>
{row.note ? <div style={{ display: "flex", fontSize: 14, lineHeight: 1.55 }}>{row.note}</div> : null}
</div>
</div>
<div style={{ display: "flex", flex: 1, alignItems: "center", justifyContent: "space-around", gap: 12, color: "#17171c", padding: "12px 16px" }}>
+92 -30
View File
@@ -1,8 +1,8 @@
"use client";
import Image from "next/image";
import { ArrowDownIcon, ArrowUpIcon, GripVerticalIcon, PlusIcon, SparklesIcon, Trash2Icon } from "lucide-react";
import { useState } from "react";
import { GripVerticalIcon, PlusIcon, SparklesIcon, Trash2Icon } from "lucide-react";
import { useState, type ReactNode } from "react";
import {
closestCenter,
DndContext,
@@ -13,6 +13,7 @@ import {
type DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
@@ -71,13 +72,13 @@ import { cn } from "@/lib/utils";
type CatalogItem = { key: string; name: string; imageUrl: string; rarity?: number | null; weaponType?: string | null };
type Guide = { id: string; characterKey: string; version: number; name: string };
type WeaponValue = { weaponKey: string; refinement: number; overallPercent: string | null; personalPercent: string | null; weapon: CatalogItem };
type ArtifactValue = { id: string; name: string; rows: Array<{ firstArtifactKey: string; secondArtifactKey: string | null; percent: string }> };
type WeaponValue = { id: string; weaponKey: string; refinement: number; overallPercent: string | null; personalPercent: string | null; note: string | null; weapon: CatalogItem };
type ArtifactValue = { id: string; name: string; rows: Array<{ firstArtifactKey: string; secondArtifactKey: string | null; percent: string; note: string | null }> };
type ArtifactProfile = { sands: string[]; goblet: string[]; circlet: string[]; substats: string[]; targets: Array<{ stat: string; value: string }>; talents: TalentPriority[]; note: string | null } | null;
type ConstellationValue = { level: number; detail: string; cumulativePercent: string; isGreat: boolean };
type CatalogConstellation = { level: number; name: string; description: string; iconUrl: string | null };
type CatalogTalent = { type: "NA" | "E" | "Q"; name: string; iconUrl: string | null };
type TeamValue = { title: string; rotationSeconds: string; combo: string; members: Array<{ characterKey: string; weaponKey: string | null; artifactKey: string | null; constellation: number; refinement: number; damage: string }> };
type TeamValue = { id: string; title: string; rotationSeconds: string; combo: string; members: Array<{ characterKey: string; weaponKey: string | null; artifactKey: string | null; constellation: number; refinement: number; damage: string }> };
export interface GuideSectionEditorProps {
section: "weapon" | "artifact" | "constellations" | "team" | "extra";
@@ -183,15 +184,41 @@ function SortableTalentCard({
);
}
function RowActions({ index, length, move, remove }: { index: number; length: number; move: (from: number, to: number) => void; remove?: () => void }) {
return <div className="flex gap-1"><Button type="button" variant="ghost" size="icon-sm" disabled={index === 0} onClick={() => move(index, index - 1)}><ArrowUpIcon /><span className="sr-only">เลื่อนขึ้น</span></Button><Button type="button" variant="ghost" size="icon-sm" disabled={index === length - 1} onClick={() => move(index, index + 1)}><ArrowDownIcon /><span className="sr-only">เลื่อนลง</span></Button>{remove ? <Button type="button" variant="ghost" size="icon-sm" onClick={remove}><Trash2Icon /><span className="sr-only">ลบ</span></Button> : null}</div>;
function SortableWeaponCard({ id, children }: { id: string; children: (dragHandle: ReactNode) => ReactNode }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn(isDragging && "opacity-60")}
>
{children(
<Button type="button" variant="ghost" size="icon-sm" className="touch-none" {...attributes} {...listeners}>
<GripVerticalIcon />
<span className="sr-only">ลากเพื่อจัดลำดับอาวุธ</span>
</Button>,
)}
</div>
);
}
function moveItem<T>(items: T[], from: number, to: number): T[] {
const next = [...items];
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next;
function SortableTeamItem({ id, children }: { id: string; children: (dragHandle: ReactNode) => ReactNode }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
return (
<AccordionItem
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
value={id}
className={cn("rounded-xl border px-4", isDragging && "opacity-60")}
>
{children(
<Button type="button" variant="ghost" size="icon-sm" className="touch-none" {...attributes} {...listeners}>
<GripVerticalIcon />
<span className="sr-only">ลากเพื่อจัดลำดับ Team</span>
</Button>,
)}
</AccordionItem>
);
}
export function GuideSectionEditor(props: GuideSectionEditorProps) {
@@ -211,16 +238,34 @@ type EditorBase = GuideSectionEditorProps;
function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
const characterWeaponType = characters.find((character) => character.key === guide.characterKey)?.weaponType;
const compatibleWeapons = weapons.filter((weapon) => supportsWeaponType(characterWeaponType, weapon.weaponType));
const [rows, setRows] = useState(initial.weapons.map((row) => ({ weaponKey: row.weaponKey, refinement: row.refinement, overallPercent: row.overallPercent ?? "", personalPercent: row.personalPercent ?? "" })));
const value = { rows: rows.map((row) => ({ ...row, overallPercent: row.overallPercent === "" ? null : Number(row.overallPercent), personalPercent: row.personalPercent === "" ? null : Number(row.personalPercent) })) };
const weaponSensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const [rows, setRows] = useState(initial.weapons.map((row) => ({ clientId: row.id, weaponKey: row.weaponKey, refinement: row.refinement, overallPercent: row.overallPercent ?? "", personalPercent: row.personalPercent ?? "", note: row.note ?? "" })));
const value = { rows: rows.map((row) => ({ weaponKey: row.weaponKey, refinement: row.refinement, overallPercent: row.overallPercent === "" ? null : Number(row.overallPercent), personalPercent: row.personalPercent === "" ? null : Number(row.personalPercent), note: row.note.trim() || null })) };
const autosave = useAutosave({ value, valid: weaponSectionSchema.safeParse({ ...value, expectedVersion: guide.version }).success, initialVersion: guide.version, persist: (next, expectedVersion) => saveWeaponSection(guide.id, { ...next, expectedVersion }) });
const handleWeaponDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return;
setRows((current) => {
const from = current.findIndex((row) => row.clientId === active.id);
const to = current.findIndex((row) => row.clientId === over.id);
return from === -1 || to === -1 ? current : arrayMove(current, from, to);
});
};
return (
<Card>
<CardHeader><CardTitle>Weapon tier list</CardTitle><CardDescription>เรียงอาวุธจากดีที่สุดลงมา และกรอกเปอร์เซ็นต์อย่างน้อยหนึ่งช่อง ระบบจะบันทึกอัตโนมัติ</CardDescription></CardHeader>
<CardHeader><CardTitle>Weapon tier list</CardTitle><CardDescription>ลากเพื่อเรียงอาวุธจากดีที่สุดลงมา และกรอกเปอร์เซ็นต์อย่างน้อยหนึ่งช่อง ระบบจะบันทึกอัตโนมัติ</CardDescription></CardHeader>
<CardContent className="flex flex-col gap-4">
{rows.map((row, rowIndex) => { const selectedWeapon = compatibleWeapons.find((weapon) => weapon.key === row.weaponKey); return <Card key={rowIndex} size="sm"><CardHeader><CardTitle className="flex items-center gap-3">{selectedWeapon ? <div className={cn("relative size-10 shrink-0 overflow-hidden rounded-lg", rarityGradientClass(selectedWeapon.rarity))}><Image src={selectedWeapon.imageUrl} alt="" fill className="object-contain" /></div> : null}<span>อันดับ {rowIndex + 1}</span></CardTitle><CardAction><RowActions index={rowIndex} length={rows.length} move={(from, to) => setRows(moveItem(rows, from, to))} remove={() => setRows(rows.filter((_, index) => index !== rowIndex))} /></CardAction></CardHeader><CardContent><FieldGroup><Field><FieldLabel>อาวุธ</FieldLabel><CatalogPicker items={compatibleWeapons} value={row.weaponKey} placeholder="ค้นหาชื่ออาวุธ…" rarityBackground onChange={(weaponKey) => setRows(rows.map((item, index) => index === rowIndex ? { ...item, weaponKey } : item))} /></Field><Field><FieldLabel>Refinement</FieldLabel><ToggleGroup variant="outline" value={[`R${row.refinement}`]} onValueChange={(selected) => { const next = selected[0]; if (next) setRows(rows.map((item, index) => index === rowIndex ? { ...item, refinement: Number(next.slice(1)) } : item)); }}>{[1, 2, 3, 4, 5].map((refinement) => <ToggleGroupItem className={SELECTED_TOGGLE_CLASS} key={refinement} value={`R${refinement}`}>R{refinement}</ToggleGroupItem>)}</ToggleGroup></Field><div className="grid gap-4 sm:grid-cols-2"><Field><FieldLabel>Overall %</FieldLabel><Input type="number" min="0" step="0.01" value={row.overallPercent} onChange={(event) => setRows(rows.map((item, index) => index === rowIndex ? { ...item, overallPercent: event.target.value } : item))} /></Field><Field><FieldLabel>Personal %</FieldLabel><Input type="number" min="0" step="0.01" value={row.personalPercent} onChange={(event) => setRows(rows.map((item, index) => index === rowIndex ? { ...item, personalPercent: event.target.value } : item))} /></Field></div></FieldGroup></CardContent></Card>; })}
<DndContext sensors={weaponSensors} collisionDetection={closestCenter} onDragEnd={handleWeaponDragEnd}>
<SortableContext items={rows.map((row) => row.clientId)} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-4">
{rows.map((row, rowIndex) => { const selectedWeapon = compatibleWeapons.find((weapon) => weapon.key === row.weaponKey); return <SortableWeaponCard key={row.clientId} id={row.clientId}>{(dragHandle) => <Card size="sm"><CardHeader><CardTitle className="flex items-center gap-3">{selectedWeapon ? <div className={cn("relative size-10 shrink-0 overflow-hidden rounded-lg", rarityGradientClass(selectedWeapon.rarity))}><Image src={selectedWeapon.imageUrl} alt="" fill className="object-contain" /></div> : null}<span>อันดับ {rowIndex + 1}</span></CardTitle><CardAction><div className="flex gap-1">{dragHandle}<Button type="button" variant="ghost" size="icon-sm" onClick={() => setRows(rows.filter((item) => item.clientId !== row.clientId))}><Trash2Icon /><span className="sr-only">ลบอาวุธ</span></Button></div></CardAction></CardHeader><CardContent><FieldGroup><Field><FieldLabel>อาวุธ</FieldLabel><CatalogPicker items={compatibleWeapons} value={row.weaponKey} placeholder="ค้นหาชื่ออาวุธ…" rarityBackground onChange={(weaponKey) => setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, weaponKey } : item))} /></Field><Field><FieldLabel>Refinement</FieldLabel><ToggleGroup variant="outline" value={[`R${row.refinement}`]} onValueChange={(selected) => { const next = selected[0]; if (next) setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, refinement: Number(next.slice(1)) } : item)); }}>{[1, 2, 3, 4, 5].map((refinement) => <ToggleGroupItem className={SELECTED_TOGGLE_CLASS} key={refinement} value={`R${refinement}`}>R{refinement}</ToggleGroupItem>)}</ToggleGroup></Field><div className="grid gap-4 sm:grid-cols-2"><Field><FieldLabel>Overall %</FieldLabel><Input type="number" min="0" step="0.01" value={row.overallPercent} onChange={(event) => setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, overallPercent: event.target.value } : item))} /></Field><Field><FieldLabel>Personal %</FieldLabel><Input type="number" min="0" step="0.01" value={row.personalPercent} onChange={(event) => setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, personalPercent: event.target.value } : item))} /></Field></div><Field><FieldLabel htmlFor={`weapon-note-${row.clientId}`}>Note (optional)</FieldLabel><Textarea id={`weapon-note-${row.clientId}`} value={row.note} onChange={(event) => setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, note: event.target.value } : item))} /></Field></FieldGroup></CardContent></Card>}</SortableWeaponCard>; })}
</div>
</SortableContext>
</DndContext>
</CardContent>
<CardFooter className="flex-wrap justify-between gap-3"><Button type="button" variant="outline" onClick={() => setRows([...rows, { weaponKey: "", refinement: 1, overallPercent: "", personalPercent: "" }])}><PlusIcon data-icon="inline-start" />เพิ่มอาวุธ</Button><AutosaveStatus state={autosave} /></CardFooter>
<CardFooter className="flex-wrap justify-between gap-3"><Button type="button" variant="outline" onClick={() => setRows([...rows, { clientId: crypto.randomUUID(), weaponKey: "", refinement: 1, overallPercent: "", personalPercent: "", note: "" }])}><PlusIcon data-icon="inline-start" />เพิ่มอาวุธ</Button><AutosaveStatus state={autosave} /></CardFooter>
</Card>
);
}
@@ -254,7 +299,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) })) })), 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.stat.trim() && 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) => {
@@ -269,7 +314,7 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas
<Card>
<CardHeader><CardTitle>Artifact tier list</CardTitle><CardDescription>จัดชุด Artifact ในกลุ่ม Late Game และ Early Game ระบบจะบันทึกอัตโนมัติ</CardDescription></CardHeader>
<CardContent className="flex flex-col gap-5">
{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="grid gap-3 rounded-lg border p-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>)}<Button variant="outline" onClick={() => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: [...item.rows, { firstArtifactKey: "", secondArtifactKey: null, percent: "100" }] } : item))}><PlusIcon data-icon="inline-start" />เพิ่มชุด Artifact</Button></CardContent></Card>)}
{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>
<Card>
@@ -358,19 +403,33 @@ function ConstellationEditor({ guide, initial, catalogConstellations }: EditorBa
function TeamEditor({ guide, characters, weapons, artifacts, initial }: EditorBase) {
const fallbackMembers = characters.slice(0, 4).map((character) => ({ characterKey: character.key, weaponKey: null, artifactKey: null, constellation: 0, refinement: 1, damage: "0" }));
const [teams, setTeams] = useState(initial.teams);
const value = { teams: teams.map((team) => ({ ...team, rotationSeconds: Number(team.rotationSeconds), members: team.members.map((member) => ({ ...member, damage: Number(member.damage) })) })) };
const teamSensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const [teams, setTeams] = useState(initial.teams.map(({ id, ...team }) => ({ clientId: id, ...team })));
const value = { teams: teams.map((team) => ({ title: team.title, rotationSeconds: Number(team.rotationSeconds), combo: team.combo, members: team.members.map((member) => ({ ...member, damage: Number(member.damage) })) })) };
const autosave = useAutosave({ value, valid: teamSectionSchema.safeParse({ ...value, expectedVersion: guide.version }).success, initialVersion: guide.version, persist: (next, expectedVersion) => saveTeamSection(guide.id, { ...next, expectedVersion }) });
const handleTeamDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return;
setTeams((current) => {
const from = current.findIndex((team) => team.clientId === active.id);
const to = current.findIndex((team) => team.clientId === over.id);
return from === -1 || to === -1 ? current : arrayMove(current, from, to);
});
};
return (
<Card>
<CardHeader><CardTitle>Team Calc</CardTitle><CardDescription>ใส่ความเสียหายของตัวละคร 4 ตัว ระบบจะแสดงสัดส่วนและ Total DPS และบันทึกอัตโนมัติ</CardDescription></CardHeader>
<CardHeader><CardTitle>Team Calc</CardTitle><CardDescription>ลากเพื่อเรียง Team แล้วใส่ความเสียหายของตัวละคร 4 ตัว ระบบจะแสดงสัดส่วนและ Total DPS และบันทึกอัตโนมัติ</CardDescription></CardHeader>
<CardContent>
<Accordion multiple defaultValue={teams.length ? ["team-0"] : []} className="gap-3">
{teams.map((team, teamIndex) => {
<DndContext sensors={teamSensors} collisionDetection={closestCenter} onDragEnd={handleTeamDragEnd}>
<SortableContext items={teams.map((team) => team.clientId)} strategy={verticalListSortingStrategy}>
<Accordion multiple defaultValue={teams.length ? [teams[0].clientId] : []} className="gap-3">
{teams.map((team, teamIndex) => {
const total = team.members.reduce((sum, member) => sum + (Number(member.damage) || 0), 0);
const dps = total / (Number(team.rotationSeconds) || 1);
return (
<AccordionItem key={teamIndex} value={`team-${teamIndex}`} className="rounded-xl border px-4">
<SortableTeamItem key={team.clientId} id={team.clientId}>{(dragHandle) => <>
<div className="flex items-center gap-2">
<AccordionTrigger className="min-w-0 py-4 hover:no-underline">
<span className="flex min-w-0 flex-col gap-1">
@@ -378,7 +437,8 @@ function TeamEditor({ guide, characters, weapons, artifacts, initial }: EditorBa
<span className="text-xs font-normal text-muted-foreground">Damage {total.toLocaleString()} • DPS {dps.toFixed(0)}</span>
</span>
</AccordionTrigger>
<RowActions index={teamIndex} length={teams.length} move={(from, to) => setTeams(moveItem(teams, from, to))} remove={() => setTeams(teams.filter((_, index) => index !== teamIndex))} />
{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>
</div>
<AccordionContent className="pb-4">
<FieldGroup>
@@ -388,12 +448,14 @@ function TeamEditor({ guide, characters, weapons, artifacts, initial }: EditorBa
<Field><FieldLabel>Combo</FieldLabel><Textarea value={team.combo} onChange={(event) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, combo: event.target.value } : item))} /></Field>
</FieldGroup>
</AccordionContent>
</AccordionItem>
</>}</SortableTeamItem>
);
})}
</Accordion>
})}
</Accordion>
</SortableContext>
</DndContext>
</CardContent>
<CardFooter className="flex-wrap justify-between gap-3"><Button variant="outline" disabled={characters.length < 4} onClick={() => setTeams([...teams, { title: "", rotationSeconds: "20", combo: "", members: fallbackMembers }])}><PlusIcon data-icon="inline-start" />เพิ่ม Team</Button><AutosaveStatus state={autosave} /></CardFooter>
<CardFooter className="flex-wrap justify-between gap-3"><Button variant="outline" disabled={characters.length < 4} onClick={() => setTeams([...teams, { clientId: crypto.randomUUID(), title: "", rotationSeconds: "20", combo: "", members: fallbackMembers }])}><PlusIcon data-icon="inline-start" />เพิ่ม Team</Button><AutosaveStatus state={autosave} /></CardFooter>
</Card>
);
}
+7 -3
View File
@@ -229,9 +229,12 @@ function WeaponSection({ guide }: { guide: PublicGuide }) {
className="object-contain"
/>
</div>
<p className="truncate text-base font-semibold sm:text-lg">
{index + 1}. {row.weapon.name} R{row.refinement}
</p>
<div className="flex min-w-0 flex-col gap-1">
<p className="truncate text-base font-semibold sm:text-lg">
{index + 1}. {row.weapon.name} R{row.refinement}
</p>
{row.note ? <p className="whitespace-pre-wrap text-sm">{row.note}</p> : null}
</div>
</div>
<div className="flex items-center justify-around gap-4 bg-white px-4 py-5 text-black">
{row.overallPercent ? (
@@ -335,6 +338,7 @@ function ArtifactSection({ guide }: { guide: PublicGuide }) {
{index + 1}. {row.first.name}
{row.second ? ` + ${row.second.name}` : ""}
</p>
{row.note ? <p className="whitespace-pre-wrap text-sm text-muted-foreground">{row.note}</p> : null}
</div>
<p className="text-2xl font-semibold">
{metric(row.percent)}
+2
View File
@@ -569,6 +569,7 @@ export const weaponRecommendations = guidesSchema.table(
refinement: integer("refinement").notNull(),
overallPercent: numeric("overall_percent", { precision: 8, scale: 2 }),
personalPercent: numeric("personal_percent", { precision: 8, scale: 2 }),
note: text("note"),
sortOrder: integer("sort_order").default(0).notNull(),
},
(table) => [index("weapon_recommendation_order_idx").on(table.guideId, table.sortOrder)],
@@ -594,6 +595,7 @@ export const artifactRecommendations = guidesSchema.table(
firstArtifactKey: varchar("first_artifact_key", { length: 64 }).notNull(),
secondArtifactKey: varchar("second_artifact_key", { length: 64 }),
percent: numeric("percent", { precision: 8, scale: 2 }).notNull(),
note: text("note"),
sortOrder: integer("sort_order").default(0).notNull(),
},
(table) => [index("artifact_recommendation_order_idx").on(table.groupId, table.sortOrder)],
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "guides"."weapon_recommendation" ADD COLUMN "note" text;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "guides"."artifact_recommendation" ADD COLUMN "note" text;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -64,6 +64,20 @@
"when": 1788090159661,
"tag": "0008_plain_gamora",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1788092326644,
"tag": "0009_steep_marvex",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1788092795014,
"tag": "0010_dear_korvac",
"breakpoints": true
}
]
}
+15 -2
View File
@@ -13,8 +13,14 @@ describe("structured guide validation", () => {
});
it("requires at least one weapon percentage", () => {
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, rows: [{ weaponKey: "1", refinement: 5, overallPercent: null, personalPercent: null }] }).success).toBe(false);
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, rows: [{ weaponKey: "1", refinement: 5, overallPercent: 104, personalPercent: null }] }).success).toBe(true);
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, rows: [{ weaponKey: "1", refinement: 5, overallPercent: null, personalPercent: null, note: null }] }).success).toBe(false);
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, rows: [{ weaponKey: "1", refinement: 5, overallPercent: 104, personalPercent: null, note: "Best free-to-play option" }] }).success).toBe(true);
});
it("limits an optional note on each weapon", () => {
const row = { weaponKey: "1", refinement: 1, overallPercent: 100, personalPercent: null };
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, rows: [{ ...row, note: null }] }).success).toBe(true);
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, rows: [{ ...row, note: "x".repeat(2_001) }] }).success).toBe(false);
});
it("limits artifact main stats to two per slot", () => {
@@ -22,6 +28,13 @@ describe("structured guide validation", () => {
expect(result.success).toBe(false);
});
it("limits an optional note on each artifact recommendation", () => {
const row = { firstArtifactKey: "1", secondArtifactKey: null, percent: 100 };
const base = { expectedVersion: 1, sands: [], goblet: [], circlet: [], substats: [], targets: [], talents: [], note: null };
expect(artifactSectionSchema.safeParse({ ...base, groups: [{ name: "Late Game", rows: [{ ...row, note: null }] }, artifactGroups[1]] }).success).toBe(true);
expect(artifactSectionSchema.safeParse({ ...base, groups: [{ name: "Late Game", rows: [{ ...row, note: "x".repeat(2_001) }] }, artifactGroups[1]] }).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);
+2
View File
@@ -25,6 +25,7 @@ export const weaponSectionSchema = z.strictObject({
refinement: z.number().int().min(1).max(5),
overallPercent: percent.nullable(),
personalPercent: percent.nullable(),
note: z.string().trim().max(2_000).nullable(),
}).refine((row) => row.overallPercent !== null || row.personalPercent !== null, {
message: "อาวุธต้องมีเปอร์เซ็นต์อย่างน้อยหนึ่งค่า",
})).max(100),
@@ -46,6 +47,7 @@ const artifactGroupSchema = (name: "Late Game" | "Early Game") => z.strictObject
firstArtifactKey: catalogKey,
secondArtifactKey: catalogKey.nullable(),
percent,
note: z.string().trim().max(2_000).nullable(),
})).max(50),
});