From 389e6cbd431c055f6d317c6ee4999507e8882be6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Sat, 18 Oct 2025 12:35:18 +0200 Subject: [PATCH] Editor tweaks --- assets/controllers/image_upload_controller.js | 245 ++++++++++-------- .../controllers/nostr_publish_controller.js | 18 +- assets/styles/03-components/form.css | 18 ++ config/packages/security.yaml | 3 +- src/Form/EditorType.php | 13 +- src/Security/NostrAuthenticator.php | 4 +- templates/pages/editor.html.twig | 10 + 7 files changed, 202 insertions(+), 109 deletions(-) diff --git a/assets/controllers/image_upload_controller.js b/assets/controllers/image_upload_controller.js index 8e5f9e4..281d165 100644 --- a/assets/controllers/image_upload_controller.js +++ b/assets/controllers/image_upload_controller.js @@ -1,118 +1,157 @@ import { Controller } from '@hotwired/stimulus'; export default class extends Controller { - static targets = ["dialog", "dropArea", "fileInput", "progress", "error", "provider"]; + static targets = ["dialog", "dropArea", "fileInput", "progress", "error", "provider"]; - // Unicode-safe base64 encoder - base64Encode(str) { - try { - return btoa(unescape(encodeURIComponent(str))); - } catch (_) { - return btoa(str); - } - } + // Unicode-safe base64 encoder + base64Encode(str) { + try { + return btoa(unescape(encodeURIComponent(str))); + } catch (_) { + return btoa(str); + } + } - openDialog() { - this.dialogTarget.classList.add('active'); - this.errorTarget.textContent = ''; - this.progressTarget.style.display = 'none'; - } + openDialog() { + this.dialogTarget.classList.add('active'); + this.clearError(); + this.hideProgress(); + } - closeDialog() { - this.dialogTarget.classList.remove('active'); - this.errorTarget.textContent = ''; - this.progressTarget.style.display = 'none'; - } + closeDialog() { + this.dialogTarget.classList.remove('active'); + this.clearError(); + this.hideProgress(); + } - connect() { - this.dropAreaTarget.addEventListener('click', () => this.fileInputTarget.click()); - this.fileInputTarget.addEventListener('change', (e) => this.handleFile(e.target.files[0])); - this.dropAreaTarget.addEventListener('dragover', (e) => { - e.preventDefault(); - this.dropAreaTarget.classList.add('dragover'); - }); - this.dropAreaTarget.addEventListener('dragleave', (e) => { - e.preventDefault(); - this.dropAreaTarget.classList.remove('dragover'); - }); - this.dropAreaTarget.addEventListener('drop', (e) => { - e.preventDefault(); - this.dropAreaTarget.classList.remove('dragover'); - if (e.dataTransfer.files.length > 0) { - this.handleFile(e.dataTransfer.files[0]); - } - }); - } + connect() { + this.dropAreaTarget.addEventListener('click', () => this.fileInputTarget.click()); + this.fileInputTarget.addEventListener('change', (e) => this.handleFile(e.target.files[0])); + this.dropAreaTarget.addEventListener('dragover', (e) => { + e.preventDefault(); + this.dropAreaTarget.classList.add('dragover'); + }); + this.dropAreaTarget.addEventListener('dragleave', (e) => { + e.preventDefault(); + this.dropAreaTarget.classList.remove('dragover'); + }); + this.dropAreaTarget.addEventListener('drop', (e) => { + e.preventDefault(); + this.dropAreaTarget.classList.remove('dragover'); + if (e.dataTransfer.files.length > 0) { + this.handleFile(e.dataTransfer.files[0]); + } + }); + // Ensure initial visibility states + this.hideProgress(); + this.clearError(); + } + + async handleFile(file) { + if (!file) return; + this.clearError(); + this.showProgress('Preparing upload...'); + try { + // NIP98: get signed HTTP Auth event from window.nostr + if (!window.nostr || !window.nostr.signEvent) { + this.showError('Nostr extension not found.'); + return; + } + // Determine provider + const provider = this.providerTarget.value; - async handleFile(file) { - if (!file) return; - this.errorTarget.textContent = ''; - this.progressTarget.style.display = ''; - this.progressTarget.textContent = 'Preparing upload...'; - try { - // NIP98: get signed HTTP Auth event from window.nostr - if (!window.nostr || !window.nostr.signEvent) { - this.errorTarget.textContent = 'Nostr extension not found.'; - return; - } - // Determine provider - const provider = this.providerTarget.value; + // Map provider -> upstream endpoint used for signing the NIP-98 event + const upstreamMap = { + nostrbuild: 'https://nostr.build/nip96/upload', + nostrcheck: 'https://nostrcheck.me/api/v2/media', + sovbit: 'https://files.sovbit.host/api/v2/media', + }; + const upstreamEndpoint = upstreamMap[provider] || upstreamMap['nostrcheck']; - // Map provider -> upstream endpoint used for signing the NIP-98 event - const upstreamMap = { - nostrbuild: 'https://nostr.build/nip96/upload', - nostrcheck: 'https://nostrcheck.me/api/v2/media', - sovbit: 'https://files.sovbit.host/api/v2/media', - }; - const upstreamEndpoint = upstreamMap[provider] || upstreamMap['nostrcheck']; + // Backend proxy endpoint to avoid third-party CORS + const proxyEndpoint = `/api/image-upload/${provider}`; - // Backend proxy endpoint to avoid third-party CORS - const proxyEndpoint = `/api/image-upload/${provider}`; + const event = { + kind: 27235, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ["u", upstreamEndpoint], + ["method", "POST"] + ], + content: "" + }; + const signed = await window.nostr.signEvent(event); + const signedJson = JSON.stringify(signed); + const authHeader = 'Nostr ' + this.base64Encode(signedJson); + // Prepare form data + const formData = new FormData(); + formData.append('uploadtype', 'media'); + formData.append('file', file); + this.showProgress('Uploading...'); + // Upload to backend proxy + const response = await fetch(proxyEndpoint, { + method: 'POST', + headers: { + 'Authorization': authHeader + }, + body: formData + }); + const result = await response.json().catch(() => ({})); + if (!response.ok || result.status !== 'success' || !result.url) { + this.showError(result.message || `Upload failed (HTTP ${response.status})`); + return; + } + this.setImageField(result.url); + this.showProgress('Upload successful!'); + // clear file input so subsequent identical uploads work + if (this.hasFileInputTarget) this.fileInputTarget.value = ''; + setTimeout(() => this.closeDialog(), 1000); + } catch (e) { + this.showError('Upload error: ' + (e.message || e)); + } + } + + setImageField(url) { + // Find the image input in the form and set its value + const imageInput = document.querySelector('input[name$="[image]"]'); + if (imageInput) { + imageInput.value = url; + imageInput.dispatchEvent(new Event('input', { bubbles: true })); + } + } + + // Helpers to manage UI visibility and content + showProgress(text = '') { + if (this.hasProgressTarget) { + this.progressTarget.style.display = 'block'; + this.progressTarget.textContent = text; + } + } + + hideProgress() { + if (this.hasProgressTarget) { + this.progressTarget.style.display = 'none'; + this.progressTarget.textContent = ''; + } + } - const event = { - kind: 27235, - created_at: Math.floor(Date.now() / 1000), - tags: [ - ["u", upstreamEndpoint], - ["method", "POST"] - ], - content: "" - }; - const signed = await window.nostr.signEvent(event); - const signedJson = JSON.stringify(signed); - const authHeader = 'Nostr ' + this.base64Encode(signedJson); - // Prepare form data - const formData = new FormData(); - formData.append('uploadtype', 'media'); - formData.append('file', file); - this.progressTarget.textContent = 'Uploading...'; - // Upload to backend proxy - const response = await fetch(proxyEndpoint, { - method: 'POST', - headers: { - 'Authorization': authHeader - }, - body: formData - }); - const result = await response.json().catch(() => ({})); - if (!response.ok || result.status !== 'success' || !result.url) { - this.errorTarget.textContent = result.message || `Upload failed (HTTP ${response.status})`; - return; - } - this.setImageField(result.url); - this.progressTarget.textContent = 'Upload successful!'; - setTimeout(() => this.closeDialog(), 1000); - } catch (e) { - this.errorTarget.textContent = 'Upload error: ' + (e.message || e); - } + showError(message) { + if (this.hasErrorTarget) { + this.errorTarget.textContent = message; + this.errorTarget.style.display = 'block'; + // make assistive tech aware + this.errorTarget.setAttribute('role', 'alert'); + this.hideProgress(); + // clear file input so user can re-select the same file + if (this.hasFileInputTarget) this.fileInputTarget.value = ''; } + } - setImageField(url) { - // Find the image input in the form and set its value - const imageInput = document.querySelector('input[name$="[image]"]'); - if (imageInput) { - imageInput.value = url; - imageInput.dispatchEvent(new Event('input', { bubbles: true })); - } + clearError() { + if (this.hasErrorTarget) { + this.errorTarget.textContent = ''; + this.errorTarget.style.display = 'none'; + this.errorTarget.removeAttribute('role'); } + } } diff --git a/assets/controllers/nostr_publish_controller.js b/assets/controllers/nostr_publish_controller.js index fe05900..623caf6 100644 --- a/assets/controllers/nostr_publish_controller.js +++ b/assets/controllers/nostr_publish_controller.js @@ -89,6 +89,8 @@ export default class extends Controller { const summary = formData.get('editor[summary]') || ''; const image = formData.get('editor[image]') || ''; const topicsString = formData.get('editor[topics]') || ''; + const isDraft = formData.get('editor[isDraft]') === '1'; + const addClientTag = formData.get('editor[clientTag]') === '1'; // Parse topics const topics = topicsString.split(',') @@ -106,7 +108,9 @@ export default class extends Controller { content, image, topics, - slug + slug, + isDraft, + addClientTag }; } @@ -119,9 +123,13 @@ export default class extends Controller { ['d', formData.slug], ['title', formData.title], ['published_at', Math.floor(Date.now() / 1000).toString()], - ['client', 'Decent Newsroom'] ]; + let kind = 30023; // Default kind for long-form content + if (formData.isDraft) { + kind = 30024; // Draft kind + } + if (formData.summary) { tags.push(['summary', formData.summary]); } @@ -135,9 +143,13 @@ export default class extends Controller { tags.push(['t', topic.replace('#', '')]); }); + if (formData.addClientTag) { + tags.push(['client', 'Decent Newsroom']); + } + // Create the Nostr event (NIP-23 long-form content) const event = { - kind: 30023, // Long-form content kind + kind: kind, // Long-form content kind created_at: Math.floor(Date.now() / 1000), tags: tags, content: formData.content, diff --git a/assets/styles/03-components/form.css b/assets/styles/03-components/form.css index 474a2ba..0139b57 100644 --- a/assets/styles/03-components/form.css +++ b/assets/styles/03-components/form.css @@ -99,3 +99,21 @@ fieldset { form ul.list-unstyled li > div > label { display: none; } + +form > div.form-check { + display: flex; + flex-direction: row-reverse; + justify-content: flex-end; + align-items: center; + margin-bottom: var(--spacing-2); +} + +form .form-check-input[type="checkbox"] { + margin-right: var(--spacing-2); + width: auto; +} + +form .form-check-label { + margin: 0; + font-weight: normal; +} diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 8264202..7bebdc9 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -28,7 +28,8 @@ security: # Easy way to control access for large sections of your site # Note: Only the *first* access control that matches will be used access_control: - - { path: ^/admin, roles: ROLE_USER } + - { path: ^/admin, roles: ROLE_ADMIN } + - { path: ^/, roles: PUBLIC_ACCESS } # - { path: ^/search, roles: ROLE_USER } # - { path: ^/nzine, roles: ROLE_USER } # - { path: ^/profile, roles: ROLE_USER } diff --git a/src/Form/EditorType.php b/src/Form/EditorType.php index e90e968..e44b5b8 100644 --- a/src/Form/EditorType.php +++ b/src/Form/EditorType.php @@ -9,6 +9,7 @@ use App\Form\DataTransformer\CommaSeparatedToJsonTransformer; use App\Form\DataTransformer\HtmlToMdTransformer; use App\Form\Type\QuillType; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\UrlType; @@ -40,7 +41,17 @@ class EditorType extends AbstractType 'required' => false, 'sanitize_html' => true, 'help' => 'Separate tags with commas, skip #', - 'attr' => ['placeholder' => 'Add tags', 'class' => 'form-control']]); + 'attr' => ['placeholder' => 'Add tags', 'class' => 'form-control']]) + ->add('clientTag', CheckboxType::class, [ + 'label' => 'Add client tag to article (Decent Newsroom)', + 'required' => false, + 'mapped' => false, + ]) + ->add('isDraft', CheckboxType::class, [ + 'label' => 'Save as draft', + 'required' => false, + 'mapped' => false, + ]); // Apply the custom transformer $builder->get('topics') diff --git a/src/Security/NostrAuthenticator.php b/src/Security/NostrAuthenticator.php index 63391c8..d04dec3 100644 --- a/src/Security/NostrAuthenticator.php +++ b/src/Security/NostrAuthenticator.php @@ -37,7 +37,9 @@ class NostrAuthenticator extends AbstractAuthenticator implements InteractiveAut */ public function supports(Request $request): ?bool { - return $request->headers->has('Authorization') && + // Only support requests with /login route + $isLogin = $request->getPathInfo() === '/login'; + return $isLogin && $request->headers->has('Authorization') && str_starts_with($request->headers->get('Authorization', ''), self::NOSTR_AUTH_SCHEME); } diff --git a/templates/pages/editor.html.twig b/templates/pages/editor.html.twig index b1dd41d..5afc883 100644 --- a/templates/pages/editor.html.twig +++ b/templates/pages/editor.html.twig @@ -72,6 +72,16 @@ {{ form_row(form.topics) }} + {{ form_row(form.clientTag, { + 'row_attr': {'class': 'mb-3 form-check'}, + 'label_attr': {'class': 'form-check-label'}, + 'attr': {'class': 'form-check-input'} + }) }} + {{ form_row(form.isDraft, { + 'row_attr': {'class': 'mb-3 form-check'}, + 'label_attr': {'class': 'form-check-label'}, + 'attr': {'class': 'form-check-input'} + }) }}