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 array Map of pubkey => metadata @@ -154,10 +154,58 @@ readonly class RedisCacheService $result[$pubkey] = $item->get(); } } + + // Batch fetch missing pubkeys using NostrClient (local relay first, fallback to public) $missedPubkeys = array_diff($pubkeys, array_keys($result)); - foreach ($missedPubkeys as $pubkey) { - $result[$pubkey] = $this->getMetadata($pubkey); + if (!empty($missedPubkeys)) { + try { + $batchMetadata = $this->nostrClient->getMetadataForPubkeys(array_values($missedPubkeys), true); + + foreach ($missedPubkeys as $pubkey) { + if (isset($batchMetadata[$pubkey])) { + // Parse and cache the metadata + $parsed = $this->parseUserMetadata($batchMetadata[$pubkey], $pubkey); + $result[$pubkey] = $parsed; + + // Store in cache for future use + try { + $cacheKey = $this->getUserCacheKey($pubkey); + $item = $this->npubCache->getItem($cacheKey); + $item->set($parsed); + $item->expiresAfter(3600); // 1 hour + $this->npubCache->save($item); + } catch (\Throwable $e) { + $this->logger->warning('Failed to cache batch-fetched metadata', [ + 'pubkey' => $pubkey, + 'error' => $e->getMessage() + ]); + } + } else { + // Fallback to default metadata if not found + $npub = NostrKeyUtil::hexToNpub($pubkey); + $defaultName = '@' . substr($npub, 5, 4) . '…' . substr($npub, -4); + $content = new \stdClass(); + $content->name = $defaultName; + $result[$pubkey] = $content; + } + } + } catch (\Throwable $e) { + $this->logger->error('Batch metadata fetch failed, using defaults', [ + 'error' => $e->getMessage(), + 'pubkeys_count' => count($missedPubkeys) + ]); + + // Fallback: create default metadata for all missed pubkeys + foreach ($missedPubkeys as $pubkey) { + $npub = NostrKeyUtil::hexToNpub($pubkey); + $defaultName = '@' . substr($npub, 5, 4) . '…' . substr($npub, -4); + $content = new \stdClass(); + $content->name = $defaultName; + $result[$pubkey] = $content; + } + } } + return $result; } diff --git a/src/Session/GracefulSessionHandler.php b/src/Session/GracefulSessionHandler.php new file mode 100644 index 0000000..cd33219 --- /dev/null +++ b/src/Session/GracefulSessionHandler.php @@ -0,0 +1,119 @@ +redisHandler = $redisHandler; + $this->fileHandler = $fileHandler; + $this->logger = $logger; + } + + protected function doRead(string $sessionId): string + { + if ($this->useRedis) { + try { + $this->handlerOpened = true; + return $this->redisHandler->read($sessionId); + } catch (\Throwable $e) { + $this->useRedis = false; + $this->logger->warning('Redis session read failed; falling back to files', ['e' => $e->getMessage()]); + } + } + $this->handlerOpened = true; + return $this->fileHandler->read($sessionId); + } + + public function updateTimestamp(string $sessionId, string $data): bool + { + if ($this->useRedis) { + try { + return $this->redisHandler->updateTimestamp($sessionId, $data); + } catch (\Throwable $e) { + $this->useRedis = false; + $this->logger->warning('Redis session touch failed; falling back to files', ['e' => $e->getMessage()]); + } + } + // NativeFileSessionHandler doesn't support updateTimestamp, just return true + return true; + } + + protected function doWrite(string $sessionId, string $data): bool + { + if ($this->useRedis) { + try { + return $this->redisHandler->write($sessionId, $data); + } catch (\Throwable $e) { + $this->useRedis = false; + $this->logger->warning('Redis session write failed; falling back to files', ['e' => $e->getMessage()]); + } + } + return $this->fileHandler->write($sessionId, $data); + } + + protected function doDestroy(string $sessionId): bool + { + if ($this->useRedis) { + try { + return $this->redisHandler->destroy($sessionId); + } catch (\Throwable $e) { + $this->useRedis = false; + $this->logger->warning('Redis session destroy failed; falling back to files', ['e' => $e->getMessage()]); + } + } + return $this->fileHandler->destroy($sessionId); + } + + public function close(): bool + { + if (!$this->handlerOpened) { + return true; + } + + try { + if ($this->useRedis) { + return $this->redisHandler->close(); + } else { + return $this->fileHandler->close(); + } + } catch (\Throwable $e) { + $this->logger->warning('Session close error', ['e' => $e->getMessage()]); + return false; + } finally { + $this->handlerOpened = false; + } + } + + public function gc(int $max_lifetime): int|false + { + if ($this->useRedis) { + try { + return $this->redisHandler->gc($max_lifetime); + } catch (\Throwable $e) { + $this->useRedis = false; + $this->logger->warning('Redis session gc failed; falling back to files', ['e' => $e->getMessage()]); + } + } + return $this->fileHandler->gc($max_lifetime); + } +} + + diff --git a/src/Twig/Components/SearchComponent.php b/src/Twig/Components/SearchComponent.php index 29a9051..4a81f7f 100644 --- a/src/Twig/Components/SearchComponent.php +++ b/src/Twig/Components/SearchComponent.php @@ -65,12 +65,16 @@ final class SearchComponent { } - public function mount($currentRoute = 'search'): void + public function mount($query = '', $currentRoute = 'search'): void { $this->currentRoute = $currentRoute; + $this->query = $query; $token = $this->tokenStorage->getToken(); $this->npub = $token?->getUserIdentifier(); + $this->logger->info('SearchComponent mount called with query: "' . $this->query . '"'); + + // Credits are only relevant for authenticated users, but search works for everyone if ($this->npub !== null) { try { $this->credits = $this->creditsManager->getBalance($this->npub); @@ -81,8 +85,29 @@ final class SearchComponent } } - // Restore search results from session if available and no query provided - if (empty($this->query) && $this->currentRoute == 'search') { + // If a query is provided (from URL or prop), perform the search automatically + if (!empty($this->query)) { + $this->logger->info('Query detected in mount, triggering search for: ' . $this->query); + // Clear cache if this is a different query than what's cached + $session = $this->requestStack->getSession(); + if ($session->has(self::SESSION_QUERY_KEY)) { + $cachedQuery = $session->get(self::SESSION_QUERY_KEY); + if ($cachedQuery !== $this->query) { + $this->clearSearchCache(); + $this->logger->info('Cleared cache for different query. Old: ' . $cachedQuery . ', New: ' . $this->query); + } + } + + try { + $this->search(); + } catch (InvalidArgumentException $e) { + $this->logger->error('Search error on mount: ' . $e->getMessage()); + } + return; + } + + // Otherwise, restore search results from session if available + if ($this->currentRoute == 'search') { $session = $this->requestStack->getSession(); if ($session->has(self::SESSION_QUERY_KEY)) { $this->query = $session->get(self::SESSION_QUERY_KEY); @@ -102,8 +127,9 @@ final class SearchComponent { $token = $this->tokenStorage->getToken(); $this->npub = $token?->getUserIdentifier(); + $isAuthenticated = $this->npub !== null; - $this->logger->info("Query: {$this->query}, npub: {$this->npub}"); + $this->logger->info("Query: {$this->query}, npub: " . ($this->npub ?? 'anonymous')); if (empty($this->query)) { $this->results = []; @@ -112,13 +138,16 @@ final class SearchComponent return; } - try { - $this->credits = $this->creditsManager->getBalance($this->npub); - } catch (InvalidArgumentException $e) { - $this->credits = $this->creditsManager->resetBalance($this->npub); + // Update credits for authenticated users + if ($isAuthenticated) { + try { + $this->credits = $this->creditsManager->getBalance($this->npub); + } catch (InvalidArgumentException $e) { + $this->credits = $this->creditsManager->resetBalance($this->npub); + } } - // Check if the same query exists in session + // Check if the same query exists in session (works for both auth and anon) $session = $this->requestStack->getSession(); if ($session->has(self::SESSION_QUERY_KEY) && $session->get(self::SESSION_QUERY_KEY) === $this->query) { @@ -129,19 +158,21 @@ final class SearchComponent return; } - if (!$this->creditsManager->canAfford($this->npub, 1)) { - $this->results = []; - $this->authors = []; - return; - } - try { $this->results = []; - $this->creditsManager->spendCredits($this->npub, 1, 'search'); - $this->credits--; - // Perform optimized single search query - $this->results = $this->performOptimizedSearch($this->query); + // Only spend credits if user is authenticated + if ($isAuthenticated && $this->creditsManager->canAfford($this->npub, 1)) { + $this->creditsManager->spendCredits($this->npub, 1, 'search'); + $this->credits--; + } + + // Set result limits: 5 for anonymous, 12 for authenticated users + $maxResults = $isAuthenticated ? 12 : 5; + $this->logger->info('Search limit: ' . $maxResults . ' results for ' . ($isAuthenticated ? 'authenticated' : 'anonymous') . ' user'); + + // Perform optimized single search query with appropriate limit + $this->results = $this->performOptimizedSearch($this->query, $maxResults); $pubkeys = array_unique(array_map(fn($art) => $art->getPubkey(), $this->results)); $this->authors = $this->redisCacheService->getMultipleMetadata($pubkeys); @@ -179,8 +210,10 @@ final class SearchComponent /** * Perform optimized single search query + * @param string $query The search query + * @param int|null $maxResults Maximum number of results (null for default) */ - private function performOptimizedSearch(string $query): array + private function performOptimizedSearch(string $query, ?int $maxResults = null): array { $mainQuery = new Query(); $boolQuery = new BoolQuery(); @@ -219,10 +252,11 @@ final class SearchComponent 'createdAt' => ['order' => 'desc'] ]); - // Pagination - $offset = ($this->page - 1) * $this->resultsPerPage; + // Pagination - use maxResults if provided, otherwise use default resultsPerPage + $effectiveResultsPerPage = $maxResults ?? $this->resultsPerPage; + $offset = ($this->page - 1) * $effectiveResultsPerPage; $mainQuery->setFrom($offset); - $mainQuery->setSize($this->resultsPerPage); + $mainQuery->setSize($effectiveResultsPerPage); // Execute the search $results = $this->finder->find($mainQuery); diff --git a/sync-strfry.sh b/sync-strfry.sh index 57ade2f..9e62ae1 100644 --- a/sync-strfry.sh +++ b/sync-strfry.sh @@ -2,7 +2,7 @@ # Sync articles, comments, media, and profiles from upstream relays # Event kinds: 30023 (articles), 30024 (drafts), 1111 (comments), 20 (pictures), 21 (videos), 22 (short videos), 0 (profiles), 9802 (highlights) -KINDS='{"kinds":[30023,30024,1111,20,21,22,0,9802]}' +KINDS='{"kinds":[30023,30024,1111,20,21,22,0,9802],"limit":5000}' echo "Starting relay sync at $(date)" diff --git a/templates/components/Atoms/ForumAside.html.twig b/templates/components/Atoms/ForumAside.html.twig index d885d35..52f67c6 100644 --- a/templates/components/Atoms/ForumAside.html.twig +++ b/templates/components/Atoms/ForumAside.html.twig @@ -1,5 +1,7 @@
-

Forum

+

+ Topics +

{% for catKey, category in topics %}
diff --git a/templates/components/SearchComponent.html.twig b/templates/components/SearchComponent.html.twig index dd717a6..e15011c 100644 --- a/templates/components/SearchComponent.html.twig +++ b/templates/components/SearchComponent.html.twig @@ -1,7 +1,9 @@
{% if interactive %} -
+ {% if is_granted('IS_AUTHENTICATED_FULLY') %}
+ {% set count = credits > 0 ? credits : 0 %} - {{ 'credit.balance'|trans({'%count%': credits, 'count': credits}) }} + {{ 'credit.balance'|trans({'%count%': count, 'count': count}) }}
{% endif %}
- {% if not is_granted('IS_AUTHENTICATED_FULLY') %} -
-

Log in to search articles.

-
- {% endif %} - - {% if is_granted('IS_AUTHENTICATED_FULLY') and credits == 0 %} + {% if is_granted('IS_AUTHENTICATED_FULLY') and not credits > 0 %}
@@ -80,6 +75,15 @@
{% else %} + + {# Show message to anonymous users about limited results #} + {% if not is_granted('IS_AUTHENTICATED_FULLY') and this.results is not empty %} +
+

Showing limited results (5 articles)

+

Sign in to see more results and unlock full search capabilities!

+ Sign In +
+ {% endif %} {% endif %} {% elseif this.query is not empty %}

{{ '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 %}
-

{{ category.name }}

+

+ {{ category.name }} +

diff --git a/templates/forum/main_topic.html.twig b/templates/forum/main_topic.html.twig new file mode 100644 index 0000000..52c287d --- /dev/null +++ b/templates/forum/main_topic.html.twig @@ -0,0 +1,32 @@ +{% extends 'layout.html.twig' %} + +{% block body %} + + +
+ {% set cat = topics[categoryKey] ?? null %} + {% if cat %} + {% for subKey, sub in cat.subcategories %} +
+

+ {{ sub.name }} +

+
+
+ {% for tag in sub.tags %} + {{ tag }} + {% endfor %} +
+
{{ sub.count|default(0) }}
+
+
+ {% endfor %} + {% else %} +
No subcategories available for this topic.
+ {% endif %} +
+{% endblock %} + +{% block aside %} + +{% endblock %} diff --git a/templates/layout.html.twig b/templates/layout.html.twig index 4f28af6..a659c20 100644 --- a/templates/layout.html.twig +++ b/templates/layout.html.twig @@ -12,7 +12,7 @@ Newsstand
  • - Latest + Explore
  • Topics @@ -23,12 +23,6 @@
  • Highlights
  • -
  • - Lists -
  • -
  • - {{ 'heading.search'|trans }} -
  • Write
  • diff --git a/templates/pages/discover.html.twig b/templates/pages/discover.html.twig new file mode 100644 index 0000000..a867b78 --- /dev/null +++ b/templates/pages/discover.html.twig @@ -0,0 +1,57 @@ +{% extends 'layout.html.twig' %} + +{% block body %} + {# Search Section #} +
    +
    +
    + +
    +
    +
    + + {# Main Topics (visible on small screens when sidebar is hidden) #} +
    +
    + {% if mainTopicsMap is defined and mainTopicsMap is not empty %} +
    + All + {% for key, name in mainTopicsMap %} + + {{ name }} + + {% endfor %} +
    + {% endif %} +
    +
    + +
    +
    + {% if articles is empty %} +
    + No articles found. +
    + {% else %} + + {% endif %} +
    +
    +{% endblock %} + +{% block aside %} + +{% endblock %} diff --git a/templates/pages/search.html.twig b/templates/pages/search.html.twig index 28d1bdc..d7870e4 100644 --- a/templates/pages/search.html.twig +++ b/templates/pages/search.html.twig @@ -4,14 +4,11 @@ {% endblock %} {% block body %} -
    -
    -

    Discover

    -

    hidden gems

    -
    -
    +
    - +
    {% endblock %}