13 changed files with 445 additions and 7 deletions
@ -0,0 +1,49 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Controller; |
||||||
|
|
||||||
|
use App\Repository\ArticleRepository; |
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; |
||||||
|
use Symfony\Component\HttpFoundation\Request; |
||||||
|
use Symfony\Component\HttpFoundation\Response; |
||||||
|
use Symfony\Component\Routing\Attribute\Route; |
||||||
|
|
||||||
|
final class TopicController extends AbstractController |
||||||
|
{ |
||||||
|
#[Route( |
||||||
|
path: '/topic/{topic}', |
||||||
|
name: 'topic', |
||||||
|
methods: ['GET'], |
||||||
|
requirements: ['topic' => '[^/]+'], |
||||||
|
)] |
||||||
|
public function byTopic( |
||||||
|
string $topic, |
||||||
|
Request $request, |
||||||
|
ArticleRepository $articleRepository, |
||||||
|
): Response { |
||||||
|
$perPage = 25; |
||||||
|
$page = max(1, $request->query->getInt('page', 1)); |
||||||
|
$total = $articleRepository->countPublishedByTopic($topic); |
||||||
|
$lastPage = max(1, (int) ceil($total / $perPage)); |
||||||
|
if ($page > $lastPage) { |
||||||
|
$page = $lastPage; |
||||||
|
} |
||||||
|
$offset = ($page - 1) * $perPage; |
||||||
|
$list = $articleRepository->findPublishedByTopic($topic, $perPage, $offset); |
||||||
|
$topicParam = $articleRepository->normalizeTopicParam(rawurldecode($topic)); |
||||||
|
|
||||||
|
return $this->render('pages/topic.html.twig', [ |
||||||
|
'topic_param' => $topicParam, |
||||||
|
'topic_label' => $topicParam, |
||||||
|
'list' => $list, |
||||||
|
'pagination' => [ |
||||||
|
'page' => $page, |
||||||
|
'per_page' => $perPage, |
||||||
|
'total' => $total, |
||||||
|
'last_page' => $lastPage, |
||||||
|
], |
||||||
|
]); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,105 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Service; |
||||||
|
|
||||||
|
use App\Enum\EventStatusEnum; |
||||||
|
use App\Repository\ArticleRepository; |
||||||
|
use Doctrine\DBAL\ArrayParameterType; |
||||||
|
|
||||||
|
/** |
||||||
|
* Top topics for the sidebar and topic browse pages. |
||||||
|
*/ |
||||||
|
final class TopicIndexService |
||||||
|
{ |
||||||
|
public function __construct( |
||||||
|
private readonly ArticleRepository $articleRepository, |
||||||
|
private readonly MagazineContentService $magazineContent, |
||||||
|
) { |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Up to 25 most relevant topic strings, scored by count + 5× featured (magazine home cards). |
||||||
|
* |
||||||
|
* @return list<string> topic labels (lowercase, no #) |
||||||
|
*/ |
||||||
|
public function getTopTopicLabels(int $limit = 25): array |
||||||
|
{ |
||||||
|
$conn = $this->articleRepository->getEntityManager()->getConnection(); |
||||||
|
$slugs = $this->magazineContent->collectFeaturedArticleSlugsForHome( |
||||||
|
$this->magazineContent->getHomeCategoryAIndexTagsFromStoreOnly(), |
||||||
|
); |
||||||
|
$featured = []; |
||||||
|
foreach ($slugs as $s) { |
||||||
|
$s = \strtolower(\trim($s)); |
||||||
|
if ($s !== '') { |
||||||
|
$featured[$s] = true; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
$rows = $conn->fetchAllAssociative( |
||||||
|
'SELECT a.slug, a.topics FROM article a |
||||||
|
WHERE a.topics IS NOT NULL |
||||||
|
AND a.content IS NOT NULL |
||||||
|
AND CHAR_LENGTH(a.content) > 250 |
||||||
|
AND a.event_status IN (:st)', |
||||||
|
[ |
||||||
|
'st' => [EventStatusEnum::PUBLISHED->value, EventStatusEnum::ARCHIVED->value], |
||||||
|
], |
||||||
|
[ |
||||||
|
'st' => ArrayParameterType::INTEGER, |
||||||
|
], |
||||||
|
); |
||||||
|
|
||||||
|
$acc = []; |
||||||
|
foreach ($rows as $row) { |
||||||
|
$raw = $row['topics'] ?? null; |
||||||
|
if (\is_array($raw)) { |
||||||
|
$dec = $raw; |
||||||
|
} elseif (\is_string($raw) && $raw !== '') { |
||||||
|
$dec = json_decode($raw, true); |
||||||
|
} else { |
||||||
|
continue; |
||||||
|
} |
||||||
|
if (!\is_array($dec)) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$slug = \strtolower(\trim((string) ($row['slug'] ?? ''))); |
||||||
|
$isFeat = $slug !== '' && isset($featured[$slug]); |
||||||
|
foreach ($dec as $t) { |
||||||
|
if (!\is_string($t) || $t === '') { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$k = \str_replace('#', '', \strtolower(\trim($t))); |
||||||
|
if ($k === '') { |
||||||
|
continue; |
||||||
|
} |
||||||
|
if (!isset($acc[$k])) { |
||||||
|
$acc[$k] = ['c' => 0, 'f' => 0]; |
||||||
|
} |
||||||
|
++$acc[$k]['c']; |
||||||
|
if ($isFeat) { |
||||||
|
++$acc[$k]['f']; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
uasort( |
||||||
|
$acc, |
||||||
|
static function (array $a, array $b): int { |
||||||
|
$sa = $a['c'] + 5 * $a['f']; |
||||||
|
$sb = $b['c'] + 5 * $b['f']; |
||||||
|
if ($sa === $sb) { |
||||||
|
return $b['c'] <=> $a['c']; |
||||||
|
} |
||||||
|
|
||||||
|
return $sb <=> $sa; |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
$keys = array_keys($acc); |
||||||
|
|
||||||
|
return \array_slice($keys, 0, max(0, $limit)); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,26 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Twig; |
||||||
|
|
||||||
|
use App\Service\TopicIndexService; |
||||||
|
use Twig\Extension\AbstractExtension; |
||||||
|
use Twig\TwigFunction; |
||||||
|
|
||||||
|
final class TopTopicsExtension extends AbstractExtension |
||||||
|
{ |
||||||
|
public function __construct( |
||||||
|
private readonly TopicIndexService $topicIndexService, |
||||||
|
) { |
||||||
|
} |
||||||
|
|
||||||
|
public function getFunctions(): array |
||||||
|
{ |
||||||
|
return [ |
||||||
|
new TwigFunction('top_topic_labels', function (int $limit = 25): array { |
||||||
|
return $this->topicIndexService->getTopTopicLabels($limit); |
||||||
|
}), |
||||||
|
]; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,15 @@ |
|||||||
|
{% if labels is defined and labels is not empty %} |
||||||
|
<section class="sidebar-top-topics" aria-label="{{ 'sidebar.topics'|trans }}"> |
||||||
|
<h2 class="sidebar-top-topics__title">{{ 'sidebar.topics'|trans }}</h2> |
||||||
|
<ul class="sidebar-top-topics__list" role="list"> |
||||||
|
{% for label in labels %} |
||||||
|
<li> |
||||||
|
<a |
||||||
|
class="topic-badge sidebar-top-topics__link" |
||||||
|
href="{{ path('topic', { topic: label }) }}" |
||||||
|
>{{ label }}</a> |
||||||
|
</li> |
||||||
|
{% endfor %} |
||||||
|
</ul> |
||||||
|
</section> |
||||||
|
{% endif %} |
||||||
@ -0,0 +1,36 @@ |
|||||||
|
{% extends 'base.html.twig' %} |
||||||
|
|
||||||
|
{% block title %}{{ topic_label }} — {{ website_name }}{% endblock %} |
||||||
|
|
||||||
|
{% block meta_description %} |
||||||
|
<meta name="description" content="{{ ('Articles tagged ' ~ topic_label ~ ' on ' ~ website_name)|e('html_attr') }}"> |
||||||
|
{% endblock %} |
||||||
|
|
||||||
|
{% block nav %}{% endblock %} |
||||||
|
|
||||||
|
{% block body %} |
||||||
|
<div class="search-page topic-page"> |
||||||
|
<h1 class="topic-page__title">{{ topic_label }}</h1> |
||||||
|
<p class="topic-page__lede text-subtle">{{ 'topic.browse'|trans }}</p> |
||||||
|
|
||||||
|
{% if list is not empty %} |
||||||
|
<twig:Organisms:CardList :list="list" class="article-list"/> |
||||||
|
{% else %} |
||||||
|
<p class="text-subtle">{{ 'topic.empty'|trans }}</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 _prev_url = _page > 1 ? path('topic', _page > 2 ? { topic: topic_param, page: _page - 1 } : { topic: topic_param }) : null %} |
||||||
|
{% set _next_url = _page < _last ? path('topic', { topic: topic_param, page: _page + 1 }) : null %} |
||||||
|
{% include 'components/Molecules/Pagination.html.twig' with { |
||||||
|
page: _page, |
||||||
|
last_page: _last, |
||||||
|
prev_url: _prev_url, |
||||||
|
next_url: _next_url, |
||||||
|
aria_label: 'Topic pagination', |
||||||
|
} only %} |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
Loading…
Reference in new issue