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 %}
+
+ {% endif %}
+ {% endif %}
+
+
+
+
+ Current entries
+ {% if current is empty %}
+ No entries yet.
+ {% else %}
+
+ {% 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 %}
+
+ {% for mag in magazines %}
+ -
+
+
+ π {{ mag.name }}
+ (slug: {{ mag.slug }})
+ Edit
+
+ {% if mag.categories is not empty %}
+
+ {% for cat in mag.categories %}
+ -
+
+
+ π {{ cat.name }} (slug: {{ cat.slug }})
+ Edit
+
+ {% if cat.files is not empty %}
+
+ {% else %}
+ No articles.
+ {% endif %}
+
+
+ {% endfor %}
+
+ {% else %}
+ No categories.
+ {% endif %}
+
+
+ {% endfor %}
+
+ {% 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 %}