12 changed files with 766 additions and 1 deletions
@ -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>`; |
||||
} |
||||
} |
||||
} |
||||
|
||||
@ -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; |
||||
} |
||||
} |
||||
@ -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 = ''; |
||||
} |
||||
|
||||
@ -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 = ''; |
||||
} |
||||
|
||||
@ -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, |
||||
]); |
||||
} |
||||
} |
||||
|
||||
@ -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, |
||||
]); |
||||
} |
||||
} |
||||
|
||||
@ -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, |
||||
]); |
||||
} |
||||
} |
||||
|
||||
@ -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 %} |
||||
@ -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 %} |
||||
@ -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…
Reference in new issue