@rimelight/cms 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +19 -0
  2. package/package.json +69 -0
  3. package/src/astro/BlockRenderer.astro +111 -0
  4. package/src/astro/PageRenderer.astro +23 -0
  5. package/src/astro/blocks/CalloutBlock.astro +78 -0
  6. package/src/astro/blocks/CardBlock.astro +43 -0
  7. package/src/astro/blocks/CardsBlock.astro +24 -0
  8. package/src/astro/blocks/CodeBlock.astro +16 -0
  9. package/src/astro/blocks/DialogueBlock.astro +23 -0
  10. package/src/astro/blocks/FileTreeBlock.astro +45 -0
  11. package/src/astro/blocks/ImageBlock.astro +17 -0
  12. package/src/astro/blocks/OrderedListBlock.astro +55 -0
  13. package/src/astro/blocks/ParagraphBlock.astro +65 -0
  14. package/src/astro/blocks/SceneBlock.astro +65 -0
  15. package/src/astro/blocks/ScriptBlock.astro +59 -0
  16. package/src/astro/blocks/SectionBlock.astro +46 -0
  17. package/src/astro/blocks/StepItemBlock.astro +25 -0
  18. package/src/astro/blocks/StepsBlock.astro +14 -0
  19. package/src/astro/blocks/TabItemBlock.astro +19 -0
  20. package/src/astro/blocks/TableBlock.astro +49 -0
  21. package/src/astro/blocks/TabsBlock.astro +67 -0
  22. package/src/astro/blocks/UnorderedListBlock.astro +55 -0
  23. package/src/auth/editor-guards.ts +148 -0
  24. package/src/auth/filter-blocks.ts +44 -0
  25. package/src/auth/filter-templates.ts +81 -0
  26. package/src/auth/permissions.ts +40 -0
  27. package/src/cache/purge.ts +37 -0
  28. package/src/client/components/RimelightBlock.tsx +487 -0
  29. package/src/client/components/RimelightBlockPicker.tsx +305 -0
  30. package/src/client/components/RimelightEditor.tsx +131 -0
  31. package/src/client/components/RimelightEditorLayout.tsx +143 -0
  32. package/src/client/components/RimelightPreview.tsx +354 -0
  33. package/src/client/components/RimelightPropertiesPanel.tsx +408 -0
  34. package/src/client/components/RimelightTemplateEditor.tsx +245 -0
  35. package/src/client/components/editors/CalloutEditor.tsx +185 -0
  36. package/src/client/components/editors/CardEditor.tsx +60 -0
  37. package/src/client/components/editors/CardsEditor.tsx +132 -0
  38. package/src/client/components/editors/CodeEditor.tsx +101 -0
  39. package/src/client/components/editors/DialogueEditor.tsx +52 -0
  40. package/src/client/components/editors/FileTreeEditor.tsx +65 -0
  41. package/src/client/components/editors/ImageEditor.tsx +70 -0
  42. package/src/client/components/editors/ParagraphEditor.tsx +314 -0
  43. package/src/client/components/editors/SceneEditor.tsx +245 -0
  44. package/src/client/components/editors/ScriptEditor.tsx +245 -0
  45. package/src/client/components/editors/SectionEditor.tsx +139 -0
  46. package/src/client/components/editors/StepItemEditor.tsx +117 -0
  47. package/src/client/components/editors/StepsEditor.tsx +105 -0
  48. package/src/client/components/editors/TabItemEditor.tsx +113 -0
  49. package/src/client/components/editors/TableEditor.tsx +181 -0
  50. package/src/client/components/editors/TabsEditor.tsx +105 -0
  51. package/src/client/components/index.ts +7 -0
  52. package/src/client/loader.ts +19 -0
  53. package/src/client/state/autosave.ts +122 -0
  54. package/src/client/state/editor-store.ts +411 -0
  55. package/src/client/state/history.ts +251 -0
  56. package/src/client/state/lock-manager.ts +89 -0
  57. package/src/client/styles/cms-editor.css +653 -0
  58. package/src/client/toast.ts +12 -0
  59. package/src/client/utils/sortable.ts +184 -0
  60. package/src/client/utils/splitpane.ts +87 -0
  61. package/src/client/utils/tree-sanitizer.ts +33 -0
  62. package/src/core/page-definitions.ts +502 -0
  63. package/src/core/registry.ts +99 -0
  64. package/src/core/template-sync.ts +75 -0
  65. package/src/core/types/blocks.ts +367 -0
  66. package/src/core/types/inline.ts +23 -0
  67. package/src/core/types/pages.ts +36 -0
  68. package/src/core/types/templates.ts +32 -0
  69. package/src/core/types/versioning.ts +44 -0
  70. package/src/core/validator.ts +28 -0
  71. package/src/env.d.ts +4 -0
  72. package/src/index.ts +47 -0
  73. package/src/markdown/index.ts +2 -0
  74. package/src/markdown/parser.ts +347 -0
  75. package/src/markdown/serializer.ts +219 -0
  76. package/src/migration.ts +155 -0
  77. package/src/schema/index.ts +8 -0
  78. package/src/schema/page_draft_locks.ts +13 -0
  79. package/src/schema/page_drafts.ts +18 -0
  80. package/src/schema/page_templates.ts +27 -0
  81. package/src/schema/page_version_approvals.ts +12 -0
  82. package/src/schema/page_version_comments.ts +14 -0
  83. package/src/schema/page_versions.ts +34 -0
  84. package/src/schema/pages.ts +42 -0
  85. package/src/schema/search.ts +23 -0
  86. package/src/search/query.ts +10 -0
  87. package/src/search/sync.ts +32 -0
@@ -0,0 +1,113 @@
1
+ import { onMount, onCleanup, For, type Component } from "solid-js"
2
+ import type { BaseBlock } from "../../../core/types/blocks"
3
+ import { initBlockSortable, cleanAllSortableClasses } from "../../utils/sortable"
4
+ import RLSInput from "@rimelight/ui/components/input/RLSInput.tsx"
5
+ import RLSButton from "@rimelight/ui/components/button/RLSButton.tsx"
6
+
7
+ export interface TabItemEditorProps {
8
+ blockId?: string
9
+ props: Record<string, any>
10
+ children?: BaseBlock[] | (() => BaseBlock[])
11
+ sectionDepth?: number
12
+ onPropsChange: (newProps: Record<string, any>) => void
13
+ onRelocate?: ((id: string, newIndex: number, targetParentId?: string | null) => void) | undefined
14
+ onOpenPicker?: () => void
15
+ renderChildBlock?: (block: BaseBlock, depth: number) => any
16
+ }
17
+
18
+ export const TabItemEditor: Component<TabItemEditorProps> = (componentProps) => {
19
+ let childrenContainer: HTMLDivElement | undefined = undefined
20
+ let sortableInstance: any = null
21
+
22
+ const childrenBlocks = (): BaseBlock[] => {
23
+ if (typeof componentProps.children === "function") {
24
+ return componentProps.children() || []
25
+ }
26
+ return componentProps.children || componentProps.props.children || []
27
+ }
28
+
29
+ onMount(() => {
30
+ if (childrenContainer) {
31
+ sortableInstance = initBlockSortable(childrenContainer, {
32
+ filter: ".cms-empty-placeholder, .locked",
33
+ onReorder: (oldIdx: number, newIdx: number) => {
34
+ cleanAllSortableClasses()
35
+ const list = [...childrenBlocks()]
36
+ const item = list.splice(oldIdx, 1)[0]
37
+ if (item) {
38
+ list.splice(newIdx, 0, item)
39
+ componentProps.onPropsChange({ ...componentProps.props, children: list })
40
+ }
41
+ requestAnimationFrame(() => cleanAllSortableClasses())
42
+ setTimeout(cleanAllSortableClasses, 50)
43
+ setTimeout(cleanAllSortableClasses, 200)
44
+ },
45
+ onAdd: (blockId: string, newIdx: number) => {
46
+ cleanAllSortableClasses()
47
+ if (componentProps.onRelocate && componentProps.blockId) {
48
+ componentProps.onRelocate(blockId, newIdx, componentProps.blockId)
49
+ }
50
+ requestAnimationFrame(() => cleanAllSortableClasses())
51
+ setTimeout(cleanAllSortableClasses, 50)
52
+ setTimeout(cleanAllSortableClasses, 200)
53
+ },
54
+ onRemove: (_blockId: string, _oldIdx: number) => {
55
+ cleanAllSortableClasses()
56
+ }
57
+ })
58
+ }
59
+
60
+ onCleanup(() => {
61
+ if (sortableInstance) sortableInstance.destroy()
62
+ })
63
+ })
64
+
65
+ const saveLabel = (label: string) => {
66
+ componentProps.onPropsChange({
67
+ ...componentProps.props,
68
+ label,
69
+ value: componentProps.props.value || label.toLowerCase().replace(/\s+/g, "-")
70
+ })
71
+ }
72
+
73
+ return (
74
+ <div class="rimelight-tab-item-editor flex flex-col gap-2 p-3 rounded-lg border border-default bg-muted/10 w-full">
75
+ <div class="flex items-center gap-2">
76
+ <span class="text-xs font-semibold text-muted-foreground uppercase">Tab Label:</span>
77
+ <div class="flex-1 max-w-xs">
78
+ <RLSInput
79
+ type="text"
80
+ placeholder="e.g. pnpm, Astro, TypeScript"
81
+ value={componentProps.props.label || ""}
82
+ onBlur={(e) => saveLabel(e.currentTarget.value)}
83
+ size="sm"
84
+ />
85
+ </div>
86
+ </div>
87
+
88
+ <div
89
+ ref={(el) => (childrenContainer = el)}
90
+ class="p-2 rounded-lg border border-dashed border-default flex flex-col gap-2 min-h-[36px] bg-background"
91
+ >
92
+ {childrenBlocks().length === 0 ? (
93
+ <div class="text-center py-2">
94
+ <span class="text-xs text-muted-foreground block">Empty tab content</span>
95
+ <RLSButton
96
+ variant="link"
97
+ color="primary"
98
+ size="xs"
99
+ onClick={() => componentProps.onOpenPicker?.()}
100
+ label="+ Add block"
101
+ />
102
+ </div>
103
+ ) : (
104
+ <For each={childrenBlocks()}>
105
+ {(childBlock) =>
106
+ componentProps.renderChildBlock?.(childBlock, componentProps.sectionDepth || 0)
107
+ }
108
+ </For>
109
+ )}
110
+ </div>
111
+ </div>
112
+ )
113
+ }
@@ -0,0 +1,181 @@
1
+ import { For, type Component } from "solid-js"
2
+ import type { TableColumnDef } from "../../../core/types/blocks"
3
+ import RLSInput from "@rimelight/ui/components/input/RLSInput.tsx"
4
+ import RLSButton from "@rimelight/ui/components/button/RLSButton.tsx"
5
+
6
+ export interface TableEditorProps {
7
+ props: Record<string, any>
8
+ onPropsChange: (newProps: Record<string, any>) => void
9
+ }
10
+
11
+ export const TableEditor: Component<TableEditorProps> = (componentProps) => {
12
+ const columns = (): TableColumnDef[] =>
13
+ componentProps.props.columns || [
14
+ { key: "col1", header: "Header 1", align: "left" },
15
+ { key: "col2", header: "Header 2", align: "left" }
16
+ ]
17
+
18
+ const rows = (): Record<string, string>[] =>
19
+ componentProps.props.rows || [{ col1: "Cell 1", col2: "Cell 2" }]
20
+
21
+ const save = (newCols: TableColumnDef[], newRows: Record<string, string>[], caption?: string) => {
22
+ componentProps.onPropsChange({
23
+ ...componentProps.props,
24
+ columns: newCols,
25
+ rows: newRows,
26
+ caption: caption !== undefined ? caption : componentProps.props.caption || ""
27
+ })
28
+ }
29
+
30
+ const addColumn = () => {
31
+ const nextIdx = columns().length + 1
32
+ const key = `col${nextIdx}`
33
+ const newCols = [...columns(), { key, header: `Header ${nextIdx}`, align: "left" as const }]
34
+ const newRows = rows().map((r) => ({ ...r, [key]: "" }))
35
+ save(newCols, newRows)
36
+ }
37
+
38
+ const removeColumn = (colIdx: number) => {
39
+ if (columns().length <= 1) return
40
+ const targetKey = columns()[colIdx]!.key
41
+ const newCols = columns().filter((_, i) => i !== colIdx)
42
+ const newRows = rows().map((r) => {
43
+ const next = { ...r }
44
+ delete next[targetKey]
45
+ return next
46
+ })
47
+ save(newCols, newRows)
48
+ }
49
+
50
+ const addRow = () => {
51
+ const newRow: Record<string, string> = {}
52
+ for (const col of columns()) {
53
+ newRow[col.key] = ""
54
+ }
55
+ save(columns(), [...rows(), newRow])
56
+ }
57
+
58
+ const removeRow = (rowIdx: number) => {
59
+ if (rows().length <= 1) return
60
+ save(
61
+ columns(),
62
+ rows().filter((_, i) => i !== rowIdx)
63
+ )
64
+ }
65
+
66
+ const updateHeader = (colIdx: number, val: string) => {
67
+ const newCols = [...columns()]
68
+ newCols[colIdx] = { ...newCols[colIdx]!, header: val }
69
+ save(newCols, rows())
70
+ }
71
+
72
+ const updateCell = (rowIdx: number, colKey: string, val: string) => {
73
+ const newRows = [...rows()]
74
+ newRows[rowIdx] = { ...newRows[rowIdx], [colKey]: val }
75
+ save(columns(), newRows)
76
+ }
77
+
78
+ return (
79
+ <div class="rimelight-table-editor flex flex-col gap-3 p-3 rounded-xl border border-default/70 w-full overflow-x-auto">
80
+ <div class="flex items-center justify-between">
81
+ <span class="text-xs font-bold uppercase tracking-wider text-muted-foreground">Table</span>
82
+ <div class="flex items-center gap-2">
83
+ <RLSButton
84
+ variant="outline"
85
+ color="neutral"
86
+ size="xs"
87
+ onClick={addColumn}
88
+ leadingIcon="plus"
89
+ label="Add Column"
90
+ />
91
+ <RLSButton
92
+ variant="outline"
93
+ color="neutral"
94
+ size="xs"
95
+ onClick={addRow}
96
+ leadingIcon="plus"
97
+ label="Add Row"
98
+ />
99
+ </div>
100
+ </div>
101
+
102
+ <div class="overflow-x-auto border border-default rounded-lg">
103
+ <table class="w-full text-xs border-collapse">
104
+ <thead>
105
+ <tr class="bg-muted/30 border-b border-default">
106
+ <For each={columns()}>
107
+ {(col, colIdx) => (
108
+ <th class="p-1 min-w-[120px] font-medium text-left">
109
+ <div class="flex items-center gap-1">
110
+ <input
111
+ type="text"
112
+ aria-label={`Column ${colIdx() + 1} header`}
113
+ class="w-full bg-transparent border border-transparent hover:border-default focus:border-primary rounded px-2 py-1 font-semibold outline-none text-foreground"
114
+ value={col.header}
115
+ onBlur={(e) => updateHeader(colIdx(), e.currentTarget.value)}
116
+ />
117
+ {columns().length > 1 && (
118
+ <button
119
+ type="button"
120
+ aria-label="Delete column"
121
+ class="text-muted-foreground hover:text-danger p-1 text-xs"
122
+ onClick={() => removeColumn(colIdx())}
123
+ title="Delete column"
124
+ >
125
+
126
+ </button>
127
+ )}
128
+ </div>
129
+ </th>
130
+ )}
131
+ </For>
132
+ <th class="w-8 p-1" aria-label="Row actions"></th>
133
+ </tr>
134
+ </thead>
135
+ <tbody>
136
+ <For each={rows()}>
137
+ {(row, rowIdx) => (
138
+ <tr class="border-b border-default/50 hover:bg-muted/20">
139
+ <For each={columns()}>
140
+ {(col) => (
141
+ <td class="p-1">
142
+ <input
143
+ type="text"
144
+ aria-label={`${col.header} row ${rowIdx() + 1}`}
145
+ class="w-full bg-transparent border border-transparent hover:border-default focus:border-primary rounded px-2 py-1 outline-none text-foreground"
146
+ value={row[col.key] || ""}
147
+ onBlur={(e) => updateCell(rowIdx(), col.key, e.currentTarget.value)}
148
+ />
149
+ </td>
150
+ )}
151
+ </For>
152
+ <td class="p-1 text-center">
153
+ {rows().length > 1 && (
154
+ <button
155
+ type="button"
156
+ aria-label="Delete row"
157
+ class="text-muted-foreground hover:text-danger p-1 text-xs"
158
+ onClick={() => removeRow(rowIdx())}
159
+ title="Delete row"
160
+ >
161
+
162
+ </button>
163
+ )}
164
+ </td>
165
+ </tr>
166
+ )}
167
+ </For>
168
+ </tbody>
169
+ </table>
170
+ </div>
171
+
172
+ <RLSInput
173
+ type="text"
174
+ placeholder="Table caption (optional)"
175
+ value={componentProps.props.caption || ""}
176
+ onBlur={(e) => save(columns(), rows(), e.currentTarget.value)}
177
+ size="sm"
178
+ />
179
+ </div>
180
+ )
181
+ }
@@ -0,0 +1,105 @@
1
+ import { onMount, onCleanup, For, type Component } from "solid-js"
2
+ import type { BaseBlock } from "../../../core/types/blocks"
3
+ import { initBlockSortable, cleanAllSortableClasses } from "../../utils/sortable"
4
+ import RLSButton from "@rimelight/ui/components/button/RLSButton.tsx"
5
+
6
+ export interface TabsEditorProps {
7
+ blockId?: string
8
+ props: Record<string, any>
9
+ children?: BaseBlock[] | (() => BaseBlock[])
10
+ sectionDepth?: number
11
+ onPropsChange: (newProps: Record<string, any>) => void
12
+ onRelocate?: ((id: string, newIndex: number, targetParentId?: string | null) => void) | undefined
13
+ onOpenPicker?: () => void
14
+ renderChildBlock?: (block: BaseBlock, depth: number) => any
15
+ }
16
+
17
+ export const TabsEditor: Component<TabsEditorProps> = (componentProps) => {
18
+ let childrenContainer: HTMLDivElement | undefined = undefined
19
+ let sortableInstance: any = null
20
+
21
+ const childrenBlocks = (): BaseBlock[] => {
22
+ if (typeof componentProps.children === "function") {
23
+ return componentProps.children() || []
24
+ }
25
+ return componentProps.children || componentProps.props.children || []
26
+ }
27
+
28
+ onMount(() => {
29
+ if (childrenContainer) {
30
+ sortableInstance = initBlockSortable(childrenContainer, {
31
+ filter: ".cms-empty-placeholder, .locked",
32
+ onReorder: (oldIdx: number, newIdx: number) => {
33
+ cleanAllSortableClasses()
34
+ const list = [...childrenBlocks()]
35
+ const item = list.splice(oldIdx, 1)[0]
36
+ if (item) {
37
+ list.splice(newIdx, 0, item)
38
+ componentProps.onPropsChange({ ...componentProps.props, children: list })
39
+ }
40
+ requestAnimationFrame(() => cleanAllSortableClasses())
41
+ setTimeout(cleanAllSortableClasses, 50)
42
+ setTimeout(cleanAllSortableClasses, 200)
43
+ },
44
+ onAdd: (blockId: string, newIdx: number) => {
45
+ cleanAllSortableClasses()
46
+ if (componentProps.onRelocate && componentProps.blockId) {
47
+ componentProps.onRelocate(blockId, newIdx, componentProps.blockId)
48
+ }
49
+ requestAnimationFrame(() => cleanAllSortableClasses())
50
+ setTimeout(cleanAllSortableClasses, 50)
51
+ setTimeout(cleanAllSortableClasses, 200)
52
+ },
53
+ onRemove: (_blockId: string, _oldIdx: number) => {
54
+ cleanAllSortableClasses()
55
+ }
56
+ })
57
+ }
58
+
59
+ onCleanup(() => {
60
+ if (sortableInstance) sortableInstance.destroy()
61
+ })
62
+ })
63
+
64
+ return (
65
+ <div class="rimelight-tabs-editor flex flex-col gap-3 p-3 rounded-xl border border-default/70 w-full">
66
+ <div class="flex items-center justify-between">
67
+ <span class="text-xs font-bold uppercase tracking-wider text-muted-foreground">
68
+ Tabs Container
69
+ </span>
70
+ <RLSButton
71
+ variant="outline"
72
+ color="neutral"
73
+ size="xs"
74
+ onClick={() => componentProps.onOpenPicker?.()}
75
+ leadingIcon="plus"
76
+ label="Add Tab Item"
77
+ />
78
+ </div>
79
+
80
+ <div
81
+ ref={(el) => (childrenContainer = el)}
82
+ class="p-2 rounded-lg border border-dashed border-default flex flex-col gap-2 min-h-[36px]"
83
+ >
84
+ {childrenBlocks().length === 0 ? (
85
+ <div class="text-center py-3">
86
+ <span class="text-xs text-muted-foreground block mb-2">No tabs in container</span>
87
+ <RLSButton
88
+ variant="link"
89
+ color="primary"
90
+ size="xs"
91
+ onClick={() => componentProps.onOpenPicker?.()}
92
+ label="+ Add Tab Item"
93
+ />
94
+ </div>
95
+ ) : (
96
+ <For each={childrenBlocks()}>
97
+ {(childBlock) =>
98
+ componentProps.renderChildBlock?.(childBlock, componentProps.sectionDepth || 0)
99
+ }
100
+ </For>
101
+ )}
102
+ </div>
103
+ </div>
104
+ )
105
+ }
@@ -0,0 +1,7 @@
1
+ export * from "./RimelightBlock"
2
+ export * from "./RimelightBlockPicker"
3
+ export * from "./RimelightEditor"
4
+ export * from "./RimelightEditorLayout"
5
+ export * from "./RimelightPreview"
6
+ export * from "./RimelightPropertiesPanel"
7
+ export * from "./RimelightTemplateEditor"
@@ -0,0 +1,19 @@
1
+ import "./styles/cms-editor.css"
2
+
3
+ // SolidJS components
4
+ export { RimelightEditorLayout } from "./components/RimelightEditorLayout"
5
+ export { RimelightEditor } from "./components/RimelightEditor"
6
+ export { RimelightBlock } from "./components/RimelightBlock"
7
+ export { RimelightPreview } from "./components/RimelightPreview"
8
+ export { RimelightPropertiesPanel } from "./components/RimelightPropertiesPanel"
9
+ export { RimelightBlockPicker } from "./components/RimelightBlockPicker"
10
+ export { RimelightTemplateEditor } from "./components/RimelightTemplateEditor"
11
+
12
+ export { ParagraphEditor } from "./components/editors/ParagraphEditor"
13
+ export { CalloutEditor } from "./components/editors/CalloutEditor"
14
+ export { CodeEditor } from "./components/editors/CodeEditor"
15
+ export { DialogueEditor } from "./components/editors/DialogueEditor"
16
+ export { ImageEditor } from "./components/editors/ImageEditor"
17
+ export { SceneEditor } from "./components/editors/SceneEditor"
18
+ export { ScriptEditor } from "./components/editors/ScriptEditor"
19
+ export { SectionEditor } from "./components/editors/SectionEditor"
@@ -0,0 +1,122 @@
1
+ export type AutosaveStatus = "saved" | "unsaved" | "saving" | "error"
2
+
3
+ export interface AutosaveOptions {
4
+ pageId: string
5
+ saveEndpoint?: string
6
+ debounceMs?: number
7
+ onStatusChange?: ((status: AutosaveStatus, errorMsg?: string) => void) | undefined
8
+ }
9
+
10
+ export class AutosaveController {
11
+ private pageId: string
12
+ private saveEndpoint: string
13
+ private debounceMs: number
14
+ private timer: number | null = null
15
+ private status: AutosaveStatus = "saved"
16
+ private lastContent: any = null
17
+ private onStatusChange?: ((status: AutosaveStatus, errorMsg?: string) => void) | undefined
18
+
19
+ constructor(options: AutosaveOptions) {
20
+ this.pageId = options.pageId
21
+ this.saveEndpoint = options.saveEndpoint || `/api/cms/pages/${this.pageId}/draft`
22
+ this.debounceMs = options.debounceMs || 500
23
+ this.onStatusChange = options.onStatusChange
24
+
25
+ if (typeof window !== "undefined") {
26
+ window.addEventListener("beforeunload", () => this.flushSync())
27
+ document.addEventListener("visibilitychange", () => {
28
+ if (document.visibilityState === "hidden") {
29
+ this.flushSync()
30
+ }
31
+ })
32
+ }
33
+ }
34
+
35
+ getPageId(): string {
36
+ return this.pageId
37
+ }
38
+
39
+ notifyChange(content: any): void {
40
+ this.lastContent = content
41
+ this.saveToLocalStorage(content)
42
+ this.setStatus("unsaved")
43
+
44
+ if (this.timer !== null) {
45
+ clearTimeout(this.timer)
46
+ }
47
+
48
+ this.timer = window.setTimeout(() => {
49
+ void this.save(content)
50
+ }, this.debounceMs)
51
+ }
52
+
53
+ async saveImmediate(content: any): Promise<boolean> {
54
+ this.lastContent = content
55
+ this.saveToLocalStorage(content)
56
+ if (this.timer !== null) {
57
+ clearTimeout(this.timer)
58
+ this.timer = null
59
+ }
60
+ return this.save(content)
61
+ }
62
+
63
+ async save(content: any): Promise<boolean> {
64
+ this.setStatus("saving")
65
+ try {
66
+ const res = await fetch(this.saveEndpoint, {
67
+ method: "POST",
68
+ headers: { "Content-Type": "application/json" },
69
+ body: JSON.stringify({ content })
70
+ })
71
+
72
+ if (res.ok) {
73
+ this.setStatus("saved")
74
+ return true
75
+ } else {
76
+ const data = await res.json().catch(() => ({}))
77
+ this.setStatus("error", data.message || "Failed to save draft")
78
+ return false
79
+ }
80
+ } catch (err: any) {
81
+ this.setStatus("error", err?.message || "Network error")
82
+ return false
83
+ }
84
+ }
85
+
86
+ private saveToLocalStorage(content: any): void {
87
+ if (typeof localStorage === "undefined" || !this.pageId) return
88
+ try {
89
+ localStorage.setItem(`cms_draft_backup_${this.pageId}`, JSON.stringify(content))
90
+ } catch (e) {
91
+ console.warn("Failed to write draft backup to localStorage", e)
92
+ }
93
+ }
94
+
95
+ private flushSync(): void {
96
+ if (this.status === "unsaved" && this.lastContent) {
97
+ if (this.timer !== null) {
98
+ clearTimeout(this.timer)
99
+ this.timer = null
100
+ }
101
+ try {
102
+ void fetch(this.saveEndpoint, {
103
+ method: "POST",
104
+ headers: { "Content-Type": "application/json" },
105
+ body: JSON.stringify({ content: this.lastContent }),
106
+ keepalive: true
107
+ })
108
+ } catch {}
109
+ }
110
+ }
111
+
112
+ getStatus(): AutosaveStatus {
113
+ return this.status
114
+ }
115
+
116
+ private setStatus(status: AutosaveStatus, errorMsg?: string) {
117
+ this.status = status
118
+ if (this.onStatusChange) {
119
+ this.onStatusChange(status, errorMsg)
120
+ }
121
+ }
122
+ }