12 changed files with 766 additions and 1 deletions
@ -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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
{% 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 @@ |
|||||||
|
{% 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 @@ |
|||||||
|
{% 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