Browse Source

Editor tweaks

imwald
Nuša Pukšič 3 months ago
parent
commit
389e6cbd43
  1. 63
      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

63
assets/controllers/image_upload_controller.js

@ -14,14 +14,14 @@ export default class extends Controller {
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() {
@ -42,17 +42,19 @@ export default class extends Controller {
this.handleFile(e.dataTransfer.files[0]); this.handleFile(e.dataTransfer.files[0]);
} }
}); });
// Ensure initial visibility states
this.hideProgress();
this.clearError();
} }
async handleFile(file) { async handleFile(file) {
if (!file) return; if (!file) return;
this.errorTarget.textContent = ''; this.clearError();
this.progressTarget.style.display = ''; this.showProgress('Preparing upload...');
this.progressTarget.textContent = 'Preparing upload...';
try { try {
// NIP98: get signed HTTP Auth event from window.nostr // NIP98: get signed HTTP Auth event from window.nostr
if (!window.nostr || !window.nostr.signEvent) { if (!window.nostr || !window.nostr.signEvent) {
this.errorTarget.textContent = 'Nostr extension not found.'; this.showError('Nostr extension not found.');
return; return;
} }
// Determine provider // Determine provider
@ -85,7 +87,7 @@ export default class extends Controller {
const formData = new FormData(); const formData = new FormData();
formData.append('uploadtype', 'media'); formData.append('uploadtype', 'media');
formData.append('file', file); formData.append('file', file);
this.progressTarget.textContent = 'Uploading...'; this.showProgress('Uploading...');
// Upload to backend proxy // Upload to backend proxy
const response = await fetch(proxyEndpoint, { const response = await fetch(proxyEndpoint, {
method: 'POST', method: 'POST',
@ -96,14 +98,16 @@ export default class extends Controller {
}); });
const result = await response.json().catch(() => ({})); const result = await response.json().catch(() => ({}));
if (!response.ok || result.status !== 'success' || !result.url) { if (!response.ok || result.status !== 'success' || !result.url) {
this.errorTarget.textContent = result.message || `Upload failed (HTTP ${response.status})`; this.showError(result.message || `Upload failed (HTTP ${response.status})`);
return; return;
} }
this.setImageField(result.url); this.setImageField(result.url);
this.progressTarget.textContent = 'Upload successful!'; this.showProgress('Upload successful!');
// clear file input so subsequent identical uploads work
if (this.hasFileInputTarget) this.fileInputTarget.value = '';
setTimeout(() => this.closeDialog(), 1000); setTimeout(() => this.closeDialog(), 1000);
} catch (e) { } catch (e) {
this.errorTarget.textContent = 'Upload error: ' + (e.message || e); this.showError('Upload error: ' + (e.message || e));
} }
} }
@ -115,4 +119,39 @@ export default class extends Controller {
imageInput.dispatchEvent(new Event('input', { bubbles: true })); 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 = '';
}
}
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 = '';
}
}
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 {
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