From 19408363934fbce46556feaa16badd8da7deb4f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Mon, 1 Sep 2025 21:30:49 +0200 Subject: [PATCH] Magazine admin --- assets/app.js | 1 + assets/styles/utilities.css | 47 +++++ .../NostrEventFromYamlDefinitionCommand.php | 27 ++- .../MagazineAdminController.php | 130 +++++++++++++ .../MagazineEditorController.php | 178 ++++++++++++++++++ .../VisitorAnalyticsController.php | 2 + src/Controller/MagazineWizardController.php | 21 ++- templates/admin/magazine_editor.html.twig | 87 +++++++++ templates/admin/magazines.html.twig | 65 +++++++ templates/components/UserMenu.html.twig | 19 +- 10 files changed, 556 insertions(+), 21 deletions(-) create mode 100644 assets/styles/utilities.css create mode 100644 src/Controller/Administration/MagazineAdminController.php create mode 100644 src/Controller/Administration/MagazineEditorController.php create mode 100644 templates/admin/magazine_editor.html.twig create mode 100644 templates/admin/magazines.html.twig diff --git a/assets/app.js b/assets/app.js index 6645057..5725158 100644 --- a/assets/app.js +++ b/assets/app.js @@ -19,6 +19,7 @@ import './styles/spinner.css'; import './styles/a2hs.css'; import './styles/analytics.css'; import './styles/modal.css'; +import './styles/utilities.css'; console.log('This log comes from assets/app.js - welcome to AssetMapper! πŸŽ‰'); diff --git a/assets/styles/utilities.css b/assets/styles/utilities.css new file mode 100644 index 0000000..24abc79 --- /dev/null +++ b/assets/styles/utilities.css @@ -0,0 +1,47 @@ +/* Utility classes (plain CSS) - spacing, text, layout, alerts, etc. */ + +/* Spacing scale: 0=0, 1=.25rem, 2=.5rem, 3=1rem, 4=1.5rem, 5=3rem */ +.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important} +.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important} +.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important} +.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important} +.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important} +.mx-0{margin-left:0!important;margin-right:0!important}.mx-1{margin-left:.25rem!important;margin-right:.25rem!important}.mx-2{margin-left:.5rem!important;margin-right:.5rem!important}.mx-3{margin-left:1rem!important;margin-right:1rem!important}.mx-4{margin-left:1.5rem!important;margin-right:1.5rem!important}.mx-5{margin-left:3rem!important;margin-right:3rem!important} +.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important} + +/* Display & layout */ +.d-flex{display:flex!important} +.d-inline{display:inline!important} +.d-block{display:block!important} +.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important} + +/* Lists */ +.list-unstyled{list-style:none;padding-left:0;margin:0} + +/* Typography & colors */ +.text-muted{color:#6c757d!important} +.text-secondary{color:#6c757d!important} +.text-center{text-align:center!important} +.small{font-size:.875em} +.hidden{display:none!important} + +/* Badge */ +.badge{display:inline-block;padding:.25em .5em;font-size:.75em;font-weight:600;color:#fff;background:#6c757d;border-radius:.25rem;vertical-align:baseline} + +/* Alerts */ +.alert{position:relative;padding:.75rem 1rem;margin-bottom:1rem;border:1px solid transparent;border-radius:.25rem} +.alert-info{color:#055160;background-color:#e9f5ff;border-color:#b6e0fe} +.alert-success{color:#0f5132;background-color:#edf7ed;border-color:#c6e6c6} +.alert-danger{color:#842029;background-color:#f8d7da;border-color:#f5c2c7} + +/* Buttons - sizes only (base buttons defined elsewhere) */ +.btn-sm{padding:.25rem .5rem;font-size:.875rem;line-height:1.25;border-radius:.2rem} + +/* Spinner (bootstrap-like) */ +.spinner-border{display:inline-block;width:1.5rem;height:1.5rem;border:.2em solid currentColor;border-right-color:transparent;border-radius:50%;animation:spinner-border .75s linear infinite} +.spinner-border-sm{width:1rem;height:1rem;border-width:.15em} +@keyframes spinner-border{to{transform:rotate(360deg)}} + +/* Details/Summary tweaks */ +details>summary{cursor:pointer} + diff --git a/src/Command/NostrEventFromYamlDefinitionCommand.php b/src/Command/NostrEventFromYamlDefinitionCommand.php index ff56c1f..9dd51de 100644 --- a/src/Command/NostrEventFromYamlDefinitionCommand.php +++ b/src/Command/NostrEventFromYamlDefinitionCommand.php @@ -20,6 +20,7 @@ use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; use Symfony\Component\Finder\Finder; use Symfony\Component\Yaml\Yaml; use Symfony\Contracts\Cache\CacheInterface; +use Redis as RedisClient; #[AsCommand(name: 'app:yaml_to_nostr', description: 'Traverses folders, converts YAML files to JSON using object mapping, and saves the result in Redis cache.')] class NostrEventFromYamlDefinitionCommand extends Command @@ -31,7 +32,8 @@ class NostrEventFromYamlDefinitionCommand extends Command private readonly ArticleFactory $factory, ParameterBagInterface $bag, private readonly ObjectPersisterInterface $itemPersister, - private readonly EntityManagerInterface $entityManager) + private readonly EntityManagerInterface $entityManager, + private readonly RedisClient $redis) { $this->nsec = $bag->get('nsec'); parent::__construct(); @@ -87,11 +89,24 @@ class NostrEventFromYamlDefinitionCommand extends Command $slug = array_filter($tags, function ($tag) { return ($tag[0] === 'd'); }); - // Generate a Redis key - $cacheKey = 'magazine-' . $slug[0][1]; - $cacheItem = $this->redisCache->getItem($cacheKey); - $cacheItem->set($event); - $this->redisCache->save($cacheItem); + $slug = $slug[0][1] ?? null; + if ($slug) { + $cacheKey = 'magazine-' . $slug; + $cacheItem = $this->redisCache->getItem($cacheKey); + $cacheItem->set($event); + $this->redisCache->save($cacheItem); + + // If top-level magazine (has 'a' tags referencing 30040 categories), record slug + $isTopLevelMagazine = false; + foreach ($tags as $t) { + if (($t[0] ?? null) === 'a' && isset($t[1]) && str_starts_with((string)$t[1], '30040:')) { + $isTopLevelMagazine = true; break; + } + } + if ($isTopLevelMagazine) { + try { $this->redis->sAdd('magazine_slugs', $slug); } catch (\Throwable) {} + } + } $output->writeln("Saved index."); } catch (\Exception $e) { diff --git a/src/Controller/Administration/MagazineAdminController.php b/src/Controller/Administration/MagazineAdminController.php new file mode 100644 index 0000000..6c8d34c --- /dev/null +++ b/src/Controller/Administration/MagazineAdminController.php @@ -0,0 +1,130 @@ +sMembers('magazine_slugs'); + if (is_array($members)) { + $slugs = array_values(array_unique(array_filter($members))); + } + } catch (\Throwable) { + // ignore set errors + } + + // 2) Ensure the known main magazine is included if present in cache + try { + $main = $redisCache->get('magazine-newsroom-magazine-by-newsroom', fn() => null); + if ($main) { + if (!in_array('newsroom-magazine-by-newsroom', $slugs, true)) { + $slugs[] = 'newsroom-magazine-by-newsroom'; + } + } + } catch (\Throwable) { + // ignore + } + + // 3) Load magazine events and build structure + $magazines = []; + + // Helper to parse tags + $parse = function($event): array { + $title = null; $slug = null; $a = []; + foreach ((array) $event->getTags() as $tag) { + if (!is_array($tag) || !isset($tag[0])) continue; + if ($tag[0] === 'title' && isset($tag[1])) $title = $tag[1]; + if ($tag[0] === 'd' && isset($tag[1])) $slug = $tag[1]; + if ($tag[0] === 'a' && isset($tag[1])) $a[] = $tag[1]; + } + return [ + 'title' => $title ?? ($slug ?? '(untitled)'), + 'slug' => $slug ?? '', + 'a' => $a, + ]; + }; + + foreach ($slugs as $slug) { + $event = $redisCache->get('magazine-' . $slug, fn() => null); + if (!$event || !method_exists($event, 'getTags')) { + continue; + } + $data = $parse($event); + + // Resolve categories + $categories = []; + foreach ($data['a'] as $coord) { + if (!str_starts_with((string)$coord, '30040:')) continue; + $parts = explode(':', (string)$coord, 3); + if (count($parts) !== 3) continue; + $catSlug = $parts[2]; + $catEvent = $redisCache->get('magazine-' . $catSlug, fn() => null); + if (!$catEvent || !method_exists($catEvent, 'getTags')) continue; + $catData = $parse($catEvent); + + // Files under category from its 'a' coordinates + $files = []; + $repo = $em->getRepository(Article::class); + foreach ($catData['a'] as $aCoord) { + $partsA = explode(':', (string)$aCoord, 3); + if (count($partsA) !== 3) continue; + $artSlug = $partsA[2]; + $authorPubkey = $partsA[1] ?? ''; + $title = null; + if ($artSlug !== '') { + $article = $repo->findOneBy(['slug' => $artSlug]); + if ($article) { $title = $article->getTitle(); } + } + $files[] = [ + 'name' => $title ?? $artSlug, + 'slug' => $artSlug, + 'coordinate' => $aCoord, + 'authorPubkey' => $authorPubkey, + ]; + } + + $categories[] = [ + 'name' => $catData['title'], + 'slug' => $catData['slug'], + 'files' => $files, + ]; + } + + $magazines[] = [ + 'name' => $data['title'], + 'slug' => $data['slug'], + 'categories' => $categories, + ]; + } + + // Sort alphabetically + usort($magazines, fn($a, $b) => strcmp($a['name'], $b['name'])); + foreach ($magazines as &$mag) { + usort($mag['categories'], fn($a, $b) => strcmp($a['name'], $b['name'])); + foreach ($mag['categories'] as &$cat) { + usort($cat['files'], fn($a, $b) => strcmp($a['name'], $b['name'])); + } + } + + return $this->render('admin/magazines.html.twig', [ + 'magazines' => $magazines, + ]); + } +} diff --git a/src/Controller/Administration/MagazineEditorController.php b/src/Controller/Administration/MagazineEditorController.php new file mode 100644 index 0000000..3f7fd3c --- /dev/null +++ b/src/Controller/Administration/MagazineEditorController.php @@ -0,0 +1,178 @@ +getItem($key); + if (!$item->isHit()) { + throw $this->createNotFoundException('Index not found'); + } + $event = $item->get(); + if (!method_exists($event, 'getTags')) { + throw $this->createNotFoundException('Invalid index'); + } + + $tags = (array) $event->getTags(); + $title = $this->getTagValue($tags, 'title') ?? $slug; + $type = $this->detectIndexType($tags); + + // Search + $q = trim((string) $request->query->get('q', '')); + $results = []; + if ($q !== '') { + $query = [ + 'query' => [ + 'multi_match' => [ + 'query' => $q, + 'fields' => ['title^3', 'summary', 'content'], + ], + ], + 'size' => 50, + ]; + $results = $finder->find($query); + } + + // Current entries from 'a' tags + $current = []; + foreach ($tags as $t) { + if (is_array($t) && ($t[0] ?? null) === 'a' && isset($t[1])) { + $coord = (string) $t[1]; + $parts = explode(':', $coord, 3); + if (count($parts) === 3) { + $current[] = [ + 'coord' => $coord, + 'kind' => $parts[0], + 'pubkey' => $parts[1], + 'slug' => $parts[2], + ]; + } + } + } + + return $this->render('admin/magazine_editor.html.twig', [ + 'slug' => $slug, + 'title' => $title, + 'type' => $type, + 'q' => $q, + 'results' => $results, + 'current' => $current, + 'csrfToken' => $this->container->get('security.csrf.token_manager')->getToken('admin_mag_edit')->getValue(), + ]); + } + + #[Route('/edit/{slug}/add-article', name: 'admin_magazine_add_article', methods: ['POST'])] + public function addArticle( + string $slug, + Request $request, + CacheItemPoolInterface $cachePool, + CsrfTokenManagerInterface $csrf + ): RedirectResponse { + $token = (string) $request->request->get('_token'); + if (!$csrf->isTokenValid(new CsrfToken('admin_mag_edit', $token))) { + throw $this->createAccessDeniedException('Invalid CSRF'); + } + $articleSlug = trim((string) $request->request->get('article_slug', '')); + $pubkey = trim((string) $request->request->get('article_pubkey', '')); + if ($articleSlug === '' || $pubkey === '') { + return $this->redirectToRoute('admin_magazine_edit', ['slug' => $slug, 'q' => $request->request->get('q', '')]); + } + $coord = sprintf('30023:%s:%s', $pubkey, $articleSlug); + $key = 'magazine-' . $slug; + $item = $cachePool->getItem($key); + if (!$item->isHit()) { + throw $this->createNotFoundException('Index not found'); + } + $event = $item->get(); + $tags = (array) $event->getTags(); + foreach ($tags as $t) { + if (is_array($t) && ($t[0] ?? null) === 'a' && ($t[1] ?? null) === $coord) { + return $this->redirectToRoute('admin_magazine_edit', ['slug' => $slug, 'q' => $request->request->get('q', '')]); + } + } + array_unshift($tags, ['a', $coord]); + $event->setTags($tags); + $item->set($event); + $cachePool->save($item); + return $this->redirectToRoute('admin_magazine_edit', ['slug' => $slug, 'q' => $request->request->get('q', '')]); + } + + #[Route('/edit/{slug}/remove-article', name: 'admin_magazine_remove_article', methods: ['POST'])] + public function removeArticle( + string $slug, + Request $request, + CacheItemPoolInterface $cachePool, + CsrfTokenManagerInterface $csrf + ): RedirectResponse { + $token = (string) $request->request->get('_token'); + if (!$csrf->isTokenValid(new CsrfToken('admin_mag_edit', $token))) { + throw $this->createAccessDeniedException('Invalid CSRF'); + } + $articleSlug = trim((string) $request->request->get('article_slug', '')); + if ($articleSlug === '') { + return $this->redirectToRoute('admin_magazine_edit', ['slug' => $slug]); + } + $key = 'magazine-' . $slug; + $item = $cachePool->getItem($key); + if (!$item->isHit()) { + throw $this->createNotFoundException('Index not found'); + } + $event = $item->get(); + $tags = (array) $event->getTags(); + $tags = array_values(array_filter($tags, function ($t) use ($articleSlug) { + if (!is_array($t) || ($t[0] ?? null) !== 'a' || !isset($t[1])) { + return true; + } + $parts = explode(':', (string) $t[1], 3); + return (($parts[2] ?? '') !== $articleSlug); + })); + $event->setTags($tags); + $item->set($event); + $cachePool->save($item); + return $this->redirectToRoute('admin_magazine_edit', ['slug' => $slug]); + } + + private function detectIndexType(array $tags): string + { + foreach ($tags as $t) { + if (is_array($t) && ($t[0] ?? null) === 'a' && isset($t[1]) && str_starts_with((string) $t[1], '30040:')) { + return 'magazine'; + } + } + return 'category'; + } + + private function getTagValue(array $tags, string $name): ?string + { + foreach ($tags as $t) { + if (is_array($t) && ($t[0] ?? null) === $name && isset($t[1])) { + return (string) $t[1]; + } + } + return null; + } +} + diff --git a/src/Controller/Administration/VisitorAnalyticsController.php b/src/Controller/Administration/VisitorAnalyticsController.php index f8d1afb..0bb9b61 100644 --- a/src/Controller/Administration/VisitorAnalyticsController.php +++ b/src/Controller/Administration/VisitorAnalyticsController.php @@ -8,10 +8,12 @@ use App\Repository\VisitRepository; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Security\Http\Attribute\IsGranted; class VisitorAnalyticsController extends AbstractController { #[Route('/admin/analytics', name: 'admin_analytics')] + #[IsGranted('ROLE_ADMIN')] public function index(VisitRepository $visitRepository): Response { $visitStats = $visitRepository->getVisitCountByRoute(); diff --git a/src/Controller/MagazineWizardController.php b/src/Controller/MagazineWizardController.php index 6761ec5..81626a8 100644 --- a/src/Controller/MagazineWizardController.php +++ b/src/Controller/MagazineWizardController.php @@ -19,6 +19,7 @@ use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Csrf\CsrfToken; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; use swentel\nostr\Key\Key; +use Redis as RedisClient; class MagazineWizardController extends AbstractController { @@ -97,6 +98,7 @@ class MagazineWizardController extends AbstractController foreach ($draft->categories as $cat) { $tags = []; $tags[] = ['d', $cat->slug]; + $tags[] = ['type', 'magazine']; if ($cat->title) { $tags[] = ['title', $cat->title]; } if ($cat->summary) { $tags[] = ['summary', $cat->summary]; } foreach ($cat->tags as $t) { $tags[] = ['t', $t]; } @@ -125,6 +127,7 @@ class MagazineWizardController extends AbstractController $magTags = []; $magTags[] = ['d', $draft->slug]; + $magTags[] = ['type', 'magazine']; if ($draft->title) { $magTags[] = ['title', $draft->title]; } if ($draft->summary) { $magTags[] = ['summary', $draft->summary]; } if ($draft->imageUrl) { $magTags[] = ['image', $draft->imageUrl]; } @@ -159,7 +162,8 @@ class MagazineWizardController extends AbstractController public function publishIndexEvent( Request $request, CacheItemPoolInterface $redisCache, - CsrfTokenManagerInterface $csrfTokenManager + CsrfTokenManagerInterface $csrfTokenManager, + RedisClient $redis ): JsonResponse { // Verify CSRF token $csrfToken = $request->headers->get('X-CSRF-TOKEN'); @@ -210,6 +214,21 @@ class MagazineWizardController extends AbstractController return new JsonResponse(['error' => 'Redis error'], 500); } + // If the event is a top-level magazine index (references 30040 categories), record slug in a set for admin listing + try { + $isTopLevelMagazine = false; + foreach ($signedEvent['tags'] as $tag) { + if (($tag[0] ?? null) === 'a' && isset($tag[1]) && str_starts_with((string)$tag[1], '30040:')) { + $isTopLevelMagazine = true; break; + } + } + if ($isTopLevelMagazine) { + $redis->sAdd('magazine_slugs', $slug); + } + } catch (\Throwable $e) { + // non-fatal + } + return new JsonResponse(['ok' => true]); } diff --git a/templates/admin/magazine_editor.html.twig b/templates/admin/magazine_editor.html.twig new file mode 100644 index 0000000..cd9678b --- /dev/null +++ b/templates/admin/magazine_editor.html.twig @@ -0,0 +1,87 @@ +{% extends 'base.html.twig' %} + +{% block body %} +

Edit Index: {{ title }}

+
+ +
+
+ + +
+ + {% if q %} +

Results for β€œ{{ q }}”

+ {% if results is empty %} +
No results.
+ {% else %} + + + {% for article in results %} + + + + + + {% endfor %} + +
+ πŸ“„ {{ article.title }} +
slug: {{ article.slug }}
+
+ {% set pubkey = article.pubkey %} + {{ pubkey|slice(0,8) ~ '…' ~ pubkey|slice(-4) }} + +
+ + + + + +
+
+ {% endif %} + {% endif %} +
+ + +
+

Current entries

+ {% if current is empty %} +
No entries yet.
+ {% else %} + + + {% for item in current %} + + + + + + {% endfor %} + +
+ {% if item.kind == '30023' %} + πŸ“„ {{ item.slug }} + {% elseif item.kind == '30040' %} + πŸ“ {{ item.slug }} + {% else %} + {{ item.slug }} + {% endif %} +
coord: {{ item.coord }}
+
+ {{ item.pubkey|slice(0,8) ~ '…' ~ item.pubkey|slice(-4) }} + + {% if item.kind == '30023' %} +
+ + + +
+ {% endif %} +
+ {% endif %} +
+
+{% endblock %} + diff --git a/templates/admin/magazines.html.twig b/templates/admin/magazines.html.twig new file mode 100644 index 0000000..0935c75 --- /dev/null +++ b/templates/admin/magazines.html.twig @@ -0,0 +1,65 @@ +{% extends 'base.html.twig' %} + +{% block body %} +

Magazines

+ + {% if magazines is empty %} +

No magazines found.

+ {% else %} + + {% endif %} +{% endblock %} diff --git a/templates/components/UserMenu.html.twig b/templates/components/UserMenu.html.twig index 21e920a..b4ce2a0 100644 --- a/templates/components/UserMenu.html.twig +++ b/templates/components/UserMenu.html.twig @@ -5,30 +5,21 @@ {% if is_granted('ROLE_ADMIN') %}Admin{% endif %} {% if is_granted('ROLE_ADMIN') %} -{# #} + {% endif %}