44 changed files with 1264 additions and 27 deletions
@ -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++; |
||||
} |
||||
} |
||||
@ -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'); |
||||
} |
||||
} |
||||
@ -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)'); |
||||
} |
||||
} |
||||
@ -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'); |
||||
} |
||||
} |
||||
@ -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'); |
||||
} |
||||
} |
||||
@ -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'); |
||||
} |
||||
} |
||||
@ -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'); |
||||
} |
||||
} |
||||
@ -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'); |
||||
} |
||||
} |
||||
@ -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'); |
||||
} |
||||
} |
||||
@ -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'); |
||||
} |
||||
} |
||||
@ -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 |
||||
]); |
||||
} |
||||
|
||||
} |
||||
@ -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; |
||||
} |
||||
|
||||
|
||||
} |
||||
@ -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; |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
} |
||||
@ -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 |
||||
]); |
||||
} |
||||
} |
||||
@ -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) |
||||
{ |
||||
} |
||||
} |
||||
@ -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 |
||||
{ |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
|
||||
} |
||||
@ -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 |
||||
} |
||||
} |
||||
@ -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.', |
||||
}; |
||||
} |
||||
} |
||||
@ -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(); |
||||
} |
||||
} |
||||
@ -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> |
||||
|
||||
@ -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> |
||||
@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
|
||||
{% if user %} |
||||
<span>{{ user.displayName }}</span> |
||||
<twig:Atoms:NameOrNpub :author="user" /> |
||||
{% else %} |
||||
<span>{{ npub }}</span> |
||||
{% endif %} |
||||
|
||||
@ -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> |
||||
@ -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 %} |
||||
@ -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 %} |
||||
Loading…
Reference in new issue