Browse Source

more reformatting

imwald
Silberengel 3 days ago
parent
commit
92b0f42be0
  1. 92
      assets/styles/layout.css
  2. 22
      src/Controller/FeaturedAuthorsController.php
  3. 2
      src/Controller/SeoController.php
  4. 11
      src/Dto/FeaturedArticleCard.php
  5. 8
      src/Entity/Article.php
  6. 42
      src/Factory/ArticleFactory.php
  7. 4
      src/Repository/ArticleRepository.php
  8. 19
      src/Repository/FeaturedAuthorRepository.php
  9. 45
      src/Service/FeaturedAuthorListedRows.php
  10. 4
      src/Twig/Components/Organisms/FeaturedList.php
  11. 27
      src/Twig/SidebarFeaturedAuthorsExtension.php
  12. 4
      templates/base.html.twig
  13. 2
      templates/components/Molecules/Card.html.twig
  14. 8
      templates/components/Organisms/FeaturedList.html.twig
  15. 32
      templates/components/Organisms/SidebarFeaturedAuthors.html.twig
  16. 10
      templates/pages/article.html.twig
  17. 2
      translations/messages.en.yaml

92
assets/styles/layout.css

@ -51,6 +51,98 @@ @@ -51,6 +51,98 @@
text-decoration: none;
}
/* Left nav: featured authors (desktop only; same site logo as header when no Nostr picture) */
.sidebar-featured-authors {
display: none;
}
@media (min-width: 1025px) {
.sidebar-featured-authors {
display: block;
margin-top: 1.1rem;
padding-top: 0.9rem;
border-top: 1px solid var(--color-border);
}
.layout > nav .sidebar-featured-authors a,
.layout > nav .sidebar-featured-authors a:hover {
color: inherit;
text-decoration: none;
}
.sidebar-featured-authors__title {
margin: 0 0 0.55rem;
font-family: var(--font-family), sans-serif;
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.07em;
text-transform: uppercase;
color: color-mix(in srgb, var(--color-text-mid) 72%, var(--color-bg) 28%);
line-height: 1.3;
}
.sidebar-featured-authors__grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 0.45rem;
align-content: flex-start;
list-style: none;
margin: 0;
padding: 0;
}
.layout > nav .sidebar-featured-authors__item,
.sidebar-featured-authors__item {
margin: 0;
padding: 0;
list-style: none;
}
.sidebar-featured-authors__link {
display: block;
border-radius: 50%;
line-height: 0;
}
.sidebar-featured-authors__link:hover {
text-decoration: none;
}
.sidebar-featured-authors__link:hover .sidebar-featured-authors__avatar {
box-shadow: 0 0 0 2px var(--color-secondary);
}
.sidebar-featured-authors__link:focus-visible .sidebar-featured-authors__avatar,
.sidebar-featured-authors__link:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
}
.sidebar-featured-authors__avatar {
display: block;
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
background: var(--color-bg-light);
box-shadow: 0 0 0 1px var(--color-border);
}
.sidebar-featured-authors__avatar img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.sidebar-featured-authors__avatar img[src*="favicon-96x96"] {
object-fit: contain;
object-position: center;
padding: 0.2rem;
box-sizing: border-box;
}
}
/* Only the app chrome in Header.html.twig (#site-header). A bare `header` rule also
matched <header class="featured-authors__intro"> and fixed it under the real bar, hiding it. */
#site-header {

22
src/Controller/FeaturedAuthorsController.php

@ -5,8 +5,7 @@ declare(strict_types=1); @@ -5,8 +5,7 @@ declare(strict_types=1);
namespace App\Controller;
use App\Repository\FeaturedAuthorRepository;
use App\Service\CacheService;
use swentel\nostr\Key\Key;
use App\Service\FeaturedAuthorListedRows;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\Request;
@ -22,11 +21,10 @@ final class FeaturedAuthorsController extends AbstractController @@ -22,11 +21,10 @@ final class FeaturedAuthorsController extends AbstractController
public function index(
Request $request,
FeaturedAuthorRepository $featuredAuthorRepository,
CacheService $cacheService,
FeaturedAuthorListedRows $featuredAuthorListedRows,
ParameterBagInterface $params,
): Response {
$domain = trim((string) $params->get('nip05_domain'));
$keys = new Key();
$perPage = 25;
$page = max(1, $request->query->getInt('page', 1));
$total = $featuredAuthorRepository->countListed();
@ -35,21 +33,7 @@ final class FeaturedAuthorsController extends AbstractController @@ -35,21 +33,7 @@ final class FeaturedAuthorsController extends AbstractController
$page = $lastPage;
}
$offset = ($page - 1) * $perPage;
$authors = [];
foreach ($featuredAuthorRepository->findListedOrderByLocalPartPaginated($perPage, $offset) as $fa) {
$npub = $keys->convertPublicKeyToBech32($fa->getPubkeyHex());
$bundle = $cacheService->getMetadataBundle($npub);
$author = $bundle['content'];
$displayName = trim((string) ($author->display_name ?? $author->name ?? ''));
$picture = trim((string) ($author->picture ?? ''));
$authors[] = [
'npub' => $npub,
'pubkey' => strtolower($fa->getPubkeyHex()),
'display_name' => $displayName,
'picture' => $picture,
'local_part' => $fa->getLocalPart(),
];
}
$authors = $featuredAuthorListedRows->buildListedByLocalPartPage($perPage, $offset);
return $this->render('pages/featured_authors.html.twig', [
'authors' => $authors,

2
src/Controller/SeoController.php

@ -300,7 +300,7 @@ final class SeoController extends AbstractController @@ -300,7 +300,7 @@ final class SeoController extends AbstractController
$entryId = 'urn:web:'.$this->urlHostId($request)
.':db-article:'.($dbId !== null && $dbId !== '' ? (string) $dbId : \spl_object_id($article));
$pub = $article->getPublishedAt() ?? $article->getCreatedAt() ?? $tArticle;
$pub = $article->getDisplayDateTime() ?? $tArticle;
$out = "\n <entry>";
$out .= "\n <title>".$this->xmlText($title)."</title>";
$out .= "\n <link href=\"".$this->xmlAttr($permalink)."\" rel=\"alternate\" type=\"text/html\"/>";

11
src/Dto/FeaturedArticleCard.php

@ -16,6 +16,7 @@ final readonly class FeaturedArticleCard @@ -16,6 +16,7 @@ final readonly class FeaturedArticleCard
private ?string $summary,
private ?string $image,
private ?\DateTimeImmutable $createdAt,
private ?\DateTimeImmutable $publishedAt,
private ?string $pubkey,
) {
}
@ -50,6 +51,16 @@ final readonly class FeaturedArticleCard @@ -50,6 +51,16 @@ final readonly class FeaturedArticleCard
return $this->createdAt;
}
public function getPublishedAt(): ?\DateTimeImmutable
{
return $this->publishedAt;
}
public function getDisplayAt(): ?\DateTimeImmutable
{
return $this->publishedAt ?? $this->createdAt;
}
public function getPubkey(): ?string
{
return $this->pubkey;

8
src/Entity/Article.php

@ -227,6 +227,14 @@ class Article @@ -227,6 +227,14 @@ class Article
return $this;
}
/**
* Prefer NIP-23 {@see publishedAt} for display/SEO; fall back to event {@see createdAt}.
*/
public function getDisplayDateTime(): ?\DateTimeImmutable
{
return $this->publishedAt ?? $this->createdAt;
}
public function getTopics()
{
return $this->topics;

42
src/Factory/ArticleFactory.php

@ -20,7 +20,11 @@ class ArticleFactory @@ -20,7 +20,11 @@ class ArticleFactory
$entity = new Article();
$entity->setRaw($source);
$entity->setEventId($source->id);
$entity->setCreatedAt(\DateTimeImmutable::createFromFormat('U', (string)$source->created_at));
$created = $this->parseEventTimeValue($source->created_at ?? null);
if ($created === null) {
throw new InvalidArgumentException('Long-form event has invalid or missing created_at');
}
$entity->setCreatedAt($created);
$entity->setContent($source->content);
$entity->setKind(KindsEnum::from($source->kind));
$entity->setPubkey($source->pubkey);
@ -44,7 +48,10 @@ class ArticleFactory @@ -44,7 +48,10 @@ class ArticleFactory
$entity->setImage($tag[1]);
break;
case 'published_at':
$entity->setPublishedAt(\DateTimeImmutable::createFromFormat('U', (string)$tag[1]));
$parsed = $this->parseEventTimeValue($tag[1] ?? null);
if ($parsed !== null) {
$entity->setPublishedAt($parsed);
}
break;
case 't':
$entity->addTopic($tag[1]);
@ -56,4 +63,35 @@ class ArticleFactory @@ -56,4 +63,35 @@ class ArticleFactory
}
return $entity;
}
/**
* NIP-23 times are usually Unix seconds; `published_at` may be ISO or other strings that make createFromFormat('U', …) return false.
*/
private function parseEventTimeValue(mixed $raw): ?\DateTimeImmutable
{
if (!\is_string($raw) && !\is_int($raw) && !\is_float($raw)) {
return null;
}
$s = trim((string) $raw);
if ($s === '') {
return null;
}
if (ctype_digit($s)) {
$sec = (int) $s;
if ($sec > 0) {
return (new \DateTimeImmutable('@'.$sec))->setTimezone(new \DateTimeZone('UTC'));
}
return null;
}
$fromU = \DateTimeImmutable::createFromFormat('U', $s);
if ($fromU instanceof \DateTimeImmutable) {
return $fromU;
}
try {
return new \DateTimeImmutable($s);
} catch (\Exception) {
return null;
}
}
}

4
src/Repository/ArticleRepository.php

@ -104,7 +104,7 @@ class ArticleRepository extends ServiceEntityRepository @@ -104,7 +104,7 @@ class ArticleRepository extends ServiceEntityRepository
$conn = $this->getEntityManager()->getConnection();
$qb = $conn->createQueryBuilder();
$qb
->select('a.id', 'a.slug', 'a.title', 'a.summary', 'a.image', 'a.created_at', 'a.pubkey')
->select('a.id', 'a.slug', 'a.title', 'a.summary', 'a.image', 'a.created_at', 'a.published_at', 'a.pubkey')
->from('article', 'a')
->where($qb->expr()->in('a.slug', ':slugs'))
->setParameter('slugs', $slugs, ArrayParameterType::STRING)
@ -115,6 +115,7 @@ class ArticleRepository extends ServiceEntityRepository @@ -115,6 +115,7 @@ class ArticleRepository extends ServiceEntityRepository
$out = [];
foreach ($rows as $row) {
$ca = $row['created_at'] ?? null;
$pa = $row['published_at'] ?? null;
$out[] = new FeaturedArticleCard(
isset($row['id']) ? (int) $row['id'] : null,
isset($row['slug']) ? (string) $row['slug'] : null,
@ -122,6 +123,7 @@ class ArticleRepository extends ServiceEntityRepository @@ -122,6 +123,7 @@ class ArticleRepository extends ServiceEntityRepository
isset($row['summary']) ? (string) $row['summary'] : null,
isset($row['image']) ? (string) $row['image'] : null,
$ca !== null && $ca !== '' ? new \DateTimeImmutable((string) $ca) : null,
$pa !== null && $pa !== '' ? new \DateTimeImmutable((string) $pa) : null,
isset($row['pubkey']) ? (string) $row['pubkey'] : null,
);
}

19
src/Repository/FeaturedAuthorRepository.php

@ -51,6 +51,25 @@ class FeaturedAuthorRepository extends ServiceEntityRepository @@ -51,6 +51,25 @@ class FeaturedAuthorRepository extends ServiceEntityRepository
->getResult();
}
/**
* Listed authors who first appeared in a category index, most recently added first.
* {@see FeaturedAuthor::createdAt} is set when the row is created (sync discovered the pubkey in an `a` tag).
*
* @return list<FeaturedAuthor>
*/
public function findListedMostRecentlyAdded(int $limit, int $offset = 0): array
{
return $this->createQueryBuilder('f')
->where('f.isListed = :t')
->setParameter('t', true)
->orderBy('f.createdAt', 'DESC')
->addOrderBy('f.id', 'DESC')
->setFirstResult($offset)
->setMaxResults($limit)
->getQuery()
->getResult();
}
/**
* @return list<FeaturedAuthor>
*/

45
src/Service/FeaturedAuthorListedRows.php

@ -0,0 +1,45 @@ @@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Repository\FeaturedAuthorRepository;
use swentel\nostr\Key\Key;
/**
* NIP-05 / listed featured author rows (same shape as {@see \App\Controller\FeaturedAuthorsController}).
*/
final class FeaturedAuthorListedRows
{
public function __construct(
private readonly FeaturedAuthorRepository $featuredAuthorRepository,
private readonly CacheService $cacheService,
) {
}
/**
* @return list<array{npub: string, pubkey: string, display_name: string, picture: string, local_part: string}>
*/
public function buildListedByLocalPartPage(int $limit, int $offset = 0): array
{
$keys = new Key();
$authors = [];
foreach ($this->featuredAuthorRepository->findListedOrderByLocalPartPaginated($limit, $offset) as $fa) {
$npub = $keys->convertPublicKeyToBech32($fa->getPubkeyHex());
$bundle = $this->cacheService->getMetadataBundle($npub);
$author = $bundle['content'];
$displayName = trim((string) ($author->display_name ?? $author->name ?? ''));
$picture = trim((string) ($author->picture ?? ''));
$authors[] = [
'npub' => $npub,
'pubkey' => strtolower($fa->getPubkeyHex()),
'display_name' => $displayName,
'picture' => $picture,
'local_part' => $fa->getLocalPart(),
];
}
return $authors;
}
}

4
src/Twig/Components/Organisms/FeaturedList.php

@ -94,8 +94,8 @@ final class FeaturedList @@ -94,8 +94,8 @@ final class FeaturedList
private static function isNewer(FeaturedArticleCard $a, FeaturedArticleCard $b): bool
{
$ca = $a->getCreatedAt();
$cb = $b->getCreatedAt();
$ca = $a->getDisplayAt();
$cb = $b->getDisplayAt();
if ($ca === null) {
return false;
}

27
src/Twig/SidebarFeaturedAuthorsExtension.php

@ -0,0 +1,27 @@ @@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Twig;
use App\Service\FeaturedAuthorListedRows;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
/** Twig function for the left nav featured-author avatars (avoids Symfony UX component context quirks). */
final class SidebarFeaturedAuthorsExtension extends AbstractExtension
{
public function __construct(
private readonly FeaturedAuthorListedRows $featuredAuthorListedRows,
) {
}
public function getFunctions(): array
{
return [
new TwigFunction('sidebar_featured_author_rows', function (int $limit = 12): array {
return $this->featuredAuthorListedRows->buildListedByLocalPartPage($limit, 0);
}),
];
}
}

4
templates/base.html.twig

@ -37,6 +37,10 @@ @@ -37,6 +37,10 @@
<div class="layout">
<nav>
<twig:UserMenu />
{% set _sidebar_fa = sidebar_featured_author_rows(12) %}
{% if _sidebar_fa is not empty %}
{% include 'components/Organisms/SidebarFeaturedAuthors.html.twig' with { rows: _sidebar_fa } only %}
{% endif %}
{% block nav %}{% endblock %}
</nav>
<main>

2
templates/components/Molecules/Card.html.twig

@ -8,7 +8,7 @@ @@ -8,7 +8,7 @@
{% if not is_author_profile %}
<p>by <twig:Molecules:UserFromNpub ident="{{ article.pubkey }}" /></p>
{% endif %}
<small>{{ article.createdAt|date('F j Y') }}</small>
<small>{{ article.displayDateTime|date('F j Y') }}</small>
{% endif %}
</div>
<a href="{{ (article.pubkey and npub_from_hex(article.pubkey) != '') ? path('article', { npub: npub_from_hex(article.pubkey), slug: article.slug }) : path('article-legacy-redirect', { slug: article.slug }) }}">

8
templates/components/Organisms/FeaturedList.html.twig

@ -13,9 +13,9 @@ @@ -13,9 +13,9 @@
</div>
<div class="card-body">
<h2 class="card-title">{{ feature.title }}</h2>
{% if feature.createdAt %}
{% if feature.displayAt %}
<p class="featured-list__meta">
<time datetime="{{ feature.createdAt|date('c') }}">{{ feature.createdAt|date('F j, Y') }}</time>
<time datetime="{{ feature.displayAt|date('c') }}">{{ feature.displayAt|date('F j, Y') }}</time>
</p>
{% endif %}
<p class="lede truncate">
@ -32,9 +32,9 @@ @@ -32,9 +32,9 @@
<a href="{{ (item.pubkey and npub_from_hex(item.pubkey) != '') ? path('article', { npub: npub_from_hex(item.pubkey), slug: item.slug }) : path('article-legacy-redirect', { slug: item.slug }) }}">
<div class="card-body">
<h2 class="card-title">{{ item.title }}</h2>
{% if item.createdAt %}
{% if item.displayAt %}
<p class="featured-list__meta">
<time datetime="{{ item.createdAt|date('c') }}">{{ item.createdAt|date('F j, Y') }}</time>
<time datetime="{{ item.displayAt|date('c') }}">{{ item.displayAt|date('F j, Y') }}</time>
</p>
{% endif %}
<p class="lede truncate">

32
templates/components/Organisms/SidebarFeaturedAuthors.html.twig

@ -0,0 +1,32 @@ @@ -0,0 +1,32 @@
{% if rows is defined and rows is not empty %}
<section class="sidebar-featured-authors" aria-label="{{ 'sidebar.featured_authors'|trans }}">
<h2 class="sidebar-featured-authors__title">{{ 'sidebar.featured_authors'|trans }}</h2>
<ul class="sidebar-featured-authors__grid" role="list">
{% for row in rows %}
{% set _name = row.display_name|default('')|trim %}
{% set _label = _name != '' ? _name : (row.npub|shortenNpub) %}
{% set _avatar_src = row.picture|default('')|trim != '' ? row.picture : asset('icons/favicon-96x96.png') %}
<li class="sidebar-featured-authors__item">
<a
class="sidebar-featured-authors__link"
href="{{ path('author-profile', { npub: row.npub }) }}"
title="{{ _label|e('html_attr') }}"
>
<span class="sidebar-featured-authors__avatar">
<img
src="{{ _avatar_src|e('html_attr') }}"
alt=""
width="40"
height="40"
loading="lazy"
decoding="async"
onerror="this.onerror=null;this.src='{{ asset('icons/favicon-96x96.png')|e('js') }}';"
>
</span>
<span class="visually-hidden">{{ _label }}</span>
</a>
</li>
{% endfor %}
</ul>
</section>
{% endif %}

10
templates/pages/article.html.twig

@ -51,7 +51,7 @@ @@ -51,7 +51,7 @@
'description': _desc,
'image': _og_image,
'url': _canonical,
'datePublished': (article.publishedAt ?? article.createdAt)|date('c'),
'datePublished': article.displayDateTime|date('c'),
'mainEntityOfPage': {'@type': 'WebPage', '@id': _canonical},
'publisher': {'@type': 'Organization', 'name': website_name},
'author': {'@type': 'Person', 'name': _author_name != '' ? _author_name : (npub|default('Author'))}
@ -80,12 +80,8 @@ @@ -80,12 +80,8 @@
<twig:Molecules:UserFromNpub ident="{{ article.pubkey }}" />
</span>
<span>
{% if article.publishedAt is not null %}
<small>{{ article.publishedAt|date('F j, Y') }}</small>
{% else %}
<small>
{# <twig:ux:icon name="heroicons:pencil" class="icon" /> #}
{{ article.createdAt|date('F j, Y') }}</small><br>
{% if article.displayDateTime %}
<small>{{ article.displayDateTime|date('F j, Y') }}</small>
{% endif %}
</span>
</div>

2
translations/messages.en.yaml

@ -1,3 +1,5 @@ @@ -1,3 +1,5 @@
sidebar:
featured_authors: 'Featured authors'
text:
byline: 'By'
search: 'Search...'

Loading…
Cancel
Save