@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,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 RLSInput from "@rimelight/ui/components/input/RLSInput.tsx"
5
+ import RLSTextarea from "@rimelight/ui/components/textarea/RLSTextarea.tsx"
6
+ import RLSBadge from "@rimelight/ui/components/badge/RLSBadge.tsx"
7
+ import RLSButton from "@rimelight/ui/components/button/RLSButton.tsx"
8
+
9
+ export interface ScriptEditorProps {
10
+ blockId?: string
11
+ props: Record<string, any>
12
+ children?: BaseBlock[] | (() => BaseBlock[])
13
+ sectionDepth?: number
14
+ onPropsChange: (newProps: Record<string, any>) => void
15
+ onRelocate?: ((id: string, newIndex: number, targetParentId?: string | null) => void) | undefined
16
+ onOpenPicker?: () => void
17
+ renderChildBlock?: (block: BaseBlock, depth: number) => any
18
+ }
19
+
20
+ export const ScriptEditor: Component<ScriptEditorProps> = (componentProps) => {
21
+ const [charInputVal, setCharInputVal] = createSignal("")
22
+ let childrenContainer: HTMLDivElement | null = null as HTMLDivElement | null
23
+ let sortableInstance: any = null
24
+
25
+ const characters = (): string[] => componentProps.props.characters || []
26
+ const childrenBlocks = (): BaseBlock[] => {
27
+ if (typeof componentProps.children === "function") {
28
+ return componentProps.children() || []
29
+ }
30
+ return componentProps.children || componentProps.props.children || []
31
+ }
32
+
33
+ onMount(() => {
34
+ if (childrenContainer) {
35
+ sortableInstance = initBlockSortable(childrenContainer, {
36
+ filter: ".cms-empty-placeholder, .locked",
37
+ onReorder: (oldIdx: number, newIdx: number) => {
38
+ cleanAllSortableClasses()
39
+ const list = [...childrenBlocks()]
40
+ const item = list.splice(oldIdx, 1)[0]
41
+ if (item) {
42
+ list.splice(newIdx, 0, item)
43
+ componentProps.onPropsChange({ ...componentProps.props, children: list })
44
+ }
45
+ requestAnimationFrame(() => cleanAllSortableClasses())
46
+ setTimeout(cleanAllSortableClasses, 50)
47
+ setTimeout(cleanAllSortableClasses, 200)
48
+ },
49
+ onAdd: (blockId: string, newIdx: number) => {
50
+ cleanAllSortableClasses()
51
+ if (componentProps.onRelocate && componentProps.blockId) {
52
+ componentProps.onRelocate(blockId, newIdx, componentProps.blockId)
53
+ }
54
+ requestAnimationFrame(() => cleanAllSortableClasses())
55
+ setTimeout(cleanAllSortableClasses, 50)
56
+ setTimeout(cleanAllSortableClasses, 200)
57
+ },
58
+ onRemove: (_blockId: string, _oldIdx: number) => {
59
+ cleanAllSortableClasses()
60
+ }
61
+ })
62
+ }
63
+
64
+ onCleanup(() => {
65
+ if (sortableInstance) sortableInstance.destroy()
66
+ })
67
+ })
68
+
69
+ const saveProps = (partial: Record<string, any>) => {
70
+ componentProps.onPropsChange({
71
+ ...componentProps.props,
72
+ ...partial,
73
+ children: childrenBlocks()
74
+ })
75
+ }
76
+
77
+ const addCharacter = (name: string) => {
78
+ const trimmed = name.trim()
79
+ if (!trimmed) return
80
+ const currentChars = characters()
81
+ if (!currentChars.includes(trimmed)) {
82
+ const updated = [...currentChars, trimmed]
83
+ setCharInputVal("")
84
+ componentProps.onPropsChange({ ...componentProps.props, characters: updated })
85
+ }
86
+ }
87
+
88
+ const removeCharacter = (name: string) => {
89
+ const updated = characters().filter((c) => c !== name)
90
+ componentProps.onPropsChange({ ...componentProps.props, characters: updated })
91
+ }
92
+
93
+ return (
94
+ <div class="rimelight-script-editor flex flex-col gap-4 p-3 border-l-2 border-primary/50 w-full">
95
+ <div class="flex flex-col gap-1">
96
+ <span class="text-xs font-bold text-muted-foreground uppercase tracking-widest">
97
+ Script Title
98
+ </span>
99
+ <RLSInput
100
+ data-field="title"
101
+ value={componentProps.props.title || ""}
102
+ onInput={(e) => saveProps({ title: e.currentTarget.value })}
103
+ placeholder="Script Title"
104
+ size="sm"
105
+ />
106
+ </div>
107
+
108
+ <div class="flex flex-col gap-1">
109
+ <span class="text-xs font-bold text-muted-foreground uppercase tracking-widest">
110
+ Synopsis
111
+ </span>
112
+ <RLSTextarea
113
+ data-field="synopsis"
114
+ value={componentProps.props.synopsis || ""}
115
+ onInput={(e) => saveProps({ synopsis: e.currentTarget.value })}
116
+ placeholder="Synopsis..."
117
+ rows={2}
118
+ />
119
+ </div>
120
+
121
+ <div class="flex flex-col gap-1">
122
+ <span class="text-xs font-bold text-muted-foreground uppercase tracking-widest">
123
+ Characters
124
+ </span>
125
+ <div class="chars-container flex flex-wrap gap-1.5 mb-1.5 min-h-[24px]">
126
+ {characters().length === 0 ? (
127
+ <div class="text-xs text-muted-foreground italic py-0.5">No characters added</div>
128
+ ) : (
129
+ <For each={characters()}>
130
+ {(char) => (
131
+ <RLSBadge variant="soft" color="primary" size="sm">
132
+ <span>{char}</span>
133
+ <button
134
+ type="button"
135
+ onClick={() => removeCharacter(char)}
136
+ class="hover:text-red-500 cursor-pointer ml-1 font-bold bg-transparent border-none p-0 inline-flex items-center"
137
+ >
138
+ ×
139
+ </button>
140
+ </RLSBadge>
141
+ )}
142
+ </For>
143
+ )}
144
+ </div>
145
+ <div class="flex gap-2">
146
+ <div class="flex-1">
147
+ <RLSInput
148
+ data-field="char-input"
149
+ value={charInputVal()}
150
+ onInput={(e) => setCharInputVal(e.currentTarget.value)}
151
+ onKeyDown={(e) => {
152
+ if (e.key === "Enter") {
153
+ e.preventDefault()
154
+ addCharacter(charInputVal())
155
+ }
156
+ }}
157
+ placeholder="New character name"
158
+ size="sm"
159
+ />
160
+ </div>
161
+ <RLSButton
162
+ variant="outline"
163
+ color="neutral"
164
+ size="sm"
165
+ onClick={() => addCharacter(charInputVal())}
166
+ label="Add"
167
+ />
168
+ </div>
169
+ </div>
170
+
171
+ <div class="flex flex-col gap-1">
172
+ <span class="text-xs font-bold text-muted-foreground uppercase tracking-widest">Plots</span>
173
+ <div class="grid grid-cols-3 gap-2">
174
+ <RLSInput
175
+ data-field="aPlot"
176
+ value={componentProps.props.aPlot || ""}
177
+ onInput={(e) => saveProps({ aPlot: e.currentTarget.value })}
178
+ placeholder="A-Plot"
179
+ size="sm"
180
+ />
181
+ <RLSInput
182
+ data-field="bPlot"
183
+ value={componentProps.props.bPlot || ""}
184
+ onInput={(e) => saveProps({ bPlot: e.currentTarget.value })}
185
+ placeholder="B-Plot"
186
+ size="sm"
187
+ />
188
+ <RLSInput
189
+ data-field="cPlot"
190
+ value={componentProps.props.cPlot || ""}
191
+ onInput={(e) => saveProps({ cPlot: e.currentTarget.value })}
192
+ placeholder="C-Plot"
193
+ size="sm"
194
+ />
195
+ </div>
196
+ </div>
197
+
198
+ <div class="flex flex-col gap-1">
199
+ <span class="text-xs font-bold text-muted-foreground uppercase tracking-widest">
200
+ Stinger
201
+ </span>
202
+ <RLSInput
203
+ data-field="stinger"
204
+ value={componentProps.props.stinger || ""}
205
+ onInput={(e) => saveProps({ stinger: e.currentTarget.value })}
206
+ placeholder="Stinger (post-credits etc)"
207
+ size="sm"
208
+ />
209
+ </div>
210
+
211
+ <div class="mt-2">
212
+ <span class="text-xs font-bold text-muted-foreground uppercase tracking-widest block mb-1">
213
+ Scenes & Content
214
+ </span>
215
+ <div
216
+ ref={(el) => (childrenContainer = el)}
217
+ class="children-container mt-1 pl-3 border-l-2 border-default flex flex-col gap-2 min-h-[60px]"
218
+ >
219
+ {childrenBlocks().length === 0 ? (
220
+ <div class="cms-empty-placeholder relative flex flex-col items-center justify-center gap-2 p-3 rounded-lg border border-dashed border-default text-center my-1">
221
+ <div class="text-xs font-semibold text-muted-foreground">Empty script content</div>
222
+ <RLSButton
223
+ variant="outline"
224
+ color="neutral"
225
+ size="xs"
226
+ onClick={(e: MouseEvent) => {
227
+ e.stopPropagation()
228
+ componentProps.onOpenPicker?.()
229
+ }}
230
+ leadingIcon="plus"
231
+ label="Add Block"
232
+ />
233
+ </div>
234
+ ) : (
235
+ <For each={childrenBlocks()}>
236
+ {(childBlock) =>
237
+ componentProps.renderChildBlock?.(childBlock, componentProps.sectionDepth || 0)
238
+ }
239
+ </For>
240
+ )}
241
+ </div>
242
+ </div>
243
+ </div>
244
+ )
245
+ }
@@ -0,0 +1,139 @@
1
+ import { onMount, onCleanup, For, type Component } from "solid-js"
2
+ import { type BaseBlock, calculateHeadingLevel } 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 SectionEditorProps {
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 SectionEditor: Component<SectionEditorProps> = (componentProps) => {
18
+ let titleInput: HTMLInputElement | null = null as HTMLInputElement | null
19
+ let descInput: HTMLInputElement | null = null as HTMLInputElement | null
20
+ let childrenContainer: HTMLDivElement | null = null as HTMLDivElement | null
21
+ let sortableInstance: any = null
22
+
23
+ const headingLevel = () => calculateHeadingLevel(componentProps.sectionDepth || 0)
24
+
25
+ const childrenBlocks = (): BaseBlock[] => {
26
+ if (typeof componentProps.children === "function") {
27
+ return componentProps.children() || []
28
+ }
29
+ return componentProps.children || componentProps.props.children || []
30
+ }
31
+
32
+ onMount(() => {
33
+ if (childrenContainer) {
34
+ sortableInstance = initBlockSortable(childrenContainer, {
35
+ filter: ".cms-empty-placeholder, .locked",
36
+ onReorder: (oldIdx: number, newIdx: number) => {
37
+ cleanAllSortableClasses()
38
+ const list = [...childrenBlocks()]
39
+ const item = list.splice(oldIdx, 1)[0]
40
+ if (item) {
41
+ list.splice(newIdx, 0, item)
42
+ componentProps.onPropsChange({ ...componentProps.props, children: list })
43
+ }
44
+ requestAnimationFrame(() => cleanAllSortableClasses())
45
+ setTimeout(cleanAllSortableClasses, 50)
46
+ setTimeout(cleanAllSortableClasses, 200)
47
+ },
48
+ onAdd: (blockId: string, newIdx: number) => {
49
+ cleanAllSortableClasses()
50
+ if (componentProps.onRelocate && componentProps.blockId) {
51
+ componentProps.onRelocate(blockId, newIdx, componentProps.blockId)
52
+ }
53
+ requestAnimationFrame(() => cleanAllSortableClasses())
54
+ setTimeout(cleanAllSortableClasses, 50)
55
+ setTimeout(cleanAllSortableClasses, 200)
56
+ },
57
+ onRemove: (_blockId: string, _oldIdx: number) => {
58
+ cleanAllSortableClasses()
59
+ }
60
+ })
61
+ }
62
+
63
+ onCleanup(() => {
64
+ if (sortableInstance) sortableInstance.destroy()
65
+ })
66
+ })
67
+
68
+ const saveProps = () => {
69
+ componentProps.onPropsChange({
70
+ ...componentProps.props,
71
+ title: titleInput?.value || "",
72
+ description: descInput?.value || "",
73
+ level: headingLevel(),
74
+ children: childrenBlocks()
75
+ })
76
+ }
77
+
78
+ return (
79
+ <div class="w-full flex flex-col gap-2 py-0.5">
80
+ <div class="flex items-center gap-2.5">
81
+ <span class="text-xs font-bold text-muted-foreground select-none shrink-0 tracking-wider">
82
+ H{headingLevel()}
83
+ </span>
84
+ <input
85
+ ref={(el) => (titleInput = el)}
86
+ type="text"
87
+ value={componentProps.props.title || ""}
88
+ onInput={saveProps}
89
+ placeholder="Section Title"
90
+ class="flex-1 font-bold text-lg bg-transparent border-0 focus:outline-none text-foreground placeholder:text-muted-foreground/60 py-0.5"
91
+ />
92
+ </div>
93
+
94
+ <input
95
+ ref={(el) => (descInput = el)}
96
+ type="text"
97
+ value={componentProps.props.description || ""}
98
+ onInput={saveProps}
99
+ placeholder="Section description (optional)..."
100
+ class="text-sm text-muted-foreground bg-transparent border-0 focus:outline-none placeholder:text-muted-foreground/50 py-0.5"
101
+ />
102
+
103
+ <div
104
+ ref={(el) => (childrenContainer = el)}
105
+ class="ml-1 pl-8 border-l-2 border-primary/30 flex flex-col gap-2 mt-1 min-h-[32px]"
106
+ >
107
+ {childrenBlocks().length === 0 ? (
108
+ <div class="flex flex-col items-center justify-center p-3 border border-dashed border-default rounded-lg text-center">
109
+ <span class="text-xs text-muted-foreground">Empty section</span>
110
+ <RLSButton
111
+ variant="link"
112
+ color="primary"
113
+ size="xs"
114
+ onClick={() => componentProps.onOpenPicker?.()}
115
+ label="+ Add first block"
116
+ />
117
+ </div>
118
+ ) : (
119
+ <For each={childrenBlocks()}>
120
+ {(childBlock: BaseBlock) =>
121
+ componentProps.renderChildBlock?.(childBlock, (componentProps.sectionDepth || 0) + 1)
122
+ }
123
+ </For>
124
+ )}
125
+ </div>
126
+
127
+ <div class="flex items-center pt-1">
128
+ <RLSButton
129
+ variant="ghost"
130
+ color="neutral"
131
+ size="xs"
132
+ onClick={() => componentProps.onOpenPicker?.()}
133
+ leadingIcon="plus"
134
+ label="Add block"
135
+ />
136
+ </div>
137
+ </div>
138
+ )
139
+ }
@@ -0,0 +1,117 @@
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 StepItemEditorProps {
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 StepItemEditor: Component<StepItemEditorProps> = (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 saveMeta = (title?: string, description?: string) => {
66
+ componentProps.onPropsChange({
67
+ ...componentProps.props,
68
+ title: title !== undefined ? title : componentProps.props.title || "",
69
+ description: description !== undefined ? description : componentProps.props.description || ""
70
+ })
71
+ }
72
+
73
+ return (
74
+ <div class="rimelight-step-item-editor flex flex-col gap-2 p-3 rounded-lg border border-default bg-muted/10 w-full">
75
+ <div class="flex flex-col gap-1.5">
76
+ <RLSInput
77
+ type="text"
78
+ placeholder="Step title (e.g. Install Dependencies)"
79
+ value={componentProps.props.title || ""}
80
+ onBlur={(e) => saveMeta(e.currentTarget.value, undefined)}
81
+ size="sm"
82
+ />
83
+ <RLSInput
84
+ type="text"
85
+ placeholder="Step description (optional)"
86
+ value={componentProps.props.description || ""}
87
+ onBlur={(e) => saveMeta(undefined, e.currentTarget.value)}
88
+ size="sm"
89
+ />
90
+ </div>
91
+
92
+ <div
93
+ ref={(el) => (childrenContainer = el)}
94
+ class="p-2 rounded-lg border border-dashed border-default flex flex-col gap-2 min-h-[36px] bg-background"
95
+ >
96
+ {childrenBlocks().length === 0 ? (
97
+ <div class="text-center py-2">
98
+ <span class="text-xs text-muted-foreground block">Empty step content</span>
99
+ <RLSButton
100
+ variant="link"
101
+ color="primary"
102
+ size="xs"
103
+ onClick={() => componentProps.onOpenPicker?.()}
104
+ label="+ Add block"
105
+ />
106
+ </div>
107
+ ) : (
108
+ <For each={childrenBlocks()}>
109
+ {(childBlock) =>
110
+ componentProps.renderChildBlock?.(childBlock, componentProps.sectionDepth || 0)
111
+ }
112
+ </For>
113
+ )}
114
+ </div>
115
+ </div>
116
+ )
117
+ }
@@ -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 StepsEditorProps {
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 StepsEditor: Component<StepsEditorProps> = (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-steps-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
+ Steps Walkthrough
69
+ </span>
70
+ <RLSButton
71
+ variant="outline"
72
+ color="neutral"
73
+ size="xs"
74
+ onClick={() => componentProps.onOpenPicker?.()}
75
+ leadingIcon="plus"
76
+ label="Add Step 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 steps in walkthrough</span>
87
+ <RLSButton
88
+ variant="link"
89
+ color="primary"
90
+ size="xs"
91
+ onClick={() => componentProps.onOpenPicker?.()}
92
+ label="+ Add Step 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
+ }