panda-cms 0.7.0 → 0.7.2

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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/panda.cms.css +0 -50
  3. data/app/components/panda/cms/admin/table_component.html.erb +4 -1
  4. data/app/components/panda/cms/code_component.rb +2 -1
  5. data/app/components/panda/cms/rich_text_component.html.erb +86 -2
  6. data/app/components/panda/cms/rich_text_component.rb +131 -20
  7. data/app/controllers/panda/cms/admin/block_contents_controller.rb +18 -7
  8. data/app/controllers/panda/cms/admin/files_controller.rb +22 -12
  9. data/app/controllers/panda/cms/admin/posts_controller.rb +33 -11
  10. data/app/controllers/panda/cms/pages_controller.rb +29 -0
  11. data/app/controllers/panda/cms/posts_controller.rb +26 -4
  12. data/app/helpers/panda/cms/admin/posts_helper.rb +23 -32
  13. data/app/helpers/panda/cms/posts_helper.rb +32 -0
  14. data/app/javascript/panda/cms/controllers/dashboard_controller.js +0 -1
  15. data/app/javascript/panda/cms/controllers/editor_form_controller.js +134 -11
  16. data/app/javascript/panda/cms/controllers/editor_iframe_controller.js +395 -130
  17. data/app/javascript/panda/cms/controllers/slug_controller.js +33 -43
  18. data/app/javascript/panda/cms/editor/editor_js_config.js +202 -73
  19. data/app/javascript/panda/cms/editor/editor_js_initializer.js +243 -194
  20. data/app/javascript/panda/cms/editor/plain_text_editor.js +1 -1
  21. data/app/javascript/panda/cms/editor/resource_loader.js +89 -0
  22. data/app/javascript/panda/cms/editor/rich_text_editor.js +162 -0
  23. data/app/models/panda/cms/page.rb +18 -0
  24. data/app/models/panda/cms/post.rb +61 -3
  25. data/app/models/panda/cms/redirect.rb +2 -2
  26. data/app/views/panda/cms/admin/posts/_form.html.erb +15 -4
  27. data/app/views/panda/cms/admin/posts/index.html.erb +5 -3
  28. data/config/routes.rb +34 -6
  29. data/db/migrate/20250106223303_add_author_id_to_panda_cms_posts.rb +5 -0
  30. data/lib/panda/cms/editor_js_content.rb +14 -1
  31. data/lib/panda/cms/engine.rb +4 -0
  32. data/lib/panda-cms/version.rb +1 -1
  33. metadata +5 -2
@@ -51,235 +51,284 @@ export class EditorJSInitializer {
51
51
  const editorCore = EDITOR_JS_RESOURCES[0]
52
52
  await ResourceLoader.loadScript(this.document, this.document.head, editorCore)
53
53
 
54
- // Then load all tools in parallel
55
- const toolLoads = EDITOR_JS_RESOURCES.slice(1).map(async (resource) => {
56
- await ResourceLoader.loadScript(this.document, this.document.head, resource)
57
- })
54
+ // Wait for EditorJS to be available
55
+ await this.waitForEditorJS()
58
56
 
59
57
  // Load CSS directly
60
58
  await ResourceLoader.embedCSS(this.document, this.document.head, EDITOR_JS_CSS)
61
59
 
62
- // Wait for all resources to load
63
- await Promise.all(toolLoads)
60
+ // Then load all tools sequentially to ensure proper initialization order
61
+ for (const resource of EDITOR_JS_RESOURCES.slice(1)) {
62
+ try {
63
+ await ResourceLoader.loadScript(this.document, this.document.head, resource)
64
+ // Extract tool name from resource URL
65
+ const toolName = resource.split('/').pop().split('@')[0]
66
+ // Wait for tool to be initialized
67
+ const toolClass = await this.waitForTool(toolName)
68
+
69
+ // If this is the nested-list tool, also make it available as 'list'
70
+ if (toolName === 'nested-list') {
71
+ const win = this.document.defaultView || window
72
+ win.List = toolClass
73
+ }
64
74
 
65
- // Wait for EditorJS to be available
66
- await this.waitForEditorJS()
75
+ console.debug(`[Panda CMS] Successfully loaded tool: ${toolName}`)
76
+ } catch (error) {
77
+ console.error(`[Panda CMS] Failed to load tool: ${resource}`, error)
78
+ throw error
79
+ }
80
+ }
81
+
82
+ console.debug('[Panda CMS] All tools successfully loaded and verified')
67
83
  } catch (error) {
84
+ console.error('[Panda CMS] Error loading Editor.js resources:', error)
68
85
  throw error
69
86
  }
70
87
  }
71
88
 
72
- async initializeEditor(element, initialData = {}, editorId = null) {
73
- // Generate a consistent holder ID if not provided
74
- const holderId = editorId || `editor-${element.id || Math.random().toString(36).substr(2, 9)}`
75
-
76
- // Create or find the holder element in the correct document context
77
- let holderElement = this.document.getElementById(holderId)
78
- if (!holderElement) {
79
- // Create the holder element in the correct document context
80
- holderElement = this.document.createElement('div')
81
- holderElement.id = holderId
82
- holderElement.className = 'editor-js-holder codex-editor'
83
-
84
- // Append to the element and force a reflow
85
- element.appendChild(holderElement)
86
- void holderElement.offsetHeight // Force a reflow
89
+ async waitForEditorJS(timeout = 10000) {
90
+ console.debug('[Panda CMS] Waiting for EditorJS core...')
91
+ const start = Date.now()
92
+ while (Date.now() - start < timeout) {
93
+ if (typeof this.document.defaultView.EditorJS === 'function') {
94
+ console.debug('[Panda CMS] EditorJS core is ready')
95
+ return
96
+ }
97
+ await new Promise(resolve => setTimeout(resolve, 100))
87
98
  }
99
+ throw new Error('[Panda CMS] Timeout waiting for EditorJS')
100
+ }
88
101
 
89
- // Verify the holder element exists in the correct document context
90
- const verifyHolder = this.document.getElementById(holderId)
91
- if (!verifyHolder) {
92
- throw new Error(`Failed to create editor holder element ${holderId}`)
102
+ /**
103
+ * Wait for a specific tool to be available in window context
104
+ */
105
+ async waitForTool(toolName, timeout = 10000) {
106
+ if (!toolName) {
107
+ console.error('[Panda CMS] Invalid tool name provided')
108
+ return null
93
109
  }
94
110
 
95
- // Clear any existing content in the holder
96
- holderElement.innerHTML = ''
97
-
98
- // Add source to initial data
99
- if (initialData && !initialData.source) {
100
- initialData.source = "editorJS"
111
+ // Clean up tool name to handle npm package format
112
+ const cleanToolName = toolName.split('/').pop().replace('@', '')
113
+
114
+ const toolMapping = {
115
+ 'paragraph': 'Paragraph',
116
+ 'header': 'Header',
117
+ 'nested-list': 'NestedList',
118
+ 'list': 'NestedList',
119
+ 'quote': 'Quote',
120
+ 'simple-image': 'SimpleImage',
121
+ 'table': 'Table',
122
+ 'embed': 'Embed'
101
123
  }
102
124
 
103
- // Get the base config but pass our document context
104
- const config = getEditorConfig(holderId, initialData, this.document)
125
+ const globalToolName = toolMapping[cleanToolName] || cleanToolName
126
+ const start = Date.now()
105
127
 
106
- // Override specific settings for iframe context
107
- const editorConfig = {
108
- ...config,
109
- holder: holderElement, // Use element reference instead of ID
110
- minHeight: 1, // Prevent auto-height issues in iframe
111
- autofocus: false, // Prevent focus issues
112
- logLevel: 'ERROR', // Only show errors
113
- tools: {
114
- ...config.tools,
115
- // Ensure tools use the correct window context
116
- paragraph: { ...config.tools.paragraph, class: this.document.defaultView.Paragraph },
117
- header: { ...config.tools.header, class: this.document.defaultView.Header },
118
- list: { ...config.tools.list, class: this.document.defaultView.NestedList },
119
- quote: { ...config.tools.quote, class: this.document.defaultView.Quote },
120
- table: { ...config.tools.table, class: this.document.defaultView.Table },
121
- image: { ...config.tools.image, class: this.document.defaultView.SimpleImage },
122
- embed: { ...config.tools.embed, class: this.document.defaultView.Embed }
128
+ while (Date.now() - start < timeout) {
129
+ const win = this.document.defaultView || window
130
+ if (win[globalToolName] && typeof win[globalToolName] === 'function') {
131
+ // If this is the NestedList tool, make it available as both list and nested-list
132
+ if (globalToolName === 'NestedList') {
133
+ win.List = win[globalToolName]
134
+ }
135
+ console.debug(`[Panda CMS] Successfully loaded tool: ${globalToolName}`)
136
+ return win[globalToolName]
123
137
  }
138
+ await new Promise(resolve => setTimeout(resolve, 100))
124
139
  }
140
+ throw new Error(`[Panda CMS] Timeout waiting for tool: ${cleanToolName} (${globalToolName})`)
141
+ }
125
142
 
126
- // Create editor instance directly
127
- const editor = new this.document.defaultView.EditorJS({
128
- ...editorConfig,
129
- onReady: () => {
130
- // Store the editor instance globally for testing
131
- if (this.withinIFrame) {
132
- this.document.defaultView.editor = editor
133
- } else {
134
- window.editor = editor
135
- }
143
+ async initializeEditor(element, initialData = {}, editorId = null) {
144
+ try {
145
+ // Wait for EditorJS core to be available with increased timeout
146
+ await this.waitForEditorJS(15000)
136
147
 
137
- // Mark editor as ready
138
- editor.isReady = true
148
+ // Get the window context (either iframe or parent)
149
+ const win = this.document.defaultView || window
139
150
 
140
- // Force redraw of toolbar and blocks
141
- setTimeout(async () => {
142
- try {
143
- const toolbar = holderElement.querySelector('.ce-toolbar')
144
- const blockWrapper = holderElement.querySelector('.ce-block')
151
+ // Create a unique ID for this editor instance if not provided
152
+ const uniqueId = editorId || `editor-${Math.random().toString(36).substring(2)}`
145
153
 
146
- if (!toolbar || !blockWrapper) {
147
- // Clear and insert a new block to force UI update
148
- await editor.blocks.clear()
149
- await editor.blocks.insert('paragraph')
154
+ // Check if editor already exists
155
+ const existingEditor = element.querySelector('.codex-editor')
156
+ if (existingEditor) {
157
+ console.debug('[Panda CMS] Editor already exists, cleaning up...')
158
+ existingEditor.remove()
159
+ }
150
160
 
151
- // Force a redraw by toggling display
152
- holderElement.style.display = 'none'
153
- void holderElement.offsetHeight
154
- holderElement.style.display = ''
161
+ // Create a holder div for the editor
162
+ const holder = this.document.createElement("div")
163
+ holder.id = uniqueId
164
+ holder.classList.add("editor-js-holder")
165
+ element.appendChild(holder)
166
+
167
+ // Process initial data to handle list items and other content
168
+ let processedData = initialData
169
+ if (initialData.blocks) {
170
+ processedData = {
171
+ ...initialData,
172
+ blocks: initialData.blocks.map(block => {
173
+ if (block.type === 'list' && block.data && Array.isArray(block.data.items)) {
174
+ return {
175
+ ...block,
176
+ data: {
177
+ ...block.data,
178
+ items: block.data.items.map(item => {
179
+ // Handle both string items and object items
180
+ if (typeof item === 'string') {
181
+ return {
182
+ content: item,
183
+ items: []
184
+ }
185
+ } else if (item.content) {
186
+ return {
187
+ content: item.content,
188
+ items: Array.isArray(item.items) ? item.items : []
189
+ }
190
+ } else {
191
+ return {
192
+ content: String(item),
193
+ items: []
194
+ }
195
+ }
196
+ })
197
+ }
198
+ }
155
199
  }
200
+ return block
201
+ })
202
+ }
203
+ }
156
204
 
157
- // Call the ready hook if it exists
158
- if (typeof window.onEditorJSReady === 'function') {
159
- window.onEditorJSReady(editor)
205
+ console.debug('[Panda CMS] Processed initial data:', processedData)
206
+
207
+ // Create editor configuration
208
+ const config = {
209
+ holder: holder,
210
+ data: processedData,
211
+ placeholder: 'Click to start writing...',
212
+ tools: {
213
+ paragraph: {
214
+ class: win.Paragraph,
215
+ inlineToolbar: true,
216
+ config: {
217
+ preserveBlank: true,
218
+ placeholder: 'Click to start writing...'
160
219
  }
161
- } catch (error) {
162
- console.error('Error during editor redraw:', error)
163
- }
164
- }, 100)
165
- },
166
- onChange: async (api, event) => {
167
- try {
168
- // Save the current editor data
169
- const outputData = await api.saver.save()
170
- outputData.source = "editorJS"
171
- const contentJson = JSON.stringify(outputData)
172
-
173
- if (!this.withinIFrame) {
174
- // For form-based editors, update the hidden input
175
- const form = element.closest('[data-controller="editor-form"]')
176
- if (form) {
177
- const hiddenInput = form.querySelector('[data-editor-form-target="hiddenField"]')
178
- if (hiddenInput) {
179
- hiddenInput.value = contentJson
180
- hiddenInput.dataset.initialContent = contentJson
181
- hiddenInput.dispatchEvent(new Event('change', { bubbles: true }))
182
- }
220
+ },
221
+ header: {
222
+ class: win.Header,
223
+ inlineToolbar: true,
224
+ config: {
225
+ placeholder: 'Enter a header',
226
+ levels: [1, 2, 3, 4, 5, 6],
227
+ defaultLevel: 2
183
228
  }
184
- } else {
185
- // For iframe-based editors, update the element's data attribute
186
- element.setAttribute('data-content', contentJson)
187
- element.dispatchEvent(new Event('change', { bubbles: true }))
188
-
189
- // Get the save button from parent window
190
- const saveButton = parent.document.getElementById('saveEditableButton')
191
- if (saveButton) {
192
- // Store the current content on the save button for later use
193
- saveButton.dataset.pendingContent = contentJson
194
-
195
- // Add click handler if not already added
196
- if (!saveButton.hasAttribute('data-handler-attached')) {
197
- saveButton.setAttribute('data-handler-attached', 'true')
198
- saveButton.addEventListener('click', async () => {
199
- try {
200
- const pageId = element.getAttribute("data-editable-page-id")
201
- const blockContentId = element.getAttribute("data-editable-block-content-id")
202
- const pendingContent = JSON.parse(saveButton.dataset.pendingContent || '{}')
203
-
204
- const response = await fetch(`${this.adminPathValue}/pages/${pageId}/block_contents/${blockContentId}`, {
205
- method: "PATCH",
206
- headers: {
207
- "Content-Type": "application/json",
208
- "X-CSRF-Token": this.csrfToken
209
- },
210
- body: JSON.stringify({ content: pendingContent })
211
- })
212
-
213
- if (!response.ok) {
214
- throw new Error('Save failed')
215
- }
216
-
217
- // Clear pending content after successful save
218
- delete saveButton.dataset.pendingContent
219
- } catch (error) {
220
- console.error('Error saving content:', error)
221
- }
222
- })
223
- }
229
+ },
230
+ 'list': { // Register as list instead of nested-list
231
+ class: win.NestedList,
232
+ inlineToolbar: true,
233
+ config: {
234
+ defaultStyle: 'unordered',
235
+ enableLineBreaks: true
236
+ }
237
+ },
238
+ quote: {
239
+ class: win.Quote,
240
+ inlineToolbar: true,
241
+ config: {
242
+ quotePlaceholder: 'Enter a quote',
243
+ captionPlaceholder: 'Quote\'s author'
224
244
  }
225
245
  }
226
- } catch (error) {
227
- console.error('Error in onChange handler:', error)
246
+ },
247
+ onChange: (api, event) => {
248
+ console.debug('[Panda CMS] Editor content changed:', { api, event })
249
+ // Save content to data attributes
250
+ api.saver.save().then((outputData) => {
251
+ const jsonString = JSON.stringify(outputData)
252
+ element.dataset.editablePreviousData = btoa(jsonString)
253
+ element.dataset.editableContent = jsonString
254
+ element.dataset.editableInitialized = 'true'
255
+ })
256
+ },
257
+ onReady: () => {
258
+ console.debug('[Panda CMS] Editor ready with data:', processedData)
259
+ element.dataset.editableInitialized = 'true'
260
+ holder.editorInstance = editor
261
+ },
262
+ onError: (error) => {
263
+ console.error('[Panda CMS] Editor error:', error)
264
+ element.dataset.editableInitialized = 'false'
265
+ throw error
228
266
  }
229
267
  }
230
- })
231
-
232
- // Store editor instance on the holder element to maintain reference
233
- holderElement.editorInstance = editor
234
-
235
- if (!this.withinIFrame) {
236
- // Store the editor instance on the controller element for potential future reference
237
- const form = element.closest('[data-controller="editor-form"]')
238
- if (form) {
239
- form.editorInstance = editor
240
- }
241
- } else {
242
- // For iframe editors, store the instance on the element itself
243
- element.editorInstance = editor
244
- }
245
-
246
- // Return a promise that resolves when the editor is ready
247
- return new Promise((resolve, reject) => {
248
- const timeout = setTimeout(() => {
249
- reject(new Error('Editor initialization timed out'))
250
- }, 30000)
251
268
 
252
- const checkReady = () => {
253
- if (editor.isReady) {
254
- clearTimeout(timeout)
255
- resolve(editor)
256
- } else {
257
- setTimeout(checkReady, 100)
258
- }
259
- }
260
- checkReady()
261
- })
262
- }
269
+ // Remove any undefined tools from the config
270
+ config.tools = Object.fromEntries(
271
+ Object.entries(config.tools)
272
+ .filter(([_, value]) => value?.class !== undefined)
273
+ )
263
274
 
264
- /**
265
- * Wait for EditorJS core to be available in window
266
- */
267
- async waitForEditorJS() {
268
- let attempts = 0
269
- const maxAttempts = 30 // 3 seconds with 100ms intervals
275
+ console.debug('[Panda CMS] Creating editor with config:', config)
270
276
 
271
- await new Promise((resolve, reject) => {
272
- const check = () => {
273
- attempts++
274
- if (window.EditorJS) {
275
- resolve()
276
- } else if (attempts >= maxAttempts) {
277
- reject(new Error('EditorJS core failed to load'))
278
- } else {
279
- setTimeout(check, 100)
277
+ // Create editor instance with extended timeout
278
+ return new Promise((resolve, reject) => {
279
+ try {
280
+ // Add timeout for initialization
281
+ const timeoutId = setTimeout(() => {
282
+ reject(new Error('Editor initialization timeout'))
283
+ }, 15000) // Increased to 15 seconds
284
+
285
+ // Create editor instance with onReady callback
286
+ const editor = new win.EditorJS({
287
+ ...config,
288
+ onReady: () => {
289
+ console.debug('[Panda CMS] Editor ready with data:', processedData)
290
+ clearTimeout(timeoutId)
291
+ holder.editorInstance = editor
292
+ element.dataset.editableInitialized = 'true'
293
+ resolve(editor)
294
+ },
295
+ onChange: (api, event) => {
296
+ console.debug('[Panda CMS] Editor content changed:', { api, event })
297
+ // Save content to data attributes
298
+ api.saver.save().then((outputData) => {
299
+ const jsonString = JSON.stringify(outputData)
300
+ element.dataset.editablePreviousData = btoa(jsonString)
301
+ element.dataset.editableContent = jsonString
302
+ })
303
+ },
304
+ onError: (error) => {
305
+ console.error('[Panda CMS] Editor error:', error)
306
+ element.dataset.editableInitialized = 'false'
307
+ clearTimeout(timeoutId)
308
+ reject(error)
309
+ }
310
+ })
311
+
312
+ // Add error handler
313
+ editor.isReady
314
+ .then(() => {
315
+ console.debug('[Panda CMS] Editor is ready')
316
+ element.dataset.editableInitialized = 'true'
317
+ })
318
+ .catch((error) => {
319
+ console.error('[Panda CMS] Editor failed to initialize:', error)
320
+ element.dataset.editableInitialized = 'false'
321
+ clearTimeout(timeoutId)
322
+ reject(error)
323
+ })
324
+ } catch (error) {
325
+ element.dataset.editableInitialized = 'false'
326
+ reject(error)
280
327
  }
281
- }
282
- check()
283
- })
328
+ })
329
+ } catch (error) {
330
+ console.error('[Panda CMS] Error initializing editor:', error)
331
+ throw error
332
+ }
284
333
  }
285
334
  }
@@ -105,6 +105,6 @@ export class PlainTextEditor {
105
105
  this.element.style.backgroundColor = "inherit"
106
106
  }, 1000)
107
107
  console.log(error)
108
- alert("Error:", error)
108
+ alert(`Error: ${error}`)
109
109
  }
110
110
  }
@@ -112,4 +112,93 @@ export class ResourceLoader {
112
112
  }
113
113
  return hash.toString(36)
114
114
  }
115
+
116
+ /**
117
+ * Load a script into the document
118
+ * @param {Document} doc - The document to load the script into
119
+ * @param {HTMLElement} target - The element to append the script to
120
+ * @param {string} url - The URL of the script to load
121
+ * @returns {Promise<void>}
122
+ */
123
+ static async loadScript(doc, target, url) {
124
+ return new ResourceLoader().loadScript(doc, target, url)
125
+ }
126
+
127
+ /**
128
+ * Embed CSS into the document
129
+ * @param {Document} doc - The document to embed the CSS into
130
+ * @param {HTMLElement} target - The element to append the style to
131
+ * @param {string} css - The CSS to embed
132
+ * @returns {Promise<void>}
133
+ */
134
+ static async embedCSS(doc, target, css) {
135
+ return new ResourceLoader().embedCSS(doc, target, css)
136
+ }
137
+
138
+ /**
139
+ * Instance method to load a script
140
+ */
141
+ async loadScript(doc, target, url) {
142
+ try {
143
+ // Check if script is already loaded
144
+ const existingScript = doc.querySelector(`script[src="${url}"]`)
145
+ if (existingScript) {
146
+ console.debug(`[Panda CMS] Script already loaded: ${url}, skipping`)
147
+ return
148
+ }
149
+
150
+ // Create and configure script element
151
+ const script = doc.createElement("script")
152
+ script.type = "text/javascript"
153
+ script.src = url
154
+ script.async = true
155
+
156
+ // Create a promise to track loading
157
+ const loadPromise = new Promise((resolve, reject) => {
158
+ script.onload = () => {
159
+ console.debug(`[Panda CMS] Script loaded: ${url}`)
160
+ resolve()
161
+ }
162
+ script.onerror = (error) => {
163
+ console.error(`[Panda CMS] Script failed to load: ${url}`, error)
164
+ reject(error)
165
+ }
166
+ })
167
+
168
+ // Add script to document
169
+ target.appendChild(script)
170
+
171
+ // Wait for script to load
172
+ await loadPromise
173
+ } catch (error) {
174
+ console.error(`[Panda CMS] Error loading script ${url}:`, error)
175
+ throw error
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Instance method to embed CSS
181
+ */
182
+ async embedCSS(doc, target, css) {
183
+ try {
184
+ // Check if styles are already embedded
185
+ const existingStyle = doc.querySelector('style[data-panda-cms-styles]')
186
+ if (existingStyle) {
187
+ console.debug(`[Panda CMS] CSS already embedded, skipping`)
188
+ return
189
+ }
190
+
191
+ // Create and configure style element
192
+ const style = doc.createElement('style')
193
+ style.setAttribute('data-panda-cms-styles', 'true')
194
+ style.textContent = css
195
+
196
+ // Add style to document
197
+ target.appendChild(style)
198
+ console.debug(`[Panda CMS] Embedded CSS styles`)
199
+ } catch (error) {
200
+ console.error('[Panda CMS] Error embedding CSS:', error)
201
+ throw error
202
+ }
203
+ }
115
204
  }