Browse Source

Magazine admin

imwald
Nuša Pukšič 4 months ago
parent
commit
1940836393
  1. 1
      assets/app.js
  2. 47
      assets/styles/utilities.css
  3. 27
      src/Command/NostrEventFromYamlDefinitionCommand.php
  4. 130
      src/Controller/Administration/MagazineAdminController.php
  5. 178
      src/Controller/Administration/MagazineEditorController.php
  6. 2
      src/Controller/Administration/VisitorAnalyticsController.php
  7. 21
      src/Controller/MagazineWizardController.php
  8. 87
      templates/admin/magazine_editor.html.twig
  9. 65
      templates/admin/magazines.html.twig
  10. 19
      templates/components/UserMenu.html.twig

1
assets/app.js

@ -19,6 +19,7 @@ import './styles/spinner.css'; @@ -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! 🎉');

47
assets/styles/utilities.css

@ -0,0 +1,47 @@ @@ -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}

27
src/Command/NostrEventFromYamlDefinitionCommand.php

@ -20,6 +20,7 @@ use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; @@ -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 @@ -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 @@ -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("<info>Saved index.</info>");
} catch (\Exception $e) {

130
src/Controller/Administration/MagazineAdminController.php

@ -0,0 +1,130 @@ @@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Controller\Administration;
use App\Entity\Article;
use Doctrine\ORM\EntityManagerInterface;
use Redis as RedisClient;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Cache\CacheInterface;
class MagazineAdminController extends AbstractController
{
#[Route('/admin/magazines', name: 'admin_magazines')]
#[IsGranted('ROLE_ADMIN')]
public function index(RedisClient $redis, CacheInterface $redisCache, EntityManagerInterface $em): Response
{
// 1) Collect known top-level magazine slugs from Redis set (populated on publish)
$slugs = [];
try {
$members = $redis->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,
]);
}
}

178
src/Controller/Administration/MagazineEditorController.php

@ -0,0 +1,178 @@ @@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
namespace App\Controller\Administration;
use FOS\ElasticaBundle\Finder\FinderInterface;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/admin/magazines')]
#[IsGranted('ROLE_ADMIN')]
class MagazineEditorController extends AbstractController
{
#[Route('/edit/{slug}', name: 'admin_magazine_edit', methods: ['GET'])]
public function edit(
string $slug,
Request $request,
CacheItemPoolInterface $cachePool,
FinderInterface $finder
): Response {
$key = 'magazine-' . $slug;
$item = $cachePool->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;
}
}

2
src/Controller/Administration/VisitorAnalyticsController.php

@ -8,10 +8,12 @@ use App\Repository\VisitRepository; @@ -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();

21
src/Controller/MagazineWizardController.php

@ -19,6 +19,7 @@ use Symfony\Component\Routing\Attribute\Route; @@ -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 @@ -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 @@ -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 @@ -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 @@ -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]);
}

87
templates/admin/magazine_editor.html.twig

@ -0,0 +1,87 @@ @@ -0,0 +1,87 @@
{% extends 'base.html.twig' %}
{% block body %}
<h1>Edit Index: {{ title }}</h1>
<div class="d-flex gap-4">
<!-- Left: Search articles -->
<section style="flex:1;min-width:320px;">
<form method="get" action="{{ path('admin_magazine_edit', {slug: slug}) }}" class="mb-3 d-flex gap-2">
<input type="text" name="q" value="{{ q }}" placeholder="Search articles…" style="flex:1;">
<button class="btn btn-primary" type="submit">Search</button>
</form>
{% if q %}
<h3 class="mb-2">Results for “{{ q }}”</h3>
{% if results is empty %}
<div class="text-muted">No results.</div>
{% else %}
<table style="width:100%;border-collapse:collapse;">
<tbody>
{% for article in results %}
<tr>
<td style="padding:.25rem .5rem;vertical-align:top;">
📄 <a href="{{ path('article-slug', {slug: article.slug|url_encode}) }}">{{ article.title }}</a>
<div class="small text-muted">slug: {{ article.slug }}</div>
</td>
<td style="padding:.25rem .5rem;white-space:nowrap;vertical-align:top;">
{% set pubkey = article.pubkey %}
<a href="{{ path('author-redirect', {pubkey: pubkey}) }}">{{ pubkey|slice(0,8) ~ '…' ~ pubkey|slice(-4) }}</a>
</td>
<td style="padding:.25rem .5rem;text-align:right;vertical-align:top;">
<form method="post" action="{{ path('admin_magazine_add_article', {slug: slug}) }}">
<input type="hidden" name="_token" value="{{ csrfToken }}">
<input type="hidden" name="article_slug" value="{{ article.slug }}">
<input type="hidden" name="article_pubkey" value="{{ article.pubkey }}">
<input type="hidden" name="q" value="{{ q }}">
<button class="btn btn-sm" type="submit">Add</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endif %}
</section>
<!-- Right: Current index contents -->
<section style="flex:1;min-width:320px;">
<h3 class="mb-2">Current entries</h3>
{% if current is empty %}
<div class="text-muted">No entries yet.</div>
{% else %}
<table style="width:100%;border-collapse:collapse;">
<tbody>
{% for item in current %}
<tr>
<td style="padding:.25rem .5rem;vertical-align:top;">
{% if item.kind == '30023' %}
📄 <a href="{{ path('article-slug', {slug: item.slug|url_encode}) }}">{{ item.slug }}</a>
{% elseif item.kind == '30040' %}
📁 <a href="{{ path('admin_magazine_edit', {slug: item.slug}) }}">{{ item.slug }}</a>
{% else %}
{{ item.slug }}
{% endif %}
<div class="small text-muted">coord: {{ item.coord }}</div>
</td>
<td style="padding:.25rem .5rem;white-space:nowrap;vertical-align:top;">
<a href="{{ path('author-redirect', {pubkey: item.pubkey}) }}">{{ item.pubkey|slice(0,8) ~ '…' ~ item.pubkey|slice(-4) }}</a>
</td>
<td style="padding:.25rem .5rem;text-align:right;vertical-align:top;">
{% if item.kind == '30023' %}
<form method="post" action="{{ path('admin_magazine_remove_article', {slug: slug}) }}">
<input type="hidden" name="_token" value="{{ csrfToken }}">
<input type="hidden" name="article_slug" value="{{ item.slug }}">
<button class="btn btn-sm" type="submit">Remove</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</section>
</div>
{% endblock %}

65
templates/admin/magazines.html.twig

@ -0,0 +1,65 @@ @@ -0,0 +1,65 @@
{% extends 'base.html.twig' %}
{% block body %}
<h1>Magazines</h1>
{% if magazines is empty %}
<p>No magazines found.</p>
{% else %}
<ul class="list-unstyled">
{% for mag in magazines %}
<li class="mb-2">
<details open>
<summary>
<strong>📁 {{ mag.name }}</strong>
<small class="text-muted">(slug: {{ mag.slug }})</small>
<a class="btn btn-sm ms-2" href="{{ path('admin_magazine_edit', {slug: mag.slug}) }}">Edit</a>
</summary>
{% if mag.categories is not empty %}
<ul class="list-unstyled ms-3 mt-2">
{% for cat in mag.categories %}
<li class="mb-1">
<details>
<summary>
📁 {{ cat.name }} <small class="text-muted">(slug: {{ cat.slug }})</small>
<a class="btn btn-sm ms-2" href="{{ path('admin_magazine_edit', {slug: cat.slug}) }}">Edit</a>
</summary>
{% if cat.files is not empty %}
<div class="ms-3 mt-2">
<table class="file-table" style="width:100%;border-collapse:collapse;">
<tbody>
{% for file in cat.files %}
<tr>
<td style="padding:.25rem .5rem;vertical-align:top;">
📄 <a href="{{ path('article-slug', {slug: file.slug|url_encode}) }}">{{ file.name }}</a>
<div class="small text-muted">slug: {{ file.slug }}</div>
</td>
<td style="padding:.25rem .5rem;vertical-align:top;white-space:nowrap;">
{% if file.authorPubkey %}
<a href="{{ path('author-redirect', {pubkey: file.authorPubkey}) }}">{{ file.authorPubkey|slice(0,8) ~ '…' ~ file.authorPubkey|slice(-4) }}</a>
{% else %}
<span class="text-muted">unknown author</span>
{% endif %}
</td>
<td class="small text-muted" style="padding:.25rem .5rem;vertical-align:top;">{{ file.coordinate }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="ms-3 text-muted">No articles.</div>
{% endif %}
</details>
</li>
{% endfor %}
</ul>
{% else %}
<div class="ms-3 text-muted">No categories.</div>
{% endif %}
</details>
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}

19
templates/components/UserMenu.html.twig

@ -5,30 +5,21 @@ @@ -5,30 +5,21 @@
{% if is_granted('ROLE_ADMIN') %}<span class="badge">Admin</span>{% endif %}
</div>
{% if is_granted('ROLE_ADMIN') %}
{# <ul>#}
{# <li>#}
{# <a href="{{ path('admin_roles') }}">{{ 'heading.roles'|trans }}</a>#}
{# </li>#}
{# </ul>#}
<ul class="user-nav">
<li><a href="{{ path('admin_magazines') }}">Magazines</a></li>
<li><a href="{{ path('admin_analytics') }}">Visit Analytics</a></li>
</ul>
{% endif %}
<ul class="user-nav">
<li>
<a href="{{ path('editor-create') }}">Write an article</a>
</li>
{% if is_granted('ROLE_ADMIN') %}
<li>
<a href="{{ path('mag_wizard_setup') }}">Create a magazine</a>
<a href="{{ path('mag_wizard_setup') }}">Create magazine</a>
</li>
{% endif %}
{# <li>#}
{# <a href="{{ path('nzine_index') }}">{{ 'heading.createNzine'|trans }}</a>#}
{# </li>#}
<li>
<a href="{{ path('app_search_index') }}">{{ 'heading.search'|trans }}</a>
</li>
{# <li>#}
{# <a href="{{ path('app_index_index') }}">{{ 'heading.index'|trans }}</a>#}
{# </li>#}
<li>
<a href="/logout" data-action="live#$render">{{ 'heading.logout'|trans }}</a>
</li>

Loading…
Cancel
Save