From 5ae23365c6d3599aebcceca3e19be859e3c55aae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Fri, 26 Dec 2025 21:42:08 +0100 Subject: [PATCH] Editor: reshuffle --- .../controllers/editor/header_controller.js | 35 ++++- .../editor/json-panel_controller.js | 35 +++++ .../controllers/editor/layout_controller.js | 50 ++++--- .../nostr/nostr_publish_controller.js | 124 +++++------------- assets/styles/03-components/form.css | 3 +- assets/styles/05-utilities/utilities.css | 2 +- templates/editor/layout.html.twig | 103 ++------------- .../editor/panels/_articlelist.html.twig | 90 +++++++++++++ templates/editor/panels/_json.html.twig | 35 +++-- templates/editor/panels/_metadata.html.twig | 2 +- 10 files changed, 241 insertions(+), 238 deletions(-) create mode 100644 templates/editor/panels/_articlelist.html.twig diff --git a/assets/controllers/editor/header_controller.js b/assets/controllers/editor/header_controller.js index 0275da4..878b724 100644 --- a/assets/controllers/editor/header_controller.js +++ b/assets/controllers/editor/header_controller.js @@ -2,19 +2,42 @@ import { Controller } from "@hotwired/stimulus"; export default class extends Controller { connect() { - // For debug - // console.log("Header controller connected"); + console.log("Header controller connected"); } saveDraft(event) { event.preventDefault(); - const btn = document.querySelector('[data-editor--layout-target="saveDraftSubmit"]'); - if (btn) btn.click(); + // Set isDraft to true + const draftCheckbox = document.querySelector('input[name*="[isDraft]"]'); + if (draftCheckbox) { + draftCheckbox.checked = true; + } else { + console.warn('[Header] Draft checkbox not found'); + } + // Trigger click on the hidden Nostr publish button + const publishButton = document.querySelector('[data-nostr--nostr-publish-target="publishButton"]'); + if (publishButton) { + publishButton.click(); + } else { + console.error('[Header] Hidden publish button not found'); + } } publish(event) { event.preventDefault(); - const btn = document.querySelector('[data-editor--layout-target="publishSubmit"]'); - if (btn) btn.click(); + // Set isDraft to false + const draftCheckbox = document.querySelector('input[name*="[isDraft]"]'); + if (draftCheckbox) { + draftCheckbox.checked = false; + } else { + console.warn('[Header] Draft checkbox not found'); + } + // Trigger click on the hidden Nostr publish button + const publishButton = document.querySelector('[data-nostr--nostr-publish-target="publishButton"]'); + if (publishButton) { + publishButton.click(); + } else { + console.error('[Header] Hidden publish button not found'); + } } } diff --git a/assets/controllers/editor/json-panel_controller.js b/assets/controllers/editor/json-panel_controller.js index 7f5c394..c075c4b 100644 --- a/assets/controllers/editor/json-panel_controller.js +++ b/assets/controllers/editor/json-panel_controller.js @@ -45,6 +45,35 @@ export default class extends Controller { } else { this.cmView = this.textarea._codemirror; } + + // Restore from localStorage if available + const savedJson = localStorage.getItem('editorState'); + if (savedJson) { + try { + JSON.parse(savedJson); // Validate JSON + this.jsonTextareaTarget.value = savedJson; + if (this.cmView) { + this.cmView.dispatch({ + changes: {from: 0, to: this.cmView.state.doc.length, insert: savedJson} + }); + } + } catch (e) { + // Ignore corrupt JSON + localStorage.removeItem('editorState'); + } + } + // Periodic save every 10 seconds + this._saveInterval = setInterval(() => { + if (this.hasJsonTextareaTarget) { + const value = this.jsonTextareaTarget.value; + try { + JSON.parse(value); // Only save valid JSON + localStorage.setItem('editorState', value); + } catch (e) { + // Do not save invalid JSON + } + } + }, 10000); } disconnect() { @@ -60,6 +89,12 @@ export default class extends Controller { } this.textarea.style.display = ''; this.textarea._codemirror = null; + + // Clear periodic save interval + if (this._saveInterval) { + clearInterval(this._saveInterval); + this._saveInterval = null; + } } handleMarkdownInput() { diff --git a/assets/controllers/editor/layout_controller.js b/assets/controllers/editor/layout_controller.js index 227fb46..56758b3 100644 --- a/assets/controllers/editor/layout_controller.js +++ b/assets/controllers/editor/layout_controller.js @@ -7,7 +7,7 @@ export default class extends Controller { 'previewBody', 'previewTitle', 'previewSummary', 'previewImage', 'previewImagePlaceholder', 'previewAuthor', 'previewDate', 'markdownEditor', 'markdownTitle', 'markdownCode', 'status', - 'saveDraftSubmit', 'publishSubmit' + 'saveDraftSubmit', 'publishSubmit', 'jsonCode' ]; connect() { @@ -26,26 +26,10 @@ export default class extends Controller { imageInput.addEventListener('change', () => this.updatePreview()); } - // If editing an existing article, load JSON event by default - if (this.element.dataset.articleId && this.hasJsonPaneTarget) { - // Find the JSON textarea in the pane and load the event - const jsonTextarea = this.jsonPaneTarget.querySelector('[data-editor--json-panel-target="jsonTextarea"]'); - if (jsonTextarea && !jsonTextarea.value.trim()) { - // Try to get the Nostr publish controller's JSON - const nostrController = this.application.getControllerForElementAndIdentifier( - this.element.querySelector('[data-controller*="nostr--nostr-publish"]'), - 'nostr--nostr-publish' - ); - if (nostrController && nostrController.hasJsonTextareaTarget) { - jsonTextarea.value = nostrController.jsonTextareaTarget.value; - // Optionally, trigger formatting/validation if needed - } - } - } - // Listen for content changes from Quill or Markdown this.element.addEventListener('content:changed', () => { this.updatePreview(); + this.updateJsonCode(); // Update Quill pane live const markdownInput = this.element.querySelector('textarea[name="editor[content]"]'); if (markdownInput && window.appQuill) { @@ -61,13 +45,6 @@ export default class extends Controller { .then(data => { window.appQuill.root.innerHTML = data.html || ''; }); } } - // If JSON pane is present, update it as well - if (this.hasJsonPaneTarget) { - const jsonTextarea = this.jsonPaneTarget.querySelector('[data-editor--json-panel-target="jsonTextarea"]'); - if (jsonTextarea && window.nostrPublishController && typeof window.nostrPublishController.regenerateJsonPreview === 'function') { - window.nostrPublishController.regenerateJsonPreview(); - } - } }); } @@ -108,8 +85,27 @@ export default class extends Controller { } else if (mode === 'preview') { this.updatePreview(); } else if (mode === 'json') { - // Optionally, trigger JSON formatting/validation + this.updateJsonCode(); + } + } + + updateJsonCode() { + // Fill the JSON code block with the latest JSON event and highlight + if (!this.hasJsonCodeTarget) return; + let json = ''; + const nostrController = this.application.getControllerForElementAndIdentifier( + this.element.querySelector('[data-controller*="nostr--nostr-publish"]'), + 'nostr--nostr-publish' + ); + if (nostrController && nostrController.hasJsonTextareaTarget) { + json = nostrController.jsonTextareaTarget.value; + } + try { + json = JSON.stringify(JSON.parse(json), null, 2); + } catch (e) { + // If not valid JSON, show as-is } + this.jsonCodeTarget.textContent = json || 'No JSON event available.'; } updateMarkdown() { @@ -255,7 +251,7 @@ export default class extends Controller { 'nostr--nostr-publish' ); - if (nostrController) { + if (nostrController && typeof nostrController.publish === 'function') { console.log('[Editor] Nostr publish controller found, calling publish()'); nostrController.publish(); } else { diff --git a/assets/controllers/nostr/nostr_publish_controller.js b/assets/controllers/nostr/nostr_publish_controller.js index bfc8b84..4d258d9 100644 --- a/assets/controllers/nostr/nostr_publish_controller.js +++ b/assets/controllers/nostr/nostr_publish_controller.js @@ -211,16 +211,21 @@ export default class extends Controller { } } - // If a user provided a partial or custom event, make sure required keys exist - applyEventDefaults(event, formData) { + // If a user provided a partial or custom event, make sure required keys exist and supplement from form + applyEventDefaults(event, formData, options = {}) { const now = Math.floor(Date.now() / 1000); const corrected = { ...event }; // Ensure tags/content/kind/created_at/pubkey exist; tags default includes d/title/summary/image/topics if (!Array.isArray(corrected.tags)) corrected.tags = []; - // Supplement missing core fields from form - if (typeof corrected.kind !== 'number') corrected.kind = formData.isDraft ? 30024 : 30023; + // Supplement missing core fields from form or options + // Kind: explicit option > formData.isDraft > event.kind + if (typeof options.kind === 'number') { + corrected.kind = options.kind; + } else if (typeof corrected.kind !== 'number') { + corrected.kind = formData.isDraft ? 30024 : 30023; + } if (typeof corrected.created_at !== 'number') corrected.created_at = now; if (typeof corrected.content !== 'string') corrected.content = formData.content || ''; @@ -228,31 +233,33 @@ export default class extends Controller { if (!corrected.pubkey) corrected.pubkey = undefined; // will be filled by createNostrEvent path if used // Guarantee a d tag (slug) - const hasD = corrected.tags.some(t => Array.isArray(t) && t[0] === 'd'); - if (!hasD && formData.slug) corrected.tags.push(['d', formData.slug]); - - // Ensure title/summary/image/topics exist if absent - const ensureTag = (name, value) => { - if (!value) return; - const exists = corrected.tags.some(t => Array.isArray(t) && t[0] === name); - if (!exists) corrected.tags.push([name, value]); - }; - ensureTag('title', formData.title); - ensureTag('summary', formData.summary); - ensureTag('image', formData.image); - for (const t of formData.topics || []) { - const exists = corrected.tags.some(tag => Array.isArray(tag) && tag[0] === 't' && tag[1] === t.replace('#', '')); - if (!exists) corrected.tags.push(['t', t.replace('#', '')]); + const tagsMap = new Map(); + for (const t of corrected.tags) { + if (Array.isArray(t) && t.length > 0) tagsMap.set(t[0], t); + } + if (formData.slug) tagsMap.set('d', ['d', formData.slug]); + if (formData.title) tagsMap.set('title', ['title', formData.title]); + if (formData.summary) tagsMap.set('summary', ['summary', formData.summary]); + if (formData.image) tagsMap.set('image', ['image', formData.image]); + // Topics: allow multiple t tags + if (formData.topics && Array.isArray(formData.topics)) { + // Remove all existing t tags + for (const key of Array.from(tagsMap.keys())) { + if (key === 't') tagsMap.delete(key); + } + for (const topic of formData.topics) { + tagsMap.set(`t:${topic.replace('#','')}`, ['t', topic.replace('#','')]); + } } - // Advanced tags from form, but don't duplicate existing tags by name if (formData.advancedMetadata) { const adv = buildAdvancedTags(formData.advancedMetadata); for (const tag of adv) { - const exists = corrected.tags.some(t => Array.isArray(t) && t[0] === tag[0]); - if (!exists) corrected.tags.push(tag); + if (!tagsMap.has(tag[0])) tagsMap.set(tag[0], tag); } } + // Rebuild tags array + corrected.tags = Array.from(tagsMap.values()); return corrected; } @@ -271,14 +278,8 @@ export default class extends Controller { const fd = new FormData(form); - // Prefer the Markdown field populated by the Quill controller - const md = fd.get('editor[content]'); - let html = fd.get('editor[content]') || fd.get('content') || ''; - - // Final content: use MD if present, otherwise convert HTML -> MD - const content = (typeof md === 'string' && md.length > 0) - ? md - : this.htmlToMarkdown(String(html)); + // Only use the Markdown field + const content = fd.get('editor[content]') || ''; const title = fd.get('editor[title]') || ''; const summary = fd.get('editor[summary]') || ''; @@ -439,69 +440,6 @@ export default class extends Controller { return await response.json(); } - htmlToMarkdown(html) { - // Basic HTML to Markdown conversion - let markdown = html; - - // Convert headers - markdown = markdown.replace(/]*>(.*?)<\/h1>/gi, '# $1\n\n'); - markdown = markdown.replace(/]*>(.*?)<\/h2>/gi, '## $1\n\n'); - markdown = markdown.replace(/]*>(.*?)<\/h3>/gi, '### $1\n\n'); - - // Convert formatting - markdown = markdown.replace(/]*>(.*?)<\/strong>/gi, '**$1**'); - markdown = markdown.replace(/]*>(.*?)<\/b>/gi, '**$1**'); - markdown = markdown.replace(/]*>(.*?)<\/em>/gi, '*$1*'); - markdown = markdown.replace(/]*>(.*?)<\/i>/gi, '*$1*'); - - // Convert links - markdown = markdown.replace(/]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)'); - - // Convert images - markdown = markdown.replace(/]*>/gi, (imgTag) => { - const srcMatch = imgTag.match(/src=["']([^"']+)["']/i); - const altMatch = imgTag.match(/alt=["']([^"']*)["']/i); - const src = srcMatch ? srcMatch[1] : ''; - const alt = altMatch ? altMatch[1] : ''; - return src ? `![${alt}](${src})` : ''; - }); - - // Convert lists - markdown = markdown.replace(/]*>(.*?)<\/ul>/gis, '$1\n'); - markdown = markdown.replace(/]*>(.*?)<\/ol>/gis, '$1\n'); - markdown = markdown.replace(/]*>(.*?)<\/li>/gi, '- $1\n'); - - // Convert paragraphs - markdown = markdown.replace(/]*>(.*?)<\/p>/gi, '$1\n\n'); - - // Convert line breaks - markdown = markdown.replace(/]*>/gi, '\n'); - - // Convert blockquotes - markdown = markdown.replace(/]*>(.*?)<\/blockquote>/gis, '> $1\n\n'); - - // Convert code blocks - markdown = markdown.replace(/]*>]*>(.*?)<\/code><\/pre>/gis, '```\n$1\n```\n\n'); - markdown = markdown.replace(/]*>(.*?)<\/code>/gi, '`$1`'); - - // Escape "_" inside display math $$...$$ and inline math $...$ - markdown = markdown.replace(/\$\$([\s\S]*?)\$\$/g, (m, g1) => `$$${g1.replace(/_/g, (u, i, s) => (i>0 && s[i-1]==='\\') ? '\\_' : '\\_')}$$`); - markdown = markdown.replace(/\$([^$]*?)\$/g, (m, g1) => `$${g1.replace(/_/g, (u, i, s) => (i>0 && s[i-1]==='\\') ? '\\_' : '\\_')}$`); - - // Clean up HTML entities and remaining tags - markdown = markdown.replace(/ /g, ' '); - markdown = markdown.replace(/&/g, '&'); - markdown = markdown.replace(/</g, '<'); - markdown = markdown.replace(/>/g, '>'); - markdown = markdown.replace(/"/g, '"'); - markdown = markdown.replace(/<[^>]*>/g, ''); - - // Clean up extra whitespace - markdown = markdown.replace(/\n{3,}/g, '\n\n').trim(); - - return markdown; - } - generateSlug(title) { // add a random seed at the end of the title to avoid collisions const randomSeed = Math.random().toString(36).substring(2, 8); diff --git a/assets/styles/03-components/form.css b/assets/styles/03-components/form.css index 1397f0a..2a04fea 100644 --- a/assets/styles/03-components/form.css +++ b/assets/styles/03-components/form.css @@ -21,7 +21,8 @@ label { clear: both; } -input, textarea, select { +input:not([type="checkbox"]):not([type="radio"]), +textarea, select { display: block; clear: both; width: 100%; diff --git a/assets/styles/05-utilities/utilities.css b/assets/styles/05-utilities/utilities.css index faaa5b0..8320faf 100644 --- a/assets/styles/05-utilities/utilities.css +++ b/assets/styles/05-utilities/utilities.css @@ -28,7 +28,7 @@ .d-block{display:block!important} .gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important} -.flex-row{flex-direction:row} +.flex-row{flex-direction:row}.flex-row-reverse{flex-direction:row-reverse} .flex-wrap{flex-wrap: wrap} .justify-content-between{justify-content:space-between!important} .justify-content-center{justify-content:center!important} diff --git a/templates/editor/layout.html.twig b/templates/editor/layout.html.twig index 47a6015..c3801ad 100644 --- a/templates/editor/layout.html.twig +++ b/templates/editor/layout.html.twig @@ -1,4 +1,3 @@ -{# templates/editor/layout.html.twig #} {% extends 'base.html.twig' %} {% form_theme form _self 'pages/_advanced_metadata.html.twig' %} @@ -16,6 +15,12 @@
← Back + {# If not create new, show btn for 'Create new' #} + {% if article.id %} + + Create new + + {% endif %}
{{ article.title|default('New article') }}
@@ -40,95 +45,11 @@
{# Insert the article list sidebar as the first grid column #} - + {% include 'editor/panels/_articlelist.html.twig' with { + readingLists: readingLists is defined ? readingLists : [], + recentArticles: recentArticles is defined ? recentArticles : [], + drafts: drafts is defined ? drafts : [] + } %} {# Center editor area (middle grid column) #}
@@ -331,7 +252,7 @@
diff --git a/templates/editor/panels/_articlelist.html.twig b/templates/editor/panels/_articlelist.html.twig new file mode 100644 index 0000000..366bb1b --- /dev/null +++ b/templates/editor/panels/_articlelist.html.twig @@ -0,0 +1,90 @@ + + diff --git a/templates/editor/panels/_json.html.twig b/templates/editor/panels/_json.html.twig index 953700b..7f6ee96 100644 --- a/templates/editor/panels/_json.html.twig +++ b/templates/editor/panels/_json.html.twig @@ -1,13 +1,13 @@
-
- -
+{#
#} +{# #} +{# Rebuild from form#} +{# #} +{#
#}
-
- - Required fields: kind, created_at, tags, content, pubkey
- -
-
+{#
#} +{# #} +{# Required fields: kind, created_at, tags, content, pubkey
#} +{# #} +{#
#} +{#
#}
diff --git a/templates/editor/panels/_metadata.html.twig b/templates/editor/panels/_metadata.html.twig index 9e28b03..9e0b796 100644 --- a/templates/editor/panels/_metadata.html.twig +++ b/templates/editor/panels/_metadata.html.twig @@ -61,7 +61,7 @@ {{ form_row(form.clientTag, { 'label': 'Add client tag (Decent Newsroom)', - 'row_attr': {'class': 'form-check d-flex flex-row'}, + 'row_attr': {'class': 'form-check mt-2 d-flex flex-row-reverse justify-content-between'}, 'label_attr': {'class': 'form-check-label'}, 'attr': {'class': 'form-check-input'} }) }}