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 @@ @@ -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');
}
}
}

18
assets/controllers/nostr_publish_controller.js

@ -89,6 +89,8 @@ export default class extends Controller { @@ -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 { @@ -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 { @@ -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 { @@ -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,

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

@ -99,3 +99,21 @@ fieldset { @@ -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;
}

3
config/packages/security.yaml

@ -28,7 +28,8 @@ security: @@ -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 }

13
src/Form/EditorType.php

@ -9,6 +9,7 @@ use App\Form\DataTransformer\CommaSeparatedToJsonTransformer; @@ -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 @@ -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')

4
src/Security/NostrAuthenticator.php

@ -37,7 +37,9 @@ class NostrAuthenticator extends AbstractAuthenticator implements InteractiveAut @@ -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);
}

10
templates/pages/editor.html.twig

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

Loading…
Cancel
Save