From f2b3a5344b0509be58781554fc034966ccc741b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Sat, 4 Jan 2025 13:06:31 +0100 Subject: [PATCH] Setting up nZines, wip --- Dockerfile | 1 + .../controllers/form-collection_controller.js | 18 ++ assets/styles/app.css | 39 ++- compose.yaml | 1 + composer.json | 3 +- config/packages/security.yaml | 1 + config/services.yaml | 1 + migrations/Version20241211164528.php | 44 +++ migrations/Version20241211174012.php | 35 +++ migrations/Version20241211175518.php | 31 ++ migrations/Version20241217153251.php | 31 ++ migrations/Version20241217154700.php | 31 ++ migrations/Version20241217154841.php | 31 ++ migrations/Version20241217170102.php | 31 ++ migrations/Version20241231150438.php | 31 ++ migrations/Version20241231153428.php | 31 ++ src/Controller/NzineController.php | 284 ++++++++++++++++++ src/Entity/Article.php | 6 +- src/Entity/Event.php | 35 +++ src/Entity/MainCategory.php | 30 ++ src/Entity/{Journal.php => Nzine.php} | 58 +++- src/Entity/NzineBot.php | 45 +++ .../CommaSeparatedToArrayTransformer.php | 52 ++++ src/Form/MainCategoryType.php | 38 +++ src/Form/NzineBotType.php | 32 ++ src/Form/NzineType.php | 30 ++ src/Repository/ArticleRepository.php | 17 ++ ...rnalRepository.php => NzineRepository.php} | 12 +- src/Service/EncryptionService.php | 36 +++ src/Twig/Components/IndexTabs.php | 62 ++++ src/Twig/Components/Molecules/Card.php | 14 + src/Twig/Components/Organisms/ZineList.php | 22 ++ .../components/Atoms/NameOrNpub.html.twig | 4 +- templates/components/Header.html.twig | 2 +- templates/components/IndexTabs.html.twig | 23 ++ templates/components/Molecules/Card.html.twig | 12 +- .../Molecules/UserFromNpub.html.twig | 2 +- .../components/Organisms/ZineList.html.twig | 5 + templates/components/UserMenu.html.twig | 5 +- templates/home.html.twig | 1 + templates/pages/author.html.twig | 12 +- templates/pages/nzine-editor.html.twig | 60 ++++ templates/pages/nzine.html.twig | 24 ++ translations/messages.en.yaml | 8 +- 44 files changed, 1264 insertions(+), 27 deletions(-) create mode 100644 assets/controllers/form-collection_controller.js create mode 100644 migrations/Version20241211164528.php create mode 100644 migrations/Version20241211174012.php create mode 100644 migrations/Version20241211175518.php create mode 100644 migrations/Version20241217153251.php create mode 100644 migrations/Version20241217154700.php create mode 100644 migrations/Version20241217154841.php create mode 100644 migrations/Version20241217170102.php create mode 100644 migrations/Version20241231150438.php create mode 100644 migrations/Version20241231153428.php create mode 100644 src/Controller/NzineController.php create mode 100644 src/Entity/MainCategory.php rename src/Entity/{Journal.php => Nzine.php} (50%) create mode 100644 src/Entity/NzineBot.php create mode 100644 src/Form/DataTransformer/CommaSeparatedToArrayTransformer.php create mode 100644 src/Form/MainCategoryType.php create mode 100644 src/Form/NzineBotType.php create mode 100644 src/Form/NzineType.php create mode 100644 src/Repository/ArticleRepository.php rename src/Repository/{JournalRepository.php => NzineRepository.php} (74%) create mode 100644 src/Service/EncryptionService.php create mode 100644 src/Twig/Components/IndexTabs.php create mode 100644 src/Twig/Components/Organisms/ZineList.php create mode 100644 templates/components/IndexTabs.html.twig create mode 100644 templates/components/Organisms/ZineList.html.twig create mode 100644 templates/pages/nzine-editor.html.twig create mode 100644 templates/pages/nzine.html.twig diff --git a/Dockerfile b/Dockerfile index fbdc52d..bd32add 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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; \ diff --git a/assets/controllers/form-collection_controller.js b/assets/controllers/form-collection_controller.js new file mode 100644 index 0000000..6b67190 --- /dev/null +++ b/assets/controllers/form-collection_controller.js @@ -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++; + } +} diff --git a/assets/styles/app.css b/assets/styles/app.css index 9c4ddcf..29a08f0 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -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 { } .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 { .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 */ } diff --git a/compose.yaml b/compose.yaml index 1b9d4d7..897d403 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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!} diff --git a/composer.json b/composer.json index a3a1710..1432a51 100644 --- a/composer.json +++ b/composer.json @@ -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": { diff --git a/config/packages/security.yaml b/config/packages/security.yaml index bcf5396..58c2449 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -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: diff --git a/config/services.yaml b/config/services.yaml index fcab339..97fe0f0 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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 diff --git a/migrations/Version20241211164528.php b/migrations/Version20241211164528.php new file mode 100644 index 0000000..a6cc0b2 --- /dev/null +++ b/migrations/Version20241211164528.php @@ -0,0 +1,44 @@ +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'); + } +} diff --git a/migrations/Version20241211174012.php b/migrations/Version20241211174012.php new file mode 100644 index 0000000..14837b7 --- /dev/null +++ b/migrations/Version20241211174012.php @@ -0,0 +1,35 @@ +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)'); + } +} diff --git a/migrations/Version20241211175518.php b/migrations/Version20241211175518.php new file mode 100644 index 0000000..020b00a --- /dev/null +++ b/migrations/Version20241211175518.php @@ -0,0 +1,31 @@ +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'); + } +} diff --git a/migrations/Version20241217153251.php b/migrations/Version20241217153251.php new file mode 100644 index 0000000..eb1cc9c --- /dev/null +++ b/migrations/Version20241217153251.php @@ -0,0 +1,31 @@ +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'); + } +} diff --git a/migrations/Version20241217154700.php b/migrations/Version20241217154700.php new file mode 100644 index 0000000..2498b3d --- /dev/null +++ b/migrations/Version20241217154700.php @@ -0,0 +1,31 @@ +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'); + } +} diff --git a/migrations/Version20241217154841.php b/migrations/Version20241217154841.php new file mode 100644 index 0000000..1dc3433 --- /dev/null +++ b/migrations/Version20241217154841.php @@ -0,0 +1,31 @@ +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'); + } +} diff --git a/migrations/Version20241217170102.php b/migrations/Version20241217170102.php new file mode 100644 index 0000000..b3ac9bc --- /dev/null +++ b/migrations/Version20241217170102.php @@ -0,0 +1,31 @@ +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'); + } +} diff --git a/migrations/Version20241231150438.php b/migrations/Version20241231150438.php new file mode 100644 index 0000000..b29ab08 --- /dev/null +++ b/migrations/Version20241231150438.php @@ -0,0 +1,31 @@ +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'); + } +} diff --git a/migrations/Version20241231153428.php b/migrations/Version20241231153428.php new file mode 100644 index 0000000..ad81158 --- /dev/null +++ b/migrations/Version20241231153428.php @@ -0,0 +1,31 @@ +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'); + } +} diff --git a/src/Controller/NzineController.php b/src/Controller/NzineController.php new file mode 100644 index 0000000..ff9eeae --- /dev/null +++ b/src/Controller/NzineController.php @@ -0,0 +1,284 @@ +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 + ]); + } + +} diff --git a/src/Entity/Article.php b/src/Entity/Article.php index 841b226..475eb25 100644 --- a/src/Entity/Article.php +++ b/src/Entity/Article.php @@ -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] diff --git a/src/Entity/Event.php b/src/Entity/Event.php index 06bc1f4..aee6777 100644 --- a/src/Entity/Event.php +++ b/src/Entity/Event.php @@ -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 } + 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; + } } diff --git a/src/Entity/MainCategory.php b/src/Entity/MainCategory.php new file mode 100644 index 0000000..f3e7b36 --- /dev/null +++ b/src/Entity/MainCategory.php @@ -0,0 +1,30 @@ +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; + } + + +} diff --git a/src/Entity/Journal.php b/src/Entity/Nzine.php similarity index 50% rename from src/Entity/Journal.php rename to src/Entity/Nzine.php index 7edfd5a..ca4be60 100644 --- a/src/Entity/Journal.php +++ b/src/Entity/Nzine.php @@ -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 #[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 #[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 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 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; + } } diff --git a/src/Entity/NzineBot.php b/src/Entity/NzineBot.php new file mode 100644 index 0000000..79c4b9c --- /dev/null +++ b/src/Entity/NzineBot.php @@ -0,0 +1,45 @@ +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; + } +} diff --git a/src/Form/DataTransformer/CommaSeparatedToArrayTransformer.php b/src/Form/DataTransformer/CommaSeparatedToArrayTransformer.php new file mode 100644 index 0000000..321c738 --- /dev/null +++ b/src/Form/DataTransformer/CommaSeparatedToArrayTransformer.php @@ -0,0 +1,52 @@ +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 + ]); + } +} diff --git a/src/Form/NzineBotType.php b/src/Form/NzineBotType.php new file mode 100644 index 0000000..f9dc068 --- /dev/null +++ b/src/Form/NzineBotType.php @@ -0,0 +1,32 @@ +add('name', TextType::class, [ + 'required' => true + ]) + ->add('about', TextareaType::class, [ + 'required' => false + ]) + ->add('submit', SubmitType::class) + ; + } + + public function configureOptions(OptionsResolver $resolver) + { + } +} diff --git a/src/Form/NzineType.php b/src/Form/NzineType.php new file mode 100644 index 0000000..86d707e --- /dev/null +++ b/src/Form/NzineType.php @@ -0,0 +1,30 @@ +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 + { + } +} diff --git a/src/Repository/ArticleRepository.php b/src/Repository/ArticleRepository.php new file mode 100644 index 0000000..161f307 --- /dev/null +++ b/src/Repository/ArticleRepository.php @@ -0,0 +1,17 @@ + + * @extends ServiceEntityRepository */ -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 // ; // } - // public function findOneBySomeField($value): ?Journal + // public function findOneBySomeField($value): ?Nzine // { // return $this->createQueryBuilder('j') // ->andWhere('j.exampleField = :val') diff --git a/src/Service/EncryptionService.php b/src/Service/EncryptionService.php new file mode 100644 index 0000000..cf788af --- /dev/null +++ b/src/Service/EncryptionService.php @@ -0,0 +1,36 @@ +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 + } +} diff --git a/src/Twig/Components/IndexTabs.php b/src/Twig/Components/IndexTabs.php new file mode 100644 index 0000000..2d53fc2 --- /dev/null +++ b/src/Twig/Components/IndexTabs.php @@ -0,0 +1,62 @@ + 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.', + }; + } +} diff --git a/src/Twig/Components/Molecules/Card.php b/src/Twig/Components/Molecules/Card.php index 41ce453..a978e30 100644 --- a/src/Twig/Components/Molecules/Card.php +++ b/src/Twig/Components/Molecules/Card.php @@ -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 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]); + } + } } diff --git a/src/Twig/Components/Organisms/ZineList.php b/src/Twig/Components/Organisms/ZineList.php new file mode 100644 index 0000000..405e859 --- /dev/null +++ b/src/Twig/Components/Organisms/ZineList.php @@ -0,0 +1,22 @@ +nzines = $nzines ?? $this->entityManager->getRepository(Nzine::class)->findAll(); + } +} diff --git a/templates/components/Atoms/NameOrNpub.html.twig b/templates/components/Atoms/NameOrNpub.html.twig index 67a33ad..3d943f1 100644 --- a/templates/components/Atoms/NameOrNpub.html.twig +++ b/templates/components/Atoms/NameOrNpub.html.twig @@ -1,7 +1,7 @@ - {% 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 }} diff --git a/templates/components/Header.html.twig b/templates/components/Header.html.twig index d4b1e2a..a5b6da3 100644 --- a/templates/components/Header.html.twig +++ b/templates/components/Header.html.twig @@ -1,3 +1,3 @@
- +
diff --git a/templates/components/IndexTabs.html.twig b/templates/components/IndexTabs.html.twig new file mode 100644 index 0000000..8181b3b --- /dev/null +++ b/templates/components/IndexTabs.html.twig @@ -0,0 +1,23 @@ +
+ + + + +
+
+ {{ this.tabContent()|raw }} +
+
+
diff --git a/templates/components/Molecules/Card.html.twig b/templates/components/Molecules/Card.html.twig index 871661e..1b1d4eb 100644 --- a/templates/components/Molecules/Card.html.twig +++ b/templates/components/Molecules/Card.html.twig @@ -1,8 +1,9 @@ +{% if article is defined %} <{{ tag }} {{ attributes }}>
{{ category }}
{{ article.createdAt|date('F j') }} -

{{ article.title }}

+

{{ article.title }}

{{ article.summary }}

@@ -11,3 +12,12 @@
+{% endif %} +{% if user is defined %} +<{{ tag }} {{ attributes }}> +
+

{{ user.name }}

+

{{ user.about }}

+
+ +{% endif %} diff --git a/templates/components/Molecules/UserFromNpub.html.twig b/templates/components/Molecules/UserFromNpub.html.twig index edc35ab..c09dea4 100644 --- a/templates/components/Molecules/UserFromNpub.html.twig +++ b/templates/components/Molecules/UserFromNpub.html.twig @@ -1,5 +1,5 @@ {% if user %} - {{ user.displayName }} + {% else %} {{ npub }} {% endif %} diff --git a/templates/components/Organisms/ZineList.html.twig b/templates/components/Organisms/ZineList.html.twig new file mode 100644 index 0000000..e1d5e8c --- /dev/null +++ b/templates/components/Organisms/ZineList.html.twig @@ -0,0 +1,5 @@ +
+ {% for item in nzines %} + + {% endfor %} +
diff --git a/templates/components/UserMenu.html.twig b/templates/components/UserMenu.html.twig index 88c0169..071ebcb 100644 --- a/templates/components/UserMenu.html.twig +++ b/templates/components/UserMenu.html.twig @@ -9,11 +9,14 @@ {% endif %}
    +
  • + Profile +
  • Write an article
  • - Create a journal + {{ 'heading.createNzine'|trans }}
  • {{ 'heading.logout'|trans }} diff --git a/templates/home.html.twig b/templates/home.html.twig index c08e6ad..a0ea23f 100644 --- a/templates/home.html.twig +++ b/templates/home.html.twig @@ -10,4 +10,5 @@ {% block aside %} {# sidebar #} + {% endblock %} diff --git a/templates/pages/author.html.twig b/templates/pages/author.html.twig index f9291b2..d5a7764 100644 --- a/templates/pages/author.html.twig +++ b/templates/pages/author.html.twig @@ -6,7 +6,13 @@ {{ author.about }}

    -
    - -
    + {% if nzine %} + View as N-Zine + {% endif %} + + +{% endblock %} + +{% block aside %} + {% endblock %} diff --git a/templates/pages/nzine-editor.html.twig b/templates/pages/nzine-editor.html.twig new file mode 100644 index 0000000..961b2a2 --- /dev/null +++ b/templates/pages/nzine-editor.html.twig @@ -0,0 +1,60 @@ +{% extends 'base.html.twig' %} + +{% block body %} + {% if nzine is not defined %} +

    {{ 'heading.createNzine'|trans }}

    + N-Zines are in active development. Expect weirdness. +

    + An N-Zine is a digital magazine definition for + collecting long form articles from the nostr 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 nostr bot which will be generated during the setup process. + Your currently logged-in npub will be assigned to the N-Zine as an editor, so you can come back later and tweak the filters. +

    + +

    N-Zine Details

    +

    + 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. +
    + We know it's lame, but right now we cannot automatically update your follows to include the N-Zine bot. +

    + + {{ form_start(form) }} + {{ form_end(form) }} + + + {% else %} +

    {{ 'heading.editNzine'|trans }}

    + +

    {{ bot.name }}

    +

    {{ bot.about }}

    + +

    Categories

    +

    + Create and edit categories. You can have as many as you like. Aim at up to 9 for the sake of your readers. +

    + + {{ form_start(catForm) }} + +
      + {% for cat in catForm.categories %} +
    • {{ form_widget(cat) }}
    • + {% endfor %} +
    + +
    +
      + +
      + + + + {{ form_end(catForm) }} + + {% endif %} +{% endblock %} diff --git a/templates/pages/nzine.html.twig b/templates/pages/nzine.html.twig new file mode 100644 index 0000000..1525cad --- /dev/null +++ b/templates/pages/nzine.html.twig @@ -0,0 +1,24 @@ +{% extends 'base.html.twig' %} + +{% block body %} +
      + {# TODO replace with main index data #} +

      {{ index.title }}

      +

      {{ index.slug }}

      +
      + +
      +
      + + + + {% if list is defined %} + + {% endif %} +
      +{% endblock %} diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 0a1dc67..0a2bfd1 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -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'