From ca5045d6a9dbce86110a0903aff3c7d8c0fc051b Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 25 Apr 2026 21:46:06 +0200 Subject: [PATCH] integrate topics --- assets/styles/app.css | 28 ++++- assets/styles/layout.css | 67 +++++++++++ config/services.yaml | 2 + src/Controller/TopicController.php | 49 ++++++++ src/Repository/ArticleRepository.php | 84 ++++++++++++++ src/Service/MagazineContentService.php | 30 +++++ src/Service/TopicIndexService.php | 105 ++++++++++++++++++ src/Twig/TopTopicsExtension.php | 26 +++++ templates/base.html.twig | 4 + .../Organisms/SidebarTopTopics.html.twig | 15 +++ templates/pages/article.html.twig | 2 +- templates/pages/topic.html.twig | 36 ++++++ translations/messages.en.yaml | 4 + 13 files changed, 445 insertions(+), 7 deletions(-) create mode 100644 src/Controller/TopicController.php create mode 100644 src/Service/TopicIndexService.php create mode 100644 src/Twig/TopTopicsExtension.php create mode 100644 templates/components/Organisms/SidebarTopTopics.html.twig create mode 100644 templates/pages/topic.html.twig diff --git a/assets/styles/app.css b/assets/styles/app.css index d0a6b13..9df1761 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -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 { 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 { } } +.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 { diff --git a/assets/styles/layout.css b/assets/styles/layout.css index 8873f93..0423699 100644 --- a/assets/styles/layout.css +++ b/assets/styles/layout.css @@ -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 @@ 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 diff --git a/config/services.yaml b/config/services.yaml index e35d76a..99cf22d 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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' diff --git a/src/Controller/TopicController.php b/src/Controller/TopicController.php new file mode 100644 index 0000000..c069f41 --- /dev/null +++ b/src/Controller/TopicController.php @@ -0,0 +1,49 @@ + '[^/]+'], + )] + 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, + ], + ]); + } +} diff --git a/src/Repository/ArticleRepository.php b/src/Repository/ArticleRepository.php index 3c23277..1cbce15 100644 --- a/src/Repository/ArticleRepository.php +++ b/src/Repository/ArticleRepository.php @@ -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
+ */ + 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
+ */ + 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
$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); + } } diff --git a/src/Service/MagazineContentService.php b/src/Service/MagazineContentService.php index 7eb71b2..e9744a8 100644 --- a/src/Service/MagazineContentService.php +++ b/src/Service/MagazineContentService.php @@ -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> $categoryATags + * + * @return list + */ + 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. diff --git a/src/Service/TopicIndexService.php b/src/Service/TopicIndexService.php new file mode 100644 index 0000000..4fc2d4d --- /dev/null +++ b/src/Service/TopicIndexService.php @@ -0,0 +1,105 @@ + 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)); + } +} diff --git a/src/Twig/TopTopicsExtension.php b/src/Twig/TopTopicsExtension.php new file mode 100644 index 0000000..8b07f45 --- /dev/null +++ b/src/Twig/TopTopicsExtension.php @@ -0,0 +1,26 @@ +topicIndexService->getTopTopicLabels($limit); + }), + ]; + } +} diff --git a/templates/base.html.twig b/templates/base.html.twig index 24dd4c5..8a4b7cc 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -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 %}
diff --git a/templates/components/Organisms/SidebarTopTopics.html.twig b/templates/components/Organisms/SidebarTopTopics.html.twig new file mode 100644 index 0000000..49a34e5 --- /dev/null +++ b/templates/components/Organisms/SidebarTopTopics.html.twig @@ -0,0 +1,15 @@ +{% if labels is defined and labels is not empty %} + +{% endif %} diff --git a/templates/pages/article.html.twig b/templates/pages/article.html.twig index 2952279..b562373 100644 --- a/templates/pages/article.html.twig +++ b/templates/pages/article.html.twig @@ -106,7 +106,7 @@
{% for tag in article.topics %} - {{ tag }} + {{ tag }} {% endfor %}
{% endif %} diff --git a/templates/pages/topic.html.twig b/templates/pages/topic.html.twig new file mode 100644 index 0000000..add0f9f --- /dev/null +++ b/templates/pages/topic.html.twig @@ -0,0 +1,36 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ topic_label }} — {{ website_name }}{% endblock %} + +{% block meta_description %} + +{% endblock %} + +{% block nav %}{% endblock %} + +{% block body %} +
+

{{ topic_label }}

+

{{ 'topic.browse'|trans }}

+ + {% if list is not empty %} + + {% else %} +

{{ 'topic.empty'|trans }}

+ {% 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 %} +
+{% endblock %} diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index ba77918..73e5e5e 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -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...'