Browse Source

add pagination to articles and categories

clean up replies
imwald
Silberengel 5 days ago
parent
commit
a6e831db20
  1. 30
      assets/controllers/comment_reply_controller.js
  2. 23
      src/Controller/ArticleController.php
  3. 38
      src/Controller/AuthorController.php
  4. 7
      src/Controller/DefaultController.php
  5. 18
      src/Controller/FeaturedAuthorsController.php
  6. 34
      src/Controller/SearchController.php
  7. 58
      src/Repository/ArticleRepository.php
  8. 25
      src/Repository/FeaturedAuthorRepository.php
  9. 39
      src/Service/ArticleCommentThreadLoader.php
  10. 65
      src/Service/CommentReplyService.php
  11. 25
      src/Service/MagazineContentService.php
  12. 13
      templates/pages/author.html.twig
  13. 33
      templates/pages/category.html.twig
  14. 14
      templates/pages/featured_authors.html.twig
  15. 35
      templates/pages/search.html.twig

30
assets/controllers/comment_reply_controller.js

@ -13,7 +13,6 @@ export default class extends Controller {
articleEventId: String, articleEventId: String,
fragmentUrl: String, fragmentUrl: String,
refreshAfter: { type: Boolean, default: true }, refreshAfter: { type: Boolean, default: true },
blurbLabel: String,
expectedTags: Array, expectedTags: Array,
parentKind: Number, parentKind: Number,
parentId: String, parentId: String,
@ -68,16 +67,12 @@ export default class extends Controller {
return; return;
} }
this.setHint('Preparing event…'); this.setHint('Preparing event…');
// `nostr-tools` entry pulls @noble/curves (bare spec → breaks in AssetMapper). NIP-19 only needs bech32 helpers.
const { naddrEncode, neventEncode } = await import('nostr-tools/nip19');
const link = this.buildParentBech32(naddrEncode, neventEncode);
// NIP-22 quote line: must still mention nostr:… for server validation; UI strips this (see formatReplyBlurbForDisplay).
const blurb = `> Replying to **${this.blurbLabelValue}** (nostr:${link})\n\n`;
const unsigned = { const unsigned = {
kind: 1111, kind: 1111,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: this._tags, tags: this._tags,
content: blurb + text, // Keep user-authored content clean; reply context is encoded in NIP-22 tags.
content: text,
}; };
let signed; let signed;
try { try {
@ -138,27 +133,6 @@ export default class extends Controller {
return typeof window.nostr !== 'undefined' && typeof window.nostr.signEvent === 'function'; return typeof window.nostr !== 'undefined' && typeof window.nostr.signEvent === 'function';
} }
/**
* @param {function(object): string} naddrEncode
* @param {function(object): string} neventEncode
*/
buildParentBech32(naddrEncode, neventEncode) {
const allZero = /^0{64}$/.test(this.parentIdValue);
const parts = (this.expectedCoordinateValue || '').split(':');
const k = parts[0] ? parseInt(parts[0], 10) : 30023;
const pub = parts[1] || this.authorPubkeyValue;
const d = parts[2] || '';
if (allZero && d !== '') {
return naddrEncode({ kind: k, pubkey: pub, identifier: d, relays: [] });
}
return neventEncode({
id: this.parentIdValue,
kind: this.parentKindValue,
pubkey: this.authorPubkeyValue,
relays: [],
});
}
/** /**
* Reload the section HTML from the article comments fragment. After publishing, relays can lag; * Reload the section HTML from the article comments fragment. After publishing, relays can lag;
* if `expectedEventIdHex` is set, re-fetch with backoff until the new note appears (or a cap is hit). * if `expectedEventIdHex` is set, re-fetch with backoff until the new note appears (or a cap is hit).

23
src/Controller/ArticleController.php

@ -569,16 +569,25 @@ class ArticleController extends AbstractController
} }
/** /**
* Display latest 20 community articles * Display latest community articles (paginated).
*/ */
#[Route('/articles', name: 'articles')] #[Route('/articles', name: 'articles')]
public function latestArticles(EntityManagerInterface $entityManager): Response public function latestArticles(Request $request, EntityManagerInterface $entityManager): Response
{ {
set_time_limit(300); // 5 minutes set_time_limit(300); // 5 minutes
ini_set('max_execution_time', '300'); ini_set('max_execution_time', '300');
$articles = $entityManager->getRepository(Article::class) $perPage = 25;
->findBy([], ['createdAt' => 'DESC'], 20); $page = max(1, $request->query->getInt('page', 1));
$offset = ($page - 1) * $perPage;
$repo = $entityManager->getRepository(Article::class);
$total = $repo->count([]);
$lastPage = max(1, (int) ceil($total / $perPage));
if ($page > $lastPage) {
$page = $lastPage;
$offset = ($page - 1) * $perPage;
}
$articles = $repo->findBy([], ['createdAt' => 'DESC'], $perPage, $offset);
$category = (object) [ $category = (object) [
'title' => 'Community Articles', 'title' => 'Community Articles',
@ -589,6 +598,12 @@ class ArticleController extends AbstractController
'category' => $category, 'category' => $category,
'list' => $articles, 'list' => $articles,
'sync_slug' => '', 'sync_slug' => '',
'pagination' => [
'page' => $page,
'per_page' => $perPage,
'total' => $total,
'last_page' => $lastPage,
],
]); ]);
} }

38
src/Controller/AuthorController.php

@ -14,6 +14,7 @@ use App\Service\ProfilePaymentLinksBuilder;
use Exception; use Exception;
use swentel\nostr\Key\Key; use swentel\nostr\Key\Key;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@ -24,6 +25,7 @@ class AuthorController extends AbstractController
*/ */
#[Route('/p/{npub}', name: 'author-profile', requirements: ['npub' => '^npub1.*'])] #[Route('/p/{npub}', name: 'author-profile', requirements: ['npub' => '^npub1.*'])]
public function index( public function index(
Request $request,
$npub, $npub,
NostrClient $nostrClient, NostrClient $nostrClient,
CacheService $cacheService, CacheService $cacheService,
@ -44,29 +46,15 @@ class AuthorController extends AbstractController
$bundle = $cacheService->getMetadataBundle($npub); $bundle = $cacheService->getMetadataBundle($npub);
$author = $bundle['content']; $author = $bundle['content'];
$kind0Tags = $bundle['kind0_tags']; $kind0Tags = $bundle['kind0_tags'];
// Retrieve long-form content for the author $perPage = 25;
try { $page = max(1, $request->query->getInt('page', 1));
$list = $nostrClient->getLongFormContentForPubkey($npub); $total = $articleRepository->countByPubkey($pubkey);
} catch (Exception $e) { $lastPage = max(1, (int) ceil($total / $perPage));
$list = []; if ($page > $lastPage) {
} $page = $lastPage;
// Also look for articles in the database by pubkey
$dbArticles = $articleRepository->findByPubkey($pubkey, 25);
$list = array_merge($list, $dbArticles);
$articles = [];
// Deduplicate by slugs
foreach ($list as $item) {
if (!key_exists((string) $item->getSlug(), $articles)) {
$articles[(string) $item->getSlug()] = $item;
}
} }
$offset = ($page - 1) * $perPage;
// Sort articles by date $articles = $articleRepository->findByPubkeyPaginated($pubkey, $perPage, $offset);
usort($articles, function ($a, $b) {
return $b->getCreatedAt() <=> $a->getCreatedAt();
});
$kind10133 = []; $kind10133 = [];
try { try {
@ -92,6 +80,12 @@ class AuthorController extends AbstractController
'profile_websites' => $profileIdentityLinks->buildWebsites($author, $kind0Tags), 'profile_websites' => $profileIdentityLinks->buildWebsites($author, $kind0Tags),
'profile_nip05' => $profileNip05, 'profile_nip05' => $profileNip05,
'profile_payment_links' => $profilePaymentLinks->buildPaymentRows($author, $kind0Tags, $extraPayto), 'profile_payment_links' => $profilePaymentLinks->buildPaymentRows($author, $kind0Tags, $extraPayto),
'pagination' => [
'page' => $page,
'per_page' => $perPage,
'total' => $total,
'last_page' => $lastPage,
],
]); ]);
} }

7
src/Controller/DefaultController.php

@ -8,6 +8,7 @@ use App\Service\MagazineContentService;
use Exception; use Exception;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@ -27,13 +28,15 @@ class DefaultController extends AbstractController
} }
#[Route('/cat/{slug}', name: 'magazine-category')] #[Route('/cat/{slug}', name: 'magazine-category')]
public function magCategory(string $slug): Response public function magCategory(Request $request, string $slug): Response
{ {
$data = $this->magazineContent->getCategoryPageData($slug); $page = max(1, $request->query->getInt('page', 1));
$data = $this->magazineContent->getCategoryPageData($slug, $page, 25);
return $this->render('pages/category.html.twig', [ return $this->render('pages/category.html.twig', [
'list' => $data['list'], 'list' => $data['list'],
'category' => $data['category'], 'category' => $data['category'],
'pagination' => $data['pagination'],
'sync_slug' => $slug, 'sync_slug' => $slug,
]); ]);
} }

18
src/Controller/FeaturedAuthorsController.php

@ -12,6 +12,7 @@ use App\Service\ProfilePaymentLinksBuilder;
use swentel\nostr\Key\Key; use swentel\nostr\Key\Key;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@ -22,6 +23,7 @@ final class FeaturedAuthorsController extends AbstractController
{ {
#[Route('/featured-authors', name: 'featured_authors', methods: ['GET'])] #[Route('/featured-authors', name: 'featured_authors', methods: ['GET'])]
public function index( public function index(
Request $request,
FeaturedAuthorRepository $featuredAuthorRepository, FeaturedAuthorRepository $featuredAuthorRepository,
CacheService $cacheService, CacheService $cacheService,
NostrClient $nostrClient, NostrClient $nostrClient,
@ -31,8 +33,16 @@ final class FeaturedAuthorsController extends AbstractController
): Response { ): Response {
$domain = trim((string) $params->get('nip05_domain')); $domain = trim((string) $params->get('nip05_domain'));
$keys = new Key(); $keys = new Key();
$perPage = 25;
$page = max(1, $request->query->getInt('page', 1));
$total = $featuredAuthorRepository->countListed();
$lastPage = max(1, (int) ceil($total / $perPage));
if ($page > $lastPage) {
$page = $lastPage;
}
$offset = ($page - 1) * $perPage;
$authors = []; $authors = [];
foreach ($featuredAuthorRepository->findAllListedOrderByLocalPart() as $fa) { foreach ($featuredAuthorRepository->findListedOrderByLocalPartPaginated($perPage, $offset) as $fa) {
$npub = $keys->convertPublicKeyToBech32($fa->getPubkeyHex()); $npub = $keys->convertPublicKeyToBech32($fa->getPubkeyHex());
$bundle = $cacheService->getMetadataBundle($npub); $bundle = $cacheService->getMetadataBundle($npub);
$author = $bundle['content']; $author = $bundle['content'];
@ -54,6 +64,12 @@ final class FeaturedAuthorsController extends AbstractController
return $this->render('pages/featured_authors.html.twig', [ return $this->render('pages/featured_authors.html.twig', [
'authors' => $authors, 'authors' => $authors,
'nip05_domain' => $domain, 'nip05_domain' => $domain,
'pagination' => [
'page' => $page,
'per_page' => $perPage,
'total' => $total,
'last_page' => $lastPage,
],
]); ]);
} }
} }

34
src/Controller/SearchController.php

@ -4,15 +4,43 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Repository\ArticleRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
class SearchController extends AbstractController class SearchController extends AbstractController
{ {
#[Route('/search')] #[Route('/search', name: 'search', methods: ['GET'])]
public function index(): Response public function index(Request $request, ArticleRepository $articleRepository): Response
{ {
return $this->render('pages/search.html.twig'); $query = trim((string) $request->query->get('q', ''));
$perPage = 25;
$page = max(1, $request->query->getInt('page', 1));
$total = 0;
$results = [];
$lastPage = 1;
if ($query !== '') {
$total = $articleRepository->countSearchArticles($query);
$lastPage = max(1, (int) ceil($total / $perPage));
if ($page > $lastPage) {
$page = $lastPage;
}
$offset = ($page - 1) * $perPage;
$results = $articleRepository->searchArticles($query, $perPage, $offset);
}
return $this->render('pages/search.html.twig', [
'query' => $query,
'results' => $results,
'pagination' => [
'page' => $page,
'per_page' => $perPage,
'total' => $total,
'last_page' => $lastPage,
],
]);
} }
} }

58
src/Repository/ArticleRepository.php

@ -54,6 +54,42 @@ class ArticleRepository extends ServiceEntityRepository
->getResult(); ->getResult();
} }
public function countSearchArticles(string $query): int
{
$qb = $this->createQueryBuilder('a')
->select('COUNT(a.id)');
$searchTerms = explode(' ', trim($query));
$conditions = $qb->expr()->orX();
foreach ($searchTerms as $index => $term) {
$term = trim($term);
if (empty($term)) {
continue;
}
$paramName = 'term' . $index;
$termCondition = $qb->expr()->orX(
$qb->expr()->like('a.title', ':' . $paramName),
$qb->expr()->like('a.content', ':' . $paramName),
$qb->expr()->like('a.summary', ':' . $paramName)
);
$conditions->add($termCondition);
$qb->setParameter($paramName, '%' . $term . '%');
}
if (\count($conditions->getParts()) === 0) {
return 0;
}
return (int) $qb
->where($conditions)
->andWhere('a.content IS NOT NULL')
->andWhere('LENGTH(a.content) > 250')
->getQuery()
->getSingleScalarResult();
}
/** /**
* List-card fields only: avoids loading `content` / `raw` (can be very large) for home/category featured rows. * List-card fields only: avoids loading `content` / `raw` (can be very large) for home/category featured rows.
* *
@ -169,6 +205,28 @@ class ArticleRepository extends ServiceEntityRepository
->getResult(); ->getResult();
} }
public function findByPubkeyPaginated(string $pubkey, int $limit, int $offset): array
{
return $this->createQueryBuilder('a')
->where('a.pubkey = :pubkey')
->setParameter('pubkey', $pubkey)
->orderBy('a.createdAt', 'DESC')
->setFirstResult($offset)
->setMaxResults($limit)
->getQuery()
->getResult();
}
public function countByPubkey(string $pubkey): int
{
return (int) $this->createQueryBuilder('a')
->select('COUNT(a.id)')
->where('a.pubkey = :pubkey')
->setParameter('pubkey', $pubkey)
->getQuery()
->getSingleScalarResult();
}
/** /**
* Published or archived long-form rows for sitemap/Atom (may include multiple rows per slug); * Published or archived long-form rows for sitemap/Atom (may include multiple rows per slug);
* callers should dedupe by slug if URLs are slug-only. * callers should dedupe by slug if URLs are slug-only.

25
src/Repository/FeaturedAuthorRepository.php

@ -51,4 +51,29 @@ class FeaturedAuthorRepository extends ServiceEntityRepository
->getResult(); ->getResult();
} }
/**
* @return list<FeaturedAuthor>
*/
public function findListedOrderByLocalPartPaginated(int $limit, int $offset): array
{
return $this->createQueryBuilder('f')
->where('f.isListed = :t')
->setParameter('t', true)
->orderBy('f.localPart', 'ASC')
->setFirstResult($offset)
->setMaxResults($limit)
->getQuery()
->getResult();
}
public function countListed(): int
{
return (int) $this->createQueryBuilder('f')
->select('COUNT(f.id)')
->where('f.isListed = :t')
->setParameter('t', true)
->getQuery()
->getSingleScalarResult();
}
} }

39
src/Service/ArticleCommentThreadLoader.php

@ -302,6 +302,9 @@ final readonly class ArticleCommentThreadLoader
$raw = isset($ev->content) ? (string) $ev->content : ''; $raw = isset($ev->content) ? (string) $ev->content : '';
$split = $this->splitNip22ReplyBlurb($raw); $split = $this->splitNip22ReplyBlurb($raw);
$blurb = $split['blurb']; $blurb = $split['blurb'];
if ($blurb === null || trim($blurb) === '') {
$blurb = $this->replyBlurbFromAddressTag($ev);
}
if (($blurb === null || trim($blurb) === '') && $id !== '' && isset($parentOf[$id])) { if (($blurb === null || trim($blurb) === '') && $id !== '' && isset($parentOf[$id])) {
$pid = $parentOf[$id]; $pid = $parentOf[$id];
if (isset($idToEvent[$pid])) { if (isset($idToEvent[$pid])) {
@ -363,6 +366,42 @@ final readonly class ArticleCommentThreadLoader
return ['blurb' => $first, 'body' => $rest]; return ['blurb' => $first, 'body' => $rest];
} }
private function replyBlurbFromAddressTag(object $event): ?string
{
if (!isset($event->tags) || !\is_array($event->tags)) {
return null;
}
foreach ($event->tags as $row) {
if (!\is_array($row) || ($row[0] ?? null) === null || ($row[1] ?? null) === null) {
continue;
}
$name = (string) $row[0];
if ($name !== 'a' && $name !== 'A') {
continue;
}
$coord = (string) $row[1];
if ($coord === '') {
continue;
}
$parts = explode(':', $coord, 3);
if (\count($parts) !== 3) {
continue;
}
$kind = ctype_digit((string) $parts[0]) ? (int) $parts[0] : 0;
if (!\in_array($kind, [30023, 30024], true)) {
continue;
}
$dTag = trim((string) $parts[2]);
if ($dTag === '') {
$dTag = $coord;
}
return '> *'.'Replying to'.'* — '."\n> ".$dTag;
}
return null;
}
/** /**
* Truncated single-line text from a parent’s content (strips a leading NIP-22 quote block when present), * Truncated single-line text from a parent’s content (strips a leading NIP-22 quote block when present),
* similar in spirit to Jumble’s {@see ParentNotePreview} + compact ContentPreview. * similar in spirit to Jumble’s {@see ParentNotePreview} + compact ContentPreview.

65
src/Service/CommentReplyService.php

@ -6,7 +6,6 @@ namespace App\Service;
use App\Entity\User; use App\Entity\User;
use App\Enum\KindsEnum; use App\Enum\KindsEnum;
use nostriphant\NIP19\Bech32;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use swentel\nostr\Event\Event as NostrWireEvent; use swentel\nostr\Event\Event as NostrWireEvent;
use swentel\nostr\Key\Key; use swentel\nostr\Key\Key;
@ -83,13 +82,8 @@ final readonly class CommentReplyService
return ['ok' => false, 'error' => 'Tags must include a/A for this article', 'code' => 400]; return ['ok' => false, 'error' => 'Tags must include a/A for this article', 'code' => 400];
} }
if (!$this->contentBlurbReferencesParent( if (!$this->tagsReferenceParent($wire->getTags(), $expectedCoordinate, $parentKind, $parentId)) {
$wire->getContent(), return ['ok' => false, 'error' => 'Tags must reference the selected parent (a/A for article or e/E for comment)', 'code' => 400];
$expectedCoordinate,
$parentKind,
$parentId
)) {
return ['ok' => false, 'error' => 'Reply must start with a quote line (>) linking the parent via nostr:nevent1 / naddr1 (reply blurb)', 'code' => 400];
} }
$rawParentAuthor = isset($payload['parent_author_pubkey']) && \is_string($payload['parent_author_pubkey']) $rawParentAuthor = isset($payload['parent_author_pubkey']) && \is_string($payload['parent_author_pubkey'])
@ -142,53 +136,42 @@ final readonly class CommentReplyService
return false; return false;
} }
private function contentBlurbReferencesParent( /**
string $content, * @param array<int, mixed> $tags
*/
private function tagsReferenceParent(
array $tags,
string $articleCoordinate, string $articleCoordinate,
int $parentKind, int $parentKind,
string $parentIdHex string $parentIdHex
): bool { ): bool {
$head = \strlen($content) > 800 ? substr($content, 0, 800) : $content; if (\in_array($parentKind, [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value], true)) {
if (!str_contains($head, "\n\n")) { foreach ($tags as $row) {
return false; if (!\is_array($row) || ($row[0] ?? null) === null) {
} continue;
[$blurb] = explode("\n\n", $head, 2);
$blurb = trim($blurb);
if ($blurb === '' || !str_starts_with($blurb, '>')) {
return false;
} }
if (!preg_match('/nostr:(nevent1[0-9a-z]+|naddr1[0-9a-z]+|note1[0-9a-z]+)/i', $blurb, $m)) { $n = (string) $row[0];
return false; if (($n === 'a' || $n === 'A') && ($row[1] ?? '') === $articleCoordinate) {
return true;
} }
try {
$decoded = new Bech32($m[1]);
} catch (\Throwable) {
return false;
} }
if ($decoded->type === 'nevent') {
$id = $decoded->data->id ?? null;
return \is_string($id) && 64 === \strlen($id) && ctype_xdigit($id) && hash_equals($parentIdHex, $id); return false;
} }
if ($decoded->type === 'note') { if ($parentKind === KindsEnum::COMMENTS->value) {
$id = $decoded->data->identifier ?? null; foreach ($tags as $row) {
if (!\is_array($row) || ($row[0] ?? null) === null) {
return \is_string($id) && 64 === \strlen($id) && ctype_xdigit($id) && hash_equals($parentIdHex, $id); continue;
} }
if ($decoded->type === 'naddr') { $n = (string) $row[0];
$d = $decoded->data; if (($n === 'e' || $n === 'E') && \is_string($row[1] ?? null) && hash_equals($parentIdHex, (string) $row[1])) {
$coord = $d->kind.':'.$d->pubkey.':'.$d->identifier; return true;
if (!\in_array($parentKind, [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value], true)) {
return false;
} }
if (!hash_equals($articleCoordinate, $coord)) {
return false;
} }
$zero = str_repeat('0', 64);
return hash_equals($parentIdHex, $zero); return false;
} }
return false; return true;
} }
} }

25
src/Service/MagazineContentService.php

@ -196,9 +196,13 @@ final class MagazineContentService
* Category listing from the persisted 30040 index and DB only. Does not call relays. * Category listing from the persisted 30040 index and DB only. Does not call relays.
* Rows come from MySQL only; run `app:prewarm` to sync new `a` tags and replaceable revisions. * Rows come from MySQL only; run `app:prewarm` to sync new `a` tags and replaceable revisions.
* *
* @return array{list: list<Article>, category: array{title: string, summary: string}} * @return array{
* list: list<Article>,
* category: array{title: string, summary: string},
* pagination: array{page: int, per_page: int, total: int, last_page: int}
* }
*/ */
public function getCategoryPageData(string $slug): array public function getCategoryPageData(string $slug, int $page = 1, int $perPage = 25): array
{ {
$this->warmCategoryIndexIfMissing($slug); $this->warmCategoryIndexIfMissing($slug);
$catIndex = $this->store->getCategory($slug); $catIndex = $this->store->getCategory($slug);
@ -256,9 +260,24 @@ final class MagazineContentService
$category['title'] = $category['title'] ?? ''; $category['title'] = $category['title'] ?? '';
$category['summary'] = $category['summary'] ?? ''; $category['summary'] = $category['summary'] ?? '';
$perPage = max(1, $perPage);
$page = max(1, $page);
$total = \count($list);
$lastPage = max(1, (int) \ceil($total / $perPage));
if ($page > $lastPage) {
$page = $lastPage;
}
$offset = ($page - 1) * $perPage;
return [ return [
'list' => $list, 'list' => \array_slice($list, $offset, $perPage),
'category' => $category, 'category' => $category,
'pagination' => [
'page' => $page,
'per_page' => $perPage,
'total' => $total,
'last_page' => $lastPage,
],
]; ];
} }

13
templates/pages/author.html.twig

@ -13,6 +13,19 @@
<hr class="author-profile__divider" /> <hr class="author-profile__divider" />
<twig:Organisms:CardList :list="articles" class="article-list"></twig:Organisms:CardList> <twig:Organisms:CardList :list="articles" class="article-list"></twig:Organisms:CardList>
{% if pagination is defined and pagination.last_page > 1 %}
{% set _page = pagination.page|default(1) %}
{% set _last = pagination.last_page|default(1) %}
<nav class="category-pagination mt-3" aria-label="Author articles pagination">
{% if _page > 1 %}
<a class="btn btn-outline-secondary" href="{{ path('author-profile', _page > 2 ? { npub: npub, page: _page - 1 } : { npub: npub }) }}">Newer</a>
{% endif %}
<span class="mx-2 text-subtle">Page {{ _page }} of {{ _last }}</span>
{% if _page < _last %}
<a class="btn btn-outline-secondary" href="{{ path('author-profile', { npub: npub, page: _page + 1 }) }}">Older</a>
{% endif %}
</nav>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}

33
templates/pages/category.html.twig

@ -12,13 +12,21 @@
{% set _title = category.title|default('') %} {% set _title = category.title|default('') %}
{% set _summary = category.summary|default('')|striptags|u.truncate(159, '…') %} {% set _summary = category.summary|default('')|striptags|u.truncate(159, '…') %}
{% set _og_image = absolute_url(asset('og-image.jpg')) %} {% set _og_image = absolute_url(asset('og-image.jpg')) %}
{% set _is_articles_route = app.request.attributes.get('_route') == 'articles' %}
{% set _is_category_route = app.request.attributes.get('_route') == 'magazine-category' %}
{% set _articles_page = app.request.query.getInt('page', 1) %}
{% set _articles_url = _articles_page > 1 ? url('articles', { page: _articles_page }) : url('articles') %}
{% set _category_slug = sync_slug|default(app.request.attributes.get('slug')) %}
{% set _category_page = app.request.query.getInt('page', 1) %}
{% set _category_url = _category_page > 1 ? url('magazine-category', {slug: _category_slug, page: _category_page}) : url('magazine-category', {slug: _category_slug}) %}
{% set _canonical_url = _is_articles_route ? _articles_url : (_is_category_route ? _category_url : url('magazine-category', {slug: _category_slug})) %}
<meta property="og:title" content="{{ _title|e('html_attr') }}"> <meta property="og:title" content="{{ _title|e('html_attr') }}">
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:url" content="{{ app.request.attributes.get('_route') == 'articles' ? url('articles') : url('magazine-category', {slug: sync_slug|default(app.request.attributes.get('slug'))}) }}"> <meta property="og:url" content="{{ _canonical_url }}">
<meta property="og:description" content="{{ (_summary != '' ? _summary : _title)|e('html_attr') }}"> <meta property="og:description" content="{{ (_summary != '' ? _summary : _title)|e('html_attr') }}">
<meta property="og:image" content="{{ _og_image|e('html_attr') }}"> <meta property="og:image" content="{{ _og_image|e('html_attr') }}">
<meta property="og:site_name" content="{{ website_name|e('html_attr') }}"> <meta property="og:site_name" content="{{ website_name|e('html_attr') }}">
<link rel="canonical" href="{{ app.request.attributes.get('_route') == 'articles' ? url('articles') : url('magazine-category', {slug: sync_slug|default(app.request.attributes.get('slug'))}) }}"> <link rel="canonical" href="{{ _canonical_url }}">
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ _title|e('html_attr') }}"> <meta name="twitter:title" content="{{ _title|e('html_attr') }}">
<meta name="twitter:description" content="{{ (_summary != '' ? _summary : _title)|e('html_attr') }}"> <meta name="twitter:description" content="{{ (_summary != '' ? _summary : _title)|e('html_attr') }}">
@ -39,6 +47,27 @@
<div class="category-body"> <div class="category-body">
<twig:Organisms:CardList :list="list" class="article-list" /> <twig:Organisms:CardList :list="list" class="article-list" />
</div> </div>
{% if pagination is defined and pagination.last_page > 1 %}
{% set _page = pagination.page|default(1) %}
{% set _last = pagination.last_page|default(1) %}
{% set _is_articles_route = app.request.attributes.get('_route') == 'articles' %}
{% set _slug = sync_slug|default(app.request.attributes.get('slug')) %}
{% set _prev_url = _is_articles_route
? path('articles', _page > 2 ? { page: _page - 1 } : {})
: path('magazine-category', _page > 2 ? { slug: _slug, page: _page - 1 } : { slug: _slug }) %}
{% set _next_url = _is_articles_route
? path('articles', { page: _page + 1 })
: path('magazine-category', { slug: _slug, page: _page + 1 }) %}
<nav class="category-pagination mt-3" aria-label="Articles pagination">
{% if _page > 1 %}
<a class="btn btn-outline-secondary" href="{{ _prev_url }}">Newer</a>
{% endif %}
<span class="mx-2 text-subtle">Page {{ _page }} of {{ _last }}</span>
{% if _page < _last %}
<a class="btn btn-outline-secondary" href="{{ _next_url }}">Older</a>
{% endif %}
</nav>
{% endif %}
{% endblock %} {% endblock %}
{% block aside %} {% block aside %}

14
templates/pages/featured_authors.html.twig

@ -35,6 +35,20 @@
{% else %} {% else %}
<p class="text-subtle">No featured authors are listed yet. They appear when authors are added to magazine category indices and synced.</p> <p class="text-subtle">No featured authors are listed yet. They appear when authors are added to magazine category indices and synced.</p>
{% endfor %} {% endfor %}
{% if pagination is defined and pagination.last_page > 1 %}
{% set _page = pagination.page|default(1) %}
{% set _last = pagination.last_page|default(1) %}
<nav class="category-pagination mt-3" aria-label="Featured authors pagination">
{% if _page > 1 %}
<a class="btn btn-outline-secondary" href="{{ path('featured_authors', _page > 2 ? { page: _page - 1 } : {}) }}">Newer</a>
{% endif %}
<span class="mx-2 text-subtle">Page {{ _page }} of {{ _last }}</span>
{% if _page < _last %}
<a class="btn btn-outline-secondary" href="{{ path('featured_authors', { page: _page + 1 }) }}">Older</a>
{% endif %}
</nav>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}

35
templates/pages/search.html.twig

@ -4,5 +4,38 @@
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<twig:SearchComponent /> <div class="search-page">
<form method="get" action="{{ path('search') }}" class="mb-3">
<label class="search">
<input
type="search"
name="q"
placeholder="{{ 'text.search'|trans }}"
value="{{ query|default('') }}"
/>
<button type="submit"><twig:ux:icon name="iconoir:search" class="icon" /></button>
</label>
</form>
{% if results|default([]) is not empty %}
<twig:Organisms:CardList :list="results" class="article-list" />
{% elseif query|default('') is not empty %}
<p><small>{{ 'text.noResults'|trans }}</small></p>
{% endif %}
{% if pagination is defined and pagination.last_page > 1 %}
{% set _page = pagination.page|default(1) %}
{% set _last = pagination.last_page|default(1) %}
{% set _query = query|default('') %}
<nav class="category-pagination mt-3" aria-label="Search pagination">
{% if _page > 1 %}
<a class="btn btn-outline-secondary" href="{{ path('search', _page > 2 ? { q: _query, page: _page - 1 } : { q: _query }) }}">Newer</a>
{% endif %}
<span class="mx-2 text-subtle">Page {{ _page }} of {{ _last }}</span>
{% if _page < _last %}
<a class="btn btn-outline-secondary" href="{{ path('search', { q: _query, page: _page + 1 }) }}">Older</a>
{% endif %}
</nav>
{% endif %}
</div>
{% endblock %} {% endblock %}

Loading…
Cancel
Save