@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.
- package/README.md +19 -0
- package/package.json +69 -0
- package/src/astro/BlockRenderer.astro +111 -0
- package/src/astro/PageRenderer.astro +23 -0
- package/src/astro/blocks/CalloutBlock.astro +78 -0
- package/src/astro/blocks/CardBlock.astro +43 -0
- package/src/astro/blocks/CardsBlock.astro +24 -0
- package/src/astro/blocks/CodeBlock.astro +16 -0
- package/src/astro/blocks/DialogueBlock.astro +23 -0
- package/src/astro/blocks/FileTreeBlock.astro +45 -0
- package/src/astro/blocks/ImageBlock.astro +17 -0
- package/src/astro/blocks/OrderedListBlock.astro +55 -0
- package/src/astro/blocks/ParagraphBlock.astro +65 -0
- package/src/astro/blocks/SceneBlock.astro +65 -0
- package/src/astro/blocks/ScriptBlock.astro +59 -0
- package/src/astro/blocks/SectionBlock.astro +46 -0
- package/src/astro/blocks/StepItemBlock.astro +25 -0
- package/src/astro/blocks/StepsBlock.astro +14 -0
- package/src/astro/blocks/TabItemBlock.astro +19 -0
- package/src/astro/blocks/TableBlock.astro +49 -0
- package/src/astro/blocks/TabsBlock.astro +67 -0
- package/src/astro/blocks/UnorderedListBlock.astro +55 -0
- package/src/auth/editor-guards.ts +148 -0
- package/src/auth/filter-blocks.ts +44 -0
- package/src/auth/filter-templates.ts +81 -0
- package/src/auth/permissions.ts +40 -0
- package/src/cache/purge.ts +37 -0
- package/src/client/components/RimelightBlock.tsx +487 -0
- package/src/client/components/RimelightBlockPicker.tsx +305 -0
- package/src/client/components/RimelightEditor.tsx +131 -0
- package/src/client/components/RimelightEditorLayout.tsx +143 -0
- package/src/client/components/RimelightPreview.tsx +354 -0
- package/src/client/components/RimelightPropertiesPanel.tsx +408 -0
- package/src/client/components/RimelightTemplateEditor.tsx +245 -0
- package/src/client/components/editors/CalloutEditor.tsx +185 -0
- package/src/client/components/editors/CardEditor.tsx +60 -0
- package/src/client/components/editors/CardsEditor.tsx +132 -0
- package/src/client/components/editors/CodeEditor.tsx +101 -0
- package/src/client/components/editors/DialogueEditor.tsx +52 -0
- package/src/client/components/editors/FileTreeEditor.tsx +65 -0
- package/src/client/components/editors/ImageEditor.tsx +70 -0
- package/src/client/components/editors/ParagraphEditor.tsx +314 -0
- package/src/client/components/editors/SceneEditor.tsx +245 -0
- package/src/client/components/editors/ScriptEditor.tsx +245 -0
- package/src/client/components/editors/SectionEditor.tsx +139 -0
- package/src/client/components/editors/StepItemEditor.tsx +117 -0
- package/src/client/components/editors/StepsEditor.tsx +105 -0
- package/src/client/components/editors/TabItemEditor.tsx +113 -0
- package/src/client/components/editors/TableEditor.tsx +181 -0
- package/src/client/components/editors/TabsEditor.tsx +105 -0
- package/src/client/components/index.ts +7 -0
- package/src/client/loader.ts +19 -0
- package/src/client/state/autosave.ts +122 -0
- package/src/client/state/editor-store.ts +411 -0
- package/src/client/state/history.ts +251 -0
- package/src/client/state/lock-manager.ts +89 -0
- package/src/client/styles/cms-editor.css +653 -0
- package/src/client/toast.ts +12 -0
- package/src/client/utils/sortable.ts +184 -0
- package/src/client/utils/splitpane.ts +87 -0
- package/src/client/utils/tree-sanitizer.ts +33 -0
- package/src/core/page-definitions.ts +502 -0
- package/src/core/registry.ts +99 -0
- package/src/core/template-sync.ts +75 -0
- package/src/core/types/blocks.ts +367 -0
- package/src/core/types/inline.ts +23 -0
- package/src/core/types/pages.ts +36 -0
- package/src/core/types/templates.ts +32 -0
- package/src/core/types/versioning.ts +44 -0
- package/src/core/validator.ts +28 -0
- package/src/env.d.ts +4 -0
- package/src/index.ts +47 -0
- package/src/markdown/index.ts +2 -0
- package/src/markdown/parser.ts +347 -0
- package/src/markdown/serializer.ts +219 -0
- package/src/migration.ts +155 -0
- package/src/schema/index.ts +8 -0
- package/src/schema/page_draft_locks.ts +13 -0
- package/src/schema/page_drafts.ts +18 -0
- package/src/schema/page_templates.ts +27 -0
- package/src/schema/page_version_approvals.ts +12 -0
- package/src/schema/page_version_comments.ts +14 -0
- package/src/schema/page_versions.ts +34 -0
- package/src/schema/pages.ts +42 -0
- package/src/schema/search.ts +23 -0
- package/src/search/query.ts +10 -0
- 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, "&")
|
|
19
|
+
.replace(/</g, "<")
|
|
20
|
+
.replace(/>/g, ">")
|
|
21
|
+
.replace(/"/g, """)
|
|
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
|
+
}
|