["onOpenChange"];
+}
+
+export function DeleteConfirmationDialog({
+ title,
+ description,
+ onConfirm,
+ confirmLabel = "ยืนยันลบ",
+ trigger,
+ children,
+ open,
+ onOpenChange,
+}: DeleteConfirmationDialogProps) {
+ return (
+
+ {trigger ? {children} : null}
+
+
+ {title}
+ {description}
+
+
+ ยกเลิก
+ {confirmLabel}
+
+
+
+ );
+}
diff --git a/components/admin/guide-section-editor.tsx b/components/admin/guide-section-editor.tsx
index 33a2e16..87e3e17 100644
--- a/components/admin/guide-section-editor.tsx
+++ b/components/admin/guide-section-editor.tsx
@@ -3,6 +3,7 @@
import Image from "next/image";
import {
ChartNoAxesCombinedIcon,
+ CopyIcon,
GemIcon,
GripVerticalIcon,
HeartPulseIcon,
@@ -44,19 +45,9 @@ import {
saveWeaponSection,
} from "@/app/admin/actions";
import { AutosaveStatus } from "@/components/admin/autosave-status";
+import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation-dialog";
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 {
@@ -366,7 +357,7 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
row.clientId)} strategy={verticalListSortingStrategy}>
- {rows.map((row, rowIndex) => { const selectedWeapon = compatibleWeapons.find((weapon) => weapon.key === row.weaponKey); return
{(dragHandle) => {selectedWeapon ?
: null}อันดับ {rowIndex + 1}{dragHandle}
อาวุธ setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, weaponKey } : item))} />Refinement { 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) => R{refinement})}
Overall % setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, overallPercent: event.target.value } : item))} />Personal % setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, personalPercent: event.target.value } : item))} />
Note (optional)}; })}
+ {rows.map((row, rowIndex) => { const selectedWeapon = compatibleWeapons.find((weapon) => weapon.key === row.weaponKey); return
{(dragHandle) => {selectedWeapon ?
: null}อันดับ {rowIndex + 1}{dragHandle}} title={`ลบ ${selectedWeapon?.name ?? `อาวุธอันดับ ${rowIndex + 1}`}?`} description="รายการอาวุธและเปอร์เซ็นต์เปรียบเทียบจะถูกลบออกจาก Guide" onConfirm={() => setRows(rows.filter((item) => item.clientId !== row.clientId))}>ลบอาวุธ
อาวุธ setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, weaponKey } : item))} />Refinement { 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) => R{refinement})}
Overall % setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, overallPercent: event.target.value } : item))} />Personal % setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, personalPercent: event.target.value } : item))} />
Note (optional)}; })}
@@ -470,8 +461,8 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas
setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, firstArtifactKey } : candidate) } : item))} />
setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, secondArtifactKey: secondArtifactKey || null } : candidate) } : item))} />
- setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: event.target.value } : candidate) } : item))} />
-
+ setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: event.target.value } : candidate) } : item))} />
+ } title={`ลบ Artifact อันดับ ${rowIndex + 1}?`} description={`รายการ Artifact ในกลุ่ม ${group.name} และเปอร์เซ็นต์เปรียบเทียบจะถูกลบออกจาก Guide`} onConfirm={() => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.filter((_, inner) => inner !== rowIndex) } : item))}>ลบ Artifact
Note (optional)
@@ -599,11 +590,11 @@ function ConstellationEditor({ guide, initial, catalogConstellations }: EditorBa
Header note (optional)
{groups.map((group, groupIndex) =>
- Condition {groupIndex + 1}ใช้ note เพื่ออธิบายอาวุธ ทีม หรือเงื่อนไขที่ใช้เปรียบเทียบ{groups.length > 1 ? }>ลบ Condition {groupIndex + 1}ลบ Condition {groupIndex + 1}?เปอร์เซ็นต์กลุ่มดาวทั้งหมดในเงื่อนไขนี้จะถูกลบออกยกเลิก setGroups(groups.filter((item) => item.clientId !== group.clientId))}>ยืนยันลบ : null}
+ Condition {groupIndex + 1}ใช้ note เพื่ออธิบายอาวุธ ทีม หรือเงื่อนไขที่ใช้เปรียบเทียบ{groups.length > 1 ? } title={`ลบ Condition ${groupIndex + 1}?`} description="เปอร์เซ็นต์กลุ่มดาวทั้งหมดในเงื่อนไขนี้จะถูกลบออก" onConfirm={() => setGroups(groups.filter((item) => item.clientId !== group.clientId))}>ลบ Condition {groupIndex + 1} : null}
Condition note (optional)
- {group.rows.map((row, rowIndex) => {row.iconUrl ? : null}C{row.level} · {row.name}{row.description}Cumulative % setGroups(groups.map((item) => item.clientId === group.clientId ? { ...item, rows: item.rows.map((candidate, index) => index === rowIndex ? { ...candidate, cumulativePercent: event.target.value } : candidate) } : item))} /> setGroups(groups.map((item) => item.clientId === group.clientId ? { ...item, rows: item.rows.map((candidate, index) => index === rowIndex ? { ...candidate, isGreat } : candidate) } : item))} aria-label={`Mark Condition ${groupIndex + 1} C${row.level} percentage as great`} className={cn(SELECTED_TOGGLE_CLASS, "data-[state=on]:text-warning")}>Great %)}
+ {group.rows.map((row, rowIndex) => {row.iconUrl ? : null}C{row.level} · {row.name}{row.description}Cumulative % setGroups(groups.map((item) => item.clientId === group.clientId ? { ...item, rows: item.rows.map((candidate, index) => index === rowIndex ? { ...candidate, cumulativePercent: event.target.value } : candidate) } : item))} /> setGroups(groups.map((item) => item.clientId === group.clientId ? { ...item, rows: item.rows.map((candidate, index) => index === rowIndex ? { ...candidate, isGreat } : candidate) } : item))} aria-label={`Mark Condition ${groupIndex + 1} C${row.level} percentage as great`} className={cn(SELECTED_TOGGLE_CLASS, "data-[state=on]:text-warning")}>Great %)}
)}
@@ -662,21 +653,38 @@ function TeamEditor({ guide, characters, weapons, artifacts, initial }: EditorBa
{dragHandle}
-
- }>
- ลบ Team
-
-
-
- ลบ {team.title || `Team ${teamIndex + 1}`}?
- ข้อมูล Team และตัวละครทั้ง 4 คนจะถูกลบออกจาก Guide
-
-
- ยกเลิก
- setTeams(teams.filter((item) => item.clientId !== team.clientId))}>ยืนยันลบ Team
-
-
-
+
+
}
+ title={`ลบ ${team.title || `Team ${teamIndex + 1}`}?`}
+ description="ข้อมูล Team และตัวละครทั้ง 4 คนจะถูกลบออกจาก Guide"
+ confirmLabel="ยืนยันลบ Team"
+ onConfirm={() => setTeams(teams.filter((item) => item.clientId !== team.clientId))}
+ >
+
ลบ Team
+
@@ -744,7 +752,72 @@ function ExtraEditor({ guide, initial }: EditorBase) {
Extra sectionsเพิ่มส่วนเสริมแบบภาพ หัวข้อ ข้อความสั้น และเปอร์เซ็นต์ ระบบจะบันทึกอัตโนมัติ
- {sections.map((section, sectionIndex) => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, title: event.target.value } : item))} />{section.rows.map((row, rowIndex) => {row.heading || `แถว ${rowIndex + 1}`}{row.imageUrl ?
: null} setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, mediaId: asset.id, imageUrl: asset.url } : candidate) } : item))} />อัตราส่วนภาพ { const ratio = selected[0]; if (ratio === "1:1" || ratio === "16:9") setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, ratio } : candidate) } : item)); }} className="flex-wrap justify-start">1:116:9หัวข้อ setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, heading: event.target.value } : candidate) } : item))} />ข้อความPercent (optional) setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: event.target.value || null } : candidate) } : item))} />)})}
+ {sections.map((section, sectionIndex) => (
+
+
+ setSections(sections.map((item, index) => index === sectionIndex ? { ...item, title: event.target.value } : item))} />
+
+ }
+ title={`ลบ ${section.title || `Extra section ${sectionIndex + 1}`}?`}
+ description="แถว ข้อความ รูปภาพ และเปอร์เซ็นต์ทั้งหมดในส่วนนี้จะถูกลบออกจาก Guide"
+ onConfirm={() => setSections(sections.filter((_, index) => index !== sectionIndex))}
+ >
+ ลบส่วน
+
+
+
+
+ {section.rows.map((row, rowIndex) => (
+
+
+ {row.heading || `แถว ${rowIndex + 1}`}
+
+ }
+ title={`ลบ ${row.heading || `แถว ${rowIndex + 1}`}?`}
+ description="ข้อความ รูปภาพ และเปอร์เซ็นต์ในแถวนี้จะถูกลบออกจาก Guide"
+ onConfirm={() => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.filter((_, inner) => inner !== rowIndex) } : item))}
+ >
+ ลบแถว
+
+
+
+
+
+ {row.imageUrl ? (
+
+
+ }
+ title="ลบภาพนี้?"
+ description="ภาพจะถูกนำออกจากแถวนี้"
+ confirmLabel="ยืนยันลบภาพ"
+ onConfirm={() => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, mediaId: null, imageUrl: null } : candidate) } : item))}
+ >
+
+
+
+ ) : null}
+ setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, mediaId: asset.id, imageUrl: asset.url } : candidate) } : item))} />
+
+ อัตราส่วนภาพ
+ { const ratio = selected[0]; if (ratio === "1:1" || ratio === "16:9") setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, ratio } : candidate) } : item)); }} className="flex-wrap justify-start">
+ 1:1
+ 16:9
+
+
+ หัวข้อ setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, heading: event.target.value } : candidate) } : item))} />
+ ข้อความ
+ Percent (optional) setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: event.target.value || null } : candidate) } : item))} />
+
+
+
+ ))}
+
+
+
+ ))}
issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} onSave={autosave.saveNow} />
diff --git a/components/admin/structured-dashboard.tsx b/components/admin/structured-dashboard.tsx
index 8c98b03..dab5dd1 100644
--- a/components/admin/structured-dashboard.tsx
+++ b/components/admin/structured-dashboard.tsx
@@ -8,7 +8,7 @@ import { useTransition } from "react";
import { toast } from "sonner";
import { deleteStructuredGuide, moveGuideToTrash, restoreStructuredGuide, startCatalogSync } from "@/app/admin/actions";
-import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
+import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
@@ -41,11 +41,11 @@ export function StructuredDashboard({ guides, trash, catalogReady }: { guides: G
{guide.name}{guide.overview}v{guide.version}
แก้ไขล่าสุด {new Date(guide.updatedAt).toLocaleString("th-TH")}
- } nativeButton={false}>แก้ไข Guide}>ย้าย {guide.name} ไปถังขยะย้าย {guide.name} ไปถังขยะ?Guide จะหายจากหน้าเว็บไซต์ทันที และสามารถกู้คืนได้ภายหลังยกเลิก run(() => moveGuideToTrash(guide.id), "ย้ายไปถังขยะแล้ว")}>ย้ายไปถังขยะ
+ } nativeButton={false}>แก้ไข Guide} title={`ย้าย ${guide.name} ไปถังขยะ?`} description="Guide จะหายจากหน้าเว็บไซต์ทันที และสามารถกู้คืนได้ภายหลัง" confirmLabel="ย้ายไปถังขยะ" onConfirm={() => run(() => moveGuideToTrash(guide.id), "ย้ายไปถังขยะแล้ว")}>ย้าย {guide.name} ไปถังขยะ
))}
) : ยังไม่มี GuideSync Catalog แล้วเลือกตัวละครเพื่อสร้าง Guide แรก} nativeButton={false} disabled={!catalogReady}>สร้าง Guide}
- {trash.length ? ถังขยะ
Guide ที่ซ่อนไว้จากเว็บไซต์
{trash.map((guide) =>
{guide.name}{guide.overview}}>ลบถาวรลบ {guide.name} ถาวร?ข้อมูล Weapons, Artifacts, Constellations, Team และ Extra ทั้งหมดจะกู้คืนไม่ได้ยกเลิก run(() => deleteStructuredGuide(guide.id), "ลบ Guide ถาวรแล้ว")}>ยืนยันลบถาวร )}
: null}
+ {trash.length ? ถังขยะ
Guide ที่ซ่อนไว้จากเว็บไซต์
{trash.map((guide) =>
{guide.name}{guide.overview}} title={`ลบ ${guide.name} ถาวร?`} description="ข้อมูล Weapons, Artifacts, Constellations, Team และ Extra ทั้งหมดจะกู้คืนไม่ได้" confirmLabel="ยืนยันลบถาวร" onConfirm={() => run(() => deleteStructuredGuide(guide.id), "ลบ Guide ถาวรแล้ว")}>ลบถาวร
)}
: null}
);
}
diff --git a/lib/guides/schemas.test.ts b/lib/guides/schemas.test.ts
index 0fe45e6..3c8c335 100644
--- a/lib/guides/schemas.test.ts
+++ b/lib/guides/schemas.test.ts
@@ -18,6 +18,12 @@ describe("structured guide validation", () => {
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, rows: [{ weaponKey: "1", refinement: 5, overallPercent: 104, personalPercent: null, note: "Best free-to-play option" }] }).success).toBe(true);
});
+ it("preserves negative weapon percentages", () => {
+ const result = weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, rows: [{ weaponKey: "1", refinement: 5, overallPercent: -12.5, personalPercent: null, note: null }] });
+ expect(result.success).toBe(true);
+ if (result.success) expect(result.data.rows[0].overallPercent).toBe(-12.5);
+ });
+
it("limits an optional note on each weapon", () => {
const row = { weaponKey: "1", refinement: 1, overallPercent: 100, personalPercent: null };
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, rows: [{ ...row, note: null }] }).success).toBe(true);
diff --git a/lib/guides/schemas.ts b/lib/guides/schemas.ts
index 8725aae..45085e7 100644
--- a/lib/guides/schemas.ts
+++ b/lib/guides/schemas.ts
@@ -5,7 +5,7 @@ import { targetStatOptions } from "@/lib/guides/stats";
export const guideSectionKinds = ["weapon", "artifact", "constellations", "team"] as const;
export type GuideSectionKind = (typeof guideSectionKinds)[number];
-const percent = z.coerce.number().finite().min(0).max(9999);
+const percent = z.coerce.number().finite().min(-9999).max(9999);
const catalogKey = z.string().trim().min(1).max(64);
export const createGuideSchema = z.strictObject({