diff --git a/assets/controllers/nzine_magazine_publish_controller.js b/assets/controllers/nzine_magazine_publish_controller.js new file mode 100644 index 0000000..434a236 --- /dev/null +++ b/assets/controllers/nzine_magazine_publish_controller.js @@ -0,0 +1,82 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ['status', 'publishButton']; + static values = { + categoryEvents: String, + magazineEvent: String, + publishUrl: String, + nzineSlug: String, + csrfToken: String + }; + + async publish(event) { + event.preventDefault(); + + if (!this.publishUrlValue || !this.csrfTokenValue || !this.nzineSlugValue) { + this.showError('Missing configuration'); + return; + } + + this.publishButtonTarget.disabled = true; + + try { + const categoryEvents = JSON.parse(this.categoryEventsValue || '[]'); + const magazineEvent = JSON.parse(this.magazineEventValue || '{}'); + + this.showStatus('Publishing magazine and categories...'); + + const response = await fetch(this.publishUrlValue, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': this.csrfTokenValue, + 'X-Requested-With': 'XMLHttpRequest' + }, + body: JSON.stringify({ + nzineSlug: this.nzineSlugValue, + categoryEvents: categoryEvents, + magazineEvent: magazineEvent + }) + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.error || `HTTP ${response.status}`); + } + + const result = await response.json(); + this.showSuccess(result.message || 'Magazine published successfully!'); + + // Redirect to home or magazine page after a short delay + setTimeout(() => { + window.location.href = '/'; + }, 2000); + + } catch (e) { + console.error(e); + this.showError(e.message || 'Publish failed'); + } finally { + this.publishButtonTarget.disabled = false; + } + } + + showStatus(message) { + if (this.hasStatusTarget) { + this.statusTarget.innerHTML = `
${message}
`; + } + } + + showSuccess(message) { + if (this.hasStatusTarget) { + this.statusTarget.innerHTML = `
${message}
`; + } + } + + showError(message) { + if (this.hasStatusTarget) { + this.statusTarget.innerHTML = `
${message}
`; + } + } +} + diff --git a/src/Command/RssFetchCommand.php b/src/Command/RssFetchCommand.php index bee215e..d05a2e7 100644 --- a/src/Command/RssFetchCommand.php +++ b/src/Command/RssFetchCommand.php @@ -461,9 +461,17 @@ class RssFetchCommand extends Command try { $this->categoryIndexService->addArticleToCategoryIndex( $categoryIndices[$categorySlug], - $articleCoordinate + $articleCoordinate, + $nzine ); + // Flush to ensure the category index is saved to the database $this->entityManager->flush(); + + $this->logger->debug('Added article to category index', [ + 'article_slug' => $article->getSlug(), + 'category_slug' => $categorySlug, + 'coordinate' => $articleCoordinate, + ]); } catch (\Exception $e) { $this->logger->warning('Failed to add article to category index', [ 'article_slug' => $article->getSlug(), @@ -471,6 +479,11 @@ class RssFetchCommand extends Command 'error' => $e->getMessage(), ]); } + } else { + $this->logger->warning('Category index not found for matched category', [ + 'category_slug' => $categorySlug, + 'available_indices' => array_keys($categoryIndices), + ]); } } diff --git a/src/Controller/DefaultController.php b/src/Controller/DefaultController.php index e4851e8..0c5d046 100644 --- a/src/Controller/DefaultController.php +++ b/src/Controller/DefaultController.php @@ -60,10 +60,26 @@ class DefaultController extends AbstractController * @throws InvalidArgumentException */ #[Route('/mag/{mag}', name: 'magazine-index')] - public function magIndex(string $mag, RedisCacheService $redisCache) : Response + public function magIndex(string $mag, EntityManagerInterface $entityManager) : Response { - // redis cache lookup of magazine index by slug - $magazine = $redisCache->getMagazineIndex($mag); + // Get latest magazine index by slug from database + $nzines = $entityManager->getRepository(Event::class)->findBy(['kind' => KindsEnum::PUBLICATION_INDEX]); + + // Filter by slug + $nzines = array_filter($nzines, function ($index) use ($mag) { + return $index->getSlug() === $mag; + }); + + if (count($nzines) === 0) { + throw $this->createNotFoundException('Magazine not found'); + } + + // Sort by createdAt, keep newest + usort($nzines, function ($a, $b) { + return $b->getCreatedAt() <=> $a->getCreatedAt(); + }); + + $magazine = array_shift($nzines); return $this->render('magazine/magazine-front.html.twig', [ 'magazine' => $magazine, @@ -197,9 +213,15 @@ class DefaultController extends AbstractController } } - // Create a simple stdClass object with the tags for template compatibility - $catIndex = new \stdClass(); - $catIndex->tags = $tags; + // Create a proper Event object for template compatibility + $catIndex = new \swentel\nostr\Event\Event(); + $catIndex->setId($eventData['id']); + $catIndex->setPublicKey($eventData['pubkey']); + $catIndex->setCreatedAt($eventData['created_at']); + $catIndex->setKind($eventData['kind']); + $catIndex->setTags($tags); + $catIndex->setContent($eventData['content']); + $catIndex->setSignature($eventData['sig']); return $this->render('pages/category.html.twig', [ 'mag' => $mag, diff --git a/src/Controller/MagazineWizardController.php b/src/Controller/MagazineWizardController.php index 35481d0..c2c34bc 100644 --- a/src/Controller/MagazineWizardController.php +++ b/src/Controller/MagazineWizardController.php @@ -6,13 +6,17 @@ namespace App\Controller; use App\Dto\CategoryDraft; use App\Dto\MagazineDraft; +use App\Entity\Nzine; use App\Enum\KindsEnum; use App\Form\CategoryArticlesType; use App\Form\MagazineSetupType; +use App\Repository\NzineRepository; +use App\Service\EncryptionService; use App\Service\RedisCacheService; use Doctrine\ORM\EntityManagerInterface; use Psr\Cache\CacheItemPoolInterface; use swentel\nostr\Event\Event; +use swentel\nostr\Sign\Sign; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -89,13 +93,23 @@ class MagazineWizardController extends AbstractController } #[Route('/magazine/wizard/review', name: 'mag_wizard_review')] - public function review(Request $request): Response + public function review(Request $request, NzineRepository $nzineRepository): Response { $draft = $this->getDraft($request); if (!$draft) { return $this->redirectToRoute('mag_wizard_setup'); } + // Check if this slug belongs to an NZine (which has a bot) + $nzine = null; + $isNzineEdit = false; + if ($draft->slug) { + $nzine = $nzineRepository->findOneBy(['slug' => $draft->slug]); + if ($nzine && $nzine->getNzineBot()) { + $isNzineEdit = true; + } + } + // Build event skeletons (without pubkey/sig/id); created_at client can adjust $categoryEvents = []; foreach ($draft->categories as $cat) { @@ -117,15 +131,25 @@ class MagazineWizardController extends AbstractController } // Determine current user's pubkey (hex) from their npub (user identifier) + // For NZine edits, use the NZine's npub instead $pubkeyHex = null; - $user = $this->getUser(); - if ($user && method_exists($user, 'getUserIdentifier')) { + if ($isNzineEdit && $nzine) { try { $key = new Key(); - $pubkeyHex = $key->convertToHex($user->getUserIdentifier()); + $pubkeyHex = $key->convertToHex($nzine->getNpub()); } catch (\Throwable $e) { $pubkeyHex = null; } + } else { + $user = $this->getUser(); + if ($user && method_exists($user, 'getUserIdentifier')) { + try { + $key = new Key(); + $pubkeyHex = $key->convertToHex($user->getUserIdentifier()); + } catch (\Throwable $e) { + $pubkeyHex = null; + } + } } $magTags = []; @@ -158,6 +182,8 @@ class MagazineWizardController extends AbstractController '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(), + 'isNzineEdit' => $isNzineEdit, + 'nzineSlug' => $isNzineEdit ? $draft->slug : null, ]); } @@ -250,6 +276,153 @@ class MagazineWizardController extends AbstractController return new JsonResponse(['ok' => true]); } + #[Route('/api/nzine-index/publish', name: 'api-nzine-index-publish', methods: ['POST'])] + public function publishNzineIndexEvent( + Request $request, + CsrfTokenManagerInterface $csrfTokenManager, + NzineRepository $nzineRepository, + EncryptionService $encryptionService, + CacheItemPoolInterface $redisCache, + RedisClient $redis, + EntityManagerInterface $entityManager + ): 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['nzineSlug']) || !isset($data['categoryEvents']) || !isset($data['magazineEvent'])) { + return new JsonResponse(['error' => 'Invalid request'], 400); + } + + $nzineSlug = $data['nzineSlug']; + $categorySkeletons = $data['categoryEvents']; + $magazineSkeleton = $data['magazineEvent']; + + // Load the NZine entity + $nzine = $nzineRepository->findOneBy(['slug' => $nzineSlug]); + if (!$nzine || !$nzine->getNzineBot()) { + return new JsonResponse(['error' => 'NZine not found or no bot configured'], 404); + } + + // Get the bot's nsec for signing + $bot = $nzine->getNzineBot(); + $bot->setEncryptionService($encryptionService); + $nsec = $bot->getNsec(); + if (!$nsec) { + return new JsonResponse(['error' => 'Bot credentials not available'], 500); + } + + $key = new Key(); + $pubkeyHex = $key->getPublicKey($nsec); + $signer = new Sign(); + + $categoryCoordinates = []; + + try { + // 1) Sign and publish each category event + foreach ($categorySkeletons as $catSkeleton) { + $catEvent = new Event(); + $catEvent->setKind($catSkeleton['kind'] ?? 30040); + $catEvent->setCreatedAt($catSkeleton['created_at'] ?? time()); + $catEvent->setTags($catSkeleton['tags'] ?? []); + $catEvent->setContent($catSkeleton['content'] ?? ''); + + // Sign with bot's nsec + $signer->signEvent($catEvent, $nsec); + + // Extract slug from d tag + $slug = null; + foreach ($catEvent->getTags() as $tag) { + if (($tag[0] ?? null) === 'd' && isset($tag[1])) { + $slug = $tag[1]; + break; + } + } + if (!$slug) { + return new JsonResponse(['error' => 'Category missing d tag'], 400); + } + + // Save to Redis + $cacheKey = 'magazine-' . $slug; + $item = $redisCache->getItem($cacheKey); + $item->set($catEvent); + $redisCache->save($item); + + // Save to database + $eventEntity = new \App\Entity\Event(); + $eventEntity->setId($catEvent->getId()); + $eventEntity->setPubkey($catEvent->getPublicKey()); + $eventEntity->setCreatedAt($catEvent->getCreatedAt()); + $eventEntity->setKind($catEvent->getKind()); + $eventEntity->setTags($catEvent->getTags()); + $eventEntity->setContent($catEvent->getContent()); + $eventEntity->setSig($catEvent->getSignature()); + $entityManager->persist($eventEntity); + + // Build coordinate + $categoryCoordinates[] = sprintf('30040:%s:%s', $pubkeyHex, $slug); + } + + // 2) Build and sign the magazine event with category references + $magEvent = new Event(); + $magEvent->setKind($magazineSkeleton['kind'] ?? 30040); + $magEvent->setCreatedAt($magazineSkeleton['created_at'] ?? time()); + + // Remove any existing 'a' tags and add the new category coordinates + $magTags = array_filter($magazineSkeleton['tags'] ?? [], fn($t) => ($t[0] ?? null) !== 'a'); + foreach ($categoryCoordinates as $coord) { + $magTags[] = ['a', $coord]; + } + $magEvent->setTags($magTags); + $magEvent->setContent($magazineSkeleton['content'] ?? ''); + + // Sign with bot's nsec + $signer->signEvent($magEvent, $nsec); + + // Extract magazine slug + $magSlug = null; + foreach ($magEvent->getTags() as $tag) { + if (($tag[0] ?? null) === 'd' && isset($tag[1])) { + $magSlug = $tag[1]; + break; + } + } + if (!$magSlug) { + return new JsonResponse(['error' => 'Magazine missing d tag'], 400); + } + + // Save magazine to Redis + $cacheKey = 'magazine-' . $magSlug; + $item = $redisCache->getItem($cacheKey); + $item->set($magEvent); + $redisCache->save($item); + + // Save magazine to database + $magEventEntity = new \App\Entity\Event(); + $magEventEntity->setId($magEvent->getId()); + $magEventEntity->setPubkey($magEvent->getPublicKey()); + $magEventEntity->setCreatedAt($magEvent->getCreatedAt()); + $magEventEntity->setKind($magEvent->getKind()); + $magEventEntity->setTags($magEvent->getTags()); + $magEventEntity->setContent($magEvent->getContent()); + $magEventEntity->setSig($magEvent->getSignature()); + $entityManager->persist($magEventEntity); + + // Record slug in Redis set for admin listing + $redis->sAdd('magazine_slugs', $magSlug); + + $entityManager->flush(); + + return new JsonResponse(['ok' => true, 'message' => 'NZine magazine updated successfully']); + + } catch (\Throwable $e) { + return new JsonResponse(['error' => $e->getMessage()], 500); + } + } + #[Route('/magazine/wizard/cancel', name: 'mag_wizard_cancel', methods: ['GET'])] public function cancel(Request $request): Response { diff --git a/src/Controller/NzineController.php b/src/Controller/NzineController.php index 18dc3af..92ac1c6 100644 --- a/src/Controller/NzineController.php +++ b/src/Controller/NzineController.php @@ -302,20 +302,20 @@ class NzineController extends AbstractController } - #[Route('/nzine/v/{npub}', name: 'nzine_view')] - public function nzineView($npub, EntityManagerInterface $entityManager): Response + #[Route('/nzine/v/{pubkey}', name: 'nzine_view')] + public function nzineView($pubkey, EntityManagerInterface $entityManager): Response { - $nzine = $entityManager->getRepository(Nzine::class)->findOneBy(['npub' => $npub]); + $nzine = $entityManager->getRepository(Nzine::class)->findOneBy(['npub' => $pubkey]); if (!$nzine) { throw $this->createNotFoundException('N-Zine not found'); } // Find all index events for this nzine - $indices = $entityManager->getRepository(EventEntity::class)->findBy(['pubkey' => $npub, 'kind' => KindsEnum::PUBLICATION_INDEX]); + $indices = $entityManager->getRepository(EventEntity::class)->findBy(['pubkey' => $pubkey, 'kind' => KindsEnum::PUBLICATION_INDEX]); $mainIndexCandidates = array_filter($indices, function ($index) use ($nzine) { return $index->getSlug() == $nzine->getSlug(); }); - dump($indices);die(); + dump($indices, $mainIndexCandidates);die(); $mainIndex = array_pop($mainIndexCandidates); diff --git a/src/Service/NzineCategoryIndexService.php b/src/Service/NzineCategoryIndexService.php new file mode 100644 index 0000000..5d684c7 --- /dev/null +++ b/src/Service/NzineCategoryIndexService.php @@ -0,0 +1,310 @@ + EventEntity + * @throws \JsonException + */ + public function ensureCategoryIndices(Nzine $nzine): array + { + $categories = $nzine->getMainCategories(); + if (empty($categories)) { + return []; + } + + $bot = $nzine->getNzineBot(); + if (!$bot) { + $this->logger->warning('Cannot create category indices: nzine bot not found', [ + 'nzine_id' => $nzine->getId(), + ]); + return []; + } + + $bot->setEncryptionService($this->encryptionService); + $privateKey = $bot->getNsec(); + + if (!$privateKey) { + $this->logger->warning('Cannot create category indices: bot private key not found', [ + 'nzine_id' => $nzine->getId(), + ]); + return []; + } + + $slugger = new AsciiSlugger(); + $categoryIndices = []; + + // Load all existing category indices for this nzine at once + $existingIndices = $this->entityManager->getRepository(EventEntity::class) + ->findBy([ + 'pubkey' => $nzine->getNpub(), + 'kind' => KindsEnum::PUBLICATION_INDEX->value, + ]); + + // Index existing events by their d-tag (slug) + $existingBySlug = []; + foreach ($existingIndices as $existingIndex) { + $slug = $this->extractSlugFromTags($existingIndex->getTags()); + if ($slug) { + $existingBySlug[$slug] = $existingIndex; + } + } + + foreach ($categories as $category) { + if (empty($category['title'])) { + continue; + } + + $title = $category['title']; + $slug = !empty($category['slug']) + ? $category['slug'] + : $slugger->slug($title)->lower()->toString(); + + // Check if category index already exists + if (isset($existingBySlug[$slug])) { + $this->logger->debug('Using existing category index', [ + 'category_slug' => $slug, + 'title' => $title, + ]); + continue; + } + + // Create new category index event + $event = new Event(); + $event->setKind(KindsEnum::PUBLICATION_INDEX->value); + $event->addTag(['d', $slug]); + $event->addTag(['title', $title]); + $event->addTag(['auto-update', 'yes']); + $event->addTag(['type', 'magazine']); + + // Add tags for RSS matching + if (isset($category['tags']) && is_array($category['tags'])) { + foreach ($category['tags'] as $tag) { + $event->addTag(['t', $tag]); + } + } + + $event->setPublicKey($nzine->getNpub()); + + // Sign the event + $signer = new Sign(); + $signer->signEvent($event, $privateKey); + + // Convert to EventEntity and save + $serializer = new Serializer([new ObjectNormalizer()], [new JsonEncoder()]); + $eventEntity = $serializer->deserialize($event->toJson(), EventEntity::class, 'json'); + + $this->entityManager->persist($eventEntity); + $categoryIndices[$slug] = $eventEntity; + + $this->logger->info('Created category index event', [ + 'nzine_id' => $nzine->getId(), + 'category_title' => $title, + 'category_slug' => $slug, + ]); + } + + $this->entityManager->flush(); + + $this->logger->info('Category indices ready', [ + 'nzine_id' => $nzine->getId(), + 'total_categories' => count($categories), + 'indexed_by_slug' => array_keys($categoryIndices), + ]); + + return $categoryIndices; + } + + /** + * Extract the slug (d-tag value) from event tags + */ + private function extractSlugFromTags(array $tags): ?string + { + foreach ($tags as $tag) { + if (is_array($tag) && $tag[0] === 'd' && isset($tag[1])) { + return $tag[1]; + } + } + return null; + } + + /** + * Add an article to a category index + * + * @param EventEntity $categoryIndex The category index event + * @param string $articleCoordinate The article coordinate (kind:pubkey:slug) + * @param Nzine $nzine The nzine entity (needed for signing) + */ + public function addArticleToCategoryIndex(EventEntity $categoryIndex, string $articleCoordinate, Nzine $nzine): void + { + // Check if article already exists in the index + $tags = $categoryIndex->getTags(); + foreach ($tags as $tag) { + if ($tag[0] === 'a' && isset($tag[1]) && $tag[1] === $articleCoordinate) { + // Article already in index + return; + } + } + + // Get the bot and private key for signing + $bot = $nzine->getNzineBot(); + if (!$bot) { + throw new \RuntimeException('Cannot sign category index: nzine bot not found'); + } + + $bot->setEncryptionService($this->encryptionService); + $privateKey = $bot->getNsec(); + + if (!$privateKey) { + throw new \RuntimeException('Cannot sign category index: bot private key not found'); + } + + // Add article coordinate to tags + $tags[] = ['a', $articleCoordinate]; + + // Create a new Event object with updated tags + $event = new Event(); + $event->setKind($categoryIndex->getKind()); + $event->setContent($categoryIndex->getContent() ?? ''); + $event->setPublicKey($categoryIndex->getPubkey()); + + // Add all tags including the new article coordinate + foreach ($tags as $tag) { + $event->addTag($tag); + } + + // Sign the event with current timestamp + $signer = new Sign(); + $signer->signEvent($event, $privateKey); + + // Convert to JSON and back to get all properties including sig + $eventJson = $event->toJson(); + $eventData = json_decode($eventJson, true); + + // Update the EventEntity with new tags, signature, ID, and timestamp + $categoryIndex->setTags($tags); + $categoryIndex->setSig($eventData['sig']); + $categoryIndex->setId($eventData['id']); + $categoryIndex->setEventId($eventData['id']); + $categoryIndex->setCreatedAt($eventData['created_at']); + + $this->entityManager->persist($categoryIndex); + + $this->logger->debug('Added article to category index and re-signed', [ + 'category_slug' => $this->extractSlugFromTags($tags), + 'article_coordinate' => $articleCoordinate, + 'event_id' => $eventData['id'], + ]); + } + + /** + * Re-sign and save category index events + * Should be called after all articles have been added to ensure valid signatures + * + * @param array $categoryIndices Map of category slug => EventEntity + * @param Nzine $nzine The nzine entity + */ + public function resignCategoryIndices(array $categoryIndices, Nzine $nzine): void + { + if (empty($categoryIndices)) { + return; + } + + $bot = $nzine->getNzineBot(); + if (!$bot) { + $this->logger->warning('Cannot re-sign category indices: nzine bot not found', [ + 'nzine_id' => $nzine->getId(), + ]); + return; + } + + $bot->setEncryptionService($this->encryptionService); + $privateKey = $bot->getNsec(); + + if (!$privateKey) { + $this->logger->warning('Cannot re-sign category indices: bot private key not found', [ + 'nzine_id' => $nzine->getId(), + ]); + return; + } + + $signer = new Sign(); + $serializer = new Serializer([new ObjectNormalizer()], [new JsonEncoder()]); + + foreach ($categoryIndices as $slug => $categoryIndex) { + try { + // Create a new Event from the existing EventEntity + $event = new Event(); + $event->setKind($categoryIndex->getKind()); + $event->setContent($categoryIndex->getContent() ?? ''); + $event->setPublicKey($categoryIndex->getPubkey()); + + // Add all tags from the category index + foreach ($categoryIndex->getTags() as $tag) { + $event->addTag($tag); + } + + // Sign the event with current timestamp + $signer->signEvent($event, $privateKey); + + // Convert to JSON and back to get all properties including sig + $eventJson = $event->toJson(); + $eventData = json_decode($eventJson, true); + + // Update the EventEntity with new signature and timestamp + $categoryIndex->setSig($eventData['sig']); + $categoryIndex->setId($eventData['id']); + $categoryIndex->setEventId($eventData['id']); + $categoryIndex->setCreatedAt($eventData['created_at']); + + $this->entityManager->persist($categoryIndex); + + $this->logger->info('Re-signed category index', [ + 'category_slug' => $slug, + 'event_id' => $eventData['id'], + 'article_count' => count(array_filter($categoryIndex->getTags(), fn($tag) => $tag[0] === 'a')), + ]); + } catch (\Exception $e) { + $this->logger->error('Failed to re-sign category index', [ + 'category_slug' => $slug, + 'error' => $e->getMessage(), + ]); + } + } + + $this->entityManager->flush(); + + $this->logger->info('Category indices re-signed', [ + 'nzine_id' => $nzine->getId(), + 'count' => count($categoryIndices), + ]); + } +} diff --git a/src/Twig/Components/Organisms/ZineList.php b/src/Twig/Components/Organisms/ZineList.php index 6441ea2..6091b5d 100644 --- a/src/Twig/Components/Organisms/ZineList.php +++ b/src/Twig/Components/Organisms/ZineList.php @@ -3,7 +3,6 @@ namespace App\Twig\Components\Organisms; use App\Entity\Event; -use App\Entity\Nzine; use App\Enum\KindsEnum; use Doctrine\ORM\EntityManagerInterface; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; @@ -23,7 +22,7 @@ final class ZineList $nzines = $this->entityManager->getRepository(Event::class)->findBy(['kind' => KindsEnum::PUBLICATION_INDEX]); // filter, only keep type === magazine - $this->nzines = array_filter($nzines, function ($index) { + $filtered = array_filter($nzines, function ($index) { // look for tags $tags = $index->getTags(); $isMagType = false; @@ -46,6 +45,15 @@ final class ZineList return $isMagType && $isTopLevel; }); + // Deduplicate by slug + $uniqueNzines = []; + foreach ($filtered as $nzine) { + $slug = $nzine->getSlug(); + if ($slug !== null) { + $uniqueNzines[$slug] = $nzine; + } + } + $this->nzines = array_values($uniqueNzines); } } diff --git a/templates/magazine/magazine_review.html.twig b/templates/magazine/magazine_review.html.twig index 3abb835..1e616aa 100644 --- a/templates/magazine/magazine_review.html.twig +++ b/templates/magazine/magazine_review.html.twig @@ -3,9 +3,15 @@ {% block body %}

Review & Sign

+ {% if isNzineEdit %} +
+

You are editing an NZine. Changes will be signed automatically using the NZine bot's credentials.

+
+ {% else %}

Review the details below. When ready, click Sign & Publish. Your NIP-07 extension will be used to sign events.

+ {% endif %}

Magazine

@@ -62,6 +68,24 @@
+ {% if isNzineEdit %} +
+
+ Cancel + +
+ +
+
+ {% else %}
+ {% endif %} {% endblock %} diff --git a/templates/nzine/list.html.twig b/templates/nzine/list.html.twig index 094eaa4..9918c46 100644 --- a/templates/nzine/list.html.twig +++ b/templates/nzine/list.html.twig @@ -30,7 +30,7 @@
Edit {% if nzine.hasMainIndex %} - View + View {% else %} View {% endif %}