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