18 changed files with 718 additions and 93 deletions
@ -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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<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 @@ |
|||||||
|
<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 @@ |
|||||||
|
{% 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 @@ |
|||||||
|
{% 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 @@ |
|||||||
|
{% 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