Browse Source

bug-fixes

imwald
Silberengel 5 days ago
parent
commit
d5f0a70086
  1. 104
      assets/styles/layout.css
  2. 2
      config/unfold.yaml
  3. 26
      migrations/Version20260423120000.php
  4. 11
      src/Command/PrewarmCommand.php
  5. 8
      src/Controller/ArticleController.php
  6. 57
      src/Controller/FeaturedAuthorsController.php
  7. 64
      src/Controller/SeoController.php
  8. 89
      src/Entity/FeaturedAuthor.php
  9. 54
      src/Repository/FeaturedAuthorRepository.php
  10. 120
      src/Service/FeaturedAuthorSync.php
  11. 37
      src/Service/MagazineContentService.php
  12. 9
      src/Service/MagazineRefresher.php
  13. 30
      templates/components/Footer.html.twig
  14. 69
      templates/pages/author.html.twig
  15. 43
      templates/pages/featured_authors.html.twig
  16. 70
      templates/partial/author_profile_header.html.twig

104
assets/styles/layout.css

@ -255,10 +255,78 @@ footer { @@ -255,10 +255,78 @@ footer {
max-width: 40rem;
}
.site-footer__syndication-actions {
.site-footer__nav {
max-width: 44rem;
}
.site-footer__syndication-list {
display: flex;
flex-wrap: wrap;
align-items: baseline;
row-gap: 0.4rem;
list-style: none;
margin: 0;
padding: 0;
font-size: 0.95rem;
line-height: 1.5;
}
.site-footer__syndication-list > li {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 0.6rem;
align-items: center;
gap: 0.4rem 0.45rem;
}
.site-footer__syndication-list > li + li::before {
content: "·";
color: var(--color-text-mid, #666);
font-weight: 300;
align-self: center;
padding: 0 0.1rem 0 0;
}
.site-footer__link {
color: var(--color-footer-link);
text-decoration: underline;
text-underline-offset: 2px;
font-weight: 400;
}
.site-footer__link:hover {
color: var(--color-text);
}
.site-footer__link:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
}
/* RSS + category feed links in one cell */
.site-footer__syndication-list__feeds {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem 0.45rem;
max-width: 100%;
}
/* Dots between feed links (skip first <a> = "All articles"). */
.site-footer__syndication-list__feeds a:not(:first-of-type)::before {
content: "·";
color: var(--color-text-mid, #666);
font-weight: 300;
margin-right: 0.45rem;
text-decoration: none;
display: inline;
}
.site-footer__feeds-icon {
display: flex;
flex-shrink: 0;
line-height: 0;
color: var(--color-text-mid, #666);
opacity: 0.72;
}
.site-footer__main {
@ -297,6 +365,38 @@ footer .footer-links { @@ -297,6 +365,38 @@ footer .footer-links {
margin: 0 0 0.5rem;
}
.featured-authors {
max-width: 48rem;
margin: 0 auto;
padding: 0 0.5rem 2rem;
}
.featured-authors__intro {
margin-bottom: 2rem;
}
.featured-authors__intro h1 {
margin-top: 0;
}
.featured-authors__card {
margin-bottom: 2.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--color-border);
}
.featured-authors__card:last-of-type {
border-bottom: none;
}
.author-profile--featured .author-profile__title {
font-size: 1.5rem;
}
.featured-authors__more {
margin: 0.75rem 0 0;
}
.footer-links a {
color: var(--color-footer-link);
text-decoration: underline;

2
config/unfold.yaml

@ -31,6 +31,8 @@ parameters: @@ -31,6 +31,8 @@ parameters:
npub: 'npub1m4ny6hjqzepn4rxknuq94c2gpqzr29ufkkw7ttcxyak7v43n6vvsajc2jl'
d_tag: 'newsroom-magazine-on-imwald-by-laeserin'
community_articles: true
# Domain for site-assigned NIP-05 for featured (magazine category) authors; must match the host serving /.well-known/nostr.json
nip05_domain: 'blog.imwald.eu'
# Base URL for "Open in Jumble" on author profile (trailing slash optional; npub is appended as /{npub}).
jumble_profile_users_base: 'https://jumble.imwald.eu/users'
external_links:

26
migrations/Version20260423120000.php

@ -0,0 +1,26 @@ @@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260423120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Featured authors for site NIP-05 (category authors)';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE featured_author (id INT AUTO_INCREMENT NOT NULL, pubkey_hex VARCHAR(64) NOT NULL, local_part VARCHAR(100) NOT NULL, is_listed TINYINT(1) NOT NULL DEFAULT 1, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_8EED8C6CE479AD9 (pubkey_hex), UNIQUE INDEX UNIQ_8EED8C6CEEEB401 (local_part), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE featured_author');
}
}

11
src/Command/PrewarmCommand.php

@ -8,6 +8,7 @@ use App\Entity\Article; @@ -8,6 +8,7 @@ use App\Entity\Article;
use App\Repository\ArticleRepository;
use App\Service\ArticleCommentThreadLoader;
use App\Service\CacheService;
use App\Service\FeaturedAuthorSync;
use App\Service\MagazineContentService;
use App\Service\MagazineRefresher;
use App\Service\Nip09DeletionApplier;
@ -43,6 +44,7 @@ final class PrewarmCommand extends Command @@ -43,6 +44,7 @@ final class PrewarmCommand extends Command
private readonly ArticleCommentThreadLoader $commentThreadLoader,
private readonly ParameterBagInterface $params,
private readonly LoggerInterface $logger,
private readonly FeaturedAuthorSync $featuredAuthorSync,
) {
parent::__construct();
}
@ -115,6 +117,15 @@ final class PrewarmCommand extends Command @@ -115,6 +117,15 @@ final class PrewarmCommand extends Command
}
} else {
$io->note('Skipping magazine (--no-magazine).');
try {
$fa = $this->featuredAuthorSync->syncNewAuthorsFromMagazineCategories();
if ($fa > 0) {
$io->writeln(sprintf(' Featured authors: added <info>%d</info> new NIP-05 row(s) from the cached category index.', $fa));
}
} catch (\Throwable $e) {
$this->logger->warning('app:prewarm featured author sync (no-magazine)', ['e' => $e->getMessage()]);
$io->warning('Featured author sync failed: '.$e->getMessage());
}
}
$io->section('Long-form in DB (category `a` tags missing from MySQL)');

8
src/Controller/ArticleController.php

@ -278,7 +278,13 @@ class ArticleController extends AbstractController @@ -278,7 +278,13 @@ class ArticleController extends AbstractController
/**
* @throws InvalidArgumentException|CommonMarkException
*/
#[Route('/article/d/{slug}', name: 'article-slug')]
// Slug is the NIP-33 d-identifier and may contain "/"; default [^/]++ would break sitemap/URL generation.
#[Route(
path: '/article/d/{slug}',
name: 'article-slug',
requirements: ['slug' => '.+'],
options: ['utf8' => true],
)]
public function article(
$slug,
EntityManagerInterface $entityManager,

57
src/Controller/FeaturedAuthorsController.php

@ -0,0 +1,57 @@ @@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\FeaturedAuthorRepository;
use App\Service\CacheService;
use App\Service\ProfileIdentityLinksBuilder;
use App\Service\ProfilePaymentLinksBuilder;
use swentel\nostr\Key\Key;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* Renders the site-managed NIP-05 list of magazine category authors.
*/
final class FeaturedAuthorsController extends AbstractController
{
#[Route('/featured-authors', name: 'featured_authors', methods: ['GET'])]
public function index(
FeaturedAuthorRepository $featuredAuthorRepository,
CacheService $cacheService,
ProfileIdentityLinksBuilder $profileIdentityLinks,
ProfilePaymentLinksBuilder $profilePaymentLinks,
ParameterBagInterface $params,
): Response {
$domain = trim((string) $params->get('nip05_domain'));
$jumbleBase = rtrim((string) $params->get('jumble_profile_users_base'), '/');
$keys = new Key();
$authors = [];
foreach ($featuredAuthorRepository->findAllListedOrderByLocalPart() as $fa) {
$npub = $keys->convertPublicKeyToBech32($fa->getPubkeyHex());
$bundle = $cacheService->getMetadataBundle($npub);
$author = $bundle['content'];
$kind0Tags = $bundle['kind0_tags'];
$siteNip05 = $fa->getLocalPart().($domain !== '' ? '@'.$domain : '');
$jumbleProfileHref = $jumbleBase !== '' ? $jumbleBase.'/'.$npub : null;
$authors[] = [
'author' => $author,
'npub' => $npub,
'site_nip05' => $siteNip05,
'profile_websites' => $profileIdentityLinks->buildWebsites($author, $kind0Tags),
'profile_nip05' => $profileIdentityLinks->buildNip05($author, $kind0Tags),
'profile_payment_links' => $profilePaymentLinks->buildPaymentRows($author, $kind0Tags, []),
'jumble_profile_href' => $jumbleProfileHref,
];
}
return $this->render('pages/featured_authors.html.twig', [
'authors' => $authors,
'nip05_domain' => $domain,
]);
}
}

64
src/Controller/SeoController.php

@ -7,10 +7,12 @@ namespace App\Controller; @@ -7,10 +7,12 @@ namespace App\Controller;
use App\Entity\Article;
use App\Enum\EventStatusEnum;
use App\Repository\ArticleRepository;
use App\Repository\FeaturedAuthorRepository;
use App\Service\MagazineContentService;
use App\Service\MagazineIndexStore;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@ -28,6 +30,7 @@ final class SeoController extends AbstractController @@ -28,6 +30,7 @@ final class SeoController extends AbstractController
private readonly MagazineContentService $magazineContent,
private readonly MagazineIndexStore $magazineIndexStore,
private readonly ParameterBagInterface $params,
private readonly FeaturedAuthorRepository $featuredAuthorRepository,
) {
}
@ -42,6 +45,8 @@ final class SeoController extends AbstractController @@ -42,6 +45,8 @@ final class SeoController extends AbstractController
$urls[] = ['loc' => $this->absoluteUrlForRoute('articles'), 'lastmod' => null];
}
$urls[] = ['loc' => $this->absoluteUrlForRoute('featured_authors'), 'lastmod' => null];
foreach ($this->magazineContent->getCategorySlugsFromStore() as $slug) {
$urls[] = [
'loc' => $this->absoluteUrlForRoute('magazine-category', ['slug' => $slug]),
@ -90,6 +95,65 @@ final class SeoController extends AbstractController @@ -90,6 +95,65 @@ final class SeoController extends AbstractController
);
}
/**
* NIP-05 well-known: maps site-assigned local-parts to hex pubkeys (featured magazine authors).
* Must not redirect. Includes recommended `relays` for clients when profile relay URLs are configured.
*/
#[Route(path: '/.well-known/nostr.json', name: 'nostr_well_known', methods: ['GET', 'HEAD'])]
public function nostrWellKnown(): JsonResponse
{
$rows = $this->featuredAuthorRepository->findAllListedOrderByLocalPart();
$names = [];
foreach ($rows as $r) {
$names[$r->getLocalPart()] = strtolower($r->getPubkeyHex());
}
$payload = ['names' => $names];
$relays = $this->buildRelaysByPubkey($names);
if ($relays !== []) {
$payload['relays'] = $relays;
}
$headers = [
'Content-Type' => 'application/json; charset=UTF-8',
'Access-Control-Allow-Origin' => '*',
'Cache-Control' => 'public, max-age=120',
];
return new JsonResponse(
$payload,
Response::HTTP_OK,
$headers
);
}
/**
* @param array<string, string> $names local-part => hex pubkey
*
* @return array<string, list<string>>
*/
private function buildRelaysByPubkey(array $names): array
{
$raw = $this->params->get('profile_relays');
if (!\is_array($raw) || $raw === []) {
return [];
}
$urls = [];
foreach ($raw as $u) {
if (\is_string($u) && (str_starts_with($u, 'wss://') || str_starts_with($u, 'ws://'))) {
$urls[] = $u;
}
}
if ($urls === []) {
return [];
}
$out = [];
foreach ($names as $hex) {
$out[strtolower($hex)] = $urls;
}
return $out;
}
#[Route('/feeds/magazine.xml', name: 'feed_magazine', methods: ['GET'])]
public function feedMagazine(Request $request): Response
{

89
src/Entity/FeaturedAuthor.php

@ -0,0 +1,89 @@ @@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\FeaturedAuthorRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
/**
* Site-assigned NIP-05 for authors who appear in a magazine category index.
* Rows are only removed or deactivated manually (is_listed = false); sync only adds new pubkeys.
*/
#[ORM\Entity(repositoryClass: FeaturedAuthorRepository::class)]
#[ORM\Table(name: 'featured_author')]
class FeaturedAuthor
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 64, unique: true)]
private string $pubkeyHex = '';
/**
* NIP-05 local-part (a–z, 0–9, -, _, .) unique across all rows.
*/
#[ORM\Column(length: 100, unique: true)]
private string $localPart = '';
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => true])]
private bool $isListed = true;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private \DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getPubkeyHex(): string
{
return $this->pubkeyHex;
}
public function setPubkeyHex(string $pubkeyHex): static
{
$this->pubkeyHex = $pubkeyHex;
return $this;
}
public function getLocalPart(): string
{
return $this->localPart;
}
public function setLocalPart(string $localPart): static
{
$this->localPart = $localPart;
return $this;
}
public function isListed(): bool
{
return $this->isListed;
}
public function setIsListed(bool $isListed): static
{
$this->isListed = $isListed;
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
}

54
src/Repository/FeaturedAuthorRepository.php

@ -0,0 +1,54 @@ @@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\FeaturedAuthor;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<FeaturedAuthor>
*/
class FeaturedAuthorRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, FeaturedAuthor::class);
}
public function findOneByPubkeyHex(string $pubkeyHex): ?FeaturedAuthor
{
$h = strtolower($pubkeyHex);
return $this->findOneBy(['pubkeyHex' => $h]);
}
public function isLocalPartTaken(string $localPart, ?int $exceptId = null): bool
{
$qb = $this->createQueryBuilder('f')
->select('COUNT(f.id)')
->where('f.localPart = :lp')
->setParameter('lp', $localPart);
if ($exceptId !== null) {
$qb->andWhere('f.id != :eid')->setParameter('eid', $exceptId);
}
return (int) $qb->getQuery()->getSingleScalarResult() > 0;
}
/**
* @return list<FeaturedAuthor>
*/
public function findAllListedOrderByLocalPart(): array
{
return $this->createQueryBuilder('f')
->where('f.isListed = :t')
->setParameter('t', true)
->orderBy('f.localPart', 'ASC')
->getQuery()
->getResult();
}
}

120
src/Service/FeaturedAuthorSync.php

@ -0,0 +1,120 @@ @@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\FeaturedAuthor;
use App\Repository\FeaturedAuthorRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key;
/**
* Adds {@see FeaturedAuthor} rows for pubkeys found in magazine category indices; assigns
* unique NIP-05 local-parts from kind-0 name when possible. Does not remove or re-list rows.
*/
final class FeaturedAuthorSync
{
public function __construct(
private readonly MagazineContentService $magazineContent,
private readonly FeaturedAuthorRepository $featuredAuthorRepository,
private readonly CacheService $cacheService,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
/**
* @return int Number of newly persisted authors
*/
public function syncNewAuthorsFromMagazineCategories(): int
{
$pubkeys = $this->magazineContent->getAllDistinctCategoryAuthorPubkeyHexes();
if ($pubkeys === []) {
return 0;
}
$keys = new Key();
$n = 0;
foreach ($pubkeys as $hex) {
if ($this->featuredAuthorRepository->findOneByPubkeyHex($hex) !== null) {
continue;
}
$entity = new FeaturedAuthor();
$entity->setPubkeyHex($hex);
$base = $this->deriveBaseLocalPart($keys, $hex);
$entity->setLocalPart($this->allocateUniqueLocalPart($base));
$this->entityManager->persist($entity);
++$n;
}
if ($n > 0) {
$this->entityManager->flush();
$this->logger->info('featured_author.sync', ['new_count' => $n]);
}
return $n;
}
private function deriveBaseLocalPart(Key $keys, string $pubkeyHex): string
{
try {
$npub = $keys->convertPublicKeyToBech32($pubkeyHex);
} catch (\Throwable) {
$npub = null;
}
if (!\is_string($npub) || $npub === '') {
return 'author'.substr($pubkeyHex, 0, 8);
}
$name = '';
try {
$c = $this->cacheService->getMetadata($npub);
$name = (string) ($c->display_name ?? $c->name ?? '');
} catch (\Throwable) {
}
$base = $this->nip05LocalPartFromLabel($name);
if ($base === '') {
$base = 'author'.substr($pubkeyHex, 0, 8);
}
return $base;
}
/**
* NIP-05: local-part uses only a–z, 0–9, -, _, .
*/
private function nip05LocalPartFromLabel(string $raw): string
{
$s = strtolower(trim($raw));
$t = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
if (\is_string($t) && $t !== '') {
$s = strtolower($t);
}
$s = preg_replace('/[^a-z0-9._-]+/', '', $s) ?? '';
$s = trim((string) $s, '._-');
if (\strlen($s) > 40) {
$s = substr($s, 0, 40);
}
$s = trim($s, '._-');
return $s;
}
private function allocateUniqueLocalPart(string $base): string
{
if ($base === '') {
$base = 'author';
}
if (!$this->featuredAuthorRepository->isLocalPartTaken($base)) {
return $base;
}
for ($i = 1; $i < 10_000; ++$i) {
$c = $base.$i;
if (!$this->featuredAuthorRepository->isLocalPartTaken($c)) {
return $c;
}
}
return $base.bin2hex(random_bytes(3));
}
}

37
src/Service/MagazineContentService.php

@ -100,6 +100,43 @@ final class MagazineContentService @@ -100,6 +100,43 @@ final class MagazineContentService
return array_values(array_unique($out));
}
/**
* Distinct author pubkeys (hex) from every category index `a` tag (kind:pubkey:identifier).
*
* @return list<string>
*/
public function getAllDistinctCategoryAuthorPubkeyHexes(): array
{
$seen = [];
$out = [];
foreach ($this->getCategorySlugsFromStore() as $slug) {
$catIndex = $this->store->getCategory($slug);
if ($catIndex === null) {
continue;
}
foreach ($catIndex->getTags() as $tag) {
if (!\is_array($tag) || ($tag[0] ?? null) !== 'a' || !isset($tag[1])) {
continue;
}
$parts = explode(':', (string) $tag[1], 3);
if (\count($parts) < 2) {
continue;
}
$pk = strtolower((string) $parts[1]);
if (64 !== \strlen($pk) || !ctype_xdigit($pk)) {
continue;
}
if (isset($seen[$pk])) {
continue;
}
$seen[$pk] = true;
$out[] = $pk;
}
}
return $out;
}
/**
* Title from cached category index event tags, or the slug when missing.
*/

9
src/Service/MagazineRefresher.php

@ -23,6 +23,7 @@ final class MagazineRefresher @@ -23,6 +23,7 @@ final class MagazineRefresher
private readonly ParameterBagInterface $params,
private readonly LoggerInterface $logger,
private readonly CacheItemPoolInterface $appCache,
private readonly FeaturedAuthorSync $featuredAuthorSync,
) {
}
@ -105,6 +106,14 @@ final class MagazineRefresher @@ -105,6 +106,14 @@ final class MagazineRefresher
}
}
try {
$this->featuredAuthorSync->syncNewAuthorsFromMagazineCategories();
} catch (\Throwable $e) {
$this->logger->warning('MagazineRefresher: featured author sync failed', [
'message' => $e->getMessage(),
]);
}
$this->touchLastRelayTime();
}

30
templates/components/Footer.html.twig

@ -1,15 +1,27 @@ @@ -1,15 +1,27 @@
<div class="site-footer">
<div class="site-footer__syndication" aria-label="Sitemap and syndication">
<div class="site-footer__syndication">
<h2 class="site-footer__syndication-title">Sitemap and feeds</h2>
<p class="site-footer__syndication-hint">For search engines and feed readers. Atom is supported by most clients.</p>
<div class="site-footer__syndication-actions">
<a class="btn btn-secondary" href="{{ path('sitemap') }}">Sitemap (XML)</a>
<a class="btn btn-secondary" href="{{ path('robots_txt') }}">Robots</a>
<a class="btn btn-secondary" href="{{ path('feed_magazine') }}">Atom — all articles</a>
{% for c in categoriesForFeed %}
<a class="btn btn-secondary" href="{{ path('feed_category', {slug: c.slug}) }}">Atom — {{ c.title }}</a>
{% endfor %}
</div>
<nav class="site-footer__nav" aria-label="Sitemap, feeds, and index">
<ul class="site-footer__syndication-list">
<li><a class="site-footer__link" href="{{ path('featured_authors') }}">Featured authors</a></li>
<li><a class="site-footer__link" href="{{ path('sitemap') }}">Sitemap (XML)</a></li>
<li><a class="site-footer__link" href="{{ path('robots_txt') }}">Robots</a></li>
<li class="site-footer__syndication-list__feeds">
<span class="site-footer__feeds-icon" title="RSS/Atom" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" width="16" height="16" focusable="false" fill="currentColor">
<circle cx="1.5" cy="6.5" r="1"/>
<path d="M0 3.5A4.5 4.5 0 0 1 4.5 8V6A2.5 2.5 0 0 0 2 3.5H0z"/>
<path d="M0 0A8 8 0 0 1 8 8H6.5A6.5 6.5 0 0 0 0 1.5V0z"/>
</svg>
</span>
<a class="site-footer__link" href="{{ path('feed_magazine') }}">All articles</a>
{% for c in categoriesForFeed %}
<a class="site-footer__link" href="{{ path('feed_category', {slug: c.slug}) }}">{{ c.title }}</a>
{% endfor %}
</li>
</ul>
</nav>
</div>
<div class="site-footer__main">
<div class="footer-links">

69
templates/pages/author.html.twig

@ -1,68 +1,15 @@ @@ -1,68 +1,15 @@
{% extends 'base.html.twig' %}
{% block body %}
{% set author_pic = null %}
{% if author.picture is defined and author.picture %}
{% set author_pic = author.picture %}
{% elseif author.image is defined and author.image %}
{% set author_pic = author.image %}
{% endif %}
{% set author_label = author.display_name|default(author.name|default(npub|shortenNpub)) %}
<div class="author-profile">
{% if author_pic %}
<div class="author-profile__avatar">
<img src="{{ author_pic }}" alt="{{ author_label }}" loading="lazy" decoding="async" onerror="this.parentElement.remove()" />
</div>
{% endif %}
<h1 class="author-profile__title"><twig:Atoms:NameOrNpub :author="author" :npub="npub"></twig:Atoms:NameOrNpub></h1>
<div class="author-profile__header-meta">
{% if profile_websites is not empty %}
<ul class="author-profile__identity" aria-label="Websites">
{% for row in profile_websites %}
<li class="author-profile__identity-row">
<span class="author-profile__identity-type">Website</span>
<a class="author-profile__identity-link" href="{{ row.href|e('html_attr') }}" rel="nofollow noopener">{{ row.label|e }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% if profile_nip05 is not empty %}
<ul class="author-profile__identity" aria-label="NIP-05">
{% for row in profile_nip05 %}
<li class="author-profile__identity-row">
<span class="author-profile__identity-type">NIP-05</span>
<a class="author-profile__identity-link" href="{{ row.href|e('html_attr') }}" rel="nofollow noopener" title="NIP-05 verification document">{{ row.label|e }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% if profile_payment_links is not empty %}
<ul class="author-profile__payments" aria-label="Payment options">
{% for row in profile_payment_links %}
<li class="author-profile__payment">
<span class="author-profile__payment-type">{{ row.type_label }}</span>
<a class="author-profile__payment-link" href="{{ row.href|e('html_attr') }}" rel="nofollow noopener">{{ row.label|e }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
<div class="author-profile__about">
{% if author.about is defined %}
{{ author.about|markdown_to_html|mentionify|linkify }}
{% endif %}
</div>
{% if jumble_profile_href is not null and jumble_profile_href != '' %}
<p class="author-profile__jumble">
<a class="btn btn-secondary" href="{{ jumble_profile_href|e('html_attr') }}" rel="nofollow noopener">View on Jumble</a>
</p>
{% endif %}
{% include 'partial/author_profile_header.html.twig' with {
author: author,
npub: npub,
profile_websites: profile_websites,
profile_nip05: profile_nip05,
profile_payment_links: profile_payment_links,
jumble_profile_href: jumble_profile_href,
} only %}
<hr class="author-profile__divider" />
<twig:Organisms:CardList :list="articles" class="article-list"></twig:Organisms:CardList>

43
templates/pages/featured_authors.html.twig

@ -0,0 +1,43 @@ @@ -0,0 +1,43 @@
{% extends 'base.html.twig' %}
{% block body %}
<div class="featured-authors">
<header class="featured-authors__intro">
<h1>Featured authors</h1>
<p class="text-subtle">
Authors whose long-form has been placed in a magazine category receive a
<abbr title="NIP-05">NIP-05</abbr> identifier
{% if nip05_domain|default('')|trim != '' %}
under <strong>{{ nip05_domain|e }}</strong>
{% endif %}
for easier discovery. Verification uses <code>/.well-known/nostr.json</code>.
</p>
</header>
{% for row in authors %}
{% set _fa_label = row.author.name|default('')|trim != '' ? row.author.name : (row.npub|shortenNpub) %}
<article class="featured-authors__card" aria-label="{{ _fa_label|e('html_attr') }}">
<div class="author-profile author-profile--featured">
{% include 'partial/author_profile_header.html.twig' with {
author: row.author,
npub: row.npub,
header_tag: 'h2',
profile_websites: row.profile_websites,
profile_nip05: row.profile_nip05,
profile_payment_links: row.profile_payment_links,
jumble_profile_href: row.jumble_profile_href,
site_nip05: row.site_nip05,
} only %}
</div>
<p class="featured-authors__more">
<a class="btn btn-secondary" href="{{ path('author-profile', { npub: row.npub }) }}">Full profile</a>
</p>
</article>
{% else %}
<p class="text-subtle">No featured authors are listed yet. They appear when authors are added to magazine category indices and synced.</p>
{% endfor %}
</div>
{% endblock %}
{% block aside %}
{% endblock %}

70
templates/partial/author_profile_header.html.twig

@ -0,0 +1,70 @@ @@ -0,0 +1,70 @@
{# Shared author “header” + about (no article list). Expects: author, npub, profile_*, jumble_profile_href; optional site_nip05 #}
{% set author_pic = null %}
{% if author.picture is defined and author.picture %}
{% set author_pic = author.picture %}
{% elseif author.image is defined and author.image %}
{% set author_pic = author.image %}
{% endif %}
{% set author_label = author.display_name|default(author.name|default(npub|shortenNpub)) %}
{% if author_pic %}
<div class="author-profile__avatar">
<img src="{{ author_pic }}" alt="{{ author_label }}" loading="lazy" decoding="async" onerror="this.parentElement.remove()" />
</div>
{% endif %}
{% set header_tag = header_tag|default('h1') %}
<{{ header_tag }} class="author-profile__title"><twig:Atoms:NameOrNpub :author="author" :npub="npub"></twig:Atoms:NameOrNpub></{{ header_tag }}>
<div class="author-profile__header-meta">
{% if profile_websites is not empty %}
<ul class="author-profile__identity" aria-label="Websites">
{% for row in profile_websites %}
<li class="author-profile__identity-row">
<span class="author-profile__identity-type">Website</span>
<a class="author-profile__identity-link" href="{{ row.href|e('html_attr') }}" rel="nofollow noopener">{{ row.label|e }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% if site_nip05|default('')|trim != '' %}
<ul class="author-profile__identity" aria-label="Magazine NIP-05">
<li class="author-profile__identity-row">
<span class="author-profile__identity-type">Magazine</span>
<span class="author-profile__site-nip05 text-subtle">{{ site_nip05|e }}</span>
</li>
</ul>
{% endif %}
{% if profile_nip05 is not empty %}
<ul class="author-profile__identity" aria-label="NIP-05">
{% for row in profile_nip05 %}
<li class="author-profile__identity-row">
<span class="author-profile__identity-type">NIP-05</span>
<a class="author-profile__identity-link" href="{{ row.href|e('html_attr') }}" rel="nofollow noopener" title="NIP-05 verification document">{{ row.label|e }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% if profile_payment_links is not empty %}
<ul class="author-profile__payments" aria-label="Payment options">
{% for row in profile_payment_links %}
<li class="author-profile__payment">
<span class="author-profile__payment-type">{{ row.type_label }}</span>
<a class="author-profile__payment-link" href="{{ row.href|e('html_attr') }}" rel="nofollow noopener">{{ row.label|e }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
<div class="author-profile__about">
{% if author.about is defined %}
{{ author.about|markdown_to_html|mentionify|linkify }}
{% endif %}
</div>
{% if jumble_profile_href is not null and jumble_profile_href != '' %}
<p class="author-profile__jumble">
<a class="btn btn-secondary" href="{{ jumble_profile_href|e('html_attr') }}" rel="nofollow noopener">View on Jumble</a>
</p>
{% endif %}
Loading…
Cancel
Save