Browse Source

Create a magazine

imwald
Nuša Pukšič 4 months ago
parent
commit
ba08bd4250
  1. 166
      assets/controllers/nostr_index_sign_controller.js
  2. 247
      src/Controller/MagazineWizardController.php
  3. 17
      src/Dto/CategoryDraft.php
  4. 19
      src/Dto/MagazineDraft.php
  5. 41
      src/Form/CategoryArticlesType.php
  6. 46
      src/Form/CategoryType.php
  7. 64
      src/Form/MagazineSetupType.php
  8. 12
      src/Util/CommonMark/NostrSchemeExtension/NostrRawNpubParser.php
  9. 5
      templates/components/UserMenu.html.twig
  10. 34
      templates/magazine/magazine_articles.html.twig
  11. 85
      templates/magazine/magazine_review.html.twig
  12. 31
      templates/magazine/magazine_setup.html.twig

166
assets/controllers/nostr_index_sign_controller.js

@ -0,0 +1,166 @@ @@ -0,0 +1,166 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['status', 'publishButton', 'computedPreview'];
static values = {
categoryEvents: String,
magazineEvent: String,
publishUrl: String,
csrfToken: String
};
async connect() {
try {
console.debug('[nostr-index-sign] connected');
await this.preparePreview();
} catch (_) {}
}
async preparePreview() {
try {
const catSkeletons = JSON.parse(this.categoryEventsValue || '[]');
const magSkeleton = JSON.parse(this.magazineEventValue || '{}');
let pubkey = '<pubkey>';
if (window.nostr && typeof window.nostr.getPublicKey === 'function') {
try { pubkey = await window.nostr.getPublicKey(); } catch (_) {}
}
const categoryCoordinates = [];
for (let i = 0; i < catSkeletons.length; i++) {
const evt = catSkeletons[i];
const slug = this.extractSlug(evt.tags);
if (slug) {
categoryCoordinates.push(`30040:${pubkey}:${slug}`);
}
}
const previewMag = JSON.parse(JSON.stringify(magSkeleton));
previewMag.tags = (previewMag.tags || []).filter(t => t[0] !== 'a');
categoryCoordinates.forEach(c => previewMag.tags.push(['a', c]));
previewMag.pubkey = pubkey;
if (this.hasComputedPreviewTarget) {
this.computedPreviewTarget.textContent = JSON.stringify(previewMag, null, 2);
}
} catch (e) {
// no-op preview errors
}
}
async signAndPublish(event) {
event.preventDefault();
if (!window.nostr) {
this.showError('Nostr extension not found');
return;
}
if (!this.publishUrlValue || !this.csrfTokenValue) {
this.showError('Missing config');
return;
}
this.publishButtonTarget.disabled = true;
try {
const pubkey = await window.nostr.getPublicKey();
const catSkeletons = JSON.parse(this.categoryEventsValue || '[]');
const magSkeleton = JSON.parse(this.magazineEventValue || '{}');
const categoryCoordinates = [];
// 1) Publish each category index
for (let i = 0; i < catSkeletons.length; i++) {
const evt = catSkeletons[i];
this.ensureCreatedAt(evt);
this.ensureContent(evt);
evt.pubkey = pubkey;
const slug = this.extractSlug(evt.tags);
if (!slug) throw new Error('Category missing slug (d tag)');
this.showStatus(`Signing category ${i + 1}/${catSkeletons.length}`);
const signed = await window.nostr.signEvent(evt);
this.showStatus(`Publishing category ${i + 1}/${catSkeletons.length}`);
await this.publishSigned(signed);
// Coordinate for the category index (kind:pubkey:slug)
const coord = `30040:${pubkey}:${slug}`;
categoryCoordinates.push(coord);
}
// 2) Build magazine event with 'a' tags referencing cats
this.showStatus('Preparing magazine index…');
this.ensureCreatedAt(magSkeleton);
this.ensureContent(magSkeleton);
magSkeleton.pubkey = pubkey;
// Remove any pre-existing 'a' to avoid duplicates, then add new ones
magSkeleton.tags = (magSkeleton.tags || []).filter(t => t[0] !== 'a');
categoryCoordinates.forEach(c => magSkeleton.tags.push(['a', c]));
// 3) Sign and publish magazine
this.showStatus('Signing magazine index…');
const signedMag = await window.nostr.signEvent(magSkeleton);
this.showStatus('Publishing magazine index…');
await this.publishSigned(signedMag);
this.showSuccess('Published magazine and categories successfully');
} catch (e) {
console.error(e);
this.showError(e.message || 'Publish failed');
} finally {
this.publishButtonTarget.disabled = false;
}
}
async publishSigned(signedEvent) {
const res = await fetch(this.publishUrlValue, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': this.csrfTokenValue,
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ event: signedEvent })
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${res.status}`);
}
return res.json();
}
extractSlug(tags) {
if (!Array.isArray(tags)) return null;
for (const t of tags) {
if (Array.isArray(t) && t[0] === 'd' && t[1]) return t[1];
}
return null;
}
ensureCreatedAt(evt) {
if (!evt.created_at) evt.created_at = Math.floor(Date.now() / 1000);
}
ensureContent(evt) {
if (typeof evt.content !== 'string') evt.content = '';
}
showStatus(message) {
if (this.hasStatusTarget) {
this.statusTarget.innerHTML = `<div class="alert alert-info">${message}</div>`;
}
}
showSuccess(message) {
if (this.hasStatusTarget) {
this.statusTarget.innerHTML = `<div class="alert alert-success">${message}</div>`;
}
}
showError(message) {
if (this.hasStatusTarget) {
this.statusTarget.innerHTML = `<div class="alert alert-danger">${message}</div>`;
}
}
}

247
src/Controller/MagazineWizardController.php

@ -0,0 +1,247 @@ @@ -0,0 +1,247 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Dto\CategoryDraft;
use App\Dto\MagazineDraft;
use App\Form\CategoryArticlesType;
use App\Form\MagazineSetupType;
use App\Service\RedisCacheService;
use Psr\Cache\CacheItemPoolInterface;
use swentel\nostr\Event\Event;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use swentel\nostr\Key\Key;
class MagazineWizardController extends AbstractController
{
private const SESSION_KEY = 'mag_wizard';
#[Route('/magazine/wizard/setup', name: 'mag_wizard_setup')]
public function setup(Request $request): Response
{
$draft = $this->getDraft($request);
if (!$draft) {
$draft = new MagazineDraft();
$draft->categories = [new CategoryDraft()];
}
$form = $this->createForm(MagazineSetupType::class, $draft);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$draft = $form->getData();
// Slug generation with a short random suffix
if (!$draft->slug) {
$draft->slug = $this->slugifyWithRandom($draft->title);
}
foreach ($draft->categories as $cat) {
if (!$cat->slug) {
$cat->slug = $this->slugifyWithRandom($cat->title);
}
}
$this->saveDraft($request, $draft);
return $this->redirectToRoute('mag_wizard_articles');
}
return $this->render('magazine/magazine_setup.html.twig', [
'form' => $form->createView(),
]);
}
#[Route('/magazine/wizard/articles', name: 'mag_wizard_articles')]
public function articles(Request $request): Response
{
$draft = $this->getDraft($request);
if (!$draft) {
return $this->redirectToRoute('mag_wizard_setup');
}
// Build a form as a collection of CategoryArticlesType
$formBuilder = $this->createFormBuilder($draft);
$formBuilder->add('categories', \Symfony\Component\Form\Extension\Core\Type\CollectionType::class, [
'entry_type' => CategoryArticlesType::class,
'allow_add' => false,
'allow_delete' => false,
'by_reference' => false,
'label' => false,
]);
$form = $formBuilder->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->saveDraft($request, $form->getData());
return $this->redirectToRoute('mag_wizard_review');
}
return $this->render('magazine/magazine_articles.html.twig', [
'form' => $form->createView(),
]);
}
#[Route('/magazine/wizard/review', name: 'mag_wizard_review')]
public function review(Request $request): Response
{
$draft = $this->getDraft($request);
if (!$draft) {
return $this->redirectToRoute('mag_wizard_setup');
}
// Build event skeletons (without pubkey/sig/id); created_at client can adjust
$categoryEvents = [];
foreach ($draft->categories as $cat) {
$tags = [];
$tags[] = ['d', $cat->slug];
if ($cat->title) { $tags[] = ['title', $cat->title]; }
if ($cat->summary) { $tags[] = ['summary', $cat->summary]; }
foreach ($cat->tags as $t) { $tags[] = ['t', $t]; }
foreach ($cat->articles as $a) {
if (is_string($a) && $a !== '') { $tags[] = ['a', $a]; }
}
$categoryEvents[] = [
'kind' => 30040,
'created_at' => time(),
'tags' => $tags,
'content' => '',
];
}
// Determine current user's pubkey (hex) from their npub (user identifier)
$pubkeyHex = null;
$user = $this->getUser();
if ($user && method_exists($user, 'getUserIdentifier')) {
try {
$key = new Key();
$pubkeyHex = $key->convertToHex($user->getUserIdentifier());
} catch (\Throwable $e) {
$pubkeyHex = null;
}
}
$magTags = [];
$magTags[] = ['d', $draft->slug];
if ($draft->title) { $magTags[] = ['title', $draft->title]; }
if ($draft->summary) { $magTags[] = ['summary', $draft->summary]; }
if ($draft->imageUrl) { $magTags[] = ['image', $draft->imageUrl]; }
if ($draft->language) { $magTags[] = ['l', $draft->language]; }
foreach ($draft->tags as $t) { $magTags[] = ['t', $t]; }
// If we know the user's pubkey, include all category coordinates as 'a' tags now
if ($pubkeyHex) {
foreach ($draft->categories as $cat) {
if ($cat->slug) {
$magTags[] = ['a', sprintf('30040:%s:%s', $pubkeyHex, $cat->slug)];
}
}
}
$magazineEvent = [
'kind' => 30040,
'created_at' => time(),
'tags' => $magTags,
'content' => '',
];
return $this->render('magazine/magazine_review.html.twig', [
'draft' => $draft,
'categoryEventsJson' => json_encode($categoryEvents, JSON_UNESCAPED_SLASHES),
'magazineEventJson' => json_encode($magazineEvent, JSON_UNESCAPED_SLASHES),
'csrfToken' => $this->container->get('security.csrf.token_manager')->getToken('nostr_publish')->getValue(),
]);
}
#[Route('/api/index/publish', name: 'api-index-publish', methods: ['POST'])]
public function publishIndexEvent(
Request $request,
CacheItemPoolInterface $redisCache,
CsrfTokenManagerInterface $csrfTokenManager
): JsonResponse {
// Verify CSRF token
$csrfToken = $request->headers->get('X-CSRF-TOKEN');
if (!$csrfTokenManager->isTokenValid(new CsrfToken('nostr_publish', $csrfToken))) {
return new JsonResponse(['error' => 'Invalid CSRF token'], 403);
}
$data = json_decode($request->getContent(), true);
if (!$data || !isset($data['event'])) {
return new JsonResponse(['error' => 'Invalid request'], 400);
}
$signedEvent = $data['event'];
// Convert array to swentel Event and verify
$eventObj = new Event();
$eventObj->setId($signedEvent['id'] ?? '');
$eventObj->setPublicKey($signedEvent['pubkey'] ?? '');
$eventObj->setCreatedAt($signedEvent['created_at'] ?? time());
$eventObj->setKind($signedEvent['kind'] ?? 30040);
$eventObj->setTags($signedEvent['tags'] ?? []);
$eventObj->setContent($signedEvent['content'] ?? '');
$eventObj->setSignature($signedEvent['sig'] ?? '');
if (!$eventObj->verify()) {
return new JsonResponse(['error' => 'Verification failed'], 400);
}
// Extract slug from 'd' tag
$slug = null;
foreach ($signedEvent['tags'] as $tag) {
if (isset($tag[0]) && $tag[0] === 'd' && isset($tag[1])) {
$slug = $tag[1];
break;
}
}
if (!$slug) {
return new JsonResponse(['error' => 'Missing d tag/slug'], 400);
}
// Save to Redis under magazine-<slug>
try {
$key = 'magazine-' . $slug;
$item = $redisCache->getItem($key);
$item->set($eventObj);
$redisCache->save($item);
} catch (\Throwable $e) {
return new JsonResponse(['error' => 'Redis error'], 500);
}
return new JsonResponse(['ok' => true]);
}
#[Route('/magazine/wizard/cancel', name: 'mag_wizard_cancel', methods: ['GET'])]
public function cancel(Request $request): Response
{
$this->clearDraft($request);
$this->addFlash('info', 'Magazine setup canceled.');
return $this->redirectToRoute('home');
}
private function getDraft(Request $request): ?MagazineDraft
{
$data = $request->getSession()->get(self::SESSION_KEY);
return $data instanceof MagazineDraft ? $data : null;
}
private function saveDraft(Request $request, MagazineDraft $draft): void
{
$request->getSession()->set(self::SESSION_KEY, $draft);
}
private function clearDraft(Request $request): void
{
$request->getSession()->remove(self::SESSION_KEY);
}
private function slugifyWithRandom(string $title): string
{
$slug = strtolower(trim(preg_replace('/[^A-Za-z0-9-]+/', '-', $title)) ?? '');
$slug = trim(preg_replace('/-+/', '-', $slug) ?? '', '-');
$rand = substr(bin2hex(random_bytes(4)), 0, 6);
return $slug !== '' ? ($slug . '-' . $rand) : $rand;
}
}

17
src/Dto/CategoryDraft.php

@ -0,0 +1,17 @@ @@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Dto;
class CategoryDraft
{
public string $title = '';
public string $summary = '';
/** @var string[] */
public array $tags = [];
/** @var string[] article coordinates like kind:pubkey|npub:slug */
public array $articles = [];
public string $slug = '';
}

19
src/Dto/MagazineDraft.php

@ -0,0 +1,19 @@ @@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Dto;
class MagazineDraft
{
public string $title = '';
public string $summary = '';
public string $imageUrl = '';
public ?string $language = null;
/** @var string[] */
public array $tags = [];
/** @var CategoryDraft[] */
public array $categories = [];
public string $slug = '';
}

41
src/Form/CategoryArticlesType.php

@ -0,0 +1,41 @@ @@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Dto\CategoryDraft;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class CategoryArticlesType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('title', TextType::class, [
'label' => 'Category',
'required' => true,
'attr' => ['readonly' => true],
])
->add('articles', CollectionType::class, [
'entry_type' => TextType::class,
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'label' => 'Article coordinates (kind:npub|pubkey:slug)',
'prototype' => true,
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => CategoryDraft::class,
]);
}
}

46
src/Form/CategoryType.php

@ -0,0 +1,46 @@ @@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Dto\CategoryDraft;
use App\Form\DataTransformer\CommaSeparatedToArrayTransformer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class CategoryType extends AbstractType
{
public function __construct(private CommaSeparatedToArrayTransformer $transformer)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('title', TextType::class, [
'label' => 'Category title',
'required' => true,
])
->add('summary', TextType::class, [
'label' => 'Summary',
'required' => false,
])
->add('tags', TextType::class, [
'label' => 'Tags (comma separated)',
'required' => false,
]);
$builder->get('tags')->addModelTransformer($this->transformer);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => CategoryDraft::class,
]);
}
}

64
src/Form/MagazineSetupType.php

@ -0,0 +1,64 @@ @@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Dto\CategoryDraft;
use App\Dto\MagazineDraft;
use App\Form\DataTransformer\CommaSeparatedToArrayTransformer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class MagazineSetupType extends AbstractType
{
public function __construct(private CommaSeparatedToArrayTransformer $transformer)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('title', TextType::class, [
'label' => 'Magazine title',
'required' => true,
])
->add('summary', TextType::class, [
'label' => 'Description / summary',
'required' => true,
])
->add('imageUrl', TextType::class, [
'label' => 'Logo / image URL',
'required' => false,
])
->add('language', TextType::class, [
'label' => 'Language (optional)',
'required' => false,
])
->add('tags', TextType::class, [
'label' => 'Tags (comma separated, optional)',
'required' => false,
])
->add('categories', CollectionType::class, [
'entry_type' => CategoryType::class,
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'label' => false,
])
;
$builder->get('tags')->addModelTransformer($this->transformer);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => MagazineDraft::class,
]);
}
}

12
src/Util/CommonMark/NostrSchemeExtension/NostrRawNpubParser.php

@ -30,8 +30,18 @@ readonly class NostrRawNpubParser implements InlineParserInterface @@ -30,8 +30,18 @@ readonly class NostrRawNpubParser implements InlineParserInterface
$fullMatch = $inlineContext->getFullMatch();
$meta = $this->redisCacheService->getMetadata($fullMatch);
// Use shortened npub as default name, from first and last 8 characters of the fullMatch
$name = substr($fullMatch, 0, 8) . '...' . substr($fullMatch, -8);
if (!empty($meta->display_name)) {
// If we have a name, use it
$name = $meta->name;
} else if (!empty($meta->name)) {
// If we have a name, use it
$name = $meta->name;
}
// Create a new inline node for the custom link
$inlineContext->getContainer()->appendChild(new NostrMentionLink($meta->name, $fullMatch));
$inlineContext->getContainer()->appendChild(new NostrMentionLink($name, $fullMatch));
// Advance the cursor to consume the matched part (important!)
$cursor->advanceBy(strlen($fullMatch));

5
templates/components/UserMenu.html.twig

@ -15,6 +15,11 @@ @@ -15,6 +15,11 @@
<li>
<a href="{{ path('editor-create') }}">Write an article</a>
</li>
{% if is_granted('ROLE_ADMIN') %}
<li>
<a href="{{ path('mag_wizard_setup') }}">Create a magazine</a>
</li>
{% endif %}
{# <li>#}
{# <a href="{{ path('nzine_index') }}">{{ 'heading.createNzine'|trans }}</a>#}
{# </li>#}

34
templates/magazine/magazine_articles.html.twig

@ -0,0 +1,34 @@ @@ -0,0 +1,34 @@
{% extends 'base.html.twig' %}
{% block body %}
<h1>Attach Articles</h1>
{{ form_start(form) }}
{% for cat in form.categories %}
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">{{ form_row(cat.title) }}</h5>
<div
{{ stimulus_controller('form-collection') }}
data-form-collection-index-value="{{ cat.articles|length > 0 ? cat.articles|last.vars.name + 1 : 0 }}"
data-form-collection-prototype-value="{{ form_widget(cat.articles.vars.prototype)|e('html_attr') }}">
<label class="form-label">Article coordinates</label>
<ul {{ stimulus_target('form-collection', 'collectionContainer') }} class="list-unstyled">
{% for art in cat.articles %}
<li class="mb-2">{{ form_widget(art, {attr: {placeholder: 'kind:pubkey|npub:slug'}}) }}</li>
{% endfor %}
</ul>
<button class="btn btn-sm btn-secondary" type="button" {{ stimulus_action('form-collection', 'addCollectionElement') }}>Add coordinate</button>
</div>
</div>
</div>
{% endfor %}
<div class="mt-3 d-flex gap-2">
<a class="btn btn-outline-secondary" href="{{ path('mag_wizard_cancel') }}">Cancel</a>
<button class="btn btn-primary">Next: Review & Sign</button>
</div>
{{ form_end(form) }}
{% endblock %}

85
templates/magazine/magazine_review.html.twig

@ -0,0 +1,85 @@ @@ -0,0 +1,85 @@
{% extends 'base.html.twig' %}
{% block body %}
<h1>Review & Sign</h1>
<div class="notice info">
<p>Review the details below. When ready, click Sign & Publish. Your NIP-07 extension will be used to sign events.</p>
</div>
<section class="mb-4">
<h2>Magazine</h2>
<ul>
<li><strong>Title:</strong> {{ draft.title }}</li>
<li><strong>Summary:</strong> {{ draft.summary }}</li>
<li><strong>Image URL:</strong> {{ draft.imageUrl ?: '—' }}</li>
<li><strong>Language:</strong> {{ draft.language ?: '—' }}</li>
<li><strong>Tags:</strong> {{ draft.tags is defined and draft.tags|length ? draft.tags|join(', ') : '—' }}</li>
<li><strong>Slug:</strong> {{ draft.slug }}</li>
</ul>
</section>
<section class="mb-4">
<h2>Categories</h2>
{% if draft.categories is defined and draft.categories|length %}
<ol>
{% for cat in draft.categories %}
<li>
<div>
<strong>{{ cat.title }}</strong> (slug: {{ cat.slug }})<br>
<em>{{ cat.summary }}</em><br>
<small>Tags: {{ cat.tags is defined and cat.tags|length ? cat.tags|join(', ') : '—' }}</small>
{% if cat.articles is defined and cat.articles|length %}
<div class="mt-2">
<strong>Articles:</strong>
<ul>
{% for a in cat.articles %}
<li><code>{{ a }}</code></li>
{% endfor %}
</ul>
</div>
{% else %}
<div class="mt-2"><small>No articles yet.</small></div>
{% endif %}
</div>
</li>
{% endfor %}
</ol>
{% else %}
<p>No categories.</p>
{% endif %}
</section>
<section class="mb-4">
<details>
<summary>Show event previews (JSON)</summary>
<div class="mt-2">
<h3>Category events</h3>
<pre class="small">{{ categoryEventsJson }}</pre>
<h3>Magazine event</h3>
<pre class="small">{{ magazineEventJson }}</pre>
</div>
</details>
</section>
<div
{{ stimulus_controller('nostr-index-sign', {
categoryEvents: categoryEventsJson,
magazineEvent: magazineEventJson,
publishUrl: path('api-index-publish'),
csrfToken: csrfToken
}) }}
>
<section class="mb-3">
<h3>Final magazine event (with category 'a' tags)</h3>
<pre class="small" data-nostr-index-sign-target="computedPreview"></pre>
</section>
<div class="d-flex gap-2">
<a class="btn btn-secondary" href="{{ path('mag_wizard_cancel') }}">Cancel</a>
<button class="btn btn-primary" data-nostr-index-sign-target="publishButton" data-action="click->nostr-index-sign#signAndPublish">Sign & Publish</button>
</div>
<div class="mt-3" data-nostr-index-sign-target="status"></div>
</div>
{% endblock %}

31
templates/magazine/magazine_setup.html.twig

@ -0,0 +1,31 @@ @@ -0,0 +1,31 @@
{% extends 'base.html.twig' %}
{% block body %}
<h1>Create Magazine</h1>
{{ form_start(form) }}
{{ form_row(form.title) }}
{{ form_row(form.summary) }}
{{ form_row(form.imageUrl) }}
{{ form_row(form.language) }}
{{ form_row(form.tags) }}
<h3>Categories</h3>
<div
{{ stimulus_controller('form-collection') }}
data-form-collection-index-value="{{ form.categories|length > 0 ? form.categories|last.vars.name + 1 : 0 }}"
data-form-collection-prototype-value="{{ form_widget(form.categories.vars.prototype)|e('html_attr') }}">
<ul {{ stimulus_target('form-collection', 'collectionContainer') }}>
{% for cat in form.categories %}
<li>{{ form_row(cat) }}</li>
{% endfor %}
</ul>
<button type="button" class="btn btn-secondary" {{ stimulus_action('form-collection', 'addCollectionElement') }}>Add category</button>
</div>
<div class="mt-3 d-flex gap-2">
<a class="btn btn-outline-secondary" href="{{ path('mag_wizard_cancel') }}">Cancel</a>
<button class="btn btn-primary">Next: Attach articles</button>
</div>
{{ form_end(form) }}
{% endblock %}
Loading…
Cancel
Save