diff --git a/assets/app.js b/assets/app.js index 3506b18..e389a28 100644 --- a/assets/app.js +++ b/assets/app.js @@ -38,6 +38,9 @@ import './styles/03-components/search.css'; import './styles/03-components/image-upload.css'; import './styles/03-components/zaps.css'; +// Editor layout +import './styles/editor-layout.css'; + // 04 - Page-specific styles import './styles/04-pages/landing.css'; import './styles/04-pages/admin.css'; diff --git a/assets/controllers/editor/articlelist-panels_controller.js b/assets/controllers/editor/articlelist-panels_controller.js new file mode 100644 index 0000000..1a16878 --- /dev/null +++ b/assets/controllers/editor/articlelist-panels_controller.js @@ -0,0 +1,17 @@ +import { Controller } from '@hotwired/stimulus'; + +// Handles tab switching for the left article list sidebar, matching the right sidebar logic +export default class extends Controller { + static targets = ['tab', 'panel']; + + switch(event) { + const panel = event.currentTarget.dataset.panel; + this.tabTargets.forEach(tab => { + tab.classList.toggle('is-active', tab.dataset.panel === panel); + }); + this.panelTargets.forEach(panelEl => { + panelEl.classList.toggle('is-hidden', panelEl.dataset.panel !== panel); + }); + } +} + diff --git a/assets/controllers/editor/json-panel_controller.js b/assets/controllers/editor/json-panel_controller.js new file mode 100644 index 0000000..e4b0c74 --- /dev/null +++ b/assets/controllers/editor/json-panel_controller.js @@ -0,0 +1,118 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ['jsonTextarea', 'status', 'dirtyHint']; + + connect() { + console.log('JSON panel controller connected'); + this.isDirty = false; + + // Load initial JSON from the Nostr publish controller + this.loadInitialJson(); + } + + loadInitialJson() { + // Wait a bit for the Nostr publish controller to initialize + setTimeout(() => { + const nostrController = this.getNostrPublishController(); + if (nostrController && nostrController.hasJsonTextareaTarget) { + const json = nostrController.jsonTextareaTarget.value; + if (json && this.hasJsonTextareaTarget) { + this.jsonTextareaTarget.value = json; + this.formatJson(); + } + } + }, 500); + } + + regenerateJson() { + const nostrController = this.getNostrPublishController(); + if (nostrController && typeof nostrController.regenerateJsonPreview === 'function') { + nostrController.regenerateJsonPreview(); + + // Copy the regenerated JSON to our textarea + setTimeout(() => { + if (nostrController.hasJsonTextareaTarget && this.hasJsonTextareaTarget) { + this.jsonTextareaTarget.value = nostrController.jsonTextareaTarget.value; + this.formatJson(); + this.isDirty = false; + this.updateDirtyHint(); + this.showStatus('JSON rebuilt from form', 'success'); + } + }, 100); + } + } + + onJsonInput(event) { + this.isDirty = true; + this.updateDirtyHint(); + this.validateJson(); + + // Sync to the hidden Nostr publish textarea + const nostrController = this.getNostrPublishController(); + if (nostrController && nostrController.hasJsonTextareaTarget) { + nostrController.jsonTextareaTarget.value = event.target.value; + } + } + + validateJson() { + if (!this.hasJsonTextareaTarget) return; + + try { + const json = JSON.parse(this.jsonTextareaTarget.value); + const required = ['kind', 'created_at', 'tags', 'content', 'pubkey']; + const missing = required.filter(field => !(field in json)); + + if (missing.length > 0) { + this.showStatus(`Missing: ${missing.join(', ')}`, 'warning'); + } else { + this.showStatus('Valid JSON', 'success'); + } + } catch (e) { + this.showStatus('Invalid JSON', 'error'); + } + } + + formatJson() { + if (!this.hasJsonTextareaTarget) return; + + try { + const json = JSON.parse(this.jsonTextareaTarget.value); + this.jsonTextareaTarget.value = JSON.stringify(json, null, 2); + this.showStatus('Formatted', 'success'); + } catch (e) { + // Silently fail if JSON is invalid + } + } + + showStatus(message, type = 'info') { + if (!this.hasStatusTarget) return; + + this.statusTarget.textContent = message; + this.statusTarget.className = `json-status json-status--${type}`; + + setTimeout(() => { + if (this.hasStatusTarget) { + this.statusTarget.textContent = ''; + this.statusTarget.className = 'json-status'; + } + }, 3000); + } + + updateDirtyHint() { + if (this.hasDirtyHintTarget) { + this.dirtyHintTarget.style.display = this.isDirty ? 'inline' : 'none'; + } + } + + getNostrPublishController() { + const element = document.querySelector('[data-controller*="nostr--nostr-publish"]'); + if (!element) return null; + + return this.application.getControllerForElementAndIdentifier( + element, + 'nostr--nostr-publish' + ); + } +} + diff --git a/assets/controllers/editor/layout_controller.js b/assets/controllers/editor/layout_controller.js new file mode 100644 index 0000000..bdb1911 --- /dev/null +++ b/assets/controllers/editor/layout_controller.js @@ -0,0 +1,211 @@ +// assets/controllers/editor/layout_controller.js +import {Controller} from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = [ + 'modeTab', 'editPane', 'markdownPane', 'previewPane', + 'previewBody', 'previewTitle', + 'previewSummary', 'previewImage', 'previewImagePlaceholder', 'previewAuthor', 'previewDate', + 'markdownEditor', 'markdownTitle', 'markdownCode', 'status' + ]; + + connect() { + console.log('Editor layout controller connected'); + this.autoSaveTimer = null; + + // Live preview for summary and image fields + const summaryInput = this.element.querySelector('textarea[name*="[summary]"], textarea[name="editor[summary]"]'); + const imageInput = this.element.querySelector('input[name*="[image]"], input[name="editor[image]"]'); + if (summaryInput) { + summaryInput.addEventListener('input', () => this.updatePreview()); + summaryInput.addEventListener('change', () => this.updatePreview()); + } + if (imageInput) { + imageInput.addEventListener('input', () => this.updatePreview()); + imageInput.addEventListener('change', () => this.updatePreview()); + } + } + + switchMode(event) { + const mode = event.currentTarget.dataset.mode; + + // Update tab states + this.modeTabTargets.forEach(tab => { + tab.classList.toggle('is-active', tab.dataset.mode === mode); + }); + + // Toggle panes - hide all, then show the selected one + this.editPaneTarget.classList.toggle('is-hidden', mode !== 'edit'); + this.markdownPaneTarget.classList.toggle('is-hidden', mode !== 'markdown'); + this.previewPaneTarget.classList.toggle('is-hidden', mode !== 'preview'); + + // Update content when switching modes + if (mode === 'markdown') { + this.updateMarkdown(); + } else if (mode === 'preview') { + this.updatePreview(); + } + } + + updateMarkdown() { + // Get title from form + const titleInput = this.element.querySelector('input[name*="[title]"]'); + if (titleInput && this.hasMarkdownTitleTarget) { + this.markdownTitleTarget.value = titleInput.value || ''; + } + + // Get markdown from Quill controller + const markdownInput = this.element.querySelector('input[name="editor[content_md]"]'); + const markdown = markdownInput ? markdownInput.value || '' : ''; + + // Set code block content and highlight + if (this.hasMarkdownCodeTarget) { + this.markdownCodeTarget.textContent = markdown; + if (window.Prism && Prism.highlightElement) { + Prism.highlightElement(this.markdownCodeTarget); + } + } + } + + async updatePreview() { + if (!this.hasPreviewBodyTarget) return; + + // Get title from form + const titleInput = this.element.querySelector('input[name*="[title]"], input[name="editor[title]"]'); + const summaryInput = this.element.querySelector('textarea[name*="[summary]"], textarea[name="editor[summary]"]'); + const imageInput = this.element.querySelector('input[name*="[image]"], input[name="editor[image]"]'); + const markdownInput = this.element.querySelector('input[name="editor[content_md]"]'); + const authorInput = this.element.querySelector('input[name*="[author]"]'); + const dateInput = this.element.querySelector('input[name*="[publishedAt]"]') || this.element.querySelector('input[name*="[createdAt]"]'); + + // Title + const title = titleInput ? titleInput.value.trim() : ''; + if (this.hasPreviewTitleTarget) { + this.previewTitleTarget.textContent = title || 'Article title'; + } + + // Author (placeholder logic) + if (this.hasPreviewAuthorTarget) { + let author = authorInput ? authorInput.value.trim() : ''; + this.previewAuthorTarget.textContent = author || 'Author'; + } + + // Date (placeholder logic) + if (this.hasPreviewDateTarget) { + const now = new Date(); + this.previewDateTarget.textContent = now.toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + } + + // Summary (always use form value) + const summary = summaryInput ? summaryInput.value.trim() : ''; + if (this.hasPreviewSummaryTarget) { + this.previewSummaryTarget.textContent = summary || 'No summary provided. This is where your article summary will appear.'; + this.previewSummaryTarget.classList.toggle('placeholder', !summary); + } + + // Image (always use form value) + const imageUrl = imageInput ? imageInput.value.trim() : ''; + if (this.hasPreviewImageTarget && this.hasPreviewImagePlaceholderTarget) { + if (imageUrl) { + this.previewImageTarget.src = imageUrl; + this.previewImageTarget.style.display = ''; + this.previewImagePlaceholderTarget.style.display = 'none'; + } else { + this.previewImageTarget.src = ''; + this.previewImageTarget.style.display = 'none'; + this.previewImagePlaceholderTarget.style.display = ''; + } + } + + // Body (markdown to HTML via backend) + let html = '

Loading preview...

'; + this.previewBodyTarget.innerHTML = html; + if (markdownInput) { + try { + const response = await fetch('/editor/markdown/preview', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ markdown: markdownInput.value || '' }) + }); + if (response.ok) { + const data = await response.json(); + html = data.html || '

No content yet. Start writing your article!

'; + } else { + html = '

Failed to load preview.

'; + } + } catch (e) { + html = '

Error loading preview.

'; + } + this.previewBodyTarget.innerHTML = html; + } else { + this.previewBodyTarget.innerHTML = '

No content yet. Start writing your article!

'; + } + } + + saveDraft() { + console.log('Saving draft...'); + + // Mark as draft - set checkbox to true + const draftCheckbox = this.element.querySelector('input[name*="[isDraft]"]'); + if (draftCheckbox) { + draftCheckbox.checked = true; + } + + // Submit the form + const form = this.element.querySelector('form'); + if (form) { + this.updateStatus('Saving draft...'); + form.requestSubmit(); + } + } + + publish() { + console.log('Publishing article...'); + + // Mark as NOT draft - set checkbox to false + const draftCheckbox = this.element.querySelector('input[name*="[isDraft]"]'); + if (draftCheckbox) { + draftCheckbox.checked = false; + } + + // Find the Nostr publish controller and trigger publish + const nostrController = this.application.getControllerForElementAndIdentifier( + this.element.querySelector('[data-controller*="nostr--nostr-publish"]'), + 'nostr--nostr-publish' + ); + + if (nostrController) { + nostrController.publish(); + } else { + console.error('Nostr publish controller not found'); + alert('Could not find publishing controller. Please try again.'); + } + } + + + updateStatus(message) { + if (this.hasStatusTarget) { + this.statusTarget.textContent = message; + + // Clear status after 3 seconds + setTimeout(() => { + if (this.hasStatusTarget) { + this.statusTarget.textContent = ''; + } + }, 3000); + } + } + + disconnect() { + if (this.autoSaveTimer) { + clearTimeout(this.autoSaveTimer); + } + } +} diff --git a/assets/controllers/editor/panels_controller.js b/assets/controllers/editor/panels_controller.js new file mode 100644 index 0000000..a16b53e --- /dev/null +++ b/assets/controllers/editor/panels_controller.js @@ -0,0 +1,23 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ['tab', 'panel']; + + switch(event) { + const panelName = event.currentTarget.dataset.panel; + + // Update tab states + this.tabTargets.forEach(tab => { + tab.classList.toggle( + 'is-active', + tab.dataset.panel === panelName + ); + }); + + // Update panel visibility + this.panelTargets.forEach(panel => { + const isActive = panel.dataset.panel === panelName; + panel.classList.toggle('is-hidden', !isActive); + }); + } +} diff --git a/assets/controllers/nostr/nostr_publish_controller.js b/assets/controllers/nostr/nostr_publish_controller.js index 0096310..89c4f4f 100644 --- a/assets/controllers/nostr/nostr_publish_controller.js +++ b/assets/controllers/nostr/nostr_publish_controller.js @@ -84,15 +84,13 @@ function validateAdvancedMetadata(metadata) { export default class extends Controller { static targets = ['form', 'publishButton', 'status', 'jsonContainer', 'jsonTextarea', 'jsonToggle', 'jsonDirtyHint']; static values = { - publishUrl: String, - csrfToken: String + publishUrl: String }; connect() { console.log('Nostr publish controller connected'); try { console.debug('[nostr-publish] publishUrl:', this.publishUrlValue || '(none)'); - console.debug('[nostr-publish] has csrfToken:', Boolean(this.csrfTokenValue)); console.debug('[nostr-publish] existing slug:', (this.element.dataset.slug || '(none)')); } catch (_) {} @@ -138,17 +136,15 @@ export default class extends Controller { if (this.hasJsonDirtyHintTarget) this.jsonDirtyHintTarget.style.display = ''; } - async publish(event) { - event.preventDefault(); + async publish(event = null) { + if (event) { + event.preventDefault(); + } if (!this.publishUrlValue) { this.showError('Publish URL is not configured'); return; } - if (!this.csrfTokenValue) { - this.showError('Missing CSRF token'); - return; - } if (!window.nostr) { this.showError('Nostr extension not found'); @@ -260,9 +256,16 @@ export default class extends Controller { } collectFormData() { - // Find the actual form element within our target - const form = this.formTarget.querySelector('form'); - if (!form) throw new Error('Form element not found'); + // Find the actual form element in the editor (it's not within our hidden container) + // Try multiple selectors to be robust + const form = document.querySelector('.editor-center-content form') + || document.querySelector('form[name="editor"]') + || document.querySelector('.editor-main form'); + + if (!form) { + console.error('Could not find form element. Available forms:', document.querySelectorAll('form')); + throw new Error('Form element not found'); + } const fd = new FormData(form); @@ -418,8 +421,7 @@ export default class extends Controller { method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-Requested-With': 'XMLHttpRequest', - 'X-CSRF-TOKEN': this.csrfTokenValue + 'X-Requested-With': 'XMLHttpRequest' }, body: JSON.stringify({ event: signedEvent, diff --git a/assets/controllers/publishing/quill_controller.js b/assets/controllers/publishing/quill_controller.js index 879785f..3716813 100644 --- a/assets/controllers/publishing/quill_controller.js +++ b/assets/controllers/publishing/quill_controller.js @@ -142,6 +142,9 @@ export default class extends Controller { this.quill = new Quill(editorEl, options); + // Expose globally for preview functionality + window.appQuill = this.quill; + // If there were formulas in the loaded HTML, we need to convert them to proper embeds if (hasFormulas) { this.convertFormulasToEmbeds(); @@ -321,6 +324,13 @@ export default class extends Controller { this.quill.setContents(deltaOps, 'silent'); } + + disconnect() { + // Clean up global reference + if (window.appQuill === this.quill) { + window.appQuill = null; + } + } } /* ---------- Delta → Markdown with $...$ / $$...$$ ---------- */ diff --git a/assets/styles/03-components/article.css b/assets/styles/03-components/article.css index a35db40..f647e16 100644 --- a/assets/styles/03-components/article.css +++ b/assets/styles/03-components/article.css @@ -125,13 +125,6 @@ blockquote p { aspect-ratio: 16/9; } -.ql-toolbar { - position: sticky; - top: 80px; - background-color: var(--color-bg); - z-index: 10; -} - .ql-snow .ql-tooltip.ql-image-tooltip { white-space: nowrap; } diff --git a/assets/styles/03-components/form.css b/assets/styles/03-components/form.css index 0139b57..57f1d03 100644 --- a/assets/styles/03-components/form.css +++ b/assets/styles/03-components/form.css @@ -27,8 +27,11 @@ input, textarea, select { width: 100%; } -input, textarea, select, .quill { - background-color: var(--color-bg); +textarea { + max-width: initial; +} + +input, textarea, select { color: var(--color-text); border: 1px solid var(--color-primary); border-radius: 0; /* Sharp edges */ diff --git a/assets/styles/editor-layout.css b/assets/styles/editor-layout.css new file mode 100644 index 0000000..47b4229 --- /dev/null +++ b/assets/styles/editor-layout.css @@ -0,0 +1,782 @@ +@import "../vendor/prismjs/themes/prism.min.css"; /* Editor IDE-like layout styles */ + +/* Main container - takes full viewport */ +main[data-controller="editor--layout"] { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + overflow: hidden; + z-index: 100; + background: var(--background, #ffffff); + margin-top: 60px; + padding: 0; +} + +/* Header - fixed at top */ +.editor-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-2); + min-height: 60px; + flex-shrink: 0; + border-bottom: 1px solid var(--color-border); +} + +.editor-header-left { + display: flex; + align-items: center; + gap: 1rem; +} + +.editor-title { + font-size: 1rem; + font-weight: 500; + color: var(--text-primary, #111827); +} + +.editor-header-right { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.editor-status { + font-size: 0.875rem; + padding-right: 0.5rem; +} + +/* Main content area - fills remaining space */ +.editor-main { + flex: 1; + display: grid; + grid-template-columns: 260px minmax(0, 2.5fr) minmax(280px, 1fr); + min-height: 0; + overflow: hidden; +} + +/* Center editor area */ +.editor-center { + display: flex; + flex-direction: column; + border-right: 1px solid var(--border-color, #e5e7eb); + min-height: 0; + background: var(--background, #ffffff); +} + +.editor-center-tabs { + display: flex; + border-bottom: 1px solid var(--border-color, #e5e7eb); + background: var(--surface, #f9fafb); +} + +.editor-tab { + padding: 0.625rem 1rem; + border: none; + background: transparent; + cursor: pointer; + font-size: 0.875rem; + font-weight: 400; + color: var(--text-secondary, #6b7280); + transition: all 0.2s ease; +} + +.editor-tab:hover { + background: var(--hover, #f3f4f6); +} + +.editor-tab.is-active { + border-bottom: 2px solid var(--primary, #2563eb); + font-weight: 600; + color: var(--text-primary, #111827); + background: var(--background, #ffffff); +} + +.editor-center-content { + flex: 1; + min-height: 0; + overflow: hidden; + display: flex; + position: relative; +} + +.editor-center-content > form { + flex: 1; + display: flex; + min-height: 0; + margin: 0; +} + +.editor-pane { + flex: 1; + overflow: hidden; + padding: 1rem; + min-height: 0; +} + +.editor-pane.is-hidden { + display: none; +} + +/* Markdown pane */ +.editor-pane--markdown { + display: flex; + flex-direction: column; + padding: 1rem; + overflow: hidden; + height: 100%; +} + +.markdown-editor-wrapper { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.editor-pane--markdown .editor-title-input { + margin-bottom: 1rem; + flex-shrink: 0; +} + +.markdown-editor { + flex: 1; + width: 100%; + min-height: 300px; + padding: 1rem; + line-height: 1.6; + resize: none; + overflow-y: auto; + overflow-x: hidden; + word-break: break-word; + white-space: pre-wrap; + box-sizing: border-box; +} + +/* Editor specific styles */ +.editor-pane--edit { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; + overflow: hidden; +} + +.editor-title-input { + margin-bottom: 1rem; +} + +.editor-title-field { + font-size: 1.5rem; + font-weight: 600; + border: none; + border-bottom: 1px solid var(--border-color, #e5e7eb); + border-radius: 0; + padding: 0.5rem 0; +} + +.editor-title-field:focus { + outline: none; + border-bottom-color: var(--primary, #2563eb); + box-shadow: none; +} + +/* Quill editor in pane */ +.editor-pane--edit .quill { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.editor-pane--edit .ql-toolbar { + flex-shrink: 0; + border-left: none; + border-right: none; + border-top: none; +} + +.editor-pane--edit .ql-container { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + border-left: none; + border-right: none; + border-bottom: none; + display: flex; + flex-direction: column; + min-height: 0; +} + +.editor-pane--edit #editor { + flex: 1; + overflow-y: auto; + min-height: 300px; +} + +/* Preview pane */ +.editor-pane--preview { + padding: 0; + margin: 0 !important; + overflow-y: auto; + background-color: var(--color-bg-light); +} + +.preview-container { + max-width: 720px; + margin: 0 auto; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 1.5rem; + padding: 0 var(--spacing-2); +} + +.preview-image-wrap { + width: 100%; + min-height: 180px; + display: flex; + align-items: center; + justify-content: center; + background: var(--surface, #f3f4f6); + overflow: hidden; + position: relative; +} + +.preview-image { + max-width: 100%; + display: block; + margin: 0 auto; + object-fit: cover; +} + +.preview-image-placeholder { + width: 100%; + min-height: 180px; + display: flex; + align-items: center; + justify-content: center; + color: #bbb; + font-size: 1.1rem; + font-style: italic; +} + +.preview-summary.placeholder { + color: #bbb; + font-style: italic; +} + +#editor-preview { + line-height: 1.7; + color: var(--text-primary, #374151); + font-size: 1.08rem; + margin-top: 1.5rem; +} + +#editor-preview img { + max-width: 100%; + height: auto; + border-radius: 0.5rem; + margin: 1rem 0; +} + +/* Sidebar - fixed, with scrollable panels */ +.editor-sidebar { + position: static; + display: flex; + flex-direction: column; + min-width: 280px; + max-width: 400px; + background: var(--surface, #f9fafb); + overflow: hidden; + margin-top: 0; + padding: 0; + max-height: unset; +} + +.editor-sidebar-tabs { + display: flex; + flex-direction: column; + flex-shrink: 0; + border-bottom: 1px solid var(--border-color, #e5e7eb); + background: var(--surface-dark, #f3f4f6); +} + +.editor-sidebar-tab { + flex: 1; + min-width: 80px; + padding: 0.625rem 0.5rem; + font-size: 0.75rem; + border: none; + background: transparent; + cursor: pointer; + color: var(--text-secondary, #6b7280); + transition: all 0.2s ease; + text-align: center; +} + +.editor-sidebar-tab:hover { + background: var(--hover, #e5e7eb); +} + +.editor-sidebar-tab.is-active { + background: var(--surface, #f9fafb); + font-weight: 600; + color: var(--text-primary, #111827); +} + +.editor-sidebar-panels { + flex: 1; + overflow: auto; + padding: 1rem; +} + +.editor-panel.is-hidden { + display: none; +} + +/* Panel sections */ +.panel-section { + font-size: 0.875rem; +} + +.panel-section h3 { + font-size: 1rem; + font-weight: 600; + margin-bottom: 0.5rem; + color: var(--text-primary, #111827); +} + +.panel-section h4 { + font-size: 0.875rem; + font-weight: 600; + margin-bottom: 0.5rem; + margin-top: 0.75rem; + color: var(--text-primary, #111827); +} + +.panel-help { + font-size: 0.75rem; + color: var(--text-secondary, #6b7280); + margin-bottom: 1rem; + line-height: 1.4; +} + +.panel-section .form-control, +.panel-section .form-select { + font-size: 0.875rem; +} + +.panel-section .form-control-sm { + font-size: 0.8125rem; +} + +.panel-subsection { + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid var(--border-color, #e5e7eb); +} + +/* Info groups for publishing panel */ +.info-group { + margin-bottom: 0.75rem; +} + +.info-group label { + display: block; + font-size: 0.75rem; + font-weight: 600; + color: var(--text-secondary, #6b7280); + margin-bottom: 0.25rem; + text-transform: uppercase; + letter-spacing: 0.025em; +} + +.info-group .info-value { + font-size: 0.875rem; + color: var(--text-primary, #111827); +} + +.info-group code { + font-size: 0.8125rem; + padding: 0.125rem 0.25rem; + background: var(--code-bg, #f3f4f6); + border-radius: 0.25rem; +} + +/* Mobile action buttons - hidden on desktop */ +.editor-mobile-actions { + display: none; +} + +/* Article list sidebar */ +.editor-articlelist-sidebar { + width: 260px; + min-width: 200px; + max-width: 320px; + background: var(--surface, #f9fafb); + border-right: 1px solid var(--border-color, #e5e7eb); + display: flex; + flex-direction: column; + padding: 0; + overflow-y: auto; + height: 100%; + max-height: 100%; + margin: 0; + position: static; +} + +.articlelist-header { + font-size: 1.1rem; + font-weight: 600; + padding: 1rem 1.25rem 0.5rem 1.25rem; + color: var(--text-primary, #111827); + border-bottom: 1px solid var(--border-color, #e5e7eb); + background: var(--surface-dark, #f3f4f6); +} + +.articlelist-content { + flex: 1; + overflow-y: auto; + font-size: 80%; +} + +.articlelist-placeholder { + color: #bbb; + font-style: italic; + padding: 1.5rem 0; + text-align: center; +} + +/* Filesystem-style reading list folders */ +.readinglist-fs { + margin: 0; + padding: 0; + list-style: none; +} +.readinglist-folder { + margin-bottom: 0.5rem; + padding-left: 0.25rem; +} +.readinglist-toggle { + background: none; + border: none; + color: var(--text-primary, #111827); + font-weight: 600; + font-size: 1rem; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.5em; + padding: 0.25em 0; + width: 100%; + text-align: left; +} +.readinglist-folder details { + margin-bottom: 0.5rem; + padding-left: 0.25rem; +} +.readinglist-folder summary { + display: flex; + flex-direction: column; + align-items: start; + font-weight: 600; + font-size: 1rem; + outline: none; + list-style: none; + background: none; + border: none; + padding: 0.25em 0; +} +.readinglist-summary { + color: #888; + font-weight: 400; +} +.readinglist-folder .readinglist-articles { + margin-top: 0.25em; + padding-left: 0.5em; +} +.article-icon { + display: inline-flex; + align-items: center; + margin-right: 0.5em; + font-size: 0.95em; + vertical-align: middle; +} +.article-kind { + font-size: 0.85em; + font-weight: 700; + margin-left: 0.1em; + margin-right: 0.2em; +} +.readinglist-article { + margin-bottom: var(--spacing-2); + font-size: 0.97em; + margin-left: var(--spacing-2); +} +.readinglist-empty { + color: #bbb; + font-style: italic; + margin-left: 1.5em; +} + +/* Responsive adjustments */ +@media (max-width: 1024px) { + .editor-main { + grid-template-columns: 180px minmax(0, 2fr) minmax(260px, 1fr); + } + .editor-articlelist-sidebar { + width: 180px; + min-width: 120px; + font-size: 0.95rem; + } +} + +@media (max-width: 768px) { + /* Disable fixed positioning on mobile */ + main[data-controller="editor--layout"] { + position: static; + height: auto; + min-height: 100vh; + } + + .editor-header { + position: static; + } + + .editor-main { + grid-template-columns: 1fr; + height: auto; + min-height: calc(100vh - 60px); + } + + .editor-center { + overflow: visible; + } + + .editor-center-content { + overflow: visible; + } + + .editor-pane { + overflow: visible; + } + + .editor-sidebar { + display: none !important; + } + + .editor-articlelist-sidebar { + display: none !important; + } + + /* Hide the mode tabs on mobile */ + .editor-center-tabs { + display: none !important; + } + + /* Force edit pane to always show, hide preview */ + .editor-pane--edit { + display: flex !important; + } + + .editor-pane--markdown, + .editor-pane--preview { + display: none !important; + } + + /* Adjust header for mobile */ + .editor-header { + padding: 0.75rem; + min-height: auto; + } + + .editor-header-left { + flex: 1; + } + + .editor-title { + font-size: 0.875rem; + } + + /* Hide header action buttons on mobile */ + .editor-header-right { + display: none !important; + } + + /* Show mobile action buttons at bottom */ + .editor-mobile-actions { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1rem; + background: var(--surface, #f9fafb); + border-top: 1px solid var(--border-color, #e5e7eb); + margin-top: auto; + } + + .editor-mobile-actions .btn { + width: 100%; + justify-content: center; + font-size: 1rem; + padding: 0.875rem 1rem; + } + + /* Alternative: buttons at bottom of form */ + .editor-center { + display: flex; + flex-direction: column; + height: auto; + } + + .editor-center-content { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + } + + .editor-center-content > form { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + } + + .editor-pane--edit { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + padding-bottom: 0; + } + + /* Ensure Quill editor is properly sized on mobile */ + .editor-pane--edit .ql-container { + min-height: 300px; + flex: 1; + } + + .editor-title-field { + font-size: 1.25rem; + } + + .editor-title-input { + flex-shrink: 0; + } +} + +/* Dark theme support */ +[data-theme="dark"] .editor-shell { + --background: #1f2937; + --surface: #111827; + --surface-dark: #0f172a; + --border-color: #374151; + --text-primary: #f9fafb; + --text-secondary: #9ca3af; + --hover: #374151; + --primary: #3b82f6; + --code-bg: #374151; +} + +/* JSON Panel Styles */ +.json-editor-container { + position: relative; + margin-bottom: 0.5rem; +} + +.json-textarea { + width: 100%; + min-height: 400px; + padding: 0.75rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; + font-size: 0.8125rem; + line-height: 1.5; + border: 1px solid var(--border-color, #e5e7eb); + border-radius: 0.375rem; + background: var(--background, #ffffff); + color: var(--text-primary, #111827); + resize: vertical; +} + +.json-textarea:focus { + outline: none; + border-color: var(--primary, #2563eb); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} + +.json-status { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + margin-top: 0.5rem; + transition: all 0.2s ease; +} + +.json-status--success { + color: #059669; + background: #d1fae5; +} + +.json-status--warning { + color: #d97706; + background: #fef3c7; +} + +.json-status--error { + color: #dc2626; + background: #fee2e2; +} + +[data-theme="dark"] .json-status--success { + color: #34d399; + background: #064e3b; +} + +[data-theme="dark"] .json-status--warning { + color: #fbbf24; + background: #78350f; +} + +[data-theme="dark"] .json-status--error { + color: #f87171; + background: #7f1d1d; +} + +[data-theme="dark"] .json-textarea { + background: #1f2937; + border-color: #374151; +} + +/* Prism.js CSS for markdown syntax highlighting - asset-mapper import */ + +.markdown-highlight { + background: #f5f2f0; + border-radius: 0.375rem; + font-size: 0.95rem; + padding: 1rem; + overflow-x: auto; + min-height: 300px; + max-height: 600px; + font-family: 'Fira Mono', 'Menlo', 'Monaco', 'Consolas', 'Liberation Mono', monospace; + line-height: 1.6; + margin: 0; +} + +.markdown-highlight code { + background: none; + color: inherit; + padding: 0; + font-size: inherit; + font-family: inherit; + white-space: pre; + word-break: normal; + word-wrap: normal; + border: none; + box-shadow: none; + outline: none; +} diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index 7fd7168..027c784 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -11,6 +11,7 @@ use App\Service\NostrClient; use App\Service\Nostr\NostrEventBuilder; use App\Service\Nostr\NostrEventParser; use App\Service\RedisCacheService; +use App\Service\RedisViewStore; use App\Util\CommonMark\Converter; use Doctrine\ORM\EntityManagerInterface; use nostriphant\NIP19\Bech32; @@ -24,8 +25,9 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -use Symfony\Component\Security\Csrf\CsrfToken; -use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; +use App\ReadModel\RedisView\RedisReadingListView; +use App\ReadModel\RedisView\RedisBaseObject; +use App\ReadModel\RedisView\RedisArticleView; class ArticleController extends AbstractController { @@ -196,6 +198,7 @@ class ArticleController extends AbstractController NostrClient $nostrClient, EntityManagerInterface $entityManager, NostrEventParser $eventParser, + RedisViewStore $redisViewStore, $slug = null ): Response { @@ -257,6 +260,12 @@ class ArticleController extends AbstractController } } + $readingLists = []; + if ($user) { + $currentPubkey = $key->convertToHex($user->getUserIdentifier()); + $readingLists = $redisViewStore->buildAndCacheUserReadingLists($entityManager, $currentPubkey); + } + $form = $this->createForm(EditorType::class, $article, ['action' => $formAction]); // Populate advanced metadata form data if ($advancedMetadata) { @@ -266,11 +275,79 @@ class ArticleController extends AbstractController $form->handleRequest($request); // load template with content editor - return $this->render('pages/editor.html.twig', [ + return $this->render('editor/layout.html.twig', [ 'article' => $article, 'form' => $form->createView(), 'recentArticles' => $recentArticles, 'drafts' => $drafts, + 'readingLists' => $readingLists, + ]); + } + + #[Route('/article-editor/preview/{npub}/{slug}', name: 'editor-preview-npub-slug')] + public function previewArticle( + $npub, + $slug, + EntityManagerInterface $entityManager, + NostrEventParser $eventParser, + RedisViewStore $redisViewStore, + Request $request, + NostrClient $nostrClient + ): Response { + // This route previews another user's article, but sidebar shows current user's lists for navigation. + $advancedMetadata = null; + $key = new Key(); + $pubkey = $key->convertToHex($npub); + $slug = urldecode($slug); + $repository = $entityManager->getRepository(Article::class); + $article = $repository->findOneBy(['slug' => $slug, 'pubkey' => $pubkey]); + if (!$article) { + throw $this->createNotFoundException('The article could not be found'); + } + // Parse advanced metadata from the raw event if available + if ($article->getRaw()) { + $tags = $article->getRaw()['tags'] ?? []; + $advancedMetadata = $eventParser->parseAdvancedMetadata($tags); + } + $formAction = $this->generateUrl('editor-preview-npub-slug', ['npub' => $npub, 'slug' => $slug]); + $form = $this->createForm(EditorType::class, $article, ['action' => $formAction]); + if ($advancedMetadata) { + $form->get('advancedMetadata')->setData($advancedMetadata); + } + $form->handleRequest($request); + + // Load current user's recent articles, drafts, and reading lists for sidebar + $recentArticles = []; + $drafts = []; + $readingLists = []; + $user = $this->getUser(); + if ($user) { + $currentPubkey = $key->convertToHex($user->getUserIdentifier()); + $recentArticles = $entityManager->getRepository(Article::class) + ->findBy(['pubkey' => $currentPubkey, 'kind' => KindsEnum::LONGFORM], ['createdAt' => 'DESC'], 5); + // Collapse by slug, keep only latest revision + $recentArticles = array_reduce($recentArticles, function ($carry, $item) { + if (!isset($carry[$item->getSlug()])) { + $carry[$item->getSlug()] = $item; + } + return $carry; + }); + $recentArticles = array_values($recentArticles ?? []); + // get drafts + $since = new \DateTime(); + $aWeekAgo = $since->sub(new \DateInterval('P1D'))->getTimestamp(); + $nostrClient->getLongFormContentForPubkey($currentPubkey, $aWeekAgo, KindsEnum::LONGFORM_DRAFT->value); + $drafts = $entityManager->getRepository(Article::class) + ->findBy(['pubkey' => $currentPubkey, 'kind' => KindsEnum::LONGFORM_DRAFT], ['createdAt' => 'DESC'], 5); + $readingLists = $redisViewStore->buildAndCacheUserReadingLists($entityManager, $currentPubkey); + } + + return $this->render('editor/layout.html.twig', [ + 'article' => $article, + 'form' => $form->createView(), + 'recentArticles' => $recentArticles, + 'drafts' => $drafts, + 'readingLists' => $readingLists, ]); } @@ -284,7 +361,6 @@ class ArticleController extends AbstractController EntityManagerInterface $entityManager, NostrClient $nostrClient, CacheItemPoolInterface $articlesCache, - CsrfTokenManagerInterface $csrfTokenManager, LoggerInterface $logger, NostrEventParser $eventParser ): JsonResponse { @@ -452,6 +528,4 @@ class ArticleController extends AbstractController return $data; } - - } diff --git a/src/Controller/Editor/MarkdownController.php b/src/Controller/Editor/MarkdownController.php new file mode 100644 index 0000000..e5d4ee6 --- /dev/null +++ b/src/Controller/Editor/MarkdownController.php @@ -0,0 +1,30 @@ +getContent(), true); + $markdown = $data['markdown'] ?? ''; + try { + $html = $converter->convertToHtml($markdown); + return new JsonResponse(['html' => $html]); + } catch (CommonMarkException $e) { + return new JsonResponse(['error' => 'Failed to convert markdown: ' . $e->getMessage()], 400); + } + } +} + diff --git a/src/Controller/Search/UserSearchController.php b/src/Controller/Search/UserSearchController.php new file mode 100644 index 0000000..58c280e --- /dev/null +++ b/src/Controller/Search/UserSearchController.php @@ -0,0 +1,134 @@ +query->get('q', ''); + $limit = min((int) $request->query->get('limit', 12), 100); + $offset = max((int) $request->query->get('offset', 0), 0); + + if (empty(trim($query))) { + return $this->json([ + 'error' => 'Query parameter "q" is required', + 'users' => [] + ], Response::HTTP_BAD_REQUEST); + } + + $users = $this->userSearch->search($query, $limit, $offset); + + return $this->json([ + 'query' => $query, + 'count' => count($users), + 'limit' => $limit, + 'offset' => $offset, + 'users' => array_map(fn($user) => [ + 'id' => $user->getId(), + 'npub' => $user->getNpub(), + 'displayName' => $user->getDisplayName(), + 'name' => $user->getName(), + 'nip05' => $user->getNip05(), + 'about' => $user->getAbout(), + 'picture' => $user->getPicture(), + 'website' => $user->getWebsite(), + 'lud16' => $user->getLud16(), + ], $users) + ]); + } + + /** + * Get featured writers with optional search + * GET /api/users/featured-writers?q=query&limit=12 + */ + #[Route('/featured-writers', name: 'api_users_featured_writers', methods: ['GET'])] + public function featuredWriters(Request $request): JsonResponse + { + $query = $request->query->get('q'); + $limit = min((int) $request->query->get('limit', 12), 100); + $offset = max((int) $request->query->get('offset', 0), 0); + + $users = $this->userSearch->findByRole( + RolesEnum::FEATURED_WRITER->value, + $query, + $limit, + $offset + ); + + return $this->json([ + 'query' => $query, + 'count' => count($users), + 'limit' => $limit, + 'offset' => $offset, + 'users' => array_map(fn($user) => [ + 'id' => $user->getId(), + 'npub' => $user->getNpub(), + 'displayName' => $user->getDisplayName(), + 'name' => $user->getName(), + 'nip05' => $user->getNip05(), + 'about' => $user->getAbout(), + 'picture' => $user->getPicture(), + 'website' => $user->getWebsite(), + 'lud16' => $user->getLud16(), + ], $users) + ]); + } + + /** + * Find users by their npubs + * POST /api/users/by-npubs + * Body: {"npubs": ["npub1...", "npub2..."]} + */ + #[Route('/by-npubs', name: 'api_users_by_npubs', methods: ['POST'])] + public function byNpubs(Request $request): JsonResponse + { + $data = json_decode($request->getContent(), true); + $npubs = $data['npubs'] ?? []; + + if (empty($npubs) || !is_array($npubs)) { + return $this->json([ + 'error' => 'Field "npubs" is required and must be an array', + 'users' => [] + ], Response::HTTP_BAD_REQUEST); + } + + $limit = min((int) ($data['limit'] ?? 200), 200); + $users = $this->userSearch->findByNpubs($npubs, $limit); + + return $this->json([ + 'count' => count($users), + 'users' => array_map(fn($user) => [ + 'id' => $user->getId(), + 'npub' => $user->getNpub(), + 'displayName' => $user->getDisplayName(), + 'name' => $user->getName(), + 'nip05' => $user->getNip05(), + 'about' => $user->getAbout(), + 'picture' => $user->getPicture(), + 'website' => $user->getWebsite(), + 'lud16' => $user->getLud16(), + ], $users) + ]); + } +} + diff --git a/src/Controller/Search/UserSearchPageController.php b/src/Controller/Search/UserSearchPageController.php new file mode 100644 index 0000000..e21cbe1 --- /dev/null +++ b/src/Controller/Search/UserSearchPageController.php @@ -0,0 +1,59 @@ +query->get('q', ''); + $limit = min((int) $request->query->get('limit', 12), 100); + $users = []; + $resultsCount = 0; + + if (!empty(trim($query))) { + $users = $this->userSearch->search($query, $limit); + $resultsCount = count($users); + } + + return $this->render('user_search/search.html.twig', [ + 'query' => $query, + 'users' => $users, + 'resultsCount' => $resultsCount, + 'limit' => $limit, + ]); + } + + #[Route('/users/featured', name: 'featured_writers_page', methods: ['GET'])] + public function featuredWritersPage(Request $request): Response + { + $query = $request->query->get('q'); + $limit = min((int) $request->query->get('limit', 12), 100); + + $users = $this->userSearch->findByRole( + RolesEnum::FEATURED_WRITER->value, + $query, + $limit + ); + + return $this->render('user_search/featured_writers.html.twig', [ + 'query' => $query, + 'users' => $users, + 'resultsCount' => count($users), + ]); + } +} + diff --git a/src/Form/EditorType.php b/src/Form/EditorType.php index 07f4786..0c74b6f 100644 --- a/src/Form/EditorType.php +++ b/src/Form/EditorType.php @@ -58,7 +58,7 @@ class EditorType extends AbstractType 'required' => false, ]) ->add('advancedMetadata', AdvancedMetadataType::class, [ - 'label' => 'Advanced metadata', + 'label' => false, 'required' => false, 'mapped' => false, ]); diff --git a/src/ReadModel/RedisView/RedisArticleView.php b/src/ReadModel/RedisView/RedisArticleView.php index da90393..1bee6b2 100644 --- a/src/ReadModel/RedisView/RedisArticleView.php +++ b/src/ReadModel/RedisView/RedisArticleView.php @@ -21,6 +21,6 @@ final class RedisArticleView public ?string $contentHtml = null, // processedHtml for article detail pages public ?\DateTimeImmutable $publishedAt = null, public array $topics = [], // For topic filtering + public ?int $kind = null // Added: kind for template access ) {} } - diff --git a/src/ReadModel/RedisView/RedisReadingListView.php b/src/ReadModel/RedisView/RedisReadingListView.php new file mode 100644 index 0000000..19ee95d --- /dev/null +++ b/src/ReadModel/RedisView/RedisReadingListView.php @@ -0,0 +1,17 @@ +getProcessedHtml(), publishedAt: $article->getPublishedAt(), topics: $article->getTopics() ?? [], + kind: $article->getKind()->value, ); } @@ -254,6 +258,7 @@ class RedisViewFactory 'contentHtml' => $view->contentHtml, 'publishedAt' => $view->publishedAt?->format(\DateTimeInterface::ATOM), 'topics' => $view->topics, + 'kind' => $view->kind, // Add kind to normalization ]; } @@ -271,6 +276,7 @@ class RedisViewFactory contentHtml: $data['contentHtml'] ?? null, publishedAt: isset($data['publishedAt']) ? new \DateTimeImmutable($data['publishedAt']) : null, topics: $data['topics'] ?? [], + kind: $data['kind'] ?? null // Add kind to denormalization ); } @@ -297,5 +303,62 @@ class RedisViewFactory refs: $data['refs'] ?? [], ); } -} + /** + * Build the user's reading lists view (array of RedisReadingListView), collating articles. + * Handles all DB lookups and view construction. + * + * @param EntityManagerInterface $em + * @param string $pubkey + * @return RedisReadingListView[] + */ + public function buildUserReadingListsView(EntityManagerInterface $em, string $pubkey): array + { + $readingListsRaw = $em->getRepository(Event::class) + ->findBy(['pubkey' => $pubkey, 'kind' => KindsEnum::PUBLICATION_INDEX->value], ['created_at' => 'DESC']) ?? []; + $readingListsRaw = array_reduce($readingListsRaw, function ($carry, $item) { + $slug = $item->getSlug(); + if (!isset($carry[$slug])) { + $carry[$slug] = $item; + } + return $carry; + }, []); + $readingListsRaw = array_values($readingListsRaw); + $readingLists = []; + foreach ($readingListsRaw as $list) { + $tags = $list->getTags(); + $articleSlugs = []; + foreach ($tags as $tag) { + if (is_array($tag) && $tag[0] === 'a' && isset($tag[1])) { + // Slug from coordinate + $parts = explode(':', $tag[1], 3); + $articleSlugs[] = $parts[2]; + } + } + $articles = []; + if ($articleSlugs) { + $dbArticles = $em->getRepository(Article::class) + ->createQueryBuilder('a') + ->where('a.slug IN (:slugs)') + ->setParameter('slugs', $articleSlugs) + ->getQuery()->getResult(); + $dbArticlesBySlug = []; + foreach ($dbArticles as $a) { + $dbArticlesBySlug[$a->getSlug()] = $a; + } + foreach ($articleSlugs as $slug) { + $a = $dbArticlesBySlug[$slug] ?? null; + if ($a) { + $articles[] = $this->articleBaseObject($a); + } + } + } + $readingLists[] = new RedisReadingListView( + $list->getTitle(), + $list->getSummary(), + $articles + ); + } + return $readingLists; + } +} diff --git a/src/Service/RedisViewStore.php b/src/Service/RedisViewStore.php index 5952617..901ebbe 100644 --- a/src/Service/RedisViewStore.php +++ b/src/Service/RedisViewStore.php @@ -4,6 +4,8 @@ namespace App\Service; use App\ReadModel\RedisView\RedisViewFactory; use App\ReadModel\RedisView\RedisBaseObject; +use App\ReadModel\RedisView\RedisReadingListView; +use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; /** @@ -230,5 +232,43 @@ class RedisViewStore { return strlen($data) > 10240; // 10KB } -} + /** + * Store all articles from user reading lists as RedisBaseObject[] + * @param string $pubkey + * @param RedisReadingListView[] $readingLists + */ + public function storeUserReadingListArticles(string $pubkey, array $readingLists): void + { + $allArticles = []; + foreach ($readingLists as $list) { + foreach ($list->articles as $articleObj) { + if ($articleObj instanceof RedisBaseObject) { + $allArticles[] = $articleObj; + } + } + } + $this->storeUserArticles($pubkey, $allArticles); + } + + /** + * Build and cache user reading lists, returning the final view for the template. + * Handles all DB lookups and stores all articles as RedisBaseObject in Redis. + * + * @param EntityManagerInterface $em + * @param string $pubkey + * @return RedisReadingListView[] + */ + public function buildAndCacheUserReadingLists(EntityManagerInterface $em, string $pubkey): array + { + $readingLists = $this->factory->buildUserReadingListsView($em, $pubkey); + $allArticles = []; + foreach ($readingLists as $list) { + foreach ($list->articles as $articleObj) { + $allArticles[] = $articleObj; + } + } + $this->storeUserArticles($pubkey . ':readinglists', $allArticles); + return $readingLists; + } +} diff --git a/templates/base.html.twig b/templates/base.html.twig index ed9b2d0..1924216 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -27,7 +27,9 @@ - +{% block header %} + +{% endblock %} {% block layout %}{% endblock %} @@ -42,9 +44,11 @@ +{% block footer %}
+{% endblock %} diff --git a/templates/editor/layout.html.twig b/templates/editor/layout.html.twig new file mode 100644 index 0000000..5a3a886 --- /dev/null +++ b/templates/editor/layout.html.twig @@ -0,0 +1,355 @@ +{# templates/editor/layout.html.twig #} +{% extends 'base.html.twig' %} + +{% form_theme form _self 'pages/_advanced_metadata.html.twig' %} + +{% block quill_widget %} +
+
+ {{ value|raw }} +
+ + +
+{% endblock %} + +{% block header %} +
+
+ ← Back +
+ {{ article.title|default('New article') }} +
+
+ +
+ {# Status indicator #} + + {% if article.id %}Editing{% else %}New article{% endif %} + + + +
+
+{% endblock %} + +{% block layout %} +
+
+ {# Insert the article list sidebar as the first grid column #} + + {# Center editor area (middle grid column) #} +
+
+ + + +
+ +
+ {{ form_start(form) }} + +
+ {# Title field at top of editor #} +
+ {{ form_row(form.title, { + 'label': false, + 'attr': {'placeholder': 'Article title', 'class': 'form-control editor-title-field'} + }) }} +
+ + {# QuillJS editor container #} + {{ form_row(form.content, {'label': false}) }} + + {# Hidden field for draft status - controlled by Save Draft / Publish buttons #} +
+ {{ form_widget(form.isDraft) }} +
+ + {# Mobile action buttons at bottom #} +
+ + +
+
+ + + + +
+
+ {# Right sidebar (last grid column) #} + + + {{ form_end(form) }} +
+ + {# Hidden container for Nostr publishing #} +
+
+
+ +
+ + + +
+
+{% endblock %} + +{% block footer %} +{% endblock %} diff --git a/templates/editor/panels/_advanced.html.twig b/templates/editor/panels/_advanced.html.twig new file mode 100644 index 0000000..b79a243 --- /dev/null +++ b/templates/editor/panels/_advanced.html.twig @@ -0,0 +1,3 @@ +
+ {{ form_row(form.advancedMetadata) }} +
diff --git a/templates/editor/panels/_json.html.twig b/templates/editor/panels/_json.html.twig new file mode 100644 index 0000000..af19f84 --- /dev/null +++ b/templates/editor/panels/_json.html.twig @@ -0,0 +1,40 @@ +
+

Raw Nostr Event

+

+ View and edit the raw Nostr event JSON. Changes here will override form values when publishing. +

+ +
+ +
+ +
+ + +
+
+ +
+ + Required fields: kind, created_at, tags, content, pubkey
+ +
+
+
+ diff --git a/templates/editor/panels/_media.html.twig b/templates/editor/panels/_media.html.twig new file mode 100644 index 0000000..d25b3d5 --- /dev/null +++ b/templates/editor/panels/_media.html.twig @@ -0,0 +1,58 @@ +
+

Media library

+

+ Upload images to use in your article. +

+ +
+ + +
+ + + +
+
+
+ + +
+
+ +
+ Tip: You can also paste images directly into the editor. +
+
+ diff --git a/templates/editor/panels/_metadata.html.twig b/templates/editor/panels/_metadata.html.twig new file mode 100644 index 0000000..9e28b03 --- /dev/null +++ b/templates/editor/panels/_metadata.html.twig @@ -0,0 +1,68 @@ +
+ {{ form_row(form.slug, { + 'label': 'Slug', + 'help': 'URL-friendly identifier', + 'attr': {'class': 'form-control form-control-sm'} + }) }} + + {{ form_row(form.summary, { + 'label': 'Summary', + 'help': 'Brief description for previews', + 'attr': {'class': 'form-control form-control-sm', 'rows': 3} + }) }} + + {{ form_row(form.topics, { + 'label': 'Tags', + 'attr': {'class': 'form-control form-control-sm'} + }) }} + +
+ {{ form_row(form.image, { + 'label': 'Cover Image', + 'help': 'Enter URL or upload an image', + 'attr': {'class': 'form-control form-control-sm', 'data-publishing--image-upload-target': 'urlInput'} + }) }} + + + +
+
+
+ + +
+
+
+ + {{ form_row(form.clientTag, { + 'label': 'Add client tag (Decent Newsroom)', + 'row_attr': {'class': 'form-check d-flex flex-row'}, + 'label_attr': {'class': 'form-check-label'}, + 'attr': {'class': 'form-check-input'} + }) }} +
diff --git a/templates/editor/panels/_publishing.html.twig b/templates/editor/panels/_publishing.html.twig new file mode 100644 index 0000000..dbeb6ec --- /dev/null +++ b/templates/editor/panels/_publishing.html.twig @@ -0,0 +1,66 @@ +
+

Publishing info

+ + {% if article.id %} +
+ +
+ {% if article.isDraft %} + Draft + {% else %} + Published + {% endif %} +
+
+ + {% if article.publishedAt %} +
+ +
+ {{ article.publishedAt|date('Y-m-d H:i') }} +
+
+ {% endif %} + + {% if article.updatedAt %} +
+ +
+ {{ article.updatedAt|date('Y-m-d H:i') }} +
+
+ {% endif %} + + {% if article.slug %} +
+ +
+ {{ article.slug }} +
+
+ {% endif %} + {% else %} +

+ This is a new article. Fill in the details and publish when ready. +

+ {% endif %} + +
+

Quick actions

+ + +
+
+ diff --git a/templates/pages/_advanced_metadata.html.twig b/templates/pages/_advanced_metadata.html.twig index b382963..d6d13b4 100644 --- a/templates/pages/_advanced_metadata.html.twig +++ b/templates/pages/_advanced_metadata.html.twig @@ -1,8 +1,5 @@ {% block _editor_advancedMetadata_widget %} - {% endblock %} diff --git a/templates/user_search/featured_writers.html.twig b/templates/user_search/featured_writers.html.twig new file mode 100644 index 0000000..554708c --- /dev/null +++ b/templates/user_search/featured_writers.html.twig @@ -0,0 +1,404 @@ +{% extends 'layout.html.twig' %} + +{% block title %}Featured Writers - Decent Newsroom{% endblock %} + +{% block body %} + + + +{% endblock %} + diff --git a/templates/user_search/search.html.twig b/templates/user_search/search.html.twig new file mode 100644 index 0000000..029c4ea --- /dev/null +++ b/templates/user_search/search.html.twig @@ -0,0 +1,416 @@ +{% extends 'layout.html.twig' %} + +{% block title %}User Search - Decent Newsroom{% endblock %} + +{% block body %} +
+
+ + + + + {% if query %} +
+
+

+ {% if resultsCount > 0 %} + Found {{ resultsCount }} user{{ resultsCount != 1 ? 's' : '' }} + {% else %} + No users found + {% endif %} +

+ {% if resultsCount > 0 %} +

Search query: {{ query }}

+ {% endif %} +
+ + {% if users|length > 0 %} +
+ {% for user in users %} +
+
+ {% if user.picture %} + {{ user.displayName ?? user.name ?? 'User' }} + {% else %} +
+ {{ (user.displayName ?? user.name ?? user.npub[:8])|slice(0, 2)|upper }} +
+ {% endif %} +
+ +
+ {% endfor %} +
+ {% else %} +
+

No users found matching "{{ query }}"

+

Try different keywords or check your spelling

+
+ {% endif %} +
+ {% else %} + + {% endif %} +
+
+ + +{% endblock %} +