feat(weapons) : add conditions and unsaved changes warning
CI / Verify (push) Failing after 42s
CI / Build immutable images and deploy (push) Has been skipped

This commit is contained in:
2026-09-04 00:33:03 +07:00 Unverified
parent a4cc93c7f6
commit c9e0fe02e3
14 changed files with 201 additions and 56 deletions
+75 -23
View File
@@ -169,6 +169,11 @@ type WeaponValue = {
note: string | null;
weapon: CatalogItem;
};
type WeaponGroupValue = {
id: string;
note: string | null;
rows: WeaponValue[];
};
type ArtifactValue = {
id: string;
name: string;
@@ -265,7 +270,7 @@ export interface GuideSectionEditorProps {
catalogTalents: CatalogTalent[];
initial: {
sectionNote: string | null;
weapons: WeaponValue[];
weaponGroups: WeaponGroupValue[];
artifactGroups: ArtifactValue[];
artifactProfile: ArtifactProfile;
constellationGroups: ConstellationGroupValue[];
@@ -696,26 +701,40 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
}),
);
const [note, setNote] = useState(initial.sectionNote ?? "");
const [rows, setRows] = useState(
initial.weapons.map((row) => ({
const [groups, setGroups] = useState(() => {
const source = initial.weaponGroups.length
? initial.weaponGroups
: [{ id: "weapon-condition-default", note: null, rows: [] }];
return source.map((group) => ({
clientId: group.id,
note: group.note ?? "",
rows: group.rows.map((row) => ({
clientId: row.id,
weaponKey: row.weaponKey,
refinement: row.refinement,
overallPercent: row.overallPercent ?? "",
personalPercent: row.personalPercent ?? "",
note: row.note ?? "",
})),
);
})),
}));
});
const [activeGroupIndex, setActiveGroupIndex] = useState(0);
const rows = groups[activeGroupIndex]?.rows ?? [];
const setRows = (update: typeof rows | ((current: typeof rows) => typeof rows)) =>
setGroups((current) => current.map((group, index) => index === activeGroupIndex
? { ...group, rows: typeof update === "function" ? update(group.rows) : update }
: group));
const value = {
note: note.trim() || null,
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,
groups: groups.map((group) => ({
note: group.note.trim() || null,
rows: group.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 validation = weaponSectionSchema.safeParse({
@@ -724,7 +743,7 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
});
const weaponIssue = (...path: (string | number)[]) =>
!validation.success && hasIssuePrefix(validation.error.issues, ...path);
const localDraft = { note, rows };
const localDraft = { note, groups, activeGroupIndex };
const autosave = useAutosave({
value,
valid: validation.success,
@@ -734,10 +753,11 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
storageKey: adminDraftKey(guide.id, "weapon"),
localDraft,
parseLocalDraft: (candidate) =>
parseEditorDraft<typeof localDraft>(candidate, ["rows"], ["note"]),
parseEditorDraft<typeof localDraft>(candidate, ["groups"], ["note"]),
restoreLocalDraft: (draft) => {
setNote(draft.note);
setRows(draft.rows);
setGroups(draft.groups);
setActiveGroupIndex(draft.activeGroupIndex ?? 0);
},
});
const handleWeaponDragEnd = ({ active, over }: DragEndEvent) => {
@@ -768,6 +788,29 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
onChange={(event) => setNote(event.target.value)}
/>
</Field>
<div className="flex flex-wrap gap-2">
{groups.map((group, index) => (
<Button key={group.clientId} type="button" variant={index === activeGroupIndex ? "default" : "outline"} onClick={() => setActiveGroupIndex(index)}>
Condition {index + 1}
</Button>
))}
<Button type="button" variant="outline" disabled={groups.length >= 20} onClick={() => {
setGroups([...groups, { clientId: createClientId("weapon-condition"), note: "", rows: [] }]);
setActiveGroupIndex(groups.length);
}}>
<PlusIcon data-icon="inline-start" />เพิ่ม Condition
</Button>
</div>
<Field>
<FieldLabel htmlFor={`weapon-condition-note-${groups[activeGroupIndex]?.clientId}`}>
Condition note (optional)
</FieldLabel>
<Textarea
id={`weapon-condition-note-${groups[activeGroupIndex]?.clientId}`}
value={groups[activeGroupIndex]?.note ?? ""}
onChange={(event) => setGroups(groups.map((group, index) => index === activeGroupIndex ? { ...group, note: event.target.value } : group))}
/>
</Field>
<DndContext
sensors={weaponSensors}
collisionDetection={closestCenter}
@@ -835,12 +878,12 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
<CardContent>
<FieldGroup>
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-end gap-4">
<Field className="min-w-0" data-invalid={weaponIssue("rows", rowIndex, "weaponKey")}>
<Field className="min-w-0" data-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex, "weaponKey")}>
<FieldLabel>อาวุธ</FieldLabel>
<CatalogPicker
items={compatibleWeapons}
value={row.weaponKey}
invalid={weaponIssue("rows", rowIndex, "weaponKey")}
invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex, "weaponKey")}
placeholder="ค้นหาชื่ออาวุธ…"
rarityBackground
onChange={(weaponKey) =>
@@ -854,7 +897,7 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
}
/>
</Field>
<Field className="shrink-0" data-invalid={weaponIssue("rows", rowIndex)}>
<Field className="shrink-0" data-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}>
<FieldLabel>Refinement</FieldLabel>
<ToggleGroup
variant="outline"
@@ -889,13 +932,13 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
</Field>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Field data-invalid={weaponIssue("rows", rowIndex)}>
<Field data-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}>
<FieldLabel>Overall %</FieldLabel>
<Input
type="text"
inputMode="decimal"
value={row.overallPercent}
aria-invalid={weaponIssue("rows", rowIndex)}
aria-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}
onChange={(event) =>
setRows(
rows.map((item) =>
@@ -929,13 +972,13 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
}
/>
</Field>
<Field data-invalid={weaponIssue("rows", rowIndex)}>
<Field data-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}>
<FieldLabel>Personal %</FieldLabel>
<Input
type="text"
inputMode="decimal"
value={row.personalPercent}
aria-invalid={weaponIssue("rows", rowIndex)}
aria-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}
onChange={(event) =>
setRows(
rows.map((item) =>
@@ -1022,6 +1065,15 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
<PlusIcon data-icon="inline-start" />
เพิ่มอาวุธ
</Button>
{groups.length > 1 ? (
<Button type="button" variant="ghost" onClick={() => {
const next = groups.filter((_, index) => index !== activeGroupIndex);
setGroups(next);
setActiveGroupIndex(Math.min(activeGroupIndex, next.length - 1));
}}>
<Trash2Icon data-icon="inline-start" />ลบ Condition นี้
</Button>
) : null}
<AutosaveStatus
state={autosave.state}
issues={
+9
View File
@@ -1,5 +1,6 @@
import Image from "next/image";
import Link from "next/link";
import type { MouseEvent } from "react";
import { ScrollableNav } from "@/components/public/scrollable-nav";
import { navigationIconPath } from "@/lib/navigation";
@@ -20,6 +21,12 @@ export function GuideSectionNav({
characterKey: string;
current: "overview" | (typeof sections)[number]["slug"];
}) {
const confirmNavigation = (event: MouseEvent<HTMLAnchorElement>) => {
const browserWindow = window as Window & { __guideHasUnsavedChanges?: boolean };
if (typeof window !== "undefined" && browserWindow.__guideHasUnsavedChanges && !window.confirm("ข้อมูลยังบันทึกไม่เสร็จหรือไม่ถูกต้อง ต้องการออกจากหน้านี้หรือไม่?")) {
event.preventDefault();
}
};
return (
<ScrollableNav
ariaLabel="เมนูแก้ไข Guide"
@@ -28,6 +35,7 @@ export function GuideSectionNav({
<Link
href={`/admin/${characterKey}`}
prefetch
onClick={confirmNavigation}
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 === "overview" ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground",
@@ -43,6 +51,7 @@ export function GuideSectionNav({
key={section.slug}
href={`/admin/${characterKey}/${section.slug}`}
prefetch
onClick={confirmNavigation}
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",
+12 -9
View File
@@ -28,7 +28,6 @@ import {
recommendedStatIconName,
statPriorityColorClass,
targetStatIconName,
targetStatOptions,
} from "@/lib/guides/stats";
import {
normalizeTalentPriorities,
@@ -346,8 +345,12 @@ function WeaponSectionContent({
) : null}
{action}
</header>
<div className="grid gap-3">
{guide.weapons.map((row, index) => (
{guide.weaponGroups.filter((group) => group.rows.length > 0).map((group, groupIndex, groups) => (
<div key={group.id} className="flex flex-col gap-3">
{groups.length > 1 ? <h2 className="font-heading text-2xl font-semibold">Condition {groupIndex + 1}</h2> : null}
{group.note ? <p className="whitespace-pre-wrap text-muted-foreground">{group.note}</p> : null}
<div className="grid gap-3">
{group.rows.map((row, index) => (
<Card key={row.id} size="sm" className="bg-white py-0">
<CardContent
className={cn(
@@ -442,6 +445,8 @@ function WeaponSectionContent({
</Card>
))}
</div>
</div>
))}
</section>
);
}
@@ -608,9 +613,9 @@ function ArtifactSectionContent({
))}
</CardContent>
</Card>
))}
</div>
))}
</div>
</div>
{profile ? (
<div className="flex flex-col gap-5">
<Card>
@@ -857,9 +862,7 @@ function ArtifactStatValue({
exporting?: boolean;
}) {
const iconName = recommendedStatIconName(value);
const hasIcon =
iconName &&
targetStatOptions.some((stat) => targetStatIconName(stat) === iconName);
const hasIcon = Boolean(iconName);
return (
<span
className={cn(
@@ -869,7 +872,7 @@ function ArtifactStatValue({
>
{hasIcon ? (
<Image
src={`/icon/stats/${iconName}.webp`}
src={`/icon/stats/${iconName}.${iconName === "elemental-bonus" ? "svg" : "webp"}`}
alt=""
width={20}
height={20}