13 changed files with 445 additions and 7 deletions
@ -0,0 +1,49 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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