@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,408 @@
1
+ import { createSignal, onMount, For, Show, type Component } from "solid-js"
2
+ import {
3
+ getPageDefinition,
4
+ type PageDefinition,
5
+ type FieldDefinition
6
+ } from "../../core/page-definitions"
7
+ import RLSInput from "@rimelight/ui/components/input/RLSInput.tsx"
8
+ import RLSSelect from "@rimelight/ui/components/select/RLSSelect.tsx"
9
+ import RLSButton from "@rimelight/ui/components/button/RLSButton.tsx"
10
+ import RLSBadge from "@rimelight/ui/components/badge/RLSBadge.tsx"
11
+ import RLSModal from "@rimelight/ui/components/modal/RLSModal.tsx"
12
+ import RLSIcon from "@rimelight/ui/components/icon/RLSIcon.tsx"
13
+
14
+ function getTextValue(value: any): string {
15
+ if (typeof value === "string") return value
16
+ if (typeof value === "object" && value !== null && "en" in value) return value.en
17
+ if (typeof value === "number") return String(value)
18
+ return ""
19
+ }
20
+
21
+ export interface RimelightPropertiesPanelProps {
22
+ pageType?: string
23
+ initialProperties?: Record<string, any>
24
+ title?: string
25
+ slug?: string
26
+ icon?: string
27
+ tags?: string[]
28
+ onPropertiesChange?: (props: Record<string, any>) => void
29
+ class?: string
30
+ }
31
+
32
+ export const RimelightPropertiesPanel: Component<RimelightPropertiesPanelProps> = (
33
+ componentProps
34
+ ) => {
35
+ const pageType = () => componentProps.pageType || "blog"
36
+ const definition = (): PageDefinition | null => getPageDefinition(pageType())
37
+
38
+ const [properties, setProperties] = createSignal<Record<string, any>>(
39
+ componentProps.initialProperties || {}
40
+ )
41
+ const [expandedGroups, setExpandedGroups] = createSignal<Record<string, boolean>>({})
42
+ const [arrayInputs, setArrayInputs] = createSignal<Record<string, string>>({})
43
+ const [links, setLinks] = createSignal<Array<{ label: string; to: string; icon?: string }>>(
44
+ componentProps.initialProperties?.links || []
45
+ )
46
+
47
+ const [isLinkModalOpen, setIsLinkModalOpen] = createSignal(false)
48
+ const [editingLinkIndex, setEditingLinkIndex] = createSignal<number | null>(null)
49
+ const [linkDraft, setLinkDraft] = createSignal({ label: "", to: "", icon: "" })
50
+
51
+ onMount(() => {
52
+ const def = definition()
53
+ if (def?.properties) {
54
+ const initialExpanded: Record<string, boolean> = {}
55
+ Object.entries(def.properties).forEach(([groupId, group]) => {
56
+ initialExpanded[groupId] = group.defaultOpen ?? true
57
+ })
58
+ setExpandedGroups(initialExpanded)
59
+ }
60
+
61
+ const handlePropsChange = (e: Event) => {
62
+ if (e instanceof CustomEvent && e.detail?.properties) {
63
+ setProperties(e.detail.properties)
64
+ }
65
+ }
66
+ document.addEventListener("properties-change", handlePropsChange)
67
+ })
68
+
69
+ const handlePropertyChange = (key: string, value: any) => {
70
+ const updated = { ...properties(), [key]: value }
71
+ setProperties(updated)
72
+ componentProps.onPropertiesChange?.(updated)
73
+ if (typeof document !== "undefined") {
74
+ document.dispatchEvent(
75
+ new CustomEvent("properties-change", { detail: { properties: updated }, bubbles: true })
76
+ )
77
+ }
78
+ if (typeof window !== "undefined") {
79
+ ;(window as any).cmsPageProperties = updated
80
+ }
81
+ }
82
+
83
+ const toggleGroup = (groupId: string) => {
84
+ setExpandedGroups((prev) => ({ ...prev, [groupId]: !prev[groupId] }))
85
+ }
86
+
87
+ const openLinkModal = (index: number | null = null) => {
88
+ setEditingLinkIndex(index)
89
+ if (index !== null && links()[index]) {
90
+ const l = links()[index]!
91
+ setLinkDraft({ label: l.label, to: l.to, icon: l.icon || "" })
92
+ } else {
93
+ setLinkDraft({ label: "", to: "", icon: "" })
94
+ }
95
+ setIsLinkModalOpen(true)
96
+ }
97
+
98
+ const saveLink = () => {
99
+ const draft = linkDraft()
100
+ if (!draft.label || !draft.to) return
101
+ const current = [...links()]
102
+ const idx = editingLinkIndex()
103
+ if (idx !== null) {
104
+ current[idx] = draft
105
+ } else {
106
+ current.push(draft)
107
+ }
108
+ setLinks(current)
109
+ handlePropertyChange("links", current)
110
+ setIsLinkModalOpen(false)
111
+ }
112
+
113
+ const removeLink = (index: number) => {
114
+ const current = [...links()]
115
+ current.splice(index, 1)
116
+ setLinks(current)
117
+ handlePropertyChange("links", current)
118
+ }
119
+
120
+ const renderField = (fieldId: string, field: FieldDefinition) => {
121
+ const label = getTextValue(field.label)
122
+ const value = properties()[fieldId] !== undefined ? properties()[fieldId] : field.defaultValue
123
+
124
+ if (field.type === "text") {
125
+ return (
126
+ <div class="flex flex-col gap-1 w-full">
127
+ <span class="text-xs font-semibold text-neutral-600 dark:text-neutral-400 block">
128
+ {label}
129
+ </span>
130
+ <RLSInput
131
+ value={getTextValue(value)}
132
+ onInput={(e: InputEvent & { currentTarget: HTMLInputElement }) =>
133
+ handlePropertyChange(fieldId, e.currentTarget.value)
134
+ }
135
+ placeholder="Type here..."
136
+ size="sm"
137
+ />
138
+ </div>
139
+ )
140
+ }
141
+
142
+ if (field.type === "number") {
143
+ return (
144
+ <div class="flex flex-col gap-1 w-full">
145
+ <span class="text-xs font-semibold text-neutral-600 dark:text-neutral-400 block">
146
+ {label}
147
+ </span>
148
+ <RLSInput
149
+ type="number"
150
+ value={value !== undefined ? String(value) : ""}
151
+ onInput={(e: InputEvent & { currentTarget: HTMLInputElement }) =>
152
+ handlePropertyChange(fieldId, Number(e.currentTarget.value))
153
+ }
154
+ placeholder="0"
155
+ size="sm"
156
+ />
157
+ </div>
158
+ )
159
+ }
160
+
161
+ if (field.type === "enum") {
162
+ const selectItems = (field.options || []).map((opt) => getTextValue(opt))
163
+ return (
164
+ <div class="flex flex-col gap-1 w-full">
165
+ <span class="text-xs font-semibold text-neutral-600 dark:text-neutral-400 block">
166
+ {label}
167
+ </span>
168
+ <RLSSelect
169
+ items={selectItems}
170
+ value={getTextValue(value)}
171
+ onChange={(e: Event & { currentTarget: HTMLSelectElement }) =>
172
+ handlePropertyChange(fieldId, e.currentTarget.value)
173
+ }
174
+ size="sm"
175
+ />
176
+ </div>
177
+ )
178
+ }
179
+
180
+ if (field.type === "text-array" || field.type === "page-array") {
181
+ const arr: string[] = Array.isArray(value) ? value : []
182
+
183
+ const addArrayItem = () => {
184
+ const inputVal = arrayInputs()[fieldId]?.trim()
185
+ if (inputVal) {
186
+ handlePropertyChange(fieldId, [...arr, inputVal])
187
+ setArrayInputs((prev) => ({ ...prev, [fieldId]: "" }))
188
+ }
189
+ }
190
+
191
+ const removeArrayItem = (idx: number) => {
192
+ const updatedArr = [...arr]
193
+ updatedArr.splice(idx, 1)
194
+ handlePropertyChange(fieldId, updatedArr)
195
+ }
196
+
197
+ return (
198
+ <div class="flex flex-col gap-1 w-full">
199
+ <span class="text-xs font-semibold text-neutral-600 dark:text-neutral-400 block">
200
+ {label}
201
+ </span>
202
+ <div class="flex flex-wrap gap-1 mb-1">
203
+ <For each={arr}>
204
+ {(item, idx) => (
205
+ <RLSBadge variant="soft" color="neutral" size="sm">
206
+ <span>{item}</span>
207
+ <button
208
+ type="button"
209
+ onClick={() => removeArrayItem(idx())}
210
+ class="ml-1 text-muted-foreground hover:text-red-500 cursor-pointer bg-transparent border-0 p-0 inline-flex items-center"
211
+ >
212
+ ×
213
+ </button>
214
+ </RLSBadge>
215
+ )}
216
+ </For>
217
+ </div>
218
+ <div class="flex gap-1 mt-1">
219
+ <div class="flex-1">
220
+ <RLSInput
221
+ value={arrayInputs()[fieldId] || ""}
222
+ onInput={(e: InputEvent & { currentTarget: HTMLInputElement }) =>
223
+ setArrayInputs((prev) => ({ ...prev, [fieldId]: e.currentTarget.value }))
224
+ }
225
+ onKeyDown={(e: KeyboardEvent) => {
226
+ if (e.key === "Enter") {
227
+ e.preventDefault()
228
+ addArrayItem()
229
+ }
230
+ }}
231
+ size="sm"
232
+ placeholder="Add item..."
233
+ />
234
+ </div>
235
+ <RLSButton
236
+ variant="outline"
237
+ color="neutral"
238
+ size="sm"
239
+ onClick={addArrayItem}
240
+ label="+"
241
+ />
242
+ </div>
243
+ </div>
244
+ )
245
+ }
246
+
247
+ return null
248
+ }
249
+
250
+ return (
251
+ <aside id="cms-properties-panel" class="flex flex-col gap-4 p-4 w-full bg-transparent">
252
+ <div class="flex flex-col">
253
+ {/* Top header content */}
254
+ <div class="flex flex-col gap-2 items-center text-center">
255
+ <div class="relative group/icon mb-1">
256
+ <div class="w-10 h-10 rounded-full flex items-center justify-center border border-default text-muted-foreground">
257
+ <RLSIcon name="fileText" size="md" />
258
+ </div>
259
+ </div>
260
+
261
+ <span class="text-xs text-dimmed uppercase tracking-wider text-center block font-semibold text-muted-foreground">
262
+ {pageType()} Properties
263
+ </span>
264
+ </div>
265
+
266
+ {/* Property Groups Accordion */}
267
+ <div class="mt-4 space-y-2 w-full">
268
+ <For each={Object.entries(definition()?.properties || {})}>
269
+ {([groupId, group]) => {
270
+ const isExpanded = () => expandedGroups()[groupId] ?? true
271
+ return (
272
+ <div class="w-full" data-group-id={groupId}>
273
+ <button
274
+ type="button"
275
+ onClick={() => toggleGroup(groupId)}
276
+ class="flex justify-between items-center w-full p-2 rounded-lg text-left font-medium text-sm text-foreground hover:bg-elevated transition cursor-pointer mt-2 bg-transparent border-0"
277
+ >
278
+ <span>{getTextValue(group.label)}</span>
279
+ <RLSIcon
280
+ name="chevronDown"
281
+ size="sm"
282
+ class={`transition-transform duration-150 text-muted-foreground ${
283
+ isExpanded() ? "rotate-180" : ""
284
+ }`}
285
+ />
286
+ </button>
287
+
288
+ {isExpanded() && (
289
+ <div class="p-3 flex flex-col gap-3 border-t border-default/50 -mt-1">
290
+ <For each={Object.entries(group.fields || {})}>
291
+ {([fieldId, field]) => renderField(fieldId, field)}
292
+ </For>
293
+ </div>
294
+ )}
295
+ </div>
296
+ )
297
+ }}
298
+ </For>
299
+ </div>
300
+ </div>
301
+
302
+ {/* Links section */}
303
+ <div class="flex flex-col gap-2 pt-2 border-t border-default">
304
+ <div class="flex items-center justify-between mb-1">
305
+ <h6 class="text-xs font-bold uppercase tracking-wider text-muted-foreground">Links</h6>
306
+ <RLSButton
307
+ variant="ghost"
308
+ color="neutral"
309
+ size="xs"
310
+ onClick={() => openLinkModal()}
311
+ leadingIcon="plus"
312
+ label="Add"
313
+ />
314
+ </div>
315
+
316
+ <Show
317
+ when={links().length > 0}
318
+ fallback={<p class="text-xs text-muted-foreground italic">No links added</p>}
319
+ >
320
+ <div class="flex flex-col gap-2">
321
+ <For each={links()}>
322
+ {(item, index) => (
323
+ <div class="flex items-center justify-between p-2 rounded-lg border border-default text-sm">
324
+ <div class="flex items-center gap-2 truncate">
325
+ <RLSIcon name="link" size="xs" class="text-muted-foreground shrink-0" />
326
+ <span class="font-medium text-foreground truncate">{item.label}</span>
327
+ </div>
328
+ <div class="flex items-center gap-1 shrink-0">
329
+ <RLSButton
330
+ variant="ghost"
331
+ color="neutral"
332
+ size="xs"
333
+ square={true}
334
+ onClick={() => openLinkModal(index())}
335
+ aria-label="Edit"
336
+ title="Edit"
337
+ leading={<RLSIcon name="edit" size="xs" />}
338
+ />
339
+ <RLSButton
340
+ variant="ghost"
341
+ color="neutral"
342
+ size="xs"
343
+ square={true}
344
+ onClick={() => removeLink(index())}
345
+ aria-label="Delete"
346
+ title="Delete"
347
+ class="!text-red-500 hover:!text-red-600"
348
+ leading={<RLSIcon name="trash" size="xs" />}
349
+ />
350
+ </div>
351
+ </div>
352
+ )}
353
+ </For>
354
+ </div>
355
+ </Show>
356
+ </div>
357
+
358
+ {/* Link Add/Edit Modal */}
359
+ <RLSModal
360
+ open={isLinkModalOpen()}
361
+ onClose={() => setIsLinkModalOpen(false)}
362
+ title={editingLinkIndex() !== null ? "Edit Link" : "Add Link"}
363
+ footerSlot={
364
+ <>
365
+ <RLSButton
366
+ variant="outline"
367
+ color="neutral"
368
+ size="sm"
369
+ onClick={() => setIsLinkModalOpen(false)}
370
+ label="Cancel"
371
+ />
372
+ <RLSButton
373
+ variant="solid"
374
+ color="primary"
375
+ size="sm"
376
+ onClick={saveLink}
377
+ label={editingLinkIndex() !== null ? "Update" : "Add"}
378
+ />
379
+ </>
380
+ }
381
+ >
382
+ <div class="flex flex-col gap-4 py-2">
383
+ <div class="flex flex-col gap-1">
384
+ <span class="text-xs font-semibold text-muted-foreground">Label</span>
385
+ <RLSInput
386
+ value={linkDraft().label}
387
+ onInput={(e: InputEvent & { currentTarget: HTMLInputElement }) =>
388
+ setLinkDraft({ ...linkDraft(), label: e.currentTarget.value })
389
+ }
390
+ placeholder="e.g. GitHub Repository"
391
+ />
392
+ </div>
393
+
394
+ <div class="flex flex-col gap-1">
395
+ <span class="text-xs font-semibold text-muted-foreground">URL</span>
396
+ <RLSInput
397
+ value={linkDraft().to}
398
+ onInput={(e: InputEvent & { currentTarget: HTMLInputElement }) =>
399
+ setLinkDraft({ ...linkDraft(), to: e.currentTarget.value })
400
+ }
401
+ placeholder="https://..."
402
+ />
403
+ </div>
404
+ </div>
405
+ </RLSModal>
406
+ </aside>
407
+ )
408
+ }
@@ -0,0 +1,245 @@
1
+ import { createSignal, 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 RLSSelect from "@rimelight/ui/components/select/RLSSelect.tsx"
5
+ import RLSInput from "@rimelight/ui/components/input/RLSInput.tsx"
6
+ import RLSCheckbox from "@rimelight/ui/components/checkbox/RLSCheckbox.tsx"
7
+ import RLSButton from "@rimelight/ui/components/button/RLSButton.tsx"
8
+ import RLSIcon from "@rimelight/ui/components/icon/RLSIcon.tsx"
9
+
10
+ export interface RimelightTemplateEditorProps {
11
+ initialBlocks?: BaseBlock[]
12
+ onChange?: (blocks: BaseBlock[]) => void
13
+ ref?: (el: any) => void
14
+ }
15
+
16
+ function escapeHtml(str: string): string {
17
+ return (str || "")
18
+ .replace(/&/g, "&amp;")
19
+ .replace(/</g, "&lt;")
20
+ .replace(/>/g, "&gt;")
21
+ .replace(/"/g, "&quot;")
22
+ }
23
+
24
+ const TEMPLATE_BLOCK_OPTIONS = [
25
+ { label: "Paragraph", value: "ParagraphBlock" },
26
+ { label: "Section Heading", value: "SectionBlock" },
27
+ { label: "Callout Box", value: "CalloutBlock" },
28
+ { label: "Code Snippet", value: "CodeBlock" },
29
+ { label: "Image", value: "ImageBlock" },
30
+ { label: "Script", value: "ScriptBlock" },
31
+ { label: "Scene", value: "SceneBlock" },
32
+ { label: "Dialogue", value: "DialogueBlock" }
33
+ ]
34
+
35
+ export const RimelightTemplateEditor: Component<RimelightTemplateEditorProps> = (props) => {
36
+ const [blocks, setBlocks] = createSignal<BaseBlock[]>(
37
+ props.initialBlocks ? JSON.parse(JSON.stringify(props.initialBlocks)) : []
38
+ )
39
+ const [selectedType, setSelectedType] = createSignal("ParagraphBlock")
40
+ let canvasContainer: HTMLDivElement | undefined
41
+ let sortableInstance: any = null
42
+
43
+ const updateBlocks = (newBlocks: BaseBlock[]) => {
44
+ setBlocks(newBlocks)
45
+ props.onChange?.(newBlocks)
46
+ }
47
+
48
+ onMount(() => {
49
+ if (canvasContainer) {
50
+ sortableInstance = initBlockSortable(canvasContainer, {
51
+ onReorder: (oldIdx: number, newIdx: number) => {
52
+ cleanAllSortableClasses()
53
+ const list = [...blocks()]
54
+ const item = list.splice(oldIdx, 1)[0]
55
+ if (item) {
56
+ list.splice(newIdx, 0, item)
57
+ updateBlocks(list)
58
+ }
59
+ requestAnimationFrame(() => cleanAllSortableClasses())
60
+ setTimeout(cleanAllSortableClasses, 50)
61
+ setTimeout(cleanAllSortableClasses, 200)
62
+ }
63
+ })
64
+ }
65
+
66
+ const publicMethods = {
67
+ getBlocks: () => JSON.parse(JSON.stringify(blocks())),
68
+ setBlocks: (newBlocks: BaseBlock[]) => updateBlocks(JSON.parse(JSON.stringify(newBlocks)))
69
+ }
70
+
71
+ if (props.ref) props.ref(publicMethods)
72
+
73
+ onCleanup(() => {
74
+ if (sortableInstance) sortableInstance.destroy()
75
+ })
76
+ })
77
+
78
+ const addBlock = (type: string) => {
79
+ let blockProps: Record<string, any> = {}
80
+ if (type === "SectionBlock") {
81
+ blockProps = { title: "Section Heading" }
82
+ } else if (type === "CalloutBlock") {
83
+ blockProps = { text: { en: "Callout note content..." }, variant: "info" }
84
+ } else if (type === "CodeBlock") {
85
+ blockProps = { code: "// Code snippet", language: "typescript" }
86
+ } else if (type === "ImageBlock") {
87
+ blockProps = { src: "https://via.placeholder.com/600x300", alt: "Image description" }
88
+ } else if (type === "ScriptBlock") {
89
+ blockProps = { title: "Scene Script" }
90
+ } else if (type === "SceneBlock") {
91
+ blockProps = { heading: "INT. SCENE - DAY" }
92
+ } else if (type === "DialogueBlock") {
93
+ blockProps = { character: "CHARACTER", text: { en: "Dialogue text..." } }
94
+ } else {
95
+ blockProps = { text: { en: "Paragraph content..." } }
96
+ }
97
+
98
+ const newBlock: BaseBlock = {
99
+ id: crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2),
100
+ type,
101
+ isTemplated: false,
102
+ props: blockProps
103
+ }
104
+
105
+ updateBlocks([...blocks(), newBlock])
106
+ }
107
+
108
+ const deleteBlock = (id: string) => {
109
+ updateBlocks(blocks().filter((b) => b.id !== id))
110
+ }
111
+
112
+ const updateBlockItem = (id: string, updater: (b: BaseBlock) => void) => {
113
+ const list: BaseBlock[] = JSON.parse(JSON.stringify(blocks()))
114
+ const target = list.find((b) => b.id === id)
115
+ if (target) {
116
+ updater(target)
117
+ updateBlocks(list)
118
+ }
119
+ }
120
+
121
+ return (
122
+ <div class="cms-template-editor border border-default rounded-xl p-4 space-y-4">
123
+ <div class="flex items-center justify-between p-3 rounded-lg border border-default">
124
+ <div>
125
+ <h4 class="text-sm font-semibold text-foreground">Initial Template Blocks</h4>
126
+ <p class="text-xs text-muted-foreground">
127
+ Visually design the initial layout and set block locks for pages instantiated from this
128
+ template.
129
+ </p>
130
+ </div>
131
+ <div class="flex items-center gap-2">
132
+ <div class="w-44">
133
+ <RLSSelect
134
+ items={TEMPLATE_BLOCK_OPTIONS}
135
+ value={selectedType()}
136
+ onChange={(e: Event & { currentTarget: HTMLSelectElement }) =>
137
+ setSelectedType(e.currentTarget.value)
138
+ }
139
+ size="sm"
140
+ />
141
+ </div>
142
+ <RLSButton
143
+ variant="solid"
144
+ color="primary"
145
+ size="sm"
146
+ onClick={() => addBlock(selectedType())}
147
+ leadingIcon="plus"
148
+ label="Add Block"
149
+ />
150
+ </div>
151
+ </div>
152
+
153
+ <div
154
+ ref={(el) => (canvasContainer = el)}
155
+ id="tmpl-block-canvas"
156
+ class="space-y-3 min-h-[120px] p-3 border border-dashed border-default rounded-lg"
157
+ >
158
+ {blocks().length === 0 ? (
159
+ <div class="text-center py-8 text-xs text-muted-foreground italic">
160
+ No initial blocks added yet. Use the dropdown above to add initial template blocks.
161
+ </div>
162
+ ) : (
163
+ <For each={blocks()}>
164
+ {(block, index) => {
165
+ const rawText =
166
+ block.props.text || block.props.title || block.props.code || block.props.src || ""
167
+ const textVal = typeof rawText === "object" ? rawText.en || "" : rawText || ""
168
+
169
+ return (
170
+ <div
171
+ data-id={block.id}
172
+ class="cms-template-block-card border border-default rounded-lg p-3 text-xs space-y-2 relative"
173
+ >
174
+ <div class="flex items-center justify-between border-b border-default/60 pb-2">
175
+ <div class="flex items-center gap-2">
176
+ <span class="font-bold text-foreground uppercase">{block.type}</span>
177
+ <span class="text-[10px] text-muted-foreground">#{index() + 1}</span>
178
+ </div>
179
+ <RLSButton
180
+ variant="ghost"
181
+ color="neutral"
182
+ size="xs"
183
+ onClick={() => deleteBlock(block.id)}
184
+ class="!text-red-500 hover:!text-red-600"
185
+ leading={<RLSIcon name="trash" size="xs" />}
186
+ label="Delete"
187
+ />
188
+ </div>
189
+
190
+ <div class="flex items-center gap-4 py-1">
191
+ <RLSCheckbox
192
+ checked={!!block.isTemplated}
193
+ onChange={(e: Event & { currentTarget: HTMLInputElement }) =>
194
+ updateBlockItem(block.id, (b) => {
195
+ b.isTemplated = e.currentTarget.checked
196
+ })
197
+ }
198
+ label="📌 Anchor (Permanent)"
199
+ size="sm"
200
+ />
201
+ <RLSCheckbox
202
+ checked={block.templatedProps === true}
203
+ onChange={(e: Event & { currentTarget: HTMLInputElement }) =>
204
+ updateBlockItem(block.id, (b) => {
205
+ b.templatedProps = e.currentTarget.checked
206
+ })
207
+ }
208
+ label="🔒 Lock Content"
209
+ size="sm"
210
+ />
211
+ </div>
212
+
213
+ <div>
214
+ <div>
215
+ <span class="block text-[11px] text-muted-foreground mb-1">
216
+ Block Content / Text
217
+ </span>
218
+ <div class="mt-1">
219
+ <RLSInput
220
+ id={`tmpl-content-${block.id}`}
221
+ value={escapeHtml(textVal)}
222
+ onInput={(e: InputEvent & { currentTarget: HTMLInputElement }) => {
223
+ const val = e.currentTarget.value
224
+ updateBlockItem(block.id, (b) => {
225
+ if (b.type === "SectionBlock") b.props.title = val
226
+ else if (b.type === "CodeBlock") b.props.code = val
227
+ else if (b.type === "ImageBlock") b.props.src = val
228
+ else b.props.text = { en: val }
229
+ })
230
+ }}
231
+ size="sm"
232
+ placeholder="Enter default block content..."
233
+ />
234
+ </div>
235
+ </div>
236
+ </div>
237
+ </div>
238
+ )
239
+ }}
240
+ </For>
241
+ )}
242
+ </div>
243
+ </div>
244
+ )
245
+ }