Browse Source

Publishing and comments

imwald
Nuša Pukšič 5 months ago
parent
commit
b6cb12e62c
  1. 26
      assets/controllers/nostr_publish_controller.js
  2. 4
      assets/styles/card.css
  3. 165
      src/Controller/ArticleController.php
  4. 2
      src/Enum/KindsEnum.php
  5. 19
      src/Form/EditorType.php
  6. 4
      src/Service/NostrClient.php
  7. 135
      src/Twig/Components/Organisms/Comments.php
  8. 20
      templates/components/Organisms/Comments.html.twig
  9. 9
      templates/pages/editor.html.twig

26
assets/controllers/nostr_publish_controller.js

@ -9,19 +9,30 @@ export default class extends Controller {
connect() { connect() {
console.log('Nostr publish controller connected'); console.log('Nostr publish controller connected');
this.checkNostrSupport(); // Helpful debug to verify values are wired from Twig
} try {
console.debug('[nostr-publish] publishUrl:', this.publishUrlValue || '(none)');
console.debug('[nostr-publish] has csrfToken:', Boolean(this.csrfTokenValue));
} catch (_) {}
checkNostrSupport() { // Provide a sensible fallback if not passed via values
if (!window.nostr) { if (!this.hasPublishUrlValue || !this.publishUrlValue) {
this.showError('Nostr extension not found. Please install a Nostr browser extension like nos2x or Alby.'); this.publishUrlValue = '/api/article/publish';
this.publishButtonTarget.disabled = true;
} }
} }
async publish(event) { async publish(event) {
event.preventDefault(); 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) { if (!window.nostr) {
this.showError('Nostr extension not found'); this.showError('Nostr extension not found');
return; return;
@ -218,6 +229,9 @@ export default class extends Controller {
} }
generateSlug(title) { generateSlug(title) {
// add a random seed at the end of the title to avoid collisions
const randomSeed = Math.random().toString(36).substring(2, 8);
title = `${title} ${randomSeed}`;
return title return title
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters .replace(/[^a-z0-9\s-]/g, '') // Remove special characters

4
assets/styles/card.css

@ -54,6 +54,10 @@ h2.card-title {
padding: 10px; padding: 10px;
} }
.card.comment.zap-comment {
border-left: 4px solid var(--color-primary);
}
.card.comment .metadata { .card.comment .metadata {
display: flex; display: flex;
align-items: center; align-items: center;

165
src/Controller/ArticleController.php

@ -15,12 +15,14 @@ use nostriphant\NIP19\Bech32;
use nostriphant\NIP19\Data\NAddr; use nostriphant\NIP19\Data\NAddr;
use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use swentel\nostr\Event\Event;
use swentel\nostr\Key\Key; use swentel\nostr\Key\Key;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\String\Slugger\AsciiSlugger; use Symfony\Component\String\Slugger\AsciiSlugger;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Component\Workflow\WorkflowInterface; use Symfony\Component\Workflow\WorkflowInterface;
@ -30,7 +32,7 @@ class ArticleController extends AbstractController
/** /**
* @throws \Exception * @throws \Exception
*/ */
#[Route('/article/{naddr}', name: 'article-naddr', requirements: ['naddr' => '^(naddr1[0-9a-z]{59})$'])] #[Route('/article/{naddr}', name: 'article-naddr', requirements: ['naddr' => '^(naddr1[0-9a-zA-Z]+)$'])]
public function naddr(NostrClient $nostrClient, $naddr) public function naddr(NostrClient $nostrClient, $naddr)
{ {
$decoded = new Bech32($naddr); $decoded = new Bech32($naddr);
@ -98,7 +100,7 @@ class ArticleController extends AbstractController
$article = $articles[0]; $article = $articles[0];
} }
$cacheKey = 'article_' . $article->getId(); $cacheKey = 'article_' . $article->getEventId();
$cacheItem = $articlesCache->getItem($cacheKey); $cacheItem = $articlesCache->getItem($cacheKey);
if (!$cacheItem->isHit()) { if (!$cacheItem->isHit()) {
$cacheItem->set($converter->convertToHtml($article->getContent())); $cacheItem->set($converter->convertToHtml($article->getContent()));
@ -118,60 +120,6 @@ class ArticleController extends AbstractController
]); ]);
} }
/**
* Fetch complete event to show as preview
* POST data contains an object with request params
*/
#[Route('/preview/', name: 'article-preview-event', methods: ['POST'])]
public function articlePreviewEvent(
Request $request,
NostrClient $nostrClient,
RedisCacheService $redisCacheService,
CacheItemPoolInterface $articlesCache
): Response {
$data = $request->getContent();
// descriptor is an object with properties type, identifier and data
// if type === 'nevent', identifier is the event id
// if type === 'naddr', identifier is the naddr
// if type === 'nprofile', identifier is the npub
$descriptor = json_decode($data);
$previewData = [];
// if nprofile, get from redis cache
if ($descriptor->type === 'nprofile') {
$hint = json_decode($descriptor->decoded);
$key = new Key();
$npub = $key->convertPublicKeyToBech32($hint->pubkey);
$metadata = $redisCacheService->getMetadata($npub);
$metadata->npub = $npub;
$metadata->pubkey = $hint->pubkey;
$metadata->type = 'nprofile';
// Render the NostrPreviewContent component with the preview data
$html = $this->renderView('components/Molecules/NostrPreviewContent.html.twig', [
'preview' => $metadata
]);
} else {
// For nevent or naddr, fetch the event data
try {
$previewData = $nostrClient->getEventFromDescriptor($descriptor);
$previewData->type = $descriptor->type; // Add type to the preview data
// Render the NostrPreviewContent component with the preview data
$html = $this->renderView('components/Molecules/NostrPreviewContent.html.twig', [
'preview' => $previewData
]);
} catch (\Exception $e) {
$html = '<span>Error fetching preview: ' . htmlspecialchars($e->getMessage()) . '</span>';
}
}
return new Response(
$html,
Response::HTTP_OK,
['Content-Type' => 'text/html']
);
}
/** /**
* Create new article * Create new article
* @throws InvalidArgumentException * @throws InvalidArgumentException
@ -234,33 +182,6 @@ class ArticleController extends AbstractController
]); ]);
} }
/**
* Preview article
* @throws InvalidArgumentException
* @throws CommonMarkException
* @throws \Exception
*/
#[Route('/article-preview/{d}', name: 'article-preview')]
public function preview($d, Converter $converter,
CacheItemPoolInterface $articlesCache): Response
{
$user = $this->getUser();
$key = new Key();
$currentPubkey = $key->convertToHex($user->getUserIdentifier());
$cacheKey = 'article_' . $currentPubkey . '_' . $d;
$cacheItem = $articlesCache->getItem($cacheKey);
$article = $cacheItem->get();
$content = $converter->convertToHtml($article->getContent());
return $this->render('pages/article.html.twig', [
'article' => $article,
'content' => $content,
'author' => $user->getMetadata(),
]);
}
/** /**
* API endpoint to receive and process signed Nostr events * API endpoint to receive and process signed Nostr events
* @throws \Exception * @throws \Exception
@ -276,7 +197,7 @@ class ArticleController extends AbstractController
try { try {
// Verify CSRF token // Verify CSRF token
$csrfToken = $request->headers->get('X-CSRF-TOKEN'); $csrfToken = $request->headers->get('X-CSRF-TOKEN');
if (!$csrfTokenManager->isTokenValid(new \Symfony\Component\Security\Csrf\CsrfToken('nostr_publish', $csrfToken))) { if (!$csrfTokenManager->isTokenValid(new CsrfToken('nostr_publish', $csrfToken))) {
return new JsonResponse(['error' => 'Invalid CSRF token'], 403); return new JsonResponse(['error' => 'Invalid CSRF token'], 403);
} }
@ -286,15 +207,20 @@ class ArticleController extends AbstractController
return new JsonResponse(['error' => 'Invalid request data'], 400); return new JsonResponse(['error' => 'Invalid request data'], 400);
} }
/* @var array $signedEvent */
$signedEvent = $data['event']; $signedEvent = $data['event'];
$formData = $data['formData'] ?? []; // Convert the signed event array to a proper Event object
$eventObj = new Event();
// Validate Nostr event structure $eventObj->setId($signedEvent['id']);
$this->validateNostrEvent($signedEvent); $eventObj->setPublicKey($signedEvent['pubkey']);
$eventObj->setCreatedAt($signedEvent['created_at']);
// Verify the event signature $eventObj->setKind($signedEvent['kind']);
if (!$this->verifyNostrSignature($signedEvent)) { $eventObj->setTags($signedEvent['tags']);
return new JsonResponse(['error' => 'Invalid event signature'], 400); $eventObj->setContent($signedEvent['content']);
$eventObj->setSignature($signedEvent['sig']);
if (!$eventObj->verify()) {
return new JsonResponse(['error' => 'Event signature verification failed'], 400);
} }
// Check if user is authenticated and matches the event pubkey // Check if user is authenticated and matches the event pubkey
@ -303,6 +229,8 @@ class ArticleController extends AbstractController
return new JsonResponse(['error' => 'User not authenticated'], 401); return new JsonResponse(['error' => 'User not authenticated'], 401);
} }
$formData = $data['formData'] ?? [];
$key = new Key(); $key = new Key();
$currentPubkey = $key->convertToHex($user->getUserIdentifier()); $currentPubkey = $key->convertToHex($user->getUserIdentifier());
@ -313,25 +241,12 @@ class ArticleController extends AbstractController
// Extract article data from the signed event // Extract article data from the signed event
$articleData = $this->extractArticleDataFromEvent($signedEvent, $formData); $articleData = $this->extractArticleDataFromEvent($signedEvent, $formData);
// Check if article with same slug already exists for this author
$repository = $entityManager->getRepository(Article::class);
$existingArticle = $repository->findOneBy([
'slug' => $articleData['slug'],
'pubkey' => $currentPubkey
]);
if ($existingArticle) {
// Update existing article (NIP-33 replaceable event)
$article = $existingArticle;
} else {
// Create new article
$article = new Article();
$article->setPubkey($currentPubkey);
$article->setKind(KindsEnum::LONGFORM);
}
// Update article properties // Create new article
$article->setEventId($this->generateEventId($signedEvent)); $article = new Article();
$article->setPubkey($currentPubkey);
$article->setKind(KindsEnum::LONGFORM);
$article->setEventId($signedEvent['id']);
$article->setSlug($articleData['slug']); $article->setSlug($articleData['slug']);
$article->setTitle($articleData['title']); $article->setTitle($articleData['title']);
$article->setSummary($articleData['summary']); $article->setSummary($articleData['summary']);
@ -343,44 +258,25 @@ class ArticleController extends AbstractController
$article->setCreatedAt(new \DateTimeImmutable('@' . $signedEvent['created_at'])); $article->setCreatedAt(new \DateTimeImmutable('@' . $signedEvent['created_at']));
$article->setPublishedAt(new \DateTimeImmutable()); $article->setPublishedAt(new \DateTimeImmutable());
// Check workflow permissions
if ($articlePublishingWorkflow->can($article, 'publish')) {
$articlePublishingWorkflow->apply($article, 'publish');
}
// Save to database // Save to database
$entityManager->persist($article); $entityManager->persist($article);
$entityManager->flush(); $entityManager->flush();
// Optionally publish to Nostr relays // Optionally publish to Nostr relays
try { try {
// Convert the signed event array to a proper Event object
$eventObj = new \swentel\nostr\Event\Event();
$eventObj->setId($signedEvent['id']);
$eventObj->setPublicKey($signedEvent['pubkey']);
$eventObj->setCreatedAt($signedEvent['created_at']);
$eventObj->setKind($signedEvent['kind']);
$eventObj->setTags($signedEvent['tags']);
$eventObj->setContent($signedEvent['content']);
$eventObj->setSignature($signedEvent['sig']);
// Get user's relays or use default ones // Get user's relays or use default ones
$relays = []; $relays = [];
if ($user && method_exists($user, 'getRelays') && $user->getRelays()) { if ($user && method_exists($user, 'getRelays') && $user->getRelays()) {
foreach ($user->getRelays() as $relayArr) { foreach ($user->getRelays() as $relayArr) {
if (isset($relayArr[1]) && isset($relayArr[2]) && $relayArr[2] === 'write') { if (isset($relayArr[1]) && isset($relayArr[2]) && $relayArr[2] === 'write') {
$relays[] = $relayArr[1]; // $relays[] = $relayArr[1];
} }
} }
} }
// Fallback to default relays if no user relays found // Fallback to default relays if no user relays found
if (empty($relays)) { if (empty($relays)) {
$relays = [ throw new \Exception('No write relays configured for user.');
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://nos.lol'
];
} }
$nostrClient->publishEvent($eventObj, $relays); $nostrClient->publishEvent($eventObj, $relays);
@ -502,11 +398,4 @@ class ArticleController extends AbstractController
return $data; return $data;
} }
private function generateEventId(array $event): string
{
return $event['id'];
}
// ...existing code...
} }

2
src/Enum/KindsEnum.php

@ -16,7 +16,7 @@ enum KindsEnum: int
case LONGFORM = 30023; // NIP-23 case LONGFORM = 30023; // NIP-23
case LONGFORM_DRAFT = 30024; // NIP-23 case LONGFORM_DRAFT = 30024; // NIP-23
case PUBLICATION_INDEX = 30040; case PUBLICATION_INDEX = 30040;
case ZAP = 9735; // NIP-57, Zaps case ZAP_RECEIPT = 9735; // NIP-57, Zaps
case HIGHLIGHTS = 9802; case HIGHLIGHTS = 9802;
case RELAY_LIST = 10002; // NIP-65, Relay list metadata case RELAY_LIST = 10002; // NIP-65, Relay list metadata
case APP_DATA = 30078; // NIP-78, Arbitrary custom app data case APP_DATA = 30078; // NIP-78, Arbitrary custom app data

19
src/Form/EditorType.php

@ -9,8 +9,6 @@ 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\FormType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
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;
@ -42,22 +40,7 @@ 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' => 'Enter tags', 'class' => 'form-control']]) 'attr' => ['placeholder' => 'Enter tags', 'class' => 'form-control']]);
->add(
$builder->create('actions', FormType::class,
['row_attr' => ['class' => 'actions'], 'label' => false, 'mapped' => false])
->add('submit', SubmitType::class, [
'label' => 'Submit',
'attr' => ['class' => 'btn btn-primary']])
->add('draft', SubmitType::class, [
'label' => 'Save as draft',
'attr' => ['class' => 'btn btn-secondary']])
->add('preview', SubmitType::class, [
'label' => 'Preview',
'attr' => ['class' => 'btn btn-secondary']])
);
// Apply the custom transformer // Apply the custom transformer
$builder->get('topics') $builder->get('topics')

4
src/Service/NostrClient.php

@ -432,7 +432,7 @@ class NostrClient
// Create request using the helper method // Create request using the helper method
$request = $this->createNostrRequest( $request = $this->createNostrRequest(
kinds: [KindsEnum::COMMENTS->value, KindsEnum::TEXT_NOTE->value, KindsEnum::ZAP->value], kinds: [KindsEnum::COMMENTS->value, KindsEnum::ZAP_RECEIPT->value],
filters: ['tag' => ['#a', [$coordinate]]], filters: ['tag' => ['#a', [$coordinate]]],
relaySet: $relaySet relaySet: $relaySet
); );
@ -470,7 +470,7 @@ class NostrClient
// Create request using the helper method // Create request using the helper method
// Zaps are kind 9735 // Zaps are kind 9735
$request = $this->createNostrRequest( $request = $this->createNostrRequest(
kinds: [KindsEnum::ZAP], kinds: [KindsEnum::ZAP_RECEIPT],
filters: ['tag' => ['#a', [$coordinate]]], filters: ['tag' => ['#a', [$coordinate]]],
relaySet: $relaySet relaySet: $relaySet
); );

135
src/Twig/Components/Organisms/Comments.php

@ -12,6 +12,8 @@ final class Comments
public array $list = []; public array $list = [];
public array $commentLinks = []; public array $commentLinks = [];
public array $processedContent = []; public array $processedContent = [];
public array $zapAmounts = [];
public array $zappers = [];
public function __construct( public function __construct(
private readonly NostrClient $nostrClient, private readonly NostrClient $nostrClient,
@ -27,9 +29,12 @@ final class Comments
{ {
// Fetch comments // Fetch comments
$this->list = $this->nostrClient->getComments($current); $this->list = $this->nostrClient->getComments($current);
// sort list by created_at descending
usort($this->list, fn($a, $b) => ($b->created_at ?? 0) <=> ($a->created_at ?? 0));
// Parse Nostr links in comments but don't fetch previews // Parse Nostr links in comments but don't fetch previews
$this->parseNostrLinks(); $this->parseNostrLinks();
// Parse Zaps to get amounts and zappers from receipts
$this->parseZaps();
} }
/** /**
@ -55,4 +60,132 @@ final class Comments
} }
} }
} }
/**
* Parse Zaps to get amounts
*/
public function parseZaps(): void
{
foreach ($this->list as $comment) {
// check if kind is 9735 to get zaps
if ($comment->kind !== 9735) {
continue;
}
$tags = $comment->tags ?? [];
if (empty($tags) || !is_array($tags)) {
continue;
}
// 1) Find and decode description JSON
$descriptionJson = $this->findTagValue($tags, 'description');
if ($descriptionJson === null) {
continue; // can't validate sender without description
}
$description = json_decode($descriptionJson);
// 3) Get amount: prefer explicit 'amount' tag (msat), fallback to BOLT11 invoice parsing
$amountSats = null;
$amountMsatStr = $this->findTagValue($description->tags, 'amount');
if ($amountMsatStr !== null && is_numeric($amountMsatStr)) {
// amount in millisats per NIP-57
$msats = (int) $amountMsatStr;
if ($msats > 0) {
$amountSats = intdiv($msats, 1000);
}
}
if (empty($amountSats)) {
$bolt11 = $this->findTagValue($tags, 'bolt11');
if (!empty($bolt11)) {
$amountSats = $this->parseBolt11ToSats($bolt11);
}
}
$this->zappers[$comment->id] = $this->findTagValue($tags, 'P');
if ($amountSats !== null && $amountSats > 0) {
$this->zapAmounts[$comment->id] = $amountSats;
}
}
}
// --- Helpers ---
/**
* Find first tag value by key.
* @param array $tags
* @param string $key
* @return string|null
*/
private function findTagValue(array $tags, string $key): ?string
{
foreach ($tags as $tag) {
if (!is_array($tag) || count($tag) < 2) {
continue;
}
if (($tag[0] ?? null) === $key) {
return (string) $tag[1];
}
}
return null;
}
/**
* Minimal BOLT11 amount parser to sats from invoice prefix (lnbc...amount[munp]).
* Returns null if amount is not embedded.
*/
private function parseBolt11ToSats(string $invoice): ?int
{
// Normalize
$inv = strtolower(trim($invoice));
// Find the amount section after the hrp prefix (lnbc or lntb etc.)
// Spec: ln + currency + amount + multiplier. We only need amount+multiplier.
if (!str_starts_with($inv, 'ln')) {
return null;
}
// Strip prefix up to first digit
$i = 0;
while ($i < strlen($inv) && !ctype_digit($inv[$i])) {
$i++;
}
if ($i >= strlen($inv)) {
return null; // no amount encoded
}
// Read numeric part
$j = $i;
while ($j < strlen($inv) && ctype_digit($inv[$j])) {
$j++;
}
$numStr = substr($inv, $i, $j - $i);
if ($numStr === '') {
return null;
}
$amount = (int) $numStr;
// Multiplier char (optional). If none, amount is in BTC (rare for zaps)
$mult = $inv[$j] ?? '';
$sats = null;
// 1 BTC = 100_000_000 sats
switch ($mult) {
case 'm': // milli-btc
$sats = (int) round($amount * 100_000); // 0.001 BTC = 100_000 sats
break;
case 'u': // micro-btc
$sats = (int) round($amount * 1000); // 0.000001 BTC = 1000 sats
break;
case 'n': // nano-btc
$sats = (int) round($amount * 0.001); // 0.000000001 BTC = 0.001 sats
break;
case 'p': // pico-btc
$sats = (int) round($amount * 0.000001); // 1e-12 BTC = 1e-4 sats
break;
default:
// No multiplier => amount in BTC
$sats = (int) round($amount * 100_000_000);
break;
}
// Ensure positive and at least 1 sat if rounding produced 0
return $sats > 0 ? $sats : null;
}
} }

20
templates/components/Organisms/Comments.html.twig

@ -1,14 +1,27 @@
<div class="comments"> <div class="comments">
{% for item in list %} {% for item in list %}
<div class="card comment {% if item.kind is defined and item.kind == 9735 %}zap-comment{% endif %}"> <div class="card comment {% if item.kind is defined and item.kind == '9735' %}zap-comment{% endif %}">
<div class="metadata"> <div class="metadata">
<p><twig:Molecules:UserFromNpub ident="{{ item.pubkey }}" /></p> <p>
<twig:Molecules:UserFromNpub ident="{{ item.kind == '9735' ? zappers[item.id] : item.pubkey }}" />
</p>
<small>{{ item.created_at|date('F j Y') }}</small> <small>{{ item.created_at|date('F j Y') }}</small>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if item.kind is defined and item.kind == '9735' %}
<div class="zap-amount">
{% if zapAmounts[item.id] is defined %}
<strong>{{ zapAmounts[item.id] }} sat</strong>
{% else %}
<em>Zap</em>
{% endif %}
</div>
{% endif %}
<twig:Atoms:Content content="{{ item.content }}" /> <twig:Atoms:Content content="{{ item.content }}" />
</div> </div>
{# Display Nostr link previews if links detected #} {# Display Nostr link previews if links detected #}
{% if commentLinks[item.id] is defined and commentLinks[item.id]|length > 0 %} {% if commentLinks[item.id] is defined and commentLinks[item.id]|length > 0 %}
<div class="card-footer nostr-previews mt-3"> <div class="card-footer nostr-previews mt-3">
@ -21,6 +34,8 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{# Display tags if user logged in and has role ROLE_ADMIN #}
{% if is_granted('ROLE_ADMIN') %}
{% if item.tags is defined and item.tags|length > 0 %} {% if item.tags is defined and item.tags|length > 0 %}
<ul> <ul>
{% for tag in item.tags %} {% for tag in item.tags %}
@ -36,6 +51,7 @@
{% endfor %} {% endfor %}
</ul> </ul>
{% endif %} {% endif %}
{% endif %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>

9
templates/pages/editor.html.twig

@ -16,7 +16,7 @@
<div {{ stimulus_controller('nostr-publish', { <div {{ stimulus_controller('nostr-publish', {
publishUrl: path('api-article-publish'), publishUrl: path('api-article-publish'),
csrfToken: csrf_token('nostr_publish') csrfToken: csrf_token('nostr_publish')
}) }} data-nostr-publish-form-target="form"> }) }} data-nostr-publish-target="form">
<!-- Status messages --> <!-- Status messages -->
<div data-nostr-publish-target="status"></div> <div data-nostr-publish-target="status"></div>
@ -37,15 +37,10 @@
class="btn btn-primary" class="btn btn-primary"
data-nostr-publish-target="publishButton" data-nostr-publish-target="publishButton"
data-action="click->nostr-publish#publish"> data-action="click->nostr-publish#publish">
Publish to Nostr Publish
</button> </button>
</div> </div>
<!-- Keep the original submit button hidden for fallback -->
<div style="display: none;">
{{ form_row(form.actions.submit) }}
</div>
{{ form_end(form) }} {{ form_end(form) }}
</div> </div>

Loading…
Cancel
Save