18 changed files with 718 additions and 93 deletions
@ -0,0 +1,135 @@
@@ -0,0 +1,135 @@
|
||||
/* Forum Styles - Old School Forum Look */ |
||||
|
||||
.forum-nav { |
||||
list-style: none; |
||||
padding: 0; |
||||
margin: 0; |
||||
} |
||||
|
||||
.forum-nav li { |
||||
margin: 0.5rem 0; |
||||
} |
||||
|
||||
.forum-nav button { |
||||
cursor: pointer; |
||||
padding: 0.5rem; |
||||
font-weight: bold; |
||||
background: #e9e9e9; |
||||
border: 1px solid #ccc; |
||||
border-radius: 4px; |
||||
width: 100%; |
||||
text-align: left; |
||||
} |
||||
|
||||
.forum-nav button:hover { |
||||
background: #ddd; |
||||
} |
||||
|
||||
.forum-nav .content { |
||||
padding: 0.5rem; |
||||
border: 1px solid #ccc; |
||||
border-top: none; |
||||
background: #f9f9f9; |
||||
} |
||||
|
||||
.forum-nav .content ul { |
||||
list-style: none; |
||||
padding: 0; |
||||
margin: 0; |
||||
} |
||||
|
||||
.forum-nav .content ul li { |
||||
margin: 0.25rem 0; |
||||
} |
||||
|
||||
.forum-nav .content ul li a { |
||||
display: block; |
||||
padding: 0.25rem 0.5rem; |
||||
text-decoration: none; |
||||
color: #333; |
||||
} |
||||
|
||||
.forum-nav .content ul li a:hover { |
||||
background: #f0f0f0; |
||||
} |
||||
|
||||
.subcategories-grid { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 1rem; |
||||
margin: 1rem 0; |
||||
} |
||||
|
||||
.sub-card { |
||||
border: 1px solid var(--color-primary); |
||||
background: #fff; |
||||
padding: 1rem; |
||||
display: flex; |
||||
flex-direction: column; |
||||
justify-content: space-between; |
||||
} |
||||
|
||||
.sub-card h3 { |
||||
margin: 0 0 0.5rem 0; |
||||
} |
||||
|
||||
.sub-card div { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
} |
||||
|
||||
.sub-card .count { |
||||
align-self: flex-end; |
||||
flex-shrink: 0; |
||||
} |
||||
|
||||
.articles-list { |
||||
list-style: none; |
||||
padding: 0; |
||||
} |
||||
|
||||
.article-item { |
||||
border: 1px solid #ddd; |
||||
padding: 1rem; |
||||
margin: 1rem 0; |
||||
background: #fafafa; |
||||
} |
||||
|
||||
.article-item h3 { |
||||
margin: 0 0 0.5rem 0; |
||||
} |
||||
|
||||
.article-item p { |
||||
margin: 0.5rem 0; |
||||
} |
||||
|
||||
.article-item small { |
||||
color: #666; |
||||
} |
||||
|
||||
.nav-sub-card { |
||||
border: 1px solid #333; |
||||
background: #fff; |
||||
padding: 0.5rem; |
||||
margin: 0.5rem 0; |
||||
display: flex; |
||||
flex-direction: column; |
||||
box-shadow: 1px 1px 3px rgba(0,0,0,0.2); |
||||
} |
||||
|
||||
.nav-sub-card h4 { |
||||
margin: 0 0 0.25rem 0; |
||||
font-size: 1rem; |
||||
} |
||||
|
||||
.nav-sub-card .tags { |
||||
flex: 1; |
||||
} |
||||
|
||||
.nav-sub-card .count { |
||||
font-size: 1.2rem; |
||||
font-weight: bold; |
||||
color: #333; |
||||
align-self: flex-end; |
||||
} |
||||
@ -0,0 +1,256 @@
@@ -0,0 +1,256 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Controller; |
||||
|
||||
use App\Util\ForumTopics; |
||||
use Elastica\Aggregation\Filters as FiltersAgg; |
||||
use Elastica\Query; |
||||
use Elastica\Query\BoolQuery; |
||||
use Elastica\Query\Term; |
||||
use Elastica\Query\Terms; |
||||
use FOS\ElasticaBundle\Finder\PaginatedFinderInterface; |
||||
use Pagerfanta\Pagerfanta; |
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; |
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire; |
||||
use Symfony\Component\HttpFoundation\Request; |
||||
use Symfony\Component\HttpFoundation\Response; |
||||
use Symfony\Component\Routing\Attribute\Route; |
||||
use Symfony\Contracts\Cache\CacheInterface; |
||||
|
||||
class ForumController extends AbstractController |
||||
{ |
||||
#[Route('/forum', name: 'forum')] |
||||
public function index( |
||||
#[Autowire(service: 'fos_elastica.index.articles')] \Elastica\Index $index, |
||||
CacheInterface $cache, |
||||
Request $request |
||||
): Response { |
||||
// Optional: small cache so we don’t hammer ES on every page view |
||||
//$categoriesWithCounts = $cache->get('forum.index.counts.v2', function (ItemInterface $item) use ($index) { |
||||
// $item->expiresAfter(30); // 30s is a nice compromise for “live enough” |
||||
$allTags = $this->flattenAllTags(ForumTopics::TOPICS); // ['tag' => true, ...] |
||||
$counts = $this->fetchTagCounts($index, array_keys($allTags)); // ['tag' => count] |
||||
|
||||
// return $this->hydrateCategoryCounts(self::TOPICS, $counts); |
||||
//}); |
||||
$categoriesWithCounts = $this->hydrateCategoryCounts(ForumTopics::TOPICS, $counts); |
||||
return $this->render('forum/index.html.twig', [ |
||||
'topics' => $categoriesWithCounts, |
||||
]); |
||||
} |
||||
|
||||
#[Route('/forum/topic/{key}', name: 'forum_topic')] |
||||
public function topic( |
||||
string $key, |
||||
#[Autowire(service: 'fos_elastica.finder.articles')] PaginatedFinderInterface $finder, |
||||
#[Autowire(service: 'fos_elastica.index.articles')] \Elastica\Index $index, |
||||
Request $request |
||||
): Response { |
||||
// key format: "{category}-{subcategory}" |
||||
$key = strtolower(trim($key)); |
||||
[$cat, $sub] = array_pad(explode('-', $key, 2), 2, null); |
||||
|
||||
if (!$cat || !$sub || !isset(ForumTopics::TOPICS[$cat]['subcategories'][$sub])) { |
||||
throw $this->createNotFoundException('Topic not found'); |
||||
} |
||||
|
||||
$topic = ForumTopics::TOPICS[$cat]['subcategories'][$sub]; |
||||
|
||||
// Count each tag in this subcategory in one shot |
||||
$tags = array_map('strval', $topic['tags']); |
||||
$tagCounts = $this->fetchTagCounts($index, $tags); |
||||
|
||||
// Fetch articles for the topic |
||||
$bool = new BoolQuery(); |
||||
$bool->addFilter(new Terms('topics', $tags)); |
||||
|
||||
$query = new Query($bool); |
||||
$query->setSize(20); |
||||
$query->setSort(['createdAt' => ['order' => 'desc']]); |
||||
|
||||
/** @var Pagerfanta $pager */ |
||||
$pager = $finder->findPaginated($query); |
||||
$pager->setMaxPerPage(20); |
||||
$pager->setCurrentPage(max(1, (int) $request->query->get('page', 1))); |
||||
$articles = iterator_to_array($pager->getCurrentPageResults()); |
||||
|
||||
// (Optional) also show latest threads under this topic scope |
||||
$page = max(1, (int) $request->query->get('page', 1)); |
||||
$perPage = 20; |
||||
$threads = $this->fetchThreads($index, [$tags]); // OR scope: any tag in subcategory |
||||
$threadsPage = array_slice($threads, ($page-1)*$perPage, $perPage); |
||||
|
||||
return $this->render('forum/topic.html.twig', [ |
||||
'categoryKey' => $cat, |
||||
'subcategoryKey' => $sub, |
||||
'topic' => [ |
||||
'name' => $topic['name'], |
||||
'tags' => $tags, |
||||
], |
||||
'tags' => $tagCounts, // ['tag' => count] |
||||
'threads' => $threadsPage, |
||||
'total' => count($threads), |
||||
'page' => $page, |
||||
'perPage' => $perPage, |
||||
'topics' => $this->getHydratedTopics($index), |
||||
'articles' => $articles |
||||
]); |
||||
} |
||||
|
||||
#[Route('/forum/tag/{tag}', name: 'forum_tag')] |
||||
public function tag( |
||||
string $tag, |
||||
#[Autowire(service: 'fos_elastica.finder.articles')] PaginatedFinderInterface $finder, |
||||
#[Autowire(service: 'fos_elastica.index.articles')] \Elastica\Index $index, |
||||
Request $request |
||||
): Response { |
||||
$tag = strtolower(trim($tag)); |
||||
|
||||
$bool = new BoolQuery(); |
||||
// Correct Term usage: |
||||
$bool->addFilter(new Term(['topics' => $tag])); |
||||
|
||||
$query = new Query($bool); |
||||
$query->setSize(20); |
||||
|
||||
$query->setSort(['createdAt' => ['order' => 'desc']]); |
||||
|
||||
/** @var Pagerfanta $pager */ |
||||
$pager = $finder->findPaginated($query); |
||||
$pager->setMaxPerPage(20); |
||||
$pager->setCurrentPage(max(1, (int) $request->query->get('page', 1))); |
||||
$articles = iterator_to_array($pager->getCurrentPageResults()); |
||||
|
||||
return $this->render('forum/tag.html.twig', [ |
||||
'tag' => $tag, |
||||
'articles' => $articles, |
||||
'pager' => $pager, // expose if you want numbered pagination links |
||||
'topics' => $this->getHydratedTopics($index), |
||||
]); |
||||
} |
||||
|
||||
// ---------- Helpers ---------- |
||||
|
||||
/** |
||||
* Flatten all tags from the taxonomy into a unique set. |
||||
* @return array<string, true> |
||||
*/ |
||||
private function flattenAllTags(array $categories): array |
||||
{ |
||||
$set = []; |
||||
foreach ($categories as $cat) { |
||||
foreach ($cat['subcategories'] as $sub) { |
||||
foreach ($sub['tags'] as $tag) { |
||||
$set[strtolower($tag)] = true; |
||||
} |
||||
} |
||||
} |
||||
return $set; |
||||
} |
||||
|
||||
/** |
||||
* Run one ES query that returns counts for each tag (OR scope per tag). |
||||
* Uses a Filters aggregation keyed by tag to avoid N queries. |
||||
* |
||||
* @param \Elastica\Index $index |
||||
* @param string[] $tags |
||||
* @return array<string,int> |
||||
*/ |
||||
private function fetchTagCounts(\Elastica\Index $index, array $tags): array |
||||
{ |
||||
$tags = array_values(array_unique(array_map('strtolower', array_map('trim', $tags)))); |
||||
if (!$tags) return []; |
||||
|
||||
$q = new Query(new Query\MatchAll()); |
||||
$filters = new FiltersAgg('tag_counts'); |
||||
|
||||
foreach ($tags as $tag) { |
||||
$b = new BoolQuery(); |
||||
$b->addFilter(new Term(['topics' => $tag])); // topics must be keyword + lowercase normalizer |
||||
$filters->addFilter($b, $tag); |
||||
} |
||||
|
||||
$q->addAggregation($filters); |
||||
$q->setSize(0); |
||||
|
||||
$res = $index->search($q); |
||||
$agg = $res->getAggregation('tag_counts')['buckets'] ?? []; |
||||
|
||||
$out = []; |
||||
foreach ($tags as $tag) { |
||||
$out[$tag] = isset($agg[$tag]['doc_count']) ? (int) $agg[$tag]['doc_count'] : 0; |
||||
} |
||||
return $out; |
||||
} |
||||
|
||||
/** |
||||
* Rehydrate taxonomy with counts per subcategory (sum of its tags). |
||||
* @param array<string,int> $counts |
||||
*/ |
||||
private function hydrateCategoryCounts(array $taxonomy, array $counts): array |
||||
{ |
||||
$out = []; |
||||
foreach ($taxonomy as $catKey => $cat) { |
||||
$subs = []; |
||||
foreach ($cat['subcategories'] as $subKey => $sub) { |
||||
$sum = 0; |
||||
foreach ($sub['tags'] as $tag) { |
||||
$sum += $counts[strtolower($tag)] ?? 0; |
||||
} |
||||
$subs[$subKey] = $sub + ['count' => $sum]; |
||||
} |
||||
$out[$catKey] = $cat; |
||||
$out[$catKey]['subcategories'] = $subs; |
||||
} |
||||
return $out; |
||||
} |
||||
|
||||
/** |
||||
* (Optional) Fetch latest threads for a given OR-scope of tag groups. |
||||
* You can replace this with your Finder if you want entity hydration. |
||||
* |
||||
* @param array<int,array<int,string>> $tagGroups e.g. [ ['bitcoin','lightning'] ] |
||||
* @return array<int,array<string,mixed>> |
||||
*/ |
||||
private function fetchThreads(\Elastica\Index $index, array $tagGroups, int $size = 200): array |
||||
{ |
||||
$bool = new BoolQuery(); |
||||
|
||||
// For a simple OR across tags: use Terms query on 'topics' |
||||
// If you pass multiple groups and want AND across groups, adapt here. |
||||
$flatTags = []; |
||||
foreach ($tagGroups as $g) { foreach ($g as $t) { $flatTags[] = strtolower($t); } } |
||||
$flatTags = array_values(array_unique($flatTags)); |
||||
|
||||
if ($flatTags) { |
||||
$bool->addFilter(new Terms('topics', $flatTags)); |
||||
} |
||||
|
||||
$q = (new Query($bool)) |
||||
->setSize($size) |
||||
->addSort(['createdAt' => ['order' => 'desc']]); |
||||
|
||||
$rs = $index->search($q); |
||||
|
||||
// Map raw sources you need (adjust to your mapping) |
||||
return array_map(static function (\Elastica\Result $hit) { |
||||
$s = $hit->getSource(); |
||||
return [ |
||||
'id' => $s['id'] ?? $hit->getId(), |
||||
'title' => $s['title'] ?? '(untitled)', |
||||
'excerpt' => $s['excerpt'] ?? null, |
||||
'topics' => $s['topics'] ?? [], |
||||
'created_at' => $s['createdAt'] ?? null, |
||||
]; |
||||
}, $rs->getResults()); |
||||
} |
||||
|
||||
private function getHydratedTopics(\Elastica\Index $index): array |
||||
{ |
||||
$allTags = $this->flattenAllTags(ForumTopics::TOPICS); |
||||
$counts = $this->fetchTagCounts($index, array_keys($allTags)); |
||||
return $this->hydrateCategoryCounts(ForumTopics::TOPICS, $counts); |
||||
} |
||||
} |
||||
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
<?php |
||||
|
||||
namespace App\Twig\Components\Atoms; |
||||
|
||||
use App\Util\ForumTopics; |
||||
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; |
||||
|
||||
#[AsTwigComponent] |
||||
final class ForumAside |
||||
{ |
||||
public array $topics = ForumTopics::TOPICS; |
||||
} |
||||
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
<?php |
||||
|
||||
namespace App\Twig\Components\Atoms; |
||||
|
||||
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; |
||||
|
||||
#[AsTwigComponent] |
||||
final class PageHeading |
||||
{ |
||||
public string $heading; |
||||
public ?string $tagline = null; |
||||
} |
||||
@ -0,0 +1,192 @@
@@ -0,0 +1,192 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Util; |
||||
|
||||
class ForumTopics |
||||
{ |
||||
public const TOPICS = [ |
||||
// ─────────── LIFESTYLE ─────────── |
||||
'lifestyle' => [ |
||||
'name' => 'Lifestyle', |
||||
'subcategories' => [ |
||||
'travel' => [ |
||||
'name' => 'Travel & Exploration', |
||||
'tags' => ['travel', 'adventure', 'destinations', 'culture', 'photography'], |
||||
], |
||||
'health' => [ |
||||
'name' => 'Health & Wellness', |
||||
'tags' => ['health', 'fitness', 'wellness', 'nutrition', 'meditation', 'mental-health'], |
||||
], |
||||
'relationships' => [ |
||||
'name' => 'Relationships & Family', |
||||
'tags' => ['relationships', 'family', 'parenting', 'marriage', 'friendship', 'community'], |
||||
], |
||||
'christianity' => [ |
||||
'name' => 'Christianity & Faith', |
||||
'tags' => ['jesus', 'christian', 'bible', 'faith', 'spirituality', 'religion'], |
||||
], |
||||
'philosophy' => [ |
||||
'name' => 'Philosophy & Ethics', |
||||
'tags' => ['philosophy', 'ethics', 'existentialism', 'metaphysics', 'logic'], |
||||
], |
||||
'education' => [ |
||||
'name' => 'Education & Learning', |
||||
'tags' => ['education', 'learning', 'school', 'university', 'teaching', 'homeschool'], |
||||
], |
||||
'finance' => [ |
||||
'name' => 'Personal Finance', |
||||
'tags' => ['budgeting', 'saving', 'investing', 'debt', 'retirement', 'economy'], |
||||
], |
||||
], |
||||
], |
||||
|
||||
// ─────────── TECH ─────────── |
||||
'tech' => [ |
||||
'name' => 'Tech', |
||||
'subcategories' => [ |
||||
'bitcoin' => [ |
||||
'name' => 'Bitcoin & Sound Money', |
||||
'tags' => ['bitcoin', 'lightning', 'decentralization', 'freedom', 'privacy', 'sovereignty'], |
||||
], |
||||
'ai' => [ |
||||
'name' => 'Artificial Intelligence', |
||||
'tags' => ['ai', 'machine-learning', 'llm', 'neural-networks', 'automation', 'robotics'], |
||||
], |
||||
'nostr' => [ |
||||
'name' => 'Nostr & Decentralized Social', |
||||
'tags' => ['nostr', 'fediverse', 'social', 'protocol', 'identity', 'nip'], |
||||
], |
||||
'software' => [ |
||||
'name' => 'Software Development', |
||||
'tags' => ['code', 'programming', 'development', 'open-source', 'python', 'php', 'javascript'], |
||||
], |
||||
'hardware' => [ |
||||
'name' => 'Hardware & Gadgets', |
||||
'tags' => ['hardware', 'devices', 'gadgets', 'controllers', 'iot', 'electronics'], |
||||
], |
||||
'cybersecurity' => [ |
||||
'name' => 'Cybersecurity & Privacy', |
||||
'tags' => ['security', 'privacy', 'encryption', 'hacking', 'infosec', 'vpn'], |
||||
], |
||||
'science' => [ |
||||
'name' => 'Science & Innovation', |
||||
'tags' => ['science', 'innovation', 'research', 'biology', 'physics', 'space', 'technology'], |
||||
], |
||||
], |
||||
], |
||||
|
||||
// ─────────── ART & CULTURE ─────────── |
||||
'art' => [ |
||||
'name' => 'Art & Culture', |
||||
'subcategories' => [ |
||||
'photography' => [ |
||||
'name' => 'Photography', |
||||
'tags' => ['photography', 'photojournalism', 'portrait', 'street', 'nature'], |
||||
], |
||||
'music' => [ |
||||
'name' => 'Music', |
||||
'tags' => ['music', 'audio', 'sound', 'composition', 'performance', 'production'], |
||||
], |
||||
'writing' => [ |
||||
'name' => 'Writing & Literature', |
||||
'tags' => ['writing', 'literature', 'books', 'poetry', 'fiction', 'non-fiction'], |
||||
], |
||||
'film' => [ |
||||
'name' => 'Film & Video', |
||||
'tags' => ['film', 'video', 'cinema', 'documentary', 'animation', 'production'], |
||||
], |
||||
'design' => [ |
||||
'name' => 'Design & Creativity', |
||||
'tags' => ['design', 'art', 'creativity', 'ui', 'ux', 'graphic-design'], |
||||
], |
||||
'history' => [ |
||||
'name' => 'History & Society', |
||||
'tags' => ['history', 'society', 'politics', 'culture', 'anthropology', 'archaeology'], |
||||
], |
||||
], |
||||
], |
||||
|
||||
// ─────────── BUSINESS ─────────── |
||||
'business' => [ |
||||
'name' => 'Business', |
||||
'subcategories' => [ |
||||
'entrepreneurship' => [ |
||||
'name' => 'Entrepreneurship', |
||||
'tags' => ['entrepreneurship', 'startup', 'business', 'innovation', 'leadership'], |
||||
], |
||||
'marketing' => [ |
||||
'name' => 'Marketing & Sales', |
||||
'tags' => ['marketing', 'sales', 'advertising', 'branding', 'customer', 'growth'], |
||||
], |
||||
'economics' => [ |
||||
'name' => 'Economics & Finance', |
||||
'tags' => ['economics', 'finance', 'markets', 'trading', 'policy', 'macro'], |
||||
], |
||||
'management' => [ |
||||
'name' => 'Management & Strategy', |
||||
'tags' => ['management', 'strategy', 'operations', 'productivity', 'leadership'], |
||||
], |
||||
'real-estate' => [ |
||||
'name' => 'Real Estate', |
||||
'tags' => ['real-estate', 'property', 'housing', 'investment', 'development'], |
||||
], |
||||
], |
||||
], |
||||
|
||||
// ─────────── SPORTS ─────────── |
||||
'sports' => [ |
||||
'name' => 'Sports', |
||||
'subcategories' => [ |
||||
'fitness' => [ |
||||
'name' => 'Fitness & Training', |
||||
'tags' => ['fitness', 'training', 'exercise', 'health', 'athletics', 'performance'], |
||||
], |
||||
'outdoor' => [ |
||||
'name' => 'Outdoor Activities', |
||||
'tags' => ['outdoor', 'hiking', 'camping', 'climbing', 'adventure', 'nature'], |
||||
], |
||||
'team-sports' => [ |
||||
'name' => 'Team Sports', |
||||
'tags' => ['football', 'basketball', 'baseball', 'soccer', 'hockey', 'team'], |
||||
], |
||||
'combat' => [ |
||||
'name' => 'Combat Sports', |
||||
'tags' => ['mma', 'boxing', 'wrestling', 'martial-arts', 'combat', 'fighting'], |
||||
], |
||||
'esports' => [ |
||||
'name' => 'Esports & Gaming', |
||||
'tags' => ['esports', 'gaming', 'video-games', 'competition', 'streaming'], |
||||
], |
||||
], |
||||
], |
||||
|
||||
// ─────────── NEWS & POLITICS ─────────── |
||||
'news' => [ |
||||
'name' => 'News & Politics', |
||||
'subcategories' => [ |
||||
'politics' => [ |
||||
'name' => 'Politics & Government', |
||||
'tags' => ['politics', 'government', 'policy', 'election', 'democracy', 'law'], |
||||
], |
||||
'world-news' => [ |
||||
'name' => 'World News', |
||||
'tags' => ['news', 'world', 'international', 'geopolitics', 'diplomacy'], |
||||
], |
||||
'us-news' => [ |
||||
'name' => 'US News', |
||||
'tags' => ['us', 'america', 'united-states', 'domestic', 'national'], |
||||
], |
||||
'activism' => [ |
||||
'name' => 'Activism & Social Issues', |
||||
'tags' => ['activism', 'social', 'justice', 'equality', 'rights', 'protest'], |
||||
], |
||||
'media' => [ |
||||
'name' => 'Media & Journalism', |
||||
'tags' => ['media', 'journalism', 'press', 'reporting', 'freedom', 'censorship'], |
||||
], |
||||
], |
||||
], |
||||
]; |
||||
} |
||||
@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
|
||||
<div class="d-flex gap-3 center mt-3 mb-3 ln-section--reader"> |
||||
<h2 class="mb-4">Forum</h2> |
||||
</div> |
||||
{% for catKey, category in topics %} |
||||
<details class="mb-2"> |
||||
<summary>{{ category.name }}</summary> |
||||
<ul class="list-unstyled ms-2"> |
||||
{% for subKey, sub in category.subcategories %} |
||||
<li class="mt-2"><a href="{{ path('forum_topic', {'key': catKey ~ '-' ~ subKey}) }}">{{ sub.name }}</a></li> |
||||
{% endfor %} |
||||
</ul> |
||||
</details> |
||||
{% endfor %} |
||||
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
<section {{ attributes }} class="d-flex gap-3 center ln-section--latest-articles"> |
||||
<div class="container mt-5 mb-5"> |
||||
<h1>{{ heading }}</h1> |
||||
{% if tagline %} |
||||
<p class="eyebrow">{{ tagline }}</p> |
||||
{% endif %} |
||||
</div> |
||||
</section> |
||||
@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
|
||||
{% extends 'layout.html.twig' %} |
||||
|
||||
{% block body %} |
||||
<twig:Atoms:PageHeading heading="Forum" tagline="Sorted by topic"/> |
||||
|
||||
{% for catKey, category in topics %} |
||||
<section class="d-flex gap-3 center ln-section--newsstand"> |
||||
<div class="container mt-5 mb-5"> |
||||
<h2>{{ category.name }}</h2> |
||||
</div> |
||||
</section> |
||||
<div class="subcategories-grid"> |
||||
{% for subKey, sub in category.subcategories %} |
||||
<div class="sub-card"> |
||||
<h3><a href="{{ path('forum_topic', {'key': catKey ~ '-' ~ subKey}) }}">{{ sub.name }}</a></h3> |
||||
<div class="d-flex flex-row"> |
||||
<div class="tags m-0"> |
||||
{% for tag in sub.tags %} |
||||
<a class="tag" href="{{ path('forum_tag', {'tag': tag}) }}">{{ tag }}</a> |
||||
{% endfor %} |
||||
</div> |
||||
<div class="count">{{ sub.count|default(0) }}</div> |
||||
</div> |
||||
</div> |
||||
{% endfor %} |
||||
</div> |
||||
{% endfor %} |
||||
{% endblock %} |
||||
|
||||
{% block aside %} |
||||
<twig:Atoms:ForumAside /> |
||||
{% endblock %} |
||||
@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
|
||||
{% extends 'layout.html.twig' %} |
||||
|
||||
{% block body %} |
||||
<twig:Atoms:PageHeading heading="{{ tag|capitalize }}" /> |
||||
|
||||
<div class="articles-list"> |
||||
{% for article in articles %} |
||||
<div class="article-item"> |
||||
<h3><a href="{{ path('article-slug', {slug: article.slug|url_encode}) }}">{{ article.title }}</a></h3> |
||||
<p>{{ article.summary|slice(0, 200) }}{% if article.summary|length > 200 %}...{% endif %}</p> |
||||
<small>Published: {{ article.createdAt|date('Y-m-d H:i') }}</small> |
||||
</div> |
||||
{% endfor %} |
||||
</div> |
||||
{% if articles is empty %} |
||||
<p>No articles found for this tag.</p> |
||||
{% endif %} |
||||
{% endblock %} |
||||
|
||||
{% block aside %} |
||||
<twig:Atoms:ForumAside /> |
||||
{% endblock %} |
||||
@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
|
||||
{% extends 'layout.html.twig' %} |
||||
|
||||
{% block body %} |
||||
<twig:Atoms:PageHeading heading="{{ topic.name }}" tagline="{{ tags|keys|join(', ') }}"/> |
||||
|
||||
<div class="articles-list"> |
||||
{% for article in articles %} |
||||
<div class="article-item"> |
||||
<h3><a href="{{ path('article-slug', {slug: article.slug|url_encode}) }}">{{ article.title }}</a></h3> |
||||
<p>{{ article.summary|slice(0, 200) }}{% if article.summary|length > 200 %}...{% endif %}</p> |
||||
<small>Published: {{ article.createdAt|date('Y-m-d H:i') }}</small> |
||||
</div> |
||||
{% endfor %} |
||||
</div> |
||||
{% if articles is empty %} |
||||
<p>No articles found for this tag.</p> |
||||
{% endif %} |
||||
{% endblock %} |
||||
|
||||
{% block aside %} |
||||
<twig:Atoms:ForumAside /> |
||||
{% endblock %} |
||||
Loading…
Reference in new issue