feat(weapons) : add conditions and unsaved changes warning
This commit is contained in:
@@ -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={
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user