Browse Source

Editor tweaks

imwald
Nuša Pukšič 3 months ago
parent
commit
389e6cbd43
  1. 245
      assets/controllers/image_upload_controller.js
  2. 18
      assets/controllers/nostr_publish_controller.js
  3. 18
      assets/styles/03-components/form.css
  4. 3
      config/packages/security.yaml
  5. 13
      src/Form/EditorType.php
  6. 4
      src/Security/NostrAuthenticator.php
  7. 10
      templates/pages/editor.html.twig

245
assets/controllers/image_upload_controller.js

@ -1,118 +1,157 @@
import { Controller } from '@hotwired/stimulus'; import { Controller } from '@hotwired/stimulus';
export default class extends Controller { 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 // Unicode-safe base64 encoder
base64Encode(str) { base64Encode(str) {
try { try {
return btoa(unescape(encodeURIComponent(str))); return btoa(unescape(encodeURIComponent(str)));
} catch (_) { } catch (_) {
return btoa(str); return btoa(str);
} }
} }
openDialog() { openDialog() {
this.dialogTarget.classList.add('active'); this.dialogTarget.classList.add('active');
this.errorTarget.textContent = ''; this.clearError();
this.progressTarget.style.display = 'none'; this.hideProgress();
} }
closeDialog() { closeDialog() {
this.dialogTarget.classList.remove('active'); this.dialogTarget.classList.remove('active');
this.errorTarget.textContent = ''; this.clearError();
this.progressTarget.style.display = 'none'; this.hideProgress();
} }
connect() { connect() {
this.dropAreaTarget.addEventListener('click', () => this.fileInputTarget.click()); this.dropAreaTarget.addEventListener('click', () => this.fileInputTarget.click());
this.fileInputTarget.addEventListener('change', (e) => this.handleFile(e.target.files[0])); this.fileInputTarget.addEventListener('change', (e) => this.handleFile(e.target.files[0]));
this.dropAreaTarget.addEventListener('dragover', (e) => { this.dropAreaTarget.addEventListener('dragover', (e) => {
e.preventDefault(); e.preventDefault();
this.dropAreaTarget.classList.add('dragover'); this.dropAreaTarget.classList.add('dragover');
}); });
this.dropAreaTarget.addEventListener('dragleave', (e) => { this.dropAreaTarget.addEventListener('dragleave', (e) => {
e.preventDefault(); e.preventDefault();
this.dropAreaTarget.classList.remove('dragover'); this.dropAreaTarget.classList.remove('dragover');
}); });
this.dropAreaTarget.addEventListener('drop', (e) => { this.dropAreaTarget.addEventListener('drop', (e) => {
e.preventDefault(); e.preventDefault();
this.dropAreaTarget.classList.remove('dragover'); this.dropAreaTarget.classList.remove('dragover');
if (e.dataTransfer.files.length > 0) { if (e.dataTransfer.files.length > 0) {
this.handleFile(e.dataTransfer.files[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) { // Map provider -> upstream endpoint used for signing the NIP-98 event
if (!file) return; const upstreamMap = {
this.errorTarget.textContent = ''; nostrbuild: 'https://nostr.build/nip96/upload',
this.progressTarget.style.display = ''; nostrcheck: 'https://nostrcheck.me/api/v2/media',
this.progressTarget.textContent = 'Preparing upload...'; sovbit: 'https://files.sovbit.host/api/v2/media',
try { };
// NIP98: get signed HTTP Auth event from window.nostr const upstreamEndpoint = upstreamMap[provider] || upstreamMap['nostrcheck'];
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 // Backend proxy endpoint to avoid third-party CORS
const upstreamMap = { const proxyEndpoint = `/api/image-upload/${provider}`;
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 event = {
const proxyEndpoint = `/api/image-upload/${provider}`; 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 = { showError(message) {
kind: 27235, if (this.hasErrorTarget) {
created_at: Math.floor(Date.now() / 1000), this.errorTarget.textContent = message;
tags: [ this.errorTarget.style.display = 'block';
["u", upstreamEndpoint], // make assistive tech aware
["method", "POST"] this.errorTarget.setAttribute('role', 'alert');
], this.hideProgress();
content: "" // clear file input so user can re-select the same file
}; if (this.hasFileInputTarget) this.fileInputTarget.value = '';
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);
}
} }
}
setImageField(url) { clearError() {
// Find the image input in the form and set its value if (this.hasErrorTarget) {
const imageInput = document.querySelector('input[name$="[image]"]'); this.errorTarget.textContent = '';
if (imageInput) { this.errorTarget.style.display = 'none';
imageInput.value = url; this.errorTarget.removeAttribute('role');
imageInput.dispatchEvent(new Event('input', { bubbles: true }));
}
} }
}
} }

18
assets/controllers/nostr_publish_controller.js

@ -89,6 +89,8 @@ export default class extends Controller {
const summary = formData.get('editor[summary]') || ''; const summary = formData.get('editor[summary]') || '';
const image = formData.get('editor[image]') || ''; const image = formData.get('editor[image]') || '';
const topicsString = formData.get('editor[topics]') || ''; const topicsString = formData.get('editor[topics]') || '';
const isDraft = formData.get('editor[isDraft]') === '1';
const addClientTag = formData.get('editor[clientTag]') === '1';
// Parse topics // Parse topics
const topics = topicsString.split(',') const topics = topicsString.split(',')
@ -106,7 +108,9 @@ export default class extends Controller {
content, content,
image, image,
topics, topics,
slug slug,
isDraft,
addClientTag
}; };
} }
@ -119,9 +123,13 @@ export default class extends Controller {
['d', formData.slug], ['d', formData.slug],
['title', formData.title], ['title', formData.title],
['published_at', Math.floor(Date.now() / 1000).toString()], ['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) { if (formData.summary) {
tags.push(['summary', formData.summary]); tags.push(['summary', formData.summary]);
} }
@ -135,9 +143,13 @@ export default class extends Controller {
tags.push(['t', topic.replace('#', '')]); tags.push(['t', topic.replace('#', '')]);
}); });
if (formData.addClientTag) {
tags.push(['client', 'Decent Newsroom']);
}
// Create the Nostr event (NIP-23 long-form content) // Create the Nostr event (NIP-23 long-form content)
const event = { const event = {
kind: 30023, // Long-form content kind kind: kind, // Long-form content kind
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: tags, tags: tags,
content: formData.content, content: formData.content,

18
assets/styles/03-components/form.css

@ -99,3 +99,21 @@ fieldset {
form ul.list-unstyled li > div > label { form ul.list-unstyled li > div > label {
display: none; 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;
}

3
config/packages/security.yaml

@ -28,7 +28,8 @@ security:
# Easy way to control access for large sections of your site # Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used # Note: Only the *first* access control that matches will be used
access_control: access_control:
- { path: ^/admin, roles: ROLE_USER } - { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/, roles: PUBLIC_ACCESS }
# - { path: ^/search, roles: ROLE_USER } # - { path: ^/search, roles: ROLE_USER }
# - { path: ^/nzine, roles: ROLE_USER } # - { path: ^/nzine, roles: ROLE_USER }
# - { path: ^/profile, roles: ROLE_USER } # - { path: ^/profile, roles: ROLE_USER }

13
src/Form/EditorType.php

@ -9,6 +9,7 @@ use App\Form\DataTransformer\CommaSeparatedToJsonTransformer;
use App\Form\DataTransformer\HtmlToMdTransformer; use App\Form\DataTransformer\HtmlToMdTransformer;
use App\Form\Type\QuillType; use App\Form\Type\QuillType;
use Symfony\Component\Form\AbstractType; 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\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\UrlType; use Symfony\Component\Form\Extension\Core\Type\UrlType;
@ -40,7 +41,17 @@ class EditorType extends AbstractType
'required' => false, 'required' => false,
'sanitize_html' => true, 'sanitize_html' => true,
'help' => 'Separate tags with commas, skip #', '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 // Apply the custom transformer
$builder->get('topics') $builder->get('topics')

4
src/Security/NostrAuthenticator.php

@ -37,7 +37,9 @@ class NostrAuthenticator extends AbstractAuthenticator implements InteractiveAut
*/ */
public function supports(Request $request): ?bool 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); str_starts_with($request->headers->get('Authorization', ''), self::NOSTR_AUTH_SCHEME);
} }

10
templates/pages/editor.html.twig

@ -72,6 +72,16 @@
{{ form_row(form.topics) }} {{ 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'}
}) }}
<div class="actions"> <div class="actions">
<button type="button" <button type="button"

Loading…
Cancel
Save