diff --git a/assets/app.js b/assets/app.js index d7d4a12..3506b18 100644 --- a/assets/app.js +++ b/assets/app.js @@ -45,6 +45,7 @@ import './styles/04-pages/analytics.css'; import './styles/04-pages/author-media.css'; import './styles/04-pages/forum.css'; import './styles/04-pages/highlights.css'; +import './styles/04-pages/discover.css'; // 05 - Utilities (last for highest specificity) import './styles/05-utilities/utilities.css'; @@ -54,16 +55,86 @@ console.log('This log comes from assets/app.js - welcome to AssetMapper! π'); import 'katex/dist/katex.min.css'; import renderMathInElement from 'katex/dist/contrib/auto-render.mjs'; +// Detect math blocks in text content while avoiding common currency patterns +function hasRealMath(text) { + if (!text) return false; + const t = text; + + // Skip plain currency-like $123.45 or $ 1,234 patterns + const currency = /\$\s*[\d.,]+(?:\b|\s)/g; + + // Detect LaTeX-style delimiters with likely math content inside + const inlineDollar = /\$(?
(?:[^$\\]|\\.)+)\$/g; // $ ... $ + const doubleDollar = /\$\$(?(?:[^$\\]|\\.)+)\$\$/g; // $$ ... $$ + const parenMath = /\\\((?(?:[^\\]|\\.)+)\\\)/g; // \( ... \) + const bracketMath = /\\\[(?(?:[^\\]|\\.)+)\\\]/g; // \[ ... \] + + // Heuristic: content contains typical math tokens (command, ^, _, { }, fractions) + const looksMathy = (s) => /\\[a-zA-Z]+|[\^_]|[{}]|\\frac|\\sum|\\int|\\lim|\\alpha|\\beta|\\gamma|\\rightarrow|\\matrm|\\mathrm|\\mathbb|\\mathbf/.test(s); + + // If text has only currency-like $... patterns and no delimiters, don't mark as math + const hasNonCurrencyDollar = (() => { + let m; + // Any $...$ that isn't just numbers + const dollarAny = /\$(?:[^$]+)\$/g; + while ((m = dollarAny.exec(t)) !== null) { + const inner = m[0].slice(1, -1); + if (!/^\s*[\d.,]+\s*$/.test(inner) && looksMathy(inner)) return true; + } + return false; + })(); + + // Check each delimiter type + const checkDelim = (regex) => { + let m; + while ((m = regex.exec(t)) !== null) { + const inner = m.groups?.body ?? ''; + if (looksMathy(inner)) return true; + } + return false; + }; + + if (checkDelim(doubleDollar)) return true; + if (checkDelim(parenMath)) return true; + if (checkDelim(bracketMath)) return true; + if (hasNonCurrencyDollar) return true; + + // Also allow $...$ where the inner isn't currency and includes letters with math markers + const inlineDollarLoose = /\$(?[^$]+)\$/g; + let m; + while ((m = inlineDollarLoose.exec(t)) !== null) { + const inner = m.groups?.body ?? ''; + if (!/^\s*[\d.,]+\s*$/.test(inner) && /[A-Za-z]/.test(inner) && /[\^_{}]|\\[a-zA-Z]+/.test(inner)) return true; + } + + return false; +} + document.addEventListener('DOMContentLoaded', () => { - // multiple possible containers for math rendering; loop over options - const root = document.querySelector('.article-main'); // the container you render $html into + // Identify containers that may include math and add the .math class when detected + const root = document.querySelector('.article-main'); // main article container const summaries = document.querySelectorAll('.lede'); // article summaries - if (summaries) { + if (root && hasRealMath(root.textContent || '')) { + root.classList.add('math'); + } + if (summaries && summaries.length) { summaries.forEach((summary) => { + if (summary && hasRealMath(summary.textContent || '')) { + summary.classList.add('math'); + } + }); + } + + // Render KaTeX inside elements marked with .math + const mathRoot = document.querySelector('.article-main.math'); + const mathSummaries = document.querySelectorAll('.lede.math'); + + if (mathSummaries && mathSummaries.length) { + mathSummaries.forEach((summary) => { renderMathInElement(summary, { delimiters: [ - { left: '$$', right: '$$', display: true }, + { left: '$$', right: '$$', display: false }, { left: '$', right: '$', display: false }, ], throwOnError: false, // donβt explode on unknown commands @@ -71,13 +142,12 @@ document.addEventListener('DOMContentLoaded', () => { }); } - if (root) { - renderMathInElement(root, { + if (mathRoot) { + renderMathInElement(mathRoot, { // Delimiters: inline $β¦$, display $$β¦$$ and the LaTeX \(β¦\)/\[β¦\] forms delimiters: [ { left: '$$', right: '$$', display: true }, { left: '$', right: '$', display: false }, - { left: '\\(', right: '\\)', display: false }, { left: '\\[', right: '\\]', display: true }, ], throwOnError: false, // donβt explode on unknown commands diff --git a/assets/styles/03-components/button.css b/assets/styles/03-components/button.css index 2c8ac7a..5b0b28a 100644 --- a/assets/styles/03-components/button.css +++ b/assets/styles/03-components/button.css @@ -6,25 +6,21 @@ button, .btn, a.btn { background: var(--color-primary); color: var(--color-text-contrast); - border: 2px solid var(--color-primary); + border: 1px solid var(--color-primary); padding: 0.75em 1.5em; font-family: var(--font-family), sans-serif; - font-size: 1rem; - font-weight: 600; - border-radius: 8px; - box-shadow: 0 2px 8px rgba(0,0,0,0.08); cursor: pointer; } button:hover, .btn:hover, a.btn:hover { background: var(--color-accent); color: var(--color-text); - border: 2px solid var(--color-accent); + border: 1px solid var(--color-accent); } button:active, .btn:active, a.btn:active { background: var(--color-primary); - border: 2px solid var(--color-primary); + border: 1px solid var(--color-primary); } a.btn, a.btn:hover, a.btn:active { @@ -41,7 +37,7 @@ a.btn, a.btn:hover, a.btn:active { } .btn.btn-accent:hover { - border: 2px solid var(--color-secondary); + border: 1px solid var(--color-secondary); color: var(--color-secondary); text-decoration: none; } @@ -49,13 +45,13 @@ a.btn, a.btn:hover, a.btn:active { .btn.btn-secondary { color: var(--color-text); background-color: transparent; - border: 2px solid var(--color-secondary); + border: 1px solid var(--color-secondary); text-decoration: none; } .btn.btn-secondary:hover { background-color: var(--color-secondary); color: var(--color-text-contrast); - border: 2px solid var(--color-secondary); + border: 1px solid var(--color-secondary); text-decoration: none; } diff --git a/assets/styles/03-components/dropdown.css b/assets/styles/03-components/dropdown.css index e01cc1e..af55b88 100644 --- a/assets/styles/03-components/dropdown.css +++ b/assets/styles/03-components/dropdown.css @@ -48,6 +48,7 @@ display: block; } +button.dropdown-item, .dropdown-item { display: block; width: 100%; @@ -64,6 +65,8 @@ transition: background-color 0.15s ease-in-out, color 0.15s ease-in-out; } +button.dropdown-item:hover, +button.dropdown-item:focus, .dropdown-item:hover, .dropdown-item:focus { color: var(--color-text); @@ -71,14 +74,15 @@ text-decoration: none; } +button.dropdown-item:active, .dropdown-item:active { color: var(--color-text-contrast); background-color: var(--color-primary); text-decoration: none; } -.dropdown-item.disabled, -.dropdown-item:disabled { +button.dropdown-item.disabled, +button.dropdown-item:disabled { color: var(--color-text-mid); pointer-events: none; background-color: transparent; diff --git a/assets/styles/04-pages/discover.css b/assets/styles/04-pages/discover.css new file mode 100644 index 0000000..1978692 --- /dev/null +++ b/assets/styles/04-pages/discover.css @@ -0,0 +1,125 @@ +/* Discover Page Styles */ + +/* Discover Search Form */ +.discover-search-form { + max-width: 100%; +} + +.discover-search-form .search { + display: flex; + gap: 0.5rem; + align-items: center; + width: 100%; +} + +.discover-search-form input[type="search"] { + flex: 1; + padding: 0.75rem 1rem; + transition: border-color 0.2s; +} + +.discover-search-form input[type="search"]:focus { + outline: none; + border-color: var(--primary-color, #007bff); +} + +.discover-search-form button[type="submit"] { + padding: 0.75rem 1.5rem; + white-space: nowrap; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.discover-search-form button[type="submit"] .icon { + width: 1.25rem; + height: 1.25rem; +} + +.discover-section .section-heading { + font-size: 1.75rem; + font-weight: 700; + margin-bottom: 0.25rem; + color: var(--text-primary, #1a1a1a); +} + +.discover-section .section-subheading { + font-size: 0.95rem; + color: var(--text-secondary, #666); + margin-bottom: 1.5rem; +} + +.discover-section .section-header { + border-bottom: 2px solid var(--border-color, #e0e0e0); + padding-bottom: 1rem; + margin-bottom: 2rem; +} + +/* Discover Sidebar */ +.discover-sidebar { + position: sticky; + top: 2rem; +} + +.discover-sidebar .sidebar-section { + background: var(--card-bg, #f8f9fa); + padding: 1.5rem; + border-radius: 8px; + border: 1px solid var(--border-color, #e0e0e0); +} + +.discover-sidebar h3 { + font-size: 1.1rem; + font-weight: 700; + margin-bottom: 1rem; + color: var(--text-primary, #1a1a1a); +} + +.discover-sidebar ul li { + margin-bottom: 0.75rem; +} + +.discover-sidebar ul li a { + color: var(--text-primary, #1a1a1a); + text-decoration: none; + transition: color 0.2s; +} + +.discover-sidebar ul li a:hover { + color: var(--primary-color, #007bff); +} + +/* Highlights Preview Grid */ +#highlights-preview .highlights-grid { + display: grid; + gap: 1.5rem; + grid-template-columns: 1fr; +} + +@media (min-width: 768px) { + #highlights-preview .highlights-grid { + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + } +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .discover-section .section-header { + flex-direction: column; + align-items: flex-start !important; + } + + .discover-section .section-header .btn { + margin-top: 1rem; + } + + .discover-search-form .search { + flex-direction: column; + } + + .discover-search-form button[type="submit"] { + width: 100%; + justify-content: center; + } +} + diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml index 9ab43ba..3bdeb02 100644 --- a/config/packages/cache.yaml +++ b/config/packages/cache.yaml @@ -7,8 +7,9 @@ framework: # The data in this cache should persist between deploys. # Other options include: - # Redis - app: cache.adapter.redis + # Use filesystem for the default app cache so the site keeps working if Redis is down + app: cache.adapter.filesystem + # Keep Redis available for specific pools default_redis_provider: Redis # Namespaced pools use the above "app" backend by default diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 462bae8..bac0368 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -5,7 +5,7 @@ framework: # Note that the session will be started ONLY if you read or write from it. session: - handler_id: Symfony\Component\HttpFoundation\Session\Storage\Handler\RedisSessionHandler + handler_id: App\Session\GracefulSessionHandler cookie_secure: auto cookie_samesite: lax cookie_lifetime: 2678400 # integer, lifetime in seconds, 0 means 'valid for the length of the browser session' diff --git a/config/services.yaml b/config/services.yaml index 4870809..49aa90f 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -53,19 +53,30 @@ services: Redis: # you can also use \RedisArray, \RedisCluster, \Relay\Relay or \Predis\Client classes class: Redis + lazy: true calls: - connect: - '%env(REDIS_HOST)%' - auth: - '%env(REDIS_PASSWORD)%' + # Native file session handler for fallback + Symfony\Component\HttpFoundation\Session\Storage\Handler\NativeFileSessionHandler: ~ + + # Graceful session handler that falls back to files on Redis outage + App\Session\GracefulSessionHandler: + arguments: + - '@Symfony\Component\HttpFoundation\Session\Storage\Handler\RedisSessionHandler' + - '@Symfony\Component\HttpFoundation\Session\Storage\Handler\NativeFileSessionHandler' + - '@logger' + App\Provider\ArticleProvider: tags: - { name: fos_elastica.pager_provider, index: articles, type: article } App\EventListener\PopulateListener: tags: - - { name: kernel.event_listener, event: 'FOS\ElasticaBundle\Event\PostIndexPopulateEvent', method: 'postIndexPopulate' } + - { name: kernel.event_listener, event: 'FOS\\ElasticaBundle\\Event\\PostIndexPopulateEvent', method: 'postIndexPopulate' } App\Command\IndexArticlesCommand: arguments: diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index 1822ebc..e3314c7 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -101,7 +101,7 @@ class ArticleController extends AbstractController ]); } - #[Route('/p/{npub}/article/{slug}', name: 'author-article-slug', requirements: ['slug' => '.+'])] + #[Route('/p/{npub}/d/{slug}', name: 'author-article-slug', requirements: ['slug' => '.+'])] public function authorArticle( $npub, $slug, diff --git a/src/Controller/DefaultController.php b/src/Controller/DefaultController.php index e011157..7105450 100644 --- a/src/Controller/DefaultController.php +++ b/src/Controller/DefaultController.php @@ -7,8 +7,10 @@ namespace App\Controller; use App\Entity\Article; use App\Entity\Event; use App\Enum\KindsEnum; +use App\Service\NostrClient; use App\Service\RedisCacheService; use App\Util\CommonMark\Converter; +use App\Util\ForumTopics; use App\Util\NostrKeyUtil; use Doctrine\ORM\EntityManagerInterface; use Elastica\Collapse; @@ -50,61 +52,53 @@ class DefaultController extends AbstractController } /** - * @throws Exception + * @throws Exception|InvalidArgumentException */ - #[Route('/latest-articles', name: 'latest_articles')] - public function latestArticles(FinderInterface $finder, - RedisCacheService $redisCacheService, - CacheItemPoolInterface $articlesCache): Response + #[Route('/discover', name: 'discover')] + public function discover( + FinderInterface $finder, + RedisCacheService $redisCacheService, + CacheItemPoolInterface $articlesCache + ): Response { - set_time_limit(300); // 5 minutes + set_time_limit(300); ini_set('max_execution_time', '300'); $env = $this->getParameter('kernel.environment'); - $cacheKey = 'latest_articles_list_' . $env ; // Use env to differentiate cache between environments + // Reuse previous latest list cache key to show same set as old 'latest' + $cacheKey = 'latest_articles_list_' . $env; $cacheItem = $articlesCache->getItem($cacheKey); $key = new Key(); $excludedPubkeys = [ - $key->convertToHex('npub1etsrcjz24fqewg4zmjze7t5q8c6rcwde5zdtdt4v3t3dz2navecscjjz94'), // Bitcoin Magazine (News Bot) - $key->convertToHex('npub1m7szwpud3jh2k3cqe73v0fd769uzsj6rzmddh4dw67y92sw22r3sk5m3ys'), // No Bullshit Bitcoin (News Bot) - $key->convertToHex('npub13wke9s6njrmugzpg6mqtvy2d49g4d6t390ng76dhxxgs9jn3f2jsmq82pk'), // TFTC (News Bot) - $key->convertToHex('npub10akm29ejpdns52ca082skmc3hr75wmv3ajv4987c9lgyrfynrmdqduqwlx'), // Discreet Log (News Bot) - $key->convertToHex('npub13uvnw9qehqkds68ds76c4nfcn3y99c2rl9z8tr0p34v7ntzsmmzspwhh99'), // Batcoinz (Just annoying) - $key->convertToHex('npub1fls5au5fxj6qj0t36sage857cs4tgfpla0ll8prshlhstagejtkqc9s2yl'), // AGORA Marketplace - feed πππ (Just annoying) - $key->convertToHex('npub1t5d8kcn0hu8zmt6dpkgatd5hwhx76956g7qmdzwnca6fzgprzlhqnqks86'), // NSFW - $key->convertToHex('npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss'), // LNgigs, job offers feed + $key->convertToHex('npub1etsrcjz24fqewg4zmjze7t5q8c6rcwde5zdtdt4v3t3dz2navecscjjz94'), + $key->convertToHex('npub1m7szwpud3jh2k3cqe73v0fd769uzsj6rzmddh4dw67y92sw22r3sk5m3ys'), + $key->convertToHex('npub13wke9s6njrmugzpg6mqtvy2d49g4d6t390ng76dhxxgs9jn3f2jsmq82pk'), + $key->convertToHex('npub10akm29ejpdns52ca082skmc3hr75wmv3ajv4987c9lgyrfynrmdqduqwlx'), + $key->convertToHex('npub13uvnw9qehqkds68ds76c4nfcn3y99c2rl9z8tr0p34v7ntzsmmzspwhh99'), + $key->convertToHex('npub1fls5au5fxj6qj0t36sage857cs4tgfpla0ll8prshlhstagejtkqc9s2yl'), + $key->convertToHex('npub1t5d8kcn0hu8zmt6dpkgatd5hwhx76956g7qmdzwnca6fzgprzlhqnqks86'), + $key->convertToHex('npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss'), ]; if (!$cacheItem->isHit()) { - // Query all articles and sort by created_at descending + // Fallback: run query now if command hasn't populated cache yet $boolQuery = new BoolQuery(); $boolQuery->addMustNot(new Query\Terms('pubkey', $excludedPubkeys)); - $query = new Query($boolQuery); - $query->setSize(30); + $query->setSize(50); $query->setSort(['createdAt' => ['order' => 'desc']]); - - // Use collapse to deduplicate by slug field - $collapse = new Collapse(); - $collapse->setFieldname('slug'); - $query->setCollapse($collapse); - - // Use collapse to deduplicate by author - $collapse2 = new Collapse(); - $collapse2->setFieldname('pubkey'); - $query->setCollapse($collapse2); - + $collapseSlug = new Collapse(); + $collapseSlug->setFieldname('slug'); + $query->setCollapse($collapseSlug); $articles = $finder->find($query); - $cacheItem->set($articles); - $cacheItem->expiresAfter(3600); // Cache for 1 hour + $cacheItem->expiresAfter(3600); // 1 hour to match command cache duration $articlesCache->save($cacheItem); } $articles = $cacheItem->get(); - // Collect all unique author pubkeys from articles $authorPubkeys = []; foreach ($articles as $article) { if (isset($article->pubkey) && NostrKeyUtil::isHexPubkey($article->pubkey)) { @@ -114,8 +108,68 @@ class DefaultController extends AbstractController } } $authorPubkeys = array_unique($authorPubkeys); + $authorsMetadata = $redisCacheService->getMultipleMetadata($authorPubkeys); + + // Build main topics key => display name map from ForumTopics constant + $mainTopicsMap = []; + foreach (ForumTopics::TOPICS as $key => $data) { + $name = $data['name'] ?? ucfirst($key); + $mainTopicsMap[$key] = $name; + } + + return $this->render('pages/discover.html.twig', [ + 'articles' => $articles, + 'authorsMetadata' => $authorsMetadata, + 'mainTopicsMap' => $mainTopicsMap, + ]); + } - // Fetch all author metadata in one batch using pubkeys + /** + * @throws Exception + */ + #[Route('/latest-articles', name: 'latest_articles')] + public function latestArticles( + RedisCacheService $redisCacheService, + NostrClient $nostrClient + ): Response + { + set_time_limit(300); // 5 minutes + ini_set('max_execution_time', '300'); + + // Direct feed: always fetch fresh from relay, no caching + $key = new Key(); + $excludedPubkeys = [ + $key->convertToHex('npub1etsrcjz24fqewg4zmjze7t5q8c6rcwde5zdtdt4v3t3dz2navecscjjz94'), // Bitcoin Magazine (News Bot) + $key->convertToHex('npub1m7szwpud3jh2k3cqe73v0fd769uzsj6rzmddh4dw67y92sw22r3sk5m3ys'), // No Bullshit Bitcoin (News Bot) + $key->convertToHex('npub13wke9s6njrmugzpg6mqtvy2d49g4d6t390ng76dhxxgs9jn3f2jsmq82pk'), // TFTC (News Bot) + $key->convertToHex('npub10akm29ejpdns52ca082skmc3hr75wmv3ajv4987c9lgyrfynrmdqduqwlx'), // Discreet Log (News Bot) + $key->convertToHex('npub13uvnw9qehqkds68ds76c4nfcn3y99c2rl9z8tr0p34v7ntzsmmzspwhh99'), // Batcoinz (Just annoying) + $key->convertToHex('npub1fls5au5fxj6qj0t36sage857cs4tgfpla0ll8prshlhstagejtkqc9s2yl'), // AGORA Marketplace - feed bot + $key->convertToHex('npub1t5d8kcn0hu8zmt6dpkgatd5hwhx76956g7qmdzwnca6fzgprzlhqnqks86'), // NSFW + $key->convertToHex('npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss'), // LNgigs + ]; + + // Fetch raw latest articles (limit 50) directly from relay + $articles = $nostrClient->getLatestLongFormArticles(50); + + // Filter out excluded pubkeys + $articles = array_filter($articles, function($article) use ($excludedPubkeys) { + if (method_exists($article, 'getPubkey')) { + return !in_array($article->getPubkey(), $excludedPubkeys, true); + } + return true; + }); + + // Collect author pubkeys for metadata + $authorPubkeys = []; + foreach ($articles as $article) { + if (method_exists($article, 'getPubkey')) { + $authorPubkeys[] = $article->getPubkey(); + } elseif (isset($article->pubkey) && NostrKeyUtil::isHexPubkey($article->pubkey)) { + $authorPubkeys[] = $article->pubkey; + } + } + $authorPubkeys = array_unique($authorPubkeys); $authorsMetadata = $redisCacheService->getMultipleMetadata($authorPubkeys); return $this->render('pages/latest-articles.html.twig', [ diff --git a/src/Controller/ForumController.php b/src/Controller/ForumController.php index 53cd36f..2807348 100644 --- a/src/Controller/ForumController.php +++ b/src/Controller/ForumController.php @@ -93,6 +93,67 @@ class ForumController extends AbstractController ]); } + #[Route('/forum/main/{topic}', name: 'forum_main_topic')] + public function mainTopic( + string $topic, + #[Autowire(service: 'fos_elastica.finder.articles')] PaginatedFinderInterface $finder, + #[Autowire(service: 'fos_elastica.index.articles')] \Elastica\Index $index, + Request $request + ): Response { + $catKey = strtolower(trim($topic)); + if (!isset(ForumTopics::TOPICS[$catKey])) { + throw $this->createNotFoundException('Main topic not found'); + } + + $category = ForumTopics::TOPICS[$catKey]; + // Collect all tags from all subcategories under this main topic + $tags = []; + foreach ($category['subcategories'] as $sub) { + foreach ($sub['tags'] as $t) { $tags[] = (string)$t; } + } + $tags = array_values(array_unique(array_map('strtolower', array_map('trim', $tags)))); + + // Count each tag in this main topic in one shot + $tagCounts = $this->fetchTagCounts($index, $tags); + + // Fetch articles for the main topic (OR across all tags), collapse by slug + $bool = new BoolQuery(); + if (!empty($tags)) { + $bool->addFilter(new Terms('topics', $tags)); + } + $query = new Query($bool); + $query->setSize(20); + $query->setSort(['createdAt' => ['order' => 'desc']]); + $collapse = new Collapse(); + $collapse->setFieldname('slug'); + $query->setCollapse($collapse); + + /** @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()); + + // Latest threads under this main topic scope + $page = max(1, (int)$request->query->get('page', 1)); + $perPage = 20; + $threads = $this->fetchThreads($index, [$tags]); + $threadsPage = array_slice($threads, ($page-1)*$perPage, $perPage); + + return $this->render('forum/main_topic.html.twig', [ + 'categoryKey' => $catKey, + 'category' => [ 'name' => $category['name'] ?? ucfirst($catKey) ], + 'tags' => $tagCounts, + 'threads' => $threadsPage, + 'total' => count($threads), + 'page' => $page, + 'perPage' => $perPage, + 'topics' => $this->getHydratedTopics($index), + 'articles' => $articles, + 'pager' => $pager, + ]); + } + #[Route('/forum/topic/{key}', name: 'forum_topic')] public function topic( string $key, diff --git a/src/Controller/MediaDiscoveryController.php b/src/Controller/MediaDiscoveryController.php index 70d052f..13ef1bf 100644 --- a/src/Controller/MediaDiscoveryController.php +++ b/src/Controller/MediaDiscoveryController.php @@ -21,7 +21,7 @@ class MediaDiscoveryController extends AbstractController 'travel' => ['travel', 'traveling', 'wanderlust', 'adventure', 'explore', 'city', 'vacation', 'trip'], ]; - #[Route('/discover', name: 'media-discovery')] + #[Route('/multimedia', name: 'media-discovery')] public function discover(CacheInterface $cache): Response { // Defaulting to all, might do topics later diff --git a/src/Controller/SearchController.php b/src/Controller/SearchController.php index 720eabf..92c7764 100644 --- a/src/Controller/SearchController.php +++ b/src/Controller/SearchController.php @@ -6,16 +6,20 @@ namespace App\Controller; use App\Util\ForumTopics; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; class SearchController extends AbstractController { #[Route('/search')] - public function index(): Response + public function index(Request $request): Response { + $query = $request->query->get('q', ''); + return $this->render('pages/search.html.twig', [ 'topics' => ForumTopics::TOPICS, + 'initialQuery' => $query, ]); } } diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index 936eec0..dc3d944 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -1137,6 +1137,75 @@ class NostrClient return array_values($uniqueEvents); } + public function getLatestLongFormArticles(int $limit = 50, ?int $since = null): array + { + // Prefer ONLY the local relay if configured; otherwise use the default relay set + if ($this->nostrDefaultRelay) { + $relaySet = $this->createRelaySet([$this->nostrDefaultRelay]); + $this->logger->info('Fetching latest long-form articles from local relay', [ + 'relay' => $this->nostrDefaultRelay, + 'limit' => $limit, + 'since' => $since + ]); + } else { + $relaySet = $this->defaultRelaySet; + $this->logger->info('Fetching latest long-form articles from default relay set (no local relay configured)', [ + 'limit' => $limit, + 'since' => $since + ]); + } + + $filters = [ 'limit' => $limit ]; + if ($since !== null && $since > 0) { + $filters['since'] = $since; + } + + try { + $request = $this->createNostrRequest( + kinds: [KindsEnum::LONGFORM->value], + filters: $filters, + relaySet: $relaySet + ); + + $events = $this->processResponse($request->send(), function($event) { + try { + $article = $this->articleFactory->createFromLongFormContentEvent($event); + // Persist newest revision if not saved yet + $this->saveEachArticleToTheDatabase($article); + return $article; + } catch (\Throwable $e) { + $this->logger->error('Failed converting event to Article', [ + 'error' => $e->getMessage(), + 'event_id' => $event->id ?? null + ]); + return null; + } + }); + } catch (\Throwable $e) { + $this->logger->error('Error fetching latest long-form articles', [ 'error' => $e->getMessage() ]); + return []; + } + + // Filter out nulls + $articles = array_filter($events, fn($a) => $a instanceof Article); + + // Deduplicate by slug keeping latest createdAt + $bySlug = []; + foreach ($articles as $article) { + $slug = $article->getSlug(); + if ($slug === '') { continue; } + if (!isset($bySlug[$slug]) || $article->getCreatedAt() > $bySlug[$slug]->getCreatedAt()) { + $bySlug[$slug] = $article; + } + } + + // Sort descending by createdAt + $deduped = array_values($bySlug); + usort($deduped, fn($a, $b) => $b->getCreatedAt() <=> $a->getCreatedAt()); + + return $deduped; + } + private function createNostrRequest(array $kinds, array $filters = [], ?RelaySet $relaySet = null, $stopGap = null ): TweakedRequest { $subscription = new Subscription(); @@ -1425,7 +1494,7 @@ class NostrClient $cachedItem = $this->npubCache->getItem($cacheKey); if (!$cachedItem->isHit() || ($event->created_at ?? 0) > ($cachedItem->get()->created_at ?? 0)) { $cachedItem->set($event); - $cachedItem->expiresAfter(3600); // 1 hour TTL + $cachedItem->expiresAfter(84000); // 24 hours $this->npubCache->save($cachedItem); } } catch (\Throwable $e) { diff --git a/src/Service/RedisCacheService.php b/src/Service/RedisCacheService.php index 5855247..153ac66 100644 --- a/src/Service/RedisCacheService.php +++ b/src/Service/RedisCacheService.php @@ -131,7 +131,7 @@ readonly class RedisCacheService /** * Fetch metadata for multiple pubkeys at once using Redis getItems. - * Falls back to getMetadata for cache misses. + * Batch fetches missing pubkeys from Nostr relays (local relay first, then public fallback). * * @param string[] $pubkeys Array of hex pubkeys * @return arrayLog in to search articles.
-Showing limited results (5 articles)
+Sign in to see more results and unlock full search capabilities!
+ Sign In +{{ 'text.noResults'|trans }}
diff --git a/templates/forum/index.html.twig b/templates/forum/index.html.twig index 98786ed..e5e16dd 100644 --- a/templates/forum/index.html.twig +++ b/templates/forum/index.html.twig @@ -31,7 +31,9 @@ {% for catKey, category in topics %}hidden gems
-