Browse Source

clean up changes

imwald
Silberengel 2 months ago
parent
commit
8b397fb34c
  1. 10
      assets/controllers/article_comments_controller.js
  2. 47
      assets/styles/app.css
  3. 29
      src/Controller/ArticleController.php
  4. 7
      src/Service/ArticleCommentThreadLoader.php
  5. 81
      src/Service/NostrClient.php
  6. 127
      src/Twig/Components/SearchComponent.php
  7. 57
      templates/components/Molecules/ArticleReplyComposer.html.twig
  8. 19
      templates/components/Molecules/Pagination.html.twig
  9. 61
      templates/components/Organisms/Comments.html.twig
  10. 29
      templates/components/SearchComponent.html.twig
  11. 2
      templates/components/UserMenu.html.twig
  12. 2
      templates/pages/article.html.twig
  13. 18
      templates/pages/author.html.twig
  14. 33
      templates/pages/category.html.twig
  15. 18
      templates/pages/featured_authors.html.twig
  16. 18
      templates/pages/search.html.twig

10
assets/controllers/article_comments_controller.js

@ -12,6 +12,7 @@ export default class extends Controller {
static targets = ['container']; static targets = ['container'];
connect() { connect() {
this.partialReloads = 0;
this.boundOnAuth = this.onAuthChanged.bind(this); this.boundOnAuth = this.onAuthChanged.bind(this);
window.addEventListener('unfold:auth-changed', this.boundOnAuth); window.addEventListener('unfold:auth-changed', this.boundOnAuth);
if (!this.hasContainerTarget || !this.urlValue) { if (!this.hasContainerTarget || !this.urlValue) {
@ -67,6 +68,15 @@ export default class extends Controller {
return; return;
} }
this.containerTarget.innerHTML = html; this.containerTarget.innerHTML = html;
const isPartial = /data-comments-partial="1"/.test(html);
if (isPartial && this.partialReloads < 2) {
this.partialReloads += 1;
window.setTimeout(() => {
if (this.hasContainerTarget) {
void this.load();
}
}, 1200);
}
const ms = Math.round(performance.now() - t0); const ms = Math.round(performance.now() - t0);
if (attempt > 1) { if (attempt > 1) {
console.info(`[article-comments] fragment OK in ${ms}ms (after ${attempt} attempts)`, this.urlValue); console.info(`[article-comments] fragment OK in ${ms}ms (after ${attempt} attempts)`, this.urlValue);

47
assets/styles/app.css

@ -954,3 +954,50 @@ a:focus-visible {
height: 40px; height: 40px;
} }
} }
.pager {
margin-top: 1.25rem;
display: flex;
justify-content: center;
width: 100%;
}
.pager__inner {
width: min(100%, 36rem);
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
background: var(--color-bg-light);
}
.pager__status {
min-width: 8rem;
text-align: center;
}
.pager__btn {
min-width: 6.5rem;
text-align: center;
}
.pager__btn.is-disabled {
opacity: 0.55;
pointer-events: none;
}
@media (max-width: 640px) {
.pager__inner {
width: 100%;
gap: 0.5rem;
}
.pager__btn {
min-width: 5.25rem;
}
.pager__status {
min-width: 7rem;
font-size: 0.92rem;
}
}

29
src/Controller/ArticleController.php

@ -383,6 +383,7 @@ class ArticleController extends AbstractController
$commentsData = null; $commentsData = null;
$commentsPreloaded = false; $commentsPreloaded = false;
$commentReplyContext = $this->buildArticleReplyContext($coordinate, $eid, $articleTitle);
$cached = $commentThreadLoader->tryLoadFromCacheOnly($coordinate, $eid); $cached = $commentThreadLoader->tryLoadFromCacheOnly($coordinate, $eid);
if (null !== $cached) { if (null !== $cached) {
$commentsData = $this->enrichCommentDataWithReplyContext( $commentsData = $this->enrichCommentDataWithReplyContext(
@ -391,6 +392,7 @@ class ArticleController extends AbstractController
$eid, $eid,
$articleTitle $articleTitle
); );
$commentReplyContext = $commentsData['comment_reply_context'] ?? $commentReplyContext;
$commentsPreloaded = true; $commentsPreloaded = true;
} }
@ -401,9 +403,36 @@ class ArticleController extends AbstractController
'content' => $html, 'content' => $html,
'comments_data' => $commentsData, 'comments_data' => $commentsData,
'comments_preloaded' => $commentsPreloaded, 'comments_preloaded' => $commentsPreloaded,
'comment_reply_context' => $commentReplyContext,
]); ]);
} }
/**
* Base article-level reply context so the top "Reply" button can render before async comments load.
*
* @return array{
* can_publish: bool,
* coordinate: string,
* article_event_id: ?string,
* parent_kind: int,
* rows: array<int, array<string, mixed>>,
* fragment_url: string
* }
*/
private function buildArticleReplyContext(string $coordinate, ?string $articleEventId, string $articleTitle): array
{
$base = [
'list' => [],
'quotes' => [],
'commentLinks' => [],
'quoteLinks' => [],
'processedContent' => [],
];
$enriched = $this->enrichCommentDataWithReplyContext($base, $coordinate, $articleEventId, $articleTitle);
return $enriched['comment_reply_context'];
}
/** /**
* Fetch complete event to show as preview * Fetch complete event to show as preview
* POST data contains an object with request params * POST data contains an object with request params

7
src/Service/ArticleCommentThreadLoader.php

@ -93,18 +93,20 @@ final readonly class ArticleCommentThreadLoader
try { try {
$discussion = $this->cache->get($cacheKey, function (ItemInterface $item) use ($coordinate, $articleEventHexId, $t0): array { $discussion = $this->cache->get($cacheKey, function (ItemInterface $item) use ($coordinate, $articleEventHexId, $t0): array {
// Prewarm + HTTP should share the same key; 2m expiry caused cold misses during normal use.
$item->expiresAfter(86400);
$this->logger->info('comments.loader.cache_miss', [ $this->logger->info('comments.loader.cache_miss', [
'elapsed_since_load_start_ms' => (int) round((microtime(true) - $t0) * 1000), 'elapsed_since_load_start_ms' => (int) round((microtime(true) - $t0) * 1000),
]); ]);
$tNostr = microtime(true); $tNostr = microtime(true);
// On failure, let this throw: Symfony cache will not store a value, so a prior good thread is not replaced by []. // On failure, let this throw: Symfony cache will not store a value, so a prior good thread is not replaced by [].
$out = $this->nostrClient->getArticleDiscussion($coordinate, $articleEventHexId); $out = $this->nostrClient->getArticleDiscussion($coordinate, $articleEventHexId);
$partial = (bool) ($out['partial'] ?? false);
// Partial relay snapshots are intentionally short-lived so the next request can pick up late relays.
$item->expiresAfter($partial ? 15 : 86400);
$this->logger->info('comments.loader.nostr_ok', [ $this->logger->info('comments.loader.nostr_ok', [
'nostr_elapsed_ms' => (int) round((microtime(true) - $tNostr) * 1000), 'nostr_elapsed_ms' => (int) round((microtime(true) - $tNostr) * 1000),
'thread' => \count($out['thread'] ?? []), 'thread' => \count($out['thread'] ?? []),
'quotes' => \count($out['quotes'] ?? []), 'quotes' => \count($out['quotes'] ?? []),
'partial' => $partial,
]); ]);
return $out; return $out;
@ -193,6 +195,7 @@ final readonly class ArticleCommentThreadLoader
'commentLinks' => $commentLinks, 'commentLinks' => $commentLinks,
'quoteLinks' => $quoteLinks, 'quoteLinks' => $quoteLinks,
'processedContent' => $processedContent, 'processedContent' => $processedContent,
'comments_partial' => (bool) ($discussion['partial'] ?? false),
]; ];
} }

81
src/Service/NostrClient.php

@ -34,6 +34,8 @@ class NostrClient
/** Extra wall time for {@see bin/nostr_relay_request_worker.php} process vs. WebSocket timeout. */ /** Extra wall time for {@see bin/nostr_relay_request_worker.php} process vs. WebSocket timeout. */
private const DISCUSSION_WORKER_GRACE_SEC = 5.0; private const DISCUSSION_WORKER_GRACE_SEC = 5.0;
/** Soft wall-time for parallel discussion collection before returning partial results. */
private const DISCUSSION_PARALLEL_SOFT_DEADLINE_SEC = 3.5;
/** /**
* Hard cap on unique relay URLs for article discussion. More relays do not help much (indexers duplicate) * Hard cap on unique relay URLs for article discussion. More relays do not help much (indexers duplicate)
@ -1224,7 +1226,7 @@ class NostrClient
* @param string $coordinate kind:pubkey:d-identifier (e.g. longform address) * @param string $coordinate kind:pubkey:d-identifier (e.g. longform address)
* @param null|string $rootEventHexId Published article event id (hex) for #e / #q matching * @param null|string $rootEventHexId Published article event id (hex) for #e / #q matching
* *
* @return array{thread: array<int, object>, quotes: array<int, object>} * @return array{thread: array<int, object>, quotes: array<int, object>, partial?: bool}
*/ */
public function getArticleDiscussion(string $coordinate, ?string $rootEventHexId = null): array public function getArticleDiscussion(string $coordinate, ?string $rootEventHexId = null): array
{ {
@ -1315,6 +1317,8 @@ class NostrClient
throw new \RuntimeException('Nostr request failed for article discussion', 0, $e); throw new \RuntimeException('Nostr request failed for article discussion', 0, $e);
} }
$respondedRelayCount = \count($response);
$partial = $respondedRelayCount < \count($plannedRelayUrls);
$tParse = microtime(true); $tParse = microtime(true);
$this->processResponse($response, function ($event) use (&$byId) { $this->processResponse($response, function ($event) use (&$byId) {
if (\is_object($event) && isset($event->id)) { if (\is_object($event) && isset($event->id)) {
@ -1369,9 +1373,12 @@ class NostrClient
$this->logger->info('nostr.article_discussion.done', [ $this->logger->info('nostr.article_discussion.done', [
'thread_count' => \count($thread), 'thread_count' => \count($thread),
'quotes_count' => \count($quotes), 'quotes_count' => \count($quotes),
'partial' => $partial,
'responded_relays' => $respondedRelayCount,
'planned_relays' => \count($plannedRelayUrls),
]); ]);
return ['thread' => $thread, 'quotes' => $quotes]; return ['thread' => $thread, 'quotes' => $quotes, 'partial' => $partial];
} }
/** /**
@ -1449,38 +1456,52 @@ class NostrClient
} }
$merged = []; $merged = [];
foreach ($procs as $wss => $p) { $pending = $procs;
$p->wait(); $deadlineAt = microtime(true) + self::DISCUSSION_PARALLEL_SOFT_DEADLINE_SEC;
if (!$p->isSuccessful()) { while ($pending !== []) {
$err = $p->getErrorOutput(); foreach ($pending as $wss => $p) {
$this->logger->warning('nostr.article_discussion.relay_worker_failed', [ if ($p->isRunning()) {
'relay' => $wss, continue;
'exit_code' => $p->getExitCode(), }
'stderr' => $err !== '' ? $err : null, unset($pending[$wss]);
]); if (!$p->isSuccessful()) {
$merged[$wss] = []; $err = $p->getErrorOutput();
$this->logger->warning('nostr.article_discussion.relay_worker_failed', [
continue; 'relay' => $wss,
} 'exit_code' => $p->getExitCode(),
$out = trim($p->getOutput()); 'stderr' => $err !== '' ? $err : null,
if ($out === '') { ]);
$merged[$wss] = [];
continue; continue;
}
$out = trim($p->getOutput());
if ($out === '') {
continue;
}
$decoded = base64_decode($out, true);
if ($decoded === false || $decoded === '') {
continue;
}
$chunk = unserialize($decoded, ['allowed_classes' => true]);
if (!\is_array($chunk)) {
continue;
}
$merged = array_replace($merged, $chunk);
} }
$decoded = base64_decode($out, true); if ($pending === []) {
if ($decoded === false || $decoded === '') { break;
$merged[$wss] = [];
continue;
} }
$chunk = unserialize($decoded, ['allowed_classes' => true]); if (microtime(true) >= $deadlineAt) {
if (!\is_array($chunk)) { foreach ($pending as $wss => $p) {
$merged[$wss] = []; $this->logger->warning('nostr.article_discussion.relay_worker_soft_timeout', [
'relay' => $wss,
continue; 'soft_deadline_sec' => self::DISCUSSION_PARALLEL_SOFT_DEADLINE_SEC,
]);
$p->stop(0.2);
}
break;
} }
$merged = array_replace($merged, $chunk); usleep(100_000);
} }
return $merged; return $merged;

127
src/Twig/Components/SearchComponent.php

@ -1,127 +0,0 @@
<?php
namespace App\Twig\Components;
use App\Repository\ArticleRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\Contracts\Cache\CacheInterface;
#[AsLiveComponent]
final class SearchComponent
{
use DefaultActionTrait;
#[LiveProp(writable: true, useSerializerForHydration: true)]
public string $query = '';
public array $results = [];
public bool $interactive = true;
#[LiveProp]
public int $vol = 0;
#[LiveProp(writable: true)]
public int $page = 1;
#[LiveProp]
public int $resultsPerPage = 12;
private const SESSION_KEY = 'last_search_results';
private const SESSION_QUERY_KEY = 'last_search_query';
public function __construct(
private readonly ArticleRepository $articleRepository,
private readonly TokenStorageInterface $tokenStorage,
private readonly LoggerInterface $logger,
private readonly CacheInterface $cache,
private readonly RequestStack $requestStack
)
{
}
public function mount(): void
{
// Restore search results from session if available and no query provided
if (empty($this->query)) {
$session = $this->requestStack->getSession();
if ($session->has(self::SESSION_QUERY_KEY)) {
$this->query = $session->get(self::SESSION_QUERY_KEY);
$this->results = $session->get(self::SESSION_KEY, []);
$this->logger->info('Restored search results from session for query: ' . $this->query);
}
}
}
#[LiveAction]
public function search(): void
{
$this->logger->info("Query: {$this->query}");
if (empty($this->query)) {
$this->results = [];
$this->clearSearchCache();
return;
}
// Check if the same query exists in session
$session = $this->requestStack->getSession();
if ($session->has(self::SESSION_QUERY_KEY) &&
$session->get(self::SESSION_QUERY_KEY) === $this->query) {
$this->results = $session->get(self::SESSION_KEY, []);
$this->logger->info('Using cached search results for query: ' . $this->query);
return;
}
try {
$this->results = [];
// Use database-based search instead of Elasticsearch
$offset = ($this->page - 1) * $this->resultsPerPage;
$results = $this->articleRepository->searchArticles(
$this->query,
$this->resultsPerPage,
$offset
);
$this->logger->info('Search results count: ' . count($results));
$this->logger->info('Search results: ', ['results' => $results]);
$this->results = $results;
// Cache the search results in session
$this->saveSearchToSession($this->query, $this->results);
} catch (\Exception $e) {
$this->logger->error('Search error: ' . $e->getMessage());
$this->results = [];
}
}
/**
* Save search results to session
*/
private function saveSearchToSession(string $query, array $results): void
{
$session = $this->requestStack->getSession();
$session->set(self::SESSION_QUERY_KEY, $query);
$session->set(self::SESSION_KEY, $results);
$this->logger->info('Saved search results to session for query: ' . $query);
}
/**
* Clear search cache from session
*/
private function clearSearchCache(): void
{
$session = $this->requestStack->getSession();
$session->remove(self::SESSION_QUERY_KEY);
$session->remove(self::SESSION_KEY);
$this->logger->info('Cleared search cache from session');
}
}

57
templates/components/Molecules/ArticleReplyComposer.html.twig

@ -0,0 +1,57 @@
{% set ctx = comment_reply_context|default(null) %}
{% if ctx and ctx.can_publish|default(false) %}
{% for row in ctx.rows|default([]) %}
{% if row.mode|default('') == 'article' %}
<div
class="comment-reply comment-reply--article card"
data-controller="comment-reply"
data-comment-reply-publish-url-value="{{ path('comment_reply_publish')|e('html_attr') }}"
data-comment-reply-csrf-value="{{ csrf_token('comment_reply')|e('html_attr') }}"
data-comment-reply-expected-coordinate-value="{{ ctx.coordinate|e('html_attr') }}"
data-comment-reply-article-event-id-value="{{ ctx.article_event_id|default('')|e('html_attr') }}"
data-comment-reply-fragment-url-value="{{ ctx.fragment_url|default('')|e('html_attr') }}"
data-comment-reply-refresh-after-value="1"
data-comment-reply-expected-tags-value='{{ row.expectedTags|default([])|json_encode|e('html_attr') }}'
data-comment-reply-parent-kind-value="{{ row.parentKind|default(0) }}"
data-comment-reply-parent-id-value="{{ row.parentId|default('')|e('html_attr') }}"
data-comment-reply-author-pubkey-value="{{ row.authorPubkey|default('')|e('html_attr') }}"
>
<div class="card-body comment-reply--article__inner">
<div class="comment-reply__toolbar">
<p class="comment-reply__lede text-subtle">Reply to this note on Nostr (kind 1111).</p>
<button
type="button"
class="btn btn-secondary btn-sm comment-reply__toggle"
data-comment-reply-target="toggleBtn"
data-action="click->comment-reply#togglePanel"
aria-expanded="false"
>Reply</button>
</div>
<div class="comment-reply__panel comment-reply__panel--hidden" data-comment-reply-target="panel">
<form
class="comment-reply__form"
data-action="submit->comment-reply#publish"
>
<div class="comment-reply__body">
<label class="visually-hidden" for="comment-reply-article-body">Your reply</label>
<textarea
class="form-control"
id="comment-reply-article-body"
name="body"
rows="4"
required
minlength="1"
placeholder="Write a NIP-22 comment (kind 1111)."
></textarea>
</div>
<div class="comment-reply__actions">
<button class="btn btn-primary" type="submit">Sign &amp; publish</button>
</div>
<p class="comment-reply__hint text-subtle" data-comment-reply-target="hint" aria-live="polite"></p>
</form>
</div>
</div>
</div>
{% endif %}
{% endfor %}
{% endif %}

19
templates/components/Molecules/Pagination.html.twig

@ -0,0 +1,19 @@
{% set _page = page|default(1) %}
{% set _last = last_page|default(1) %}
{% if _last > 1 %}
<nav class="pager" aria-label="{{ aria_label|default('Pagination') }}">
<div class="pager__inner">
{% if prev_url is not empty %}
<a class="btn btn-outline-secondary pager__btn" href="{{ prev_url }}">Newer</a>
{% else %}
<span class="btn btn-outline-secondary pager__btn is-disabled" aria-disabled="true">Newer</span>
{% endif %}
<span class="pager__status text-subtle">Page {{ _page }} of {{ _last }}</span>
{% if next_url is not empty %}
<a class="btn btn-outline-secondary pager__btn" href="{{ next_url }}">Older</a>
{% else %}
<span class="btn btn-outline-secondary pager__btn is-disabled" aria-disabled="true">Older</span>
{% endif %}
</div>
</nav>
{% endif %}

61
templates/components/Organisms/Comments.html.twig

@ -1,64 +1,5 @@
{% set ctx = comment_reply_context|default(null) %} {% set ctx = comment_reply_context|default(null) %}
{# `rows` can be non-empty for nested replies when the article-level NIP-22 row is missing; do not require length > 0. #} <div class="comments" data-comments-partial="{{ comments_partial|default(false) ? '1' : '0' }}">
{% if ctx and ctx.can_publish|default(false) %}
{% for row in ctx.rows %}
{% if row.mode|default('') == 'article' %}
<div
class="comment-reply comment-reply--article card"
data-controller="comment-reply"
data-comment-reply-publish-url-value="{{ path('comment_reply_publish')|e('html_attr') }}"
data-comment-reply-csrf-value="{{ csrf_token('comment_reply')|e('html_attr') }}"
data-comment-reply-expected-coordinate-value="{{ ctx.coordinate|e('html_attr') }}"
data-comment-reply-article-event-id-value="{{ ctx.article_event_id|default('')|e('html_attr') }}"
data-comment-reply-fragment-url-value="{{ ctx.fragment_url|default('')|e('html_attr') }}"
data-comment-reply-refresh-after-value="1"
data-comment-reply-blurb-label-value="{{ row.blurbLabel|default('Article')|e('html_attr') }}"
data-comment-reply-expected-tags-value='{{ row.expectedTags|default([])|json_encode|e('html_attr') }}'
data-comment-reply-parent-kind-value="{{ row.parentKind|default(0) }}"
data-comment-reply-parent-id-value="{{ row.parentId|default('')|e('html_attr') }}"
data-comment-reply-author-pubkey-value="{{ row.authorPubkey|default('')|e('html_attr') }}"
>
<div class="card-body comment-reply--article__inner">
<div class="comment-reply__toolbar">
<p class="comment-reply__lede text-subtle">Reply to this note on Nostr (kind 1111).</p>
<button
type="button"
class="btn btn-secondary btn-sm comment-reply__toggle"
data-comment-reply-target="toggleBtn"
data-action="click->comment-reply#togglePanel"
aria-expanded="false"
>Reply</button>
</div>
<div class="comment-reply__panel comment-reply__panel--hidden" data-comment-reply-target="panel">
<form
class="comment-reply__form"
data-action="submit->comment-reply#publish"
>
<div class="comment-reply__body">
<label class="visually-hidden" for="comment-reply-article-body">Your reply</label>
<textarea
class="form-control"
id="comment-reply-article-body"
name="body"
rows="4"
required
minlength="1"
placeholder="Write a NIP-22 comment (kind 1111). A quoted parent line is added when you publish."
></textarea>
</div>
<div class="comment-reply__actions">
<button class="btn btn-primary" type="submit">Sign &amp; publish</button>
</div>
<p class="comment-reply__hint text-subtle" data-comment-reply-target="hint" aria-live="polite"></p>
</form>
</div>
</div>
</div>
{% endif %}
{% endfor %}
{% endif %}
<div class="comments">
{% for item in list %} {% for item in list %}
{% set cid = item.id|default('')|lower %} {% set cid = item.id|default('')|lower %}
{% set cpk = item.pubkey|default('') %} {% set cpk = item.pubkey|default('') %}

29
templates/components/SearchComponent.html.twig

@ -1,29 +0,0 @@
<div {{ attributes }}>
{% if interactive %}
<form data-live-action-param="search"
data-action="live#action:prevent">
<label class="search">
<input type="search"
placeholder="{{ 'text.search'|trans }}"
data-model="norender|query"
value="{{ this.query }}"
/>
<button type="submit"><twig:ux:icon name="iconoir:search" class="icon" /></button>
</label>
</form>
<!-- Loading Indicator -->
<div style="text-align: center">
<div class="spinner" data-loading>
<div class="lds-dual-ring"></div>
</div>
</div>
{% endif %}
<!-- Results -->
{% if this.results is not empty %}
<twig:Organisms:CardList :list="this.results" class="article-list" />
{% elseif this.query is not empty %}
<p><small>{{ 'text.noResults'|trans }}</small></p>
{% endif %}
</div>

2
templates/components/UserMenu.html.twig

@ -16,7 +16,7 @@
{# <a href="{{ path('editor-create') }}">Write an article</a>#} {# <a href="{{ path('editor-create') }}">Write an article</a>#}
{# </li>#} {# </li>#}
<li> <li>
<a href="{{ path('app_search_index') }}">{{ 'heading.search'|trans }}</a> <a href="{{ path('search') }}">{{ 'heading.search'|trans }}</a>
</li> </li>
{# <li>#} {# <li>#}
{# <a href="{{ path('app_index_index') }}">{{ 'heading.index'|trans }}</a>#} {# <a href="{{ path('app_index_index') }}">{{ 'heading.index'|trans }}</a>#}

2
templates/pages/article.html.twig

@ -124,6 +124,8 @@
{# </pre>#} {# </pre>#}
{% set article_coordinate = (article.kind ? article.kind.value : 30023) ~ ':' ~ article.pubkey ~ ':' ~ article.slug %} {% set article_coordinate = (article.kind ? article.kind.value : 30023) ~ ':' ~ article.pubkey ~ ':' ~ article.slug %}
{% set comments_query = { coordinate: article_coordinate, title: article.title|default('') }|merge(article.eventId ? { e: article.eventId } : {}) %} {% set comments_query = { coordinate: article_coordinate, title: article.title|default('') }|merge(article.eventId ? { e: article.eventId } : {}) %}
{% set _reply_ctx = comments_data.comment_reply_context|default(comment_reply_context|default(null)) %}
{% include 'components/Molecules/ArticleReplyComposer.html.twig' with { comment_reply_context: _reply_ctx } only %}
<section class="article-comments-async" id="article-comments" aria-label="Comments"> <section class="article-comments-async" id="article-comments" aria-label="Comments">
<div <div
data-controller="article-comments" data-controller="article-comments"

18
templates/pages/author.html.twig

@ -16,15 +16,15 @@
{% if pagination is defined and pagination.last_page > 1 %} {% if pagination is defined and pagination.last_page > 1 %}
{% set _page = pagination.page|default(1) %} {% set _page = pagination.page|default(1) %}
{% set _last = pagination.last_page|default(1) %} {% set _last = pagination.last_page|default(1) %}
<nav class="category-pagination mt-3" aria-label="Author articles pagination"> {% set _prev_url = _page > 1 ? path('author-profile', _page > 2 ? { npub: npub, page: _page - 1 } : { npub: npub }) : null %}
{% if _page > 1 %} {% set _next_url = _page < _last ? path('author-profile', { npub: npub, page: _page + 1 }) : null %}
<a class="btn btn-outline-secondary" href="{{ path('author-profile', _page > 2 ? { npub: npub, page: _page - 1 } : { npub: npub }) }}">Newer</a> {% include 'components/Molecules/Pagination.html.twig' with {
{% endif %} page: _page,
<span class="mx-2 text-subtle">Page {{ _page }} of {{ _last }}</span> last_page: _last,
{% if _page < _last %} prev_url: _prev_url,
<a class="btn btn-outline-secondary" href="{{ path('author-profile', { npub: npub, page: _page + 1 }) }}">Older</a> next_url: _next_url,
{% endif %} aria_label: 'Author articles pagination',
</nav> } only %}
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}

33
templates/pages/category.html.twig

@ -18,8 +18,13 @@
{% set _articles_url = _articles_page > 1 ? url('articles', { page: _articles_page }) : url('articles') %} {% 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_slug = sync_slug|default(app.request.attributes.get('slug')) %}
{% set _category_page = app.request.query.getInt('page', 1) %} {% 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 _category_has_slug = (_category_slug|default('')|trim) != '' %}
{% set _canonical_url = _is_articles_route ? _articles_url : (_is_category_route ? _category_url : url('magazine-category', {slug: _category_slug})) %} {% set _category_url = _category_has_slug
? (_category_page > 1
? url('magazine-category', {slug: _category_slug, page: _category_page})
: url('magazine-category', {slug: _category_slug}))
: _articles_url %}
{% set _canonical_url = _is_articles_route ? _articles_url : (_is_category_route and _category_has_slug ? _category_url : _articles_url) %}
<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="{{ _canonical_url }}"> <meta property="og:url" content="{{ _canonical_url }}">
@ -52,21 +57,19 @@
{% set _last = pagination.last_page|default(1) %} {% set _last = pagination.last_page|default(1) %}
{% set _is_articles_route = app.request.attributes.get('_route') == 'articles' %} {% set _is_articles_route = app.request.attributes.get('_route') == 'articles' %}
{% set _slug = sync_slug|default(app.request.attributes.get('slug')) %} {% set _slug = sync_slug|default(app.request.attributes.get('slug')) %}
{% set _prev_url = _is_articles_route {% set _prev_url = _page > 1 ? (_is_articles_route
? path('articles', _page > 2 ? { page: _page - 1 } : {}) ? path('articles', _page > 2 ? { page: _page - 1 } : {})
: path('magazine-category', _page > 2 ? { slug: _slug, page: _page - 1 } : { slug: _slug }) %} : path('magazine-category', _page > 2 ? { slug: _slug, page: _page - 1 } : { slug: _slug })) : null %}
{% set _next_url = _is_articles_route {% set _next_url = _page < _last ? (_is_articles_route
? path('articles', { page: _page + 1 }) ? path('articles', { page: _page + 1 })
: path('magazine-category', { slug: _slug, page: _page + 1 }) %} : path('magazine-category', { slug: _slug, page: _page + 1 })) : null %}
<nav class="category-pagination mt-3" aria-label="Articles pagination"> {% include 'components/Molecules/Pagination.html.twig' with {
{% if _page > 1 %} page: _page,
<a class="btn btn-outline-secondary" href="{{ _prev_url }}">Newer</a> last_page: _last,
{% endif %} prev_url: _prev_url,
<span class="mx-2 text-subtle">Page {{ _page }} of {{ _last }}</span> next_url: _next_url,
{% if _page < _last %} aria_label: 'Articles pagination',
<a class="btn btn-outline-secondary" href="{{ _next_url }}">Older</a> } only %}
{% endif %}
</nav>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

18
templates/pages/featured_authors.html.twig

@ -39,15 +39,15 @@
{% if pagination is defined and pagination.last_page > 1 %} {% if pagination is defined and pagination.last_page > 1 %}
{% set _page = pagination.page|default(1) %} {% set _page = pagination.page|default(1) %}
{% set _last = pagination.last_page|default(1) %} {% set _last = pagination.last_page|default(1) %}
<nav class="category-pagination mt-3" aria-label="Featured authors pagination"> {% set _prev_url = _page > 1 ? path('featured_authors', _page > 2 ? { page: _page - 1 } : {}) : null %}
{% if _page > 1 %} {% set _next_url = _page < _last ? path('featured_authors', { page: _page + 1 }) : null %}
<a class="btn btn-outline-secondary" href="{{ path('featured_authors', _page > 2 ? { page: _page - 1 } : {}) }}">Newer</a> {% include 'components/Molecules/Pagination.html.twig' with {
{% endif %} page: _page,
<span class="mx-2 text-subtle">Page {{ _page }} of {{ _last }}</span> last_page: _last,
{% if _page < _last %} prev_url: _prev_url,
<a class="btn btn-outline-secondary" href="{{ path('featured_authors', { page: _page + 1 }) }}">Older</a> next_url: _next_url,
{% endif %} aria_label: 'Featured authors pagination',
</nav> } only %}
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}

18
templates/pages/search.html.twig

@ -27,15 +27,15 @@
{% set _page = pagination.page|default(1) %} {% set _page = pagination.page|default(1) %}
{% set _last = pagination.last_page|default(1) %} {% set _last = pagination.last_page|default(1) %}
{% set _query = query|default('') %} {% set _query = query|default('') %}
<nav class="category-pagination mt-3" aria-label="Search pagination"> {% set _prev_url = _page > 1 ? path('search', _page > 2 ? { q: _query, page: _page - 1 } : { q: _query }) : null %}
{% if _page > 1 %} {% set _next_url = _page < _last ? path('search', { q: _query, page: _page + 1 }) : null %}
<a class="btn btn-outline-secondary" href="{{ path('search', _page > 2 ? { q: _query, page: _page - 1 } : { q: _query }) }}">Newer</a> {% include 'components/Molecules/Pagination.html.twig' with {
{% endif %} page: _page,
<span class="mx-2 text-subtle">Page {{ _page }} of {{ _last }}</span> last_page: _last,
{% if _page < _last %} prev_url: _prev_url,
<a class="btn btn-outline-secondary" href="{{ path('search', { q: _query, page: _page + 1 }) }}">Older</a> next_url: _next_url,
{% endif %} aria_label: 'Search pagination',
</nav> } only %}
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}

Loading…
Cancel
Save