Browse Source

Setting up nZines, wip

imwald
Nuša Pukšič 1 year ago
parent
commit
f2b3a5344b
  1. 1
      Dockerfile
  2. 18
      assets/controllers/form-collection_controller.js
  3. 39
      assets/styles/app.css
  4. 1
      compose.yaml
  5. 3
      composer.json
  6. 1
      config/packages/security.yaml
  7. 1
      config/services.yaml
  8. 44
      migrations/Version20241211164528.php
  9. 35
      migrations/Version20241211174012.php
  10. 31
      migrations/Version20241211175518.php
  11. 31
      migrations/Version20241217153251.php
  12. 31
      migrations/Version20241217154700.php
  13. 31
      migrations/Version20241217154841.php
  14. 31
      migrations/Version20241217170102.php
  15. 31
      migrations/Version20241231150438.php
  16. 31
      migrations/Version20241231153428.php
  17. 284
      src/Controller/NzineController.php
  18. 6
      src/Entity/Article.php
  19. 35
      src/Entity/Event.php
  20. 30
      src/Entity/MainCategory.php
  21. 58
      src/Entity/Nzine.php
  22. 45
      src/Entity/NzineBot.php
  23. 52
      src/Form/DataTransformer/CommaSeparatedToArrayTransformer.php
  24. 38
      src/Form/MainCategoryType.php
  25. 32
      src/Form/NzineBotType.php
  26. 30
      src/Form/NzineType.php
  27. 17
      src/Repository/ArticleRepository.php
  28. 12
      src/Repository/NzineRepository.php
  29. 36
      src/Service/EncryptionService.php
  30. 62
      src/Twig/Components/IndexTabs.php
  31. 14
      src/Twig/Components/Molecules/Card.php
  32. 22
      src/Twig/Components/Organisms/ZineList.php
  33. 4
      templates/components/Atoms/NameOrNpub.html.twig
  34. 2
      templates/components/Header.html.twig
  35. 23
      templates/components/IndexTabs.html.twig
  36. 12
      templates/components/Molecules/Card.html.twig
  37. 2
      templates/components/Molecules/UserFromNpub.html.twig
  38. 5
      templates/components/Organisms/ZineList.html.twig
  39. 5
      templates/components/UserMenu.html.twig
  40. 1
      templates/home.html.twig
  41. 12
      templates/pages/author.html.twig
  42. 60
      templates/pages/nzine-editor.html.twig
  43. 24
      templates/pages/nzine.html.twig
  44. 8
      translations/messages.en.yaml

1
Dockerfile

@ -23,6 +23,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -23,6 +23,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
gettext \
git \
libnss3-tools \
cron \
&& rm -rf /var/lib/apt/lists/*
RUN set -eux; \

18
assets/controllers/form-collection_controller.js

@ -0,0 +1,18 @@ @@ -0,0 +1,18 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ["collectionContainer"]
static values = {
index : Number,
prototype: String,
}
addCollectionElement(event)
{
const item = document.createElement('li');
item.innerHTML = this.prototypeValue.replace(/__name__/g, this.indexValue);
this.collectionContainerTarget.appendChild(item);
this.indexValue++;
}
}

39
assets/styles/app.css

@ -22,6 +22,7 @@ h1, h2, h3, h4, h5, h6 { @@ -22,6 +22,7 @@ h1, h2, h3, h4, h5, h6 {
h1 {
font-size: 3.2rem;
margin-top: 0.25em;
font-weight: 600;
}
h2 {
@ -319,8 +320,6 @@ footer p { @@ -319,8 +320,6 @@ footer p {
}
.alert {
background-color: var(--color-bg-light);
color: var(--color-text-mid);
padding: 10px 20px; /* Padding around the text */
border-radius: 5px; /* Rounded corners */
margin: 20px 0; /* Spacing around the alert */
@ -328,4 +327,40 @@ footer p { @@ -328,4 +327,40 @@ footer p {
.alert.alert-success {
background-color: var(--color-secondary);
color: var(--color-text-contrast);
}
/* Tabs Container */
.nav-tabs {
display: flex; /* Arrange items in a row */
justify-content: center;
padding: 0; /* Remove padding */
margin: 0; /* Remove margin */
list-style: none; /* Remove list item styling */
}
/* Individual Tab Item */
.nav-tabs .nav-item {
margin: 0; /* No margin around list items */
}
/* NON-Active Tab */
.nav-tabs .nav-link {
color: var(--color-text);
background-color: transparent;
border: none;
}
/* Active Tab */
.nav-tabs .nav-link.active {
color: var(--color-text-contrast);
background-color: var(--color-primary);
font-weight: bold;
}
/* Content Container */
.tab-content {
padding: 15px; /* Spacing inside the content */
border-top: none; /* Remove border overlap with active tab */
}

1
compose.yaml

@ -5,6 +5,7 @@ services: @@ -5,6 +5,7 @@ services:
context: .
dockerfile: Dockerfile
environment:
APP_ENCRYPTION_KEY: '%env(APP_ENCRYPTION_KEY)%'
SERVER_NAME: ${SERVER_NAME:-localhost}, php:80
MERCURE_PUBLISHER_JWT_KEY: ${MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!}
MERCURE_SUBSCRIBER_JWT_KEY: ${MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!}

3
composer.json

@ -40,7 +40,8 @@ @@ -40,7 +40,8 @@
"symfony/ux-live-component": "^2.21",
"symfony/yaml": "7.1.*",
"twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^3.15"
"twig/twig": "^3.15",
"ext-openssl": "*"
},
"config": {
"allow-plugins": {

1
config/packages/security.yaml

@ -29,6 +29,7 @@ security: @@ -29,6 +29,7 @@ security:
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/admin, roles: ROLE_USER }
- { path: ^/nzine, roles: ROLE_USER }
# - { path: ^/profile, roles: ROLE_USER }
when@test:

1
config/services.yaml

@ -4,6 +4,7 @@ @@ -4,6 +4,7 @@
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
encryption_key: '%env(APP_ENCRYPTION_KEY)%'
services:
# default configuration for services in *this* file

44
migrations/Version20241211164528.php

@ -0,0 +1,44 @@ @@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20241211164528 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE nzine (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, npub VARCHAR(255) NOT NULL, main_categories JSON NOT NULL, lists JSON DEFAULT NULL, editor VARCHAR(255) DEFAULT NULL, nzine_bot_id INT DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE UNIQUE INDEX UNIQ_65025D9871FD5427 ON nzine (nzine_bot_id)');
$this->addSql('CREATE TABLE nzine_bot (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, nsec VARCHAR(255) NOT NULL, PRIMARY KEY(id))');
$this->addSql('ALTER TABLE nzine ADD CONSTRAINT FK_65025D9871FD5427 FOREIGN KEY (nzine_bot_id) REFERENCES nzine_bot (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('DROP TABLE journal');
$this->addSql('ALTER TABLE app_user ADD nzine_bot_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE app_user ADD CONSTRAINT FK_88BDF3E971FD5427 FOREIGN KEY (nzine_bot_id) REFERENCES nzine_bot (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE UNIQUE INDEX UNIQ_88BDF3E971FD5427 ON app_user (nzine_bot_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE journal (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, npub VARCHAR(255) NOT NULL, main_categories JSON NOT NULL, lists JSON DEFAULT NULL, editor VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('ALTER TABLE nzine DROP CONSTRAINT FK_65025D9871FD5427');
$this->addSql('DROP TABLE nzine');
$this->addSql('DROP TABLE nzine_bot');
$this->addSql('ALTER TABLE app_user DROP CONSTRAINT FK_88BDF3E971FD5427');
$this->addSql('DROP INDEX UNIQ_88BDF3E971FD5427');
$this->addSql('ALTER TABLE app_user DROP nzine_bot_id');
}
}

35
migrations/Version20241211174012.php

@ -0,0 +1,35 @@ @@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20241211174012 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE app_user DROP CONSTRAINT fk_88bdf3e971fd5427');
$this->addSql('DROP INDEX uniq_88bdf3e971fd5427');
$this->addSql('ALTER TABLE app_user DROP nzine_bot_id');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE app_user ADD nzine_bot_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE app_user ADD CONSTRAINT fk_88bdf3e971fd5427 FOREIGN KEY (nzine_bot_id) REFERENCES nzine_bot (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE UNIQUE INDEX uniq_88bdf3e971fd5427 ON app_user (nzine_bot_id)');
}
}

31
migrations/Version20241211175518.php

@ -0,0 +1,31 @@ @@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20241211175518 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE nzine_bot RENAME COLUMN nsec TO encrypted_nsec');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE nzine_bot RENAME COLUMN encrypted_nsec TO nsec');
}
}

31
migrations/Version20241217153251.php

@ -0,0 +1,31 @@ @@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20241217153251 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE event (id VARCHAR(225) NOT NULL, kind INT NOT NULL, pubkey VARCHAR(255) NOT NULL, content TEXT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, tags JSON NOT NULL, sig VARCHAR(255) NOT NULL, PRIMARY KEY(id))');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE event');
}
}

31
migrations/Version20241217154700.php

@ -0,0 +1,31 @@ @@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20241217154700 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE nzine ADD slug TEXT DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE nzine DROP slug');
}
}

31
migrations/Version20241217154841.php

@ -0,0 +1,31 @@ @@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20241217154841 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
// $this->addSql('ALTER TABLE nzine ADD slug TEXT DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE nzine DROP slug');
}
}

31
migrations/Version20241217170102.php

@ -0,0 +1,31 @@ @@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20241217170102 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE event ALTER created_at TYPE BIGINT USING EXTRACT(EPOCH FROM created_at)::BIGINT');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE event ALTER created_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE');
}
}

31
migrations/Version20241231150438.php

@ -0,0 +1,31 @@ @@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20241231150438 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE event ADD slug TEXT DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE event DROP slug');
}
}

31
migrations/Version20241231153428.php

@ -0,0 +1,31 @@ @@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20241231153428 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE event DROP slug');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE event ADD slug TEXT DEFAULT NULL');
}
}

284
src/Controller/NzineController.php

@ -0,0 +1,284 @@ @@ -0,0 +1,284 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Article;
use App\Entity\Event as EventEntity;
use App\Entity\Nzine;
use App\Entity\NzineBot;
use App\Entity\User;
use App\Enum\KindsEnum;
use App\Enum\RolesEnum;
use App\Form\NzineBotType;
use App\Form\NzineType;
use App\Service\EncryptionService;
use App\Service\NostrClient;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use Exception;
use swentel\nostr\Event\Event;
use swentel\nostr\Key\Key;
use swentel\nostr\Message\EventMessage;
use swentel\nostr\Relay\Relay;
use swentel\nostr\Sign\Sign;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\String\Slugger\AsciiSlugger;
class NzineController extends AbstractController
{
/**
* @throws \JsonException
*/
#[Route('/nzine', name: 'nzine_index')]
public function index(Request $request, EncryptionService $encryptionService, EntityManagerInterface $entityManager): Response
{
$form = $this->createForm(NzineBotType::class);
$form->handleRequest($request);
$user = $this->getUser();
// TODO change into a workflow
if ($form->isSubmitted() && $form->isValid()) {
$data = $form->getData();
// create NZine bot
$key = new Key();
$private_key = '8c55771e896581fffea62c6440e306d502630e9dbd067e484bf6fc9c83ede28c';
// $private_key = $key->generatePrivateKey();
// $bot = new NzineBot($encryptionService);
// $bot->setNsec($private_key);
$bot = $entityManager->getRepository(NzineBot::class)->find(1);
//$entityManager->persist($bot);
//$entityManager->flush();
$profileContent = [
'name' => $data['name'],
'about' => $data['about'],
'bot' => true
];
// publish bot profile
$profileEvent = new Event();
$profileEvent->setKind(0);
$profileEvent->setContent(json_encode($profileContent));
$signer = new Sign();
$signer->signEvent($profileEvent, $private_key);
$eventMessage = new EventMessage($profileEvent);
$relayUrl = 'wss://purplepag.es';
$relay = new Relay($relayUrl);
$relay->setMessage($eventMessage);
// $result = $relay->send();
// create NZine entity
$nzine = new Nzine();
$public_key = $key->getPublicKey($private_key);
$nzine->setNpub($public_key);
$nzine->setNzineBot($bot);
$nzine->setEditor($user->getUserIdentifier());
// $entityManager->persist($nzine);
// $entityManager->flush();
// TODO add EDITOR role to the user
$role = RolesEnum::EDITOR->value;
$user = $entityManager->getRepository(User::class)->findOneBy(['npub' => $user->getUserIdentifier()]);
$user->addRole($role);
// $entityManager->persist($user);
// $entityManager->flush();
$slugger = new AsciiSlugger();
$title = $profileContent['name'];
$slug = 'nzine-'.$slugger->slug($title)->lower().'-'.rand(10000,99999);
// create NZine main index
$index = new Event();
$index->setKind(KindsEnum::PUBLICATION_INDEX->value);
$index->addTag(['d' => $slug]);
$index->addTag(['title' => $title]);
$index->addTag(['summary' => $profileContent['about']]);
$index->addTag(['auto-update' => 'yes']);
$index->addTag(['type' => 'magazine']);
$signer = new Sign();
$signer->signEvent($index, $private_key);
// save to persistence, first map to EventEntity
$serializer = new Serializer([new ObjectNormalizer()],[new JsonEncoder()]);
$i = $serializer->deserialize($index->toJson(), EventEntity::class, 'json');
// don't save any more for now
$entityManager->persist($i);
// $entityManager->flush();
// TODO publish index to relays
// TODO remove this, this is temporary, to not create a host of nzines
$nzine = $entityManager->getRepository(Nzine::class)->findOneBy(['npub' => $nzine->getNpub()]);
$nzine->setSlug($slug);
$entityManager->persist($nzine);
$entityManager->flush();
return $this->redirectToRoute('nzine_edit', ['npub' => $public_key ]);
}
// on submit, create a key pair and save it securely
// create a new NZine entity and link it to the key pair
// then redirect to edit
return $this->render('pages/nzine-editor.html.twig', [
'form' => $form
]);
}
#[Route('/nzine/{npub}', name: 'nzine_edit')]
public function edit(Request $request, $npub, EntityManagerInterface $entityManager,
ManagerRegistry $managerRegistry, NostrClient $nostrClient): Response
{
$nzine = $entityManager->getRepository(Nzine::class)->findOneBy(['npub' => $npub]);
if (!$nzine) {
throw $this->createNotFoundException('N-Zine not found');
}
try {
$bot = $entityManager->getRepository(User::class)->findOneBy(['npub' => $npub]);
} catch (\Exception $e) {
// sth went wrong, but whatever
$managerRegistry->resetManager();
}
$catForm = $this->createForm(NzineType::class, ['categories' => $nzine->getMainCategories()]);
$catForm->handleRequest($request);
if ($catForm->isSubmitted() && $catForm->isValid()) {
// Process and normalize the 'tags' field
$data = $catForm->get('categories')->getData();
$nzine->setMainCategories($data);
// try {
// $entityManager->beginTransaction();
// $entityManager->persist($nzine);
// $entityManager->flush();
// $entityManager->commit();
// } catch (Exception $e) {
// $entityManager->rollback();
// $managerRegistry->resetManager();
// }
// existing indices
$indices = $entityManager->getRepository(EventEntity::class)->findBy(['pubkey' => $npub, 'kind' => KindsEnum::PUBLICATION_INDEX]);
// get oldest, treat it as root
$mainIndex = $indices[0];
// TODO create and update indices
foreach ($data as $cat) {
// find or create new index
$slugger = new AsciiSlugger();
$title = $cat['title'];
$slug = $mainIndex->getSlug().'-'.$slugger->slug($title)->lower();
// create category index
$index = new Event();
$index->setKind(KindsEnum::PUBLICATION_INDEX->value);
$index->addTag(['d' => $slug]);
$index->addTag(['title' => $title]);
$index->addTag(['auto-update' => 'yes']);
$index->addTag(['type' => 'magazine']);
// TODO add indexed items that fall into the category
$signer = new Sign();
// TODO get key
// $signer->signEvent($index, $private_key);
// save to persistence, first map to EventEntity
$serializer = new Serializer([new ObjectNormalizer()],[new JsonEncoder()]);
$i = $serializer->deserialize($index->toJson(), EventEntity::class, 'json');
// don't save any more for now
$entityManager->persist($i);
// $entityManager->flush();
// TODO publish index to relays
}
// TODO add the new and updated indices to the main index
// redirect to route nzine_view
return $this->redirectToRoute('nzine_view', [
'npub' => $nzine->getNpub(),
]);
}
return $this->render('pages/nzine-editor.html.twig', [
'nzine' => $nzine,
'bot' => $bot,
'catForm' => $catForm
]);
}
/**
* Update and (re)publish indices,
* when you want to look for new articles or
* when categories have changed
* @return void
*/
#[Route('/nzine/{npub}', name: 'nzine_update')]
public function nzineUpdate()
{
// TODO make this a separate step and create all the indices and populate with articles all at once
}
#[Route('/nzine/v/{npub}', name: 'nzine_view')]
public function nzineView($npub, EntityManagerInterface $entityManager) {
$nzine = $entityManager->getRepository(Nzine::class)->findOneBy(['npub' => $npub]);
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]);
// TODO Filter out the main index by the d-tag saved to entity or something
$main = $indices[0];
// let's pretend we have some nested indices in this zine
$main->setTags(['a', '30040:'.$npub.':1'.$nzine->getSlug()]);
$main->setTags(['a', '30040:'.$npub.':2'.$nzine->getSlug()]);
return $this->render('pages/nzine.html.twig', [
'nzine' => $nzine,
'index' => $main
]);
}
#[Route('/nzine/v/{npub}/{cat}', name: 'nzine_category')]
public function nzineCategory($npub, $cat, EntityManagerInterface $entityManager) {
$nzine = $entityManager->getRepository(Nzine::class)->findOneBy(['npub' => $npub]);
if (!$nzine) {
throw $this->createNotFoundException('N-Zine not found');
}
$bot = $entityManager->getRepository(User::class)->findOneBy(['npub' => $npub]);
$tags = [];
foreach ($nzine->getMainCategories() as $category) {
if (isset($category['title']) && $category['title'] === $cat) {
$tags = $category['tags'] ?? [];
}
}
$all = $entityManager->getRepository(Article::class)->findAll();
$list = array_slice($all, 0, 100);
$filtered = [];
foreach ($tags as $tag) {
$partial = array_filter($list, function($v) use ($tag) {
/* @var Article $v */
return in_array($tag, $v->getTopics() ?? []);
});
$filtered = array_merge($filtered, $partial);
}
return $this->render('pages/nzine.html.twig', [
'nzine' => $nzine,
'bot' => $bot,
'list' => $filtered
]);
}
}

6
src/Entity/Article.php

@ -5,14 +5,18 @@ namespace App\Entity; @@ -5,14 +5,18 @@ namespace App\Entity;
use App\Enum\EventStatusEnum;
use App\Enum\IndexStatusEnum;
use App\Enum\KindsEnum;
use App\Repository\ArticleRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
/**
* Entity storing long-form articles
* Needed beyond the Event entity, because of the local functionalities built on top of the original events
* - editor
* - indexing and search
* NIP-23, kinds 30023, 30024
*/
#[ORM\Entity]
#[ORM\Entity(repositoryClass: ArticleRepository::class)]
class Article
{
#[ORM\Id]

35
src/Entity/Event.php

@ -2,14 +2,29 @@ @@ -2,14 +2,29 @@
namespace App\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
/**
* Nostr events
*/
#[ORM\Entity]
class Event
{
#[ORM\Id]
#[ORM\Column(length: 225)]
private string $id;
#[ORM\Column(type: Types::INTEGER)]
private int $kind = 0;
#[ORM\Column(length: 255)]
private string $pubkey = '';
#[ORM\Column(type: Types::TEXT)]
private string $content = '';
#[ORM\Column(type: Types::BIGINT)]
private int $created_at = 0;
#[ORM\Column(type: Types::JSON)]
private array $tags = [];
#[ORM\Column(length: 255)]
private string $sig = '';
public function getId(): string
@ -83,4 +98,24 @@ class Event @@ -83,4 +98,24 @@ class Event
}
public function getTitle(): ?string
{
foreach ($this->getTags() as $tag) {
if (array_key_first($tag) === 'title') {
return $tag['title'];
}
}
return null;
}
public function getSlug(): ?string
{
foreach ($this->getTags() as $tag) {
if (array_key_first($tag) === 'd') {
return $tag['d'];
}
}
return null;
}
}

30
src/Entity/MainCategory.php

@ -0,0 +1,30 @@ @@ -0,0 +1,30 @@
<?php
namespace App\Entity;
class MainCategory {
private string $title;
private array $tags;
public function getTitle(): string
{
return $this->title;
}
public function setTitle(string $title): void
{
$this->title = $title;
}
public function getTags(): array
{
return $this->tags;
}
public function setTags(array $tags): void
{
$this->tags = $tags;
}
}

58
src/Entity/Journal.php → src/Entity/Nzine.php

@ -2,11 +2,13 @@ @@ -2,11 +2,13 @@
namespace App\Entity;
use App\Repository\JournalRepository;
use App\Repository\NzineRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: JournalRepository::class)]
class Journal
#[ORM\Entity(repositoryClass: NzineRepository::class)]
class Nzine
{
#[ORM\Id]
#[ORM\GeneratedValue]
@ -16,8 +18,12 @@ class Journal @@ -16,8 +18,12 @@ class Journal
#[ORM\Column(length: 255)]
private ?string $npub = null;
#[ORM\Column]
private array $mainCategories = [];
#[ORM\OneToOne(targetEntity: NzineBot::class)]
#[ORM\JoinColumn(nullable: true)]
private ?NzineBot $nzineBot = null;
#[ORM\Column(type: Types::JSON)]
private array|ArrayCollection $mainCategories;
#[ORM\Column(nullable: true)]
private ?array $lists = null;
@ -25,6 +31,18 @@ class Journal @@ -25,6 +31,18 @@ class Journal
#[ORM\Column(length: 255, nullable: true)]
private ?string $editor = null;
/**
* Slug (d-tag) of the main index event that contains all the main category indices
* @var string|null
*/
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $slug = null;
public function __construct()
{
$this->mainCategories = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
@ -42,6 +60,16 @@ class Journal @@ -42,6 +60,16 @@ class Journal
return $this;
}
public function getNsec(): ?string
{
return $this->nsec;
}
public function setNsec(?string $nsec): void
{
$this->nsec = $nsec;
}
public function getMainCategories(): array
{
return $this->mainCategories;
@ -77,4 +105,24 @@ class Journal @@ -77,4 +105,24 @@ class Journal
return $this;
}
public function getNzineBot(): ?NzineBot
{
return $this->nzineBot;
}
public function setNzineBot(?NzineBot $nzineBot): void
{
$this->nzineBot = $nzineBot;
}
public function getSlug(): ?string
{
return $this->slug;
}
public function setSlug(?string $slug): void
{
$this->slug = $slug;
}
}

45
src/Entity/NzineBot.php

@ -0,0 +1,45 @@ @@ -0,0 +1,45 @@
<?php
namespace App\Entity;
use App\Service\EncryptionService;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Ignore;
#[ORM\Entity]
class NzineBot
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 255)]
private ?string $encryptedNsec = null;
#[Ignore]
private ?string $nsec = null;
public function __construct(private readonly EncryptionService $encryptionService)
{
}
public function getId(): ?int
{
return $this->id;
}
public function getNsec(): ?string
{
if (null === $this->nsec && null !== $this->encryptedNsec) {
$this->nsec = $this->encryptionService->decrypt($this->encryptedNsec);
}
return $this->nsec;
}
public function setNsec(?string $nsec): self
{
$this->nsec = $nsec;
$this->encryptedNsec = $this->encryptionService->encrypt($nsec);
return $this;
}
}

52
src/Form/DataTransformer/CommaSeparatedToArrayTransformer.php

@ -0,0 +1,52 @@ @@ -0,0 +1,52 @@
<?php
namespace App\Form\DataTransformer;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;
class CommaSeparatedToArrayTransformer implements DataTransformerInterface
{
/**
* Transforms an array into a comma-separated string.
*
* @param array|null $array
* @return string
*/
public function transform($array): string
{
if (null === $array || [] === $array) {
return '';
}
if (!is_array($array)) {
throw new TransformationFailedException('Expected an array.');
}
return implode(', ', $array);
}
/**
* Transforms a comma-separated string into an array.
*
* @param string|null $string
* @return array
*/
public function reverseTransform($string): array
{
if (null === $string || '' === trim($string)) {
return [];
}
if (!is_string($string)) {
throw new TransformationFailedException('Expected a string.');
}
// Split by commas, trim whitespace, and filter out empty values
$items = array_filter(array_map('trim', explode(',', $string)), function ($value) {
return $value !== '';
});
return $items;
}
}

38
src/Form/MainCategoryType.php

@ -0,0 +1,38 @@ @@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Form;
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 MainCategoryType extends AbstractType
{
public function __construct(private CommaSeparatedToArrayTransformer $transformer)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('title', TextType::class, [
'label' => 'Title',
])
->add('tags', TextType::class, [
'label' => 'Tags (comma-separated, no #s)',
]);
$builder->get('tags')->addModelTransformer($this->transformer);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => null, // This form maps directly to the object array
]);
}
}

32
src/Form/NzineBotType.php

@ -0,0 +1,32 @@ @@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class NzineBotType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, [
'required' => true
])
->add('about', TextareaType::class, [
'required' => false
])
->add('submit', SubmitType::class)
;
}
public function configureOptions(OptionsResolver $resolver)
{
}
}

30
src/Form/NzineType.php

@ -0,0 +1,30 @@ @@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class NzineType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('categories', CollectionType::class, [
'entry_type' => MainCategoryType::class,
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'prototype' => true, // Enables the JavaScript prototype feature
'label' => false,
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
}
}

17
src/Repository/ArticleRepository.php

@ -0,0 +1,17 @@ @@ -0,0 +1,17 @@
<?php
namespace App\Repository;
use App\Entity\Article;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\DBAL\Exception;
use Doctrine\Persistence\ManagerRegistry;
class ArticleRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Article::class);
}
}

12
src/Repository/JournalRepository.php → src/Repository/NzineRepository.php

@ -2,22 +2,22 @@ @@ -2,22 +2,22 @@
namespace App\Repository;
use App\Entity\Journal;
use App\Entity\Nzine;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Journal>
* @extends ServiceEntityRepository<Nzine>
*/
class JournalRepository extends ServiceEntityRepository
class NzineRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Journal::class);
parent::__construct($registry, Nzine::class);
}
// /**
// * @return Journal[] Returns an array of Journal objects
// * @return Nzine[] Returns an array of Nzine objects
// */
// public function findByExampleField($value): array
// {
@ -31,7 +31,7 @@ class JournalRepository extends ServiceEntityRepository @@ -31,7 +31,7 @@ class JournalRepository extends ServiceEntityRepository
// ;
// }
// public function findOneBySomeField($value): ?Journal
// public function findOneBySomeField($value): ?Nzine
// {
// return $this->createQueryBuilder('j')
// ->andWhere('j.exampleField = :val')

36
src/Service/EncryptionService.php

@ -0,0 +1,36 @@ @@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class EncryptionService
{
private string $encryptionKey;
public function __construct(ParameterBagInterface $bag)
{
// Retrieve the symmetric encryption key securely from Symfony Secrets
$this->encryptionKey = $bag->get('encryption_key');
}
public function encrypt(string $data): string
{
return openssl_encrypt($data, 'aes-256-cbc', $this->encryptionKey, 0, $this->getIv());
}
public function decrypt(string $encryptedData): string
{
return openssl_decrypt($encryptedData, 'aes-256-cbc', $this->encryptionKey, 0, $this->getIv());
}
private function getIv(): string
{
return substr(hash('sha256', $this->encryptionKey), 0, 16); // AES-256 requires a 16-byte IV
}
}

62
src/Twig/Components/IndexTabs.php

@ -0,0 +1,62 @@ @@ -0,0 +1,62 @@
<?php
namespace App\Twig\Components;
use App\Entity\Event as EventEntity;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
class IndexTabs
{
use DefaultActionTrait;
private $index;
#[LiveProp(writable: true)]
public int $activeTab = 1; // Default active tab
#[LiveProp]
public array $tabs = [
['id' => 1, 'label' => 'Tab 1'],
['id' => 2, 'label' => 'Tab 2'],
['id' => 3, 'label' => 'Tab 3'],
];
#[LiveAction]
public function changeTab(#[LiveArg] int $id): void
{
$this->activeTab = $id;
}
public function __construct(private EntityManagerInterface $entityManager)
{
}
public function mount(EventEntity $index): void
{
$this->index = $index;
// TODO extract categories from index and feed into tabs
foreach ($index->getTags() as $tag) {
if (array_key_first($tag) === 'a') {
$ref = $tag['a'];
list($kind,$npub,$slug) = explode(':',$ref);
// find all connected indices
$this->entityManager->getRepository(EventEntity::class)->findOneBy(['slug' => $slug]);
}
}
}
public function getTabContent(): string
{
return match ($this->activeTab) {
1 => 'This is content for Tab 1. Loaded directly in Live Component!',
2 => 'This is content for Tab 2. No AJAX needed!',
3 => 'This is content for Tab 3. Server-side rendering!',
default => 'No content available.',
};
}
}

14
src/Twig/Components/Molecules/Card.php

@ -2,6 +2,8 @@ @@ -2,6 +2,8 @@
namespace App\Twig\Components\Molecules;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
@ -10,4 +12,16 @@ final class Card @@ -10,4 +12,16 @@ final class Card
public string $tag = 'div';
public string $category = '';
public object $article;
public object $user;
public function __construct(private readonly EntityManagerInterface $entityManager)
{
}
public function mount(?string $npub = null): void
{
if ($npub) {
$this->user = $this->entityManager->getRepository(User::class)->findOneBy(['npub' => $npub]);
}
}
}

22
src/Twig/Components/Organisms/ZineList.php

@ -0,0 +1,22 @@ @@ -0,0 +1,22 @@
<?php
namespace App\Twig\Components\Organisms;
use App\Entity\Nzine;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
final class ZineList
{
public array $nzines = [];
public function __construct(private readonly EntityManagerInterface $entityManager)
{
}
public function mount(?array $nzines = null): void
{
$this->nzines = $nzines ?? $this->entityManager->getRepository(Nzine::class)->findAll();
}
}

4
templates/components/Atoms/NameOrNpub.html.twig

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
<span>
{% if author.displayName %}
{% if author.displayName is not empty %}
{{ author.displayName }}
{% elseif author.name %}
{% elseif author.name is not empty %}
{{ author.name }}
{% else %}
{{ author.npub }}

2
templates/components/Header.html.twig

@ -1,3 +1,3 @@ @@ -1,3 +1,3 @@
<header class="header">
<div class="header__logo"><h1><a href="/">No News Journal</a></h1></div>
<div class="header__logo"><h1><a href="/">Newsroom</a></h1></div>
</header>

23
templates/components/IndexTabs.html.twig

@ -0,0 +1,23 @@ @@ -0,0 +1,23 @@
<div {{ attributes }}>
<!-- Navigation Tabs -->
<ul class="nav nav-tabs">
{% for tab in tabs %}
<li class="nav-item">
<button
class="nav-link {{ activeTab == tab.id ? 'active' : '' }}"
{{ live_action('changeTab', {'id': tab.id }) }}
>
{{ tab.label }}
</button>
</li>
{% endfor %}
</ul>
<!-- Tab Content -->
<div class="tab-content mt-3">
<div class="tab-pane fade show active">
{{ this.tabContent()|raw }}
</div>
</div>
</div>

12
templates/components/Molecules/Card.html.twig

@ -1,8 +1,9 @@ @@ -1,8 +1,9 @@
{% if article is defined %}
<{{ tag }} {{ attributes }}>
<div class="card-header"><small class="text-uppercase">{{ category }}</small></div>
<div class="card-body">
<small>{{ article.createdAt|date('F j') }}</small>
<h1 class="card-title">{{ article.title }}</h1>
<h2 class="card-title">{{ article.title }}</h2>
<p class="lede">
{{ article.summary }}
</p>
@ -11,3 +12,12 @@ @@ -11,3 +12,12 @@
<a href="{{ path('author-profile', { npub: article.pubkey })}}"><twig:Molecules:UserFromNpub npub="{{ article.pubkey }}" /></a>
</div>
</{{ tag }}>
{% endif %}
{% if user is defined %}
<{{ tag }} {{ attributes }}>
<div class="card-body">
<h3 class="card-title">{{ user.name }}</h3>
<p>{{ user.about }}</p>
</div>
</{{ tag }}>
{% endif %}

2
templates/components/Molecules/UserFromNpub.html.twig

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
{% if user %}
<span>{{ user.displayName }}</span>
<twig:Atoms:NameOrNpub :author="user" />
{% else %}
<span>{{ npub }}</span>
{% endif %}

5
templates/components/Organisms/ZineList.html.twig

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
<div {{ attributes }}>
{% for item in nzines %}
<twig:Molecules:Card class="card user" :npub="item.npub" tag="a" href="{{ path('author-profile', {npub: item.npub}) }}" ></twig:Molecules:Card>
{% endfor %}
</div>

5
templates/components/UserMenu.html.twig

@ -9,11 +9,14 @@ @@ -9,11 +9,14 @@
</ul>
{% endif %}
<ul>
<li>
<a href="{{ path('author-profile', {npub: app.user.npub }) }}">Profile</a>
</li>
<li>
<a href="#">Write an article</a>
</li>
<li>
<a href="#">Create a journal</a>
<a href="{{ path('nzine_index') }}">{{ 'heading.createNzine'|trans }}</a>
</li>
<li>
<a href="/logout" data-action="live#$render">{{ 'heading.logout'|trans }}</a>

1
templates/home.html.twig

@ -10,4 +10,5 @@ @@ -10,4 +10,5 @@
{% block aside %}
{# sidebar #}
<twig:Organisms:ZineList />
{% endblock %}

12
templates/pages/author.html.twig

@ -6,7 +6,13 @@ @@ -6,7 +6,13 @@
{{ author.about }}
</p>
<div>
<twig:Organisms:CardList :list="articles"></twig:Organisms:CardList>
</div>
{% if nzine %}
<a href="{{ path('nzine_view', {npub: author.npub}) }}">View as N-Zine</a>
{% endif %}
<twig:Organisms:CardList :list="articles"></twig:Organisms:CardList>
{% endblock %}
{% block aside %}
<twig:Organisms:ZineList :nzines="nzines" />
{% endblock %}

60
templates/pages/nzine-editor.html.twig

@ -0,0 +1,60 @@ @@ -0,0 +1,60 @@
{% extends 'base.html.twig' %}
{% block body %}
{% if nzine is not defined %}
<h1>{{ 'heading.createNzine'|trans }}</h1>
<twig:Atoms:Alert >N-Zines are in active development. Expect weirdness.</twig:Atoms:Alert>
<p class="lede">
An N-Zine is a digital magazine definition for
collecting long form articles from the <em>nostr</em> ecosystem according to specified filters.
The N-Zine can then be read and browsed as a traditional digital magazine made available on this platform.
Additionally, it can be subscribed to using the <em>nostr</em> bot which will be generated during the setup process.
Your currently logged-in <em>npub</em> will be assigned to the N-Zine as an editor, so you can come back later and tweak the filters.
</p>
<h2>N-Zine Details</h2>
<p>
Choose a title and write a description for your N-Zine.
A profile for your N-Zine bot will also be created.
The bot will publish an update when a new article is found that matches N-Zine's filters.
<br>
<small>We know it's lame, but right now we cannot automatically update your follows to include the N-Zine bot.</small>
</p>
{{ form_start(form) }}
{{ form_end(form) }}
{% else %}
<h1>{{ 'heading.editNzine'|trans }}</h1>
<h2>{{ bot.name }}</h2>
<p class="lede">{{ bot.about }}</p>
<h2>Categories</h2>
<p>
Create and edit categories. You can have as many as you like. Aim at up to 9 for the sake of your readers.
</p>
{{ form_start(catForm) }}
<ul class="tags">
{% for cat in catForm.categories %}
<li>{{ form_widget(cat) }}</li>
{% endfor %}
</ul>
<div {{ stimulus_controller('form-collection') }}
data-form-collection-index-value="{{ catForm.categories|length > 0 ? catForm.categories|last.vars.name + 1 : 0 }}"
data-form-collection-prototype-value="{{ form_widget(catForm.categories.vars.prototype)|e('html_attr') }}"
>
<ul {{ stimulus_target('form-collection', 'collectionContainer') }}></ul>
<button type="button" {{ stimulus_action('form-collection', 'addCollectionElement') }}>Add item</button>
</div>
<button class="btn btn-primary">Save</button>
{{ form_end(catForm) }}
{% endif %}
{% endblock %}

24
templates/pages/nzine.html.twig

@ -0,0 +1,24 @@ @@ -0,0 +1,24 @@
{% extends 'base.html.twig' %}
{% block body %}
<div>
{# TODO replace with main index data #}
<h1>{{ index.title }}</h1>
<p>{{ index.slug }}</p>
<div>
<nav>
{# TODO replace this with a loop over the main index #}
{% for cat in nzine.mainCategories %}
<a href="{{ path('nzine_category', {npub: nzine.npub, cat: cat.title}) }}">{{ cat.title }}</a>
{% endfor %}
</nav>
</div>
<br>
<twig:IndexTabs :index="index" />
{% if list is defined %}
<twig:Organisms:CardList :list="list" />
{% endif %}
</div>
{% endblock %}

8
translations/messages.en.yaml

@ -1,6 +1,8 @@ @@ -1,6 +1,8 @@
text:
byline: 'By'
heading:
roles: 'Roles'
logout: 'Log out'
logIn: 'Log in'
roles: 'Roles'
logout: 'Log out'
logIn: 'Log in'
createNzine: 'Create an N-Zine'
editNzine: 'Edit your N-Zine'

Loading…
Cancel
Save