diff --git a/bun.lock b/bun.lock index 789fa19..8d05ebf 100644 --- a/bun.lock +++ b/bun.lock @@ -68,6 +68,7 @@ "drizzle-kit": "0.31.11", "eslint": "^9", "eslint-config-next": "16.3.3", + "happy-dom": "20.14.6", "shadcn": "4.19.0", "tailwindcss": "^4", "typescript": "^5", @@ -717,6 +718,8 @@ "@types/web-push": ["@types/web-push@3.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.68.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.68.0", "@typescript-eslint/type-utils": "8.68.0", "@typescript-eslint/utils": "8.68.0", "@typescript-eslint/visitor-keys": "8.68.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.68.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q=="], @@ -887,6 +890,8 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -1253,6 +1258,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "happy-dom": ["happy-dom@20.14.6", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-5DDsf4QmEIlRL6NkCv/fxSjYHHkZceL/IaYQdJc2h3G7DRTEai++VTjbfL0LB9geW2VrA3+vrjF3Efx+OTtX0Q=="], + "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -1955,6 +1962,8 @@ "web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], diff --git a/components/admin/editors/artifact-editor.tsx b/components/admin/editors/artifact-editor.tsx index 813af8c..2d034d7 100644 --- a/components/admin/editors/artifact-editor.tsx +++ b/components/admin/editors/artifact-editor.tsx @@ -7,7 +7,7 @@ import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, us import { arrayMove, rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { saveArtifactSection } from "@/app/admin/actions"; -import { AutosaveStatus } from "@/components/admin/autosave-status"; +import { SaveStatus } from "@/components/admin/save-status"; import { DeleteConfirmationDialog } from "@/components/admin/ui/delete-confirmation-dialog"; import { ArtifactSelect } from "@/components/admin/ui/catalog-select"; import { Button } from "@/components/ui/button"; @@ -18,7 +18,7 @@ import { ClearableInput as Input } from "@/components/admin/ui/clearable-input"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; -import { useAutosave } from "@/hooks/use-autosave"; +import { useManualSave } from "@/hooks/use-manual-save"; import { adminDraftKey } from "@/lib/editor/local-draft"; import { artifactSectionSchema, mainStatOptions, substatOptions, talentLevelOptions } from "@/lib/guides/schemas"; import { normalizeTargetStat, recommendedStatElement, recommendedStatIconName, recommendedStatOptionConflicts, targetStatDefaults, targetStatIconName, targetStatOptions, statPriorityColorClass } from "@/lib/guides/stats"; @@ -450,7 +450,7 @@ export function ArtifactEditor({ talents, note, }; - const autosave = useAutosave({ + const manualSave = useManualSave({ guideId: guide.id, scope: "artifact", value, @@ -535,7 +535,7 @@ export function ArtifactEditor({ Artifact tier list จัดชุด Artifact ในกลุ่ม Late Game และ Early Game - ระบบจะบันทึกอัตโนมัติ + กดปุ่มบันทึกเพื่อบันทึกการเปลี่ยนแปลง @@ -987,12 +987,12 @@ export function ArtifactEditor({ - diff --git a/components/admin/editors/constellation-editor.tsx b/components/admin/editors/constellation-editor.tsx index 6996a80..6bc7a5a 100644 --- a/components/admin/editors/constellation-editor.tsx +++ b/components/admin/editors/constellation-editor.tsx @@ -4,14 +4,14 @@ import Image from "@/components/ui/resilient-image"; import { SparklesIcon } from "lucide-react"; import { useState } from "react"; import { saveConstellationSection } from "@/app/admin/actions"; -import { AutosaveStatus } from "@/components/admin/autosave-status"; +import { SaveStatus } from "@/components/admin/save-status"; import { LunarisText } from "@/components/public/lunaris-text"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { Field, FieldLabel } from "@/components/ui/field"; import { ClearableInput as Input } from "@/components/admin/ui/clearable-input"; import { Textarea } from "@/components/ui/textarea"; import { Toggle } from "@/components/ui/toggle"; -import { useAutosave } from "@/hooks/use-autosave"; +import { useManualSave } from "@/hooks/use-manual-save"; import { adminDraftKey } from "@/lib/editor/local-draft"; import { constellationSectionSchema } from "@/lib/guides/schemas"; import { finalizePercentInput, normalizePercentInput } from "@/lib/guides/percent-input"; @@ -86,7 +86,7 @@ export function ConstellationEditor({ const constellationIssue = (...path: (string | number)[]) => !validation.success && hasIssuePrefix(validation.error.issues, ...path); const localDraft = { note, groups }; - const autosave = useAutosave({ + const manualSave = useManualSave({ guideId: guide.id, scope: "constellations", value, @@ -309,12 +309,12 @@ export function ConstellationEditor({ );}) : null} - diff --git a/components/admin/editors/extra-editor.tsx b/components/admin/editors/extra-editor.tsx index a36736a..cea8c4e 100644 --- a/components/admin/editors/extra-editor.tsx +++ b/components/admin/editors/extra-editor.tsx @@ -10,7 +10,7 @@ import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, us import { arrayMove, rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { deleteGuideExtra, saveGuideExtra } from "@/app/admin/actions"; -import { AutosaveStatus } from "@/components/admin/autosave-status"; +import { SaveStatus } from "@/components/admin/save-status"; import { DeleteConfirmationDialog } from "@/components/admin/ui/delete-confirmation-dialog"; import { CatalogDropdown } from "@/components/admin/ui/catalog-select"; import { MediaUploader } from "@/components/admin/media-uploader"; @@ -23,7 +23,7 @@ import { ClearableInput as Input } from "@/components/admin/ui/clearable-input"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; -import { useAutosave } from "@/hooks/use-autosave"; +import { useManualSave } from "@/hooks/use-manual-save"; import { adminDraftKey } from "@/lib/editor/local-draft"; import { extraSectionSchema } from "@/lib/guides/schemas"; import { rarityGradientClass } from "@/lib/catalog/rarity"; @@ -265,7 +265,7 @@ export function ExtraEditor({ guide, extraSectionId, initial, characters, weapon const extraIssue = (...path: (string | number)[]) => !validation.success && hasIssuePrefix(validation.error.issues, ...path); const localDraft = { sections }; - const autosave = useAutosave({ + const manualSave = useManualSave({ guideId: guide.id, scope: `extra:${sectionId}`, value, @@ -322,7 +322,7 @@ export function ExtraEditor({ guide, extraSectionId, initial, characters, weapon {sections[0]?.title ?? "Extra section"} เพิ่มส่วนเสริมแบบภาพ หัวข้อ ข้อความที่จัดรูปแบบได้ และเปอร์เซ็นต์ - ระบบจะบันทึกอัตโนมัติ + กดปุ่มบันทึกเพื่อบันทึกการเปลี่ยนแปลง @@ -896,18 +896,19 @@ export function ExtraEditor({ guide, extraSectionId, initial, characters, weapon ))} - diff --git a/components/admin/editors/team-editor.tsx b/components/admin/editors/team-editor.tsx index f22434d..89292be 100644 --- a/components/admin/editors/team-editor.tsx +++ b/components/admin/editors/team-editor.tsx @@ -9,7 +9,7 @@ import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, us import { arrayMove, rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { listTeamImportGuides, listTeamImportsForCharacter, loadTeamImportGuide, saveTeamSection } from "@/app/admin/actions"; -import { AutosaveStatus } from "@/components/admin/autosave-status"; +import { SaveStatus } from "@/components/admin/save-status"; import { DeleteConfirmationDialog } from "@/components/admin/ui/delete-confirmation-dialog"; import { ArtifactSelect, CatalogDropdown, CharacterSelect, WeaponSelect } from "@/components/admin/ui/catalog-select"; import { ConstellationSelect, RefinementSelect } from "@/components/admin/ui/option-select"; @@ -32,7 +32,7 @@ import { Switch } from "@/components/ui/switch"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { useAutosave } from "@/hooks/use-autosave"; +import { useManualSave } from "@/hooks/use-manual-save"; import { adminDraftKey } from "@/lib/editor/local-draft"; import { teamSectionSchema } from "@/lib/guides/schemas"; import { elementBackgroundColor } from "@/lib/catalog/element"; @@ -478,7 +478,7 @@ export function TeamEditor({ // Browser storage is optional; the editor remains usable without it. } }, [guide.id, presets, presetsGuideId, presetsHydrated]); - const autosave = useAutosave({ + const manualSave = useManualSave({ guideId: guide.id, scope: "team", value, @@ -764,7 +764,7 @@ export function TeamEditor({ Team Calc ลากเพื่อเรียง Team แล้วใส่ความเสียหายของตัวละคร 4 ตัว - ระบบจะแสดงสัดส่วนและ Total DPS และบันทึกอัตโนมัติ + ระบบจะแสดงสัดส่วนและ Total DPS กดปุ่มบันทึกเพื่อบันทึกการเปลี่ยนแปลง @@ -1383,12 +1383,12 @@ export function TeamEditor({ Import Team - team.title), }) } - onSave={autosave.saveNow} + onSave={manualSave.saveNow} + canSave={manualSave.canSave} /> diff --git a/components/admin/editors/weapon-editor.tsx b/components/admin/editors/weapon-editor.tsx index 92b34c8..60ff45c 100644 --- a/components/admin/editors/weapon-editor.tsx +++ b/components/admin/editors/weapon-editor.tsx @@ -7,7 +7,7 @@ import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, us import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { saveWeaponSection } from "@/app/admin/actions"; -import { AutosaveStatus } from "@/components/admin/autosave-status"; +import { SaveStatus } from "@/components/admin/save-status"; import { DeleteConfirmationDialog } from "@/components/admin/ui/delete-confirmation-dialog"; import { WeaponSelect } from "@/components/admin/ui/catalog-select"; import { Button } from "@/components/ui/button"; @@ -16,7 +16,7 @@ import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { ClearableInput as Input } from "@/components/admin/ui/clearable-input"; import { Textarea } from "@/components/ui/textarea"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; -import { useAutosave } from "@/hooks/use-autosave"; +import { useManualSave } from "@/hooks/use-manual-save"; import { adminDraftKey } from "@/lib/editor/local-draft"; import { weaponSectionSchema } from "@/lib/guides/schemas"; import { supportsWeaponType } from "@/lib/catalog/lunaris"; @@ -129,7 +129,7 @@ export function WeaponEditor({ guide, weapons, characters, initial }: EditorBase const weaponIssue = (...path: (string | number)[]) => !validation.success && hasIssuePrefix(validation.error.issues, ...path); const localDraft = { note, groups, activeGroupIndex }; - const autosave = useAutosave({ + const manualSave = useManualSave({ guideId: guide.id, scope: "weapon", value, @@ -161,7 +161,7 @@ export function WeaponEditor({ guide, weapons, characters, initial }: EditorBase Weapon tier list ลากเพื่อเรียงอาวุธจากดีที่สุดลงมา และกรอกเปอร์เซ็นต์อย่างน้อยหนึ่งช่อง - ระบบจะบันทึกอัตโนมัติ + กดปุ่มบันทึกเพื่อบันทึกการเปลี่ยนแปลง @@ -487,18 +487,19 @@ export function WeaponEditor({ guide, weapons, characters, initial }: EditorBase เพิ่มอาวุธ - diff --git a/components/admin/guide-overview-form.tsx b/components/admin/guide-overview-form.tsx index 90571a4..0af5fdc 100644 --- a/components/admin/guide-overview-form.tsx +++ b/components/admin/guide-overview-form.tsx @@ -8,7 +8,7 @@ import { GripVerticalIcon, PlusIcon, Trash2Icon } from "lucide-react"; import { useState } from "react"; import { saveGuideOverview } from "@/app/admin/actions"; -import { AutosaveStatus } from "@/components/admin/autosave-status"; +import { SaveStatus } from "@/components/admin/save-status"; import { MediaUploader } from "@/components/admin/media-uploader"; import { MaterialPoster, type PosterBlendMode } from "@/components/public/material-poster"; import { Button } from "@/components/ui/button"; @@ -38,7 +38,7 @@ import { } from "@/components/ui/combobox"; import { Slider } from "@/components/ui/slider"; import { Textarea } from "@/components/ui/textarea"; -import { useAutosave } from "@/hooks/use-autosave"; +import { useManualSave } from "@/hooks/use-manual-save"; import { adminDraftKey } from "@/lib/editor/local-draft"; import { parseYouTubeUrl } from "@/lib/guides/youtube"; import { detectThemeColorsFromPixels } from "@/lib/overview-image"; @@ -204,7 +204,7 @@ export function GuideOverviewForm({ const invalidCover = !cover.id; const invalidOverview = !overview.trim(); const localDraft = { overview, youtubeLinks, cover, posterArt, posterArtOffset, posterArtScale, posterArtRotation, posterTopColors, posterBottomColors, bleeding }; - const autosave = useAutosave({ + const manualSave = useManualSave({ guideId: guide.id, scope: "overview", value, @@ -503,14 +503,15 @@ export function GuideOverviewForm({ - diff --git a/components/admin/save-status.test.ts b/components/admin/save-status.test.ts new file mode 100644 index 0000000..1fd7571 --- /dev/null +++ b/components/admin/save-status.test.ts @@ -0,0 +1,64 @@ +// @vitest-environment happy-dom +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { SaveStatus } from "./save-status"; + +let root: Root; +let container: HTMLDivElement; +const onSave = vi.fn(); +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.clearAllMocks(); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); +async function render(props: Partial[0]> = {}) { + await act(async () => root.render(createElement(SaveStatus, { state: "saved", canSave: false, onSave, ...props }))); +} + +describe("floating SaveStatus", () => { + it("keeps Save visible when saved and enables it for unsaved changes", async () => { + await render(); + expect(container.querySelector("button")?.textContent).toContain("บันทึก"); + expect(container.querySelector("button")?.disabled).toBe(true); + expect(container.querySelector(".fixed")).not.toBeNull(); + await render({ state: "unsaved", canSave: true }); + await act(async () => container.querySelector("button")!.click()); + expect(onSave).toHaveBeenCalledTimes(1); + }); + + it("shows saving feedback and blocks more save requests", async () => { + await render({ state: "saving", canSave: false }); + expect(container.querySelector("button")?.textContent).toContain("กำลังบันทึก"); + await act(async () => container.querySelector("button")!.click()); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("shows validation issues while keeping Save disabled", async () => { + await render({ state: "incomplete", issues: ["Missing title"] }); + const buttons = container.querySelectorAll("button"); + expect(buttons[1].disabled).toBe(true); + await act(async () => buttons[0].click()); + expect(container.textContent).toContain("Missing title"); + expect(buttons[0].getAttribute("aria-expanded")).toBe("true"); + }); + + it("keeps conflict actions available and blocks ordinary Save", async () => { + const onReloadLatest = vi.fn(); + const onOverwrite = vi.fn(); + await render({ state: "conflict", conflictVersion: 8, canOverwrite: true, onReloadLatest, onOverwrite }); + const buttons = container.querySelectorAll("button"); + await act(async () => { buttons[0].click(); buttons[1].click(); }); + expect(onReloadLatest).toHaveBeenCalledTimes(1); + expect(onOverwrite).toHaveBeenCalledTimes(1); + expect(buttons[2].disabled).toBe(true); + }); +}); diff --git a/components/admin/autosave-status.tsx b/components/admin/save-status.tsx similarity index 71% rename from components/admin/autosave-status.tsx rename to components/admin/save-status.tsx index b511d46..0634e48 100644 --- a/components/admin/autosave-status.tsx +++ b/components/admin/save-status.tsx @@ -1,29 +1,31 @@ -import { AlertCircleIcon, CheckIcon, ClockIcon } from "lucide-react"; +"use client"; + +import { AlertCircleIcon, CheckIcon, ClockIcon, SaveIcon } from "lucide-react"; import { useState } from "react"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; -import type { AutosaveState } from "@/hooks/use-autosave"; +import type { SaveState } from "@/hooks/use-manual-save"; -export function AutosaveStatus({ - state, - issues = [], - onSave, - conflictVersion, - canOverwrite = false, - onReloadLatest, - onOverwrite, -}: { - state: AutosaveState; +interface SaveFeedbackProps { + state: SaveState; issues?: readonly string[]; - onSave?: () => void; conflictVersion?: number | null; canOverwrite?: boolean; onReloadLatest?: () => void; onOverwrite?: () => void; -}) { +} + +function SaveFeedback({ + state, + issues = [], + conflictVersion, + canOverwrite = false, + onReloadLatest, + onOverwrite, +}: SaveFeedbackProps) { const [showIssues, setShowIssues] = useState(false); if (state === "saving") @@ -37,7 +39,7 @@ export function AutosaveStatus({ return ( - รอบันทึก… + ยังไม่ได้บันทึก ); if (state === "incomplete") @@ -60,7 +62,7 @@ export function AutosaveStatus({

- แก้ไขรายการด้านล่าง แล้วระบบจะบันทึกให้อัตโนมัติ + แก้ไขรายการด้านล่าง แล้วกดปุ่มบันทึก ข้อมูลที่กรอกไว้ยังไม่หาย

{issues.length ? ( @@ -79,7 +81,7 @@ export function AutosaveStatus({ if (state === "conflict") return ( @@ -88,7 +90,7 @@ export function AutosaveStatus({

- ระบบหยุด Autosave ไว้เพื่อไม่ให้ Draft นี้เขียนทับข้อมูลล่าสุด + กรุณาเลือกโหลดข้อมูลล่าสุดหรือเขียนทับด้วย Draft นี้ {conflictVersion ? ` (version ${conflictVersion})` : ""}

@@ -123,13 +125,8 @@ export function AutosaveStatus({
- บันทึกอัตโนมัติไม่สำเร็จ + บันทึกไม่สำเร็จ - {onSave ? ( - - ) : null}
); return ( @@ -139,3 +136,29 @@ export function AutosaveStatus({ ); } + +export function SaveStatus({ + canSave, + onSave, + ...props +}: SaveFeedbackProps & { canSave: boolean; onSave: () => void }) { + return ( + <> + : null} ); } @@ -1058,6 +1071,37 @@ export function StygianEditor({ const [version, setVersion] = useState(schedule.version); const [level, setLevel] = useState<105 | 110>(105); const [bossPosition, setBossPosition] = useState(0); + const [visitedTabs, setVisitedTabs] = useState(() => new Set(["105:0"])); + const [dirtyTabs, setDirtyTabs] = useState>({}); + const onDirtyChange = useCallback((key: string, dirty: boolean) => { + setDirtyTabs((current) => current[key] === dirty ? current : { ...current, [key]: dirty }); + }, []); + const hasUnsavedChanges = Object.values(dirtyTabs).some(Boolean); + + useEffect(() => { + if (!hasUnsavedChanges) return; + const warnBeforeLeaving = (event: BeforeUnloadEvent) => { + event.preventDefault(); + event.returnValue = ""; + }; + const confirmNavigation = (event: MouseEvent) => { + const link = event.target instanceof Element ? event.target.closest("a[href]") : null; + if (!(link instanceof HTMLAnchorElement) || link.target === "_blank" || link.hasAttribute("download") || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || event.button !== 0) return; + if (link.getAttribute("href")?.startsWith("#")) return; + if (!window.confirm("มีข้อมูลที่ยังไม่ได้บันทึก ต้องการออกจากหน้านี้หรือไม่?")) { + event.preventDefault(); + event.stopPropagation(); + Object.assign(event, { pageProgressCancelled: true }); + } + }; + window.addEventListener("beforeunload", warnBeforeLeaving); + document.addEventListener("click", confirmNavigation, true); + return () => { + window.removeEventListener("beforeunload", warnBeforeLeaving); + document.removeEventListener("click", confirmNavigation, true); + }; + }, [hasUnsavedChanges]); + return (