10 changed files with 556 additions and 21 deletions
@ -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} |
||||||
|
|
||||||
@ -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, |
||||||
|
]); |
||||||
|
} |
||||||
|
} |
||||||
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -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 %} |
||||||
|
|
||||||
@ -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 %} |
||||||
Loading…
Reference in new issue