Browse Source

integrate topics

imwald
Silberengel 3 days ago
parent
commit
ca5045d6a9
  1. 28
      assets/styles/app.css
  2. 67
      assets/styles/layout.css
  3. 2
      config/services.yaml
  4. 49
      src/Controller/TopicController.php
  5. 84
      src/Repository/ArticleRepository.php
  6. 30
      src/Service/MagazineContentService.php
  7. 105
      src/Service/TopicIndexService.php
  8. 26
      src/Twig/TopTopicsExtension.php
  9. 4
      templates/base.html.twig
  10. 15
      templates/components/Organisms/SidebarTopTopics.html.twig
  11. 2
      templates/pages/article.html.twig
  12. 36
      templates/pages/topic.html.twig
  13. 4
      translations/messages.en.yaml

28
assets/styles/app.css

@ -683,7 +683,8 @@ footer a { @@ -683,7 +683,8 @@ footer a {
gap: 10px; /* Adds spacing between individual tags */
}
/* Individual tag */
/* Individual tag (spans on non-article pages, links on article body) */
a.tag,
.tag {
background-color: var(--color-bg-light);
color: var(--color-text-mid);
@ -693,13 +694,15 @@ footer a { @@ -693,13 +694,15 @@ footer a {
cursor: pointer; /* Cursor turns to pointer for clickable tags */
text-decoration: none; /* Removes any text decoration (e.g., underline) */
display: inline-block; /* Makes sure each tag behaves like a block with padding */
transition: background-color 0.3s ease; /* Smooth hover effect */
transition: background-color 0.2s ease, color 0.2s ease;
}
/*!* Hover effect for tags *!*/
/*.tag:hover {*/
/* color: var(--color-text-contrast);*/
/*}*/
a.tag:hover,
a.tag:focus-visible {
background-color: color-mix(in srgb, var(--color-primary) 12%, var(--color-bg-light));
color: var(--color-primary);
text-decoration: none;
}
/* Optional: Responsive adjustments for smaller screens */
@media (max-width: 768px) {
@ -708,6 +711,19 @@ footer a { @@ -708,6 +711,19 @@ footer a {
}
}
.topic-page__title {
font-size: 1.85rem;
font-weight: 700;
color: var(--color-primary);
margin: 0 0 0.35rem;
font-family: var(--heading-font), serif;
}
.topic-page__lede {
margin: 0 0 1.5rem;
font-size: 0.95rem;
}
.card.card__horizontal {

67
assets/styles/layout.css

@ -56,6 +56,11 @@ @@ -56,6 +56,11 @@
display: none;
}
/* Top topics list (same visibility pattern as featured authors) */
.sidebar-top-topics {
display: none;
}
@media (min-width: 1025px) {
.sidebar-featured-authors {
display: block;
@ -141,6 +146,68 @@ @@ -141,6 +146,68 @@
padding: 0.2rem;
box-sizing: border-box;
}
.sidebar-top-topics {
display: block;
margin-top: 1.1rem;
padding-top: 0.9rem;
border-top: 1px solid var(--color-border);
}
.sidebar-top-topics__title {
margin: 0 0 0.5rem;
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-top-topics__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.4rem 0.35rem;
align-items: center;
}
.layout > nav .sidebar-top-topics__list li {
margin: 0;
}
/* Pill badges: align with article `a.tag` (app.css) but scoped to the nav */
.layout > nav a.topic-badge.sidebar-top-topics__link,
.layout > nav a.topic-badge {
display: inline-block;
max-width: 100%;
background-color: var(--color-bg-light);
color: var(--color-text-mid);
padding: 0.22rem 0.55rem;
border-radius: 999px;
font-size: 0.72rem;
line-height: 1.35;
font-weight: 500;
text-decoration: none;
border: 1px solid var(--color-border);
box-sizing: border-box;
word-break: break-word;
transition:
background-color 0.2s ease,
color 0.2s ease,
border-color 0.2s ease;
}
.layout > nav a.topic-badge:hover,
.layout > nav a.topic-badge:focus-visible {
background-color: color-mix(in srgb, var(--color-primary) 12%, var(--color-bg-light));
color: var(--color-primary);
border-color: color-mix(in srgb, var(--color-primary) 22%, var(--color-border));
text-decoration: none;
}
}
/* Only the app chrome in Header.html.twig (#site-header). A bare `header` rule also

2
config/services.yaml

@ -49,6 +49,8 @@ services: @@ -49,6 +49,8 @@ services:
tags: [ 'twig.extension' ]
App\Twig\MagazineJumbleExtension:
tags: [ 'twig.extension' ]
App\Twig\TopTopicsExtension:
tags: [ 'twig.extension' ]
App\Service\MagazineRefresher:
arguments:
$appCache: '@cache.app'

49
src/Controller/TopicController.php

@ -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,
],
]);
}
}

84
src/Repository/ArticleRepository.php

@ -247,4 +247,88 @@ class ArticleRepository extends ServiceEntityRepository @@ -247,4 +247,88 @@ class ArticleRepository extends ServiceEntityRepository
->getQuery()
->getResult();
}
/**
* Published or archived long-form with at least one stored topic, matched case-insensitively.
* Ordered newest first. Uses an in-process filter; suitable for moderate table sizes.
*
* @return list<Article>
*/
public function findPublishedByTopic(string $topic, int $limit, int $offset): array
{
$all = $this->articlesMatchingTopicNormalized(
$this->normalizeTopicLabel($topic)
);
return \array_slice($all, $offset, $limit);
}
public function countPublishedByTopic(string $topic): int
{
return \count(
$this->articlesMatchingTopicNormalized(
$this->normalizeTopicLabel($topic)
)
);
}
/**
* @return list<Article>
*/
private function articlesMatchingTopicNormalized(string $topicKey): array
{
if ($topicKey === '') {
return [];
}
$qb = $this->createQueryBuilder('a')
->where('a.topics IS NOT NULL')
->andWhere('a.content IS NOT NULL')
->andWhere('LENGTH(a.content) > 250')
->andWhere('a.eventStatus IN (:st)')
->setParameter('st', [EventStatusEnum::PUBLISHED, EventStatusEnum::ARCHIVED])
->orderBy('a.createdAt', 'DESC');
/** @var list<Article> $candidates */
$candidates = $qb->getQuery()->getResult();
$out = [];
foreach ($candidates as $a) {
$topics = $a->getTopics();
if (!\is_array($topics) || $topics === []) {
continue;
}
foreach ($topics as $t) {
if (!\is_string($t)) {
continue;
}
$k = $this->normalizeTopicLabel($t);
if ($k === $topicKey) {
$out[] = $a;
break;
}
}
}
return $out;
}
/**
* Public key for {@see findPublishedByTopic} and generating `/topic/…` URLs.
*/
public function normalizeTopicParam(string $topic): string
{
return $this->normalizeTopicLabel($topic);
}
private function normalizeTopicLabel(string $topic): string
{
$t = \strtolower(\trim($topic));
if ($t === '') {
return '';
}
if (\str_starts_with($t, '#')) {
$t = ltrim($t, '#');
}
return \trim($t);
}
}

30
src/Service/MagazineContentService.php

@ -631,6 +631,36 @@ final class MagazineContentService @@ -631,6 +631,36 @@ final class MagazineContentService
}
}
/**
* Article slugs that appear in any home “featured” block (per-category first pages), for topic ranking.
*
* @param list<array<int, string>> $categoryATags
*
* @return list<string>
*/
public function collectFeaturedArticleSlugsForHome(array $categoryATags): array
{
$out = [];
foreach ($categoryATags as $row) {
$coord = $row[1] ?? '';
if (!\is_string($coord) || $coord === '') {
continue;
}
$b = $this->buildCategoryFeaturedBlock($coord);
if ($b === null) {
continue;
}
foreach ($b['cards'] as $card) {
$s = \trim((string) $card->getSlug());
if ($s !== '') {
$out[$s] = true;
}
}
}
return array_keys($out);
}
/**
* Interleaves up to four articles per home category in round-robin order (one “wall” mixing all topics).
* Duplicate slugs across categories are skipped so each article appears at most once.

105
src/Service/TopicIndexService.php

@ -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));
}
}

26
src/Twig/TopTopicsExtension.php

@ -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);
}),
];
}
}

4
templates/base.html.twig

@ -41,6 +41,10 @@ @@ -41,6 +41,10 @@
{% if _sidebar_fa is not empty %}
{% include 'components/Organisms/SidebarFeaturedAuthors.html.twig' with { rows: _sidebar_fa } only %}
{% endif %}
{% set _top_topics = top_topic_labels(25) %}
{% if _top_topics is not empty %}
{% include 'components/Organisms/SidebarTopTopics.html.twig' with { labels: _top_topics } only %}
{% endif %}
{% block nav %}{% endblock %}
</nav>
<main>

15
templates/components/Organisms/SidebarTopTopics.html.twig

@ -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 %}

2
templates/pages/article.html.twig

@ -106,7 +106,7 @@ @@ -106,7 +106,7 @@
<hr class="divider" />
<div class="tags">
{% for tag in article.topics %}
<span class="tag">{{ tag }}</span>
<a class="tag" href="{{ path('topic', { topic: tag }) }}">{{ tag }}</a>
{% endfor %}
</div>
{% endif %}

36
templates/pages/topic.html.twig

@ -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 %}

4
translations/messages.en.yaml

@ -1,5 +1,9 @@ @@ -1,5 +1,9 @@
sidebar:
featured_authors: 'Featured authors'
topics: 'Topics'
topic:
browse: 'Articles with this tag'
empty: 'No published articles with this tag yet.'
text:
byline: 'By'
search: 'Search...'

Loading…
Cancel
Save