Browse Source

Better article management

imwald
Nuša Pukšič 8 months ago
parent
commit
5446fed640
  1. 2
      assets/styles/layout.css
  2. 2
      src/Command/ElevateUserCommand.php
  3. 19
      src/Controller/AuthorController.php
  4. 51
      src/Controller/DefaultController.php
  5. 314
      src/Service/NostrClient.php
  6. 5
      templates/components/UserMenu.html.twig
  7. 11
      templates/pages/article.html.twig

2
assets/styles/layout.css

@ -24,6 +24,7 @@
nav { nav {
width: 21vw; width: 21vw;
min-width: 150px; min-width: 150px;
max-width: 280px;
flex-shrink: 0; flex-shrink: 0;
padding: 1em; padding: 1em;
overflow-y: auto; /* Ensure the menu is scrollable if content is too long */ overflow-y: auto; /* Ensure the menu is scrollable if content is too long */
@ -107,6 +108,7 @@ main {
top: 150px; top: 150px;
width: 21vw; width: 21vw;
min-width: 150px; min-width: 150px;
max-width: 280px;
} }
.user-nav { .user-nav {

2
src/Command/ElevateUserCommand.php

@ -38,6 +38,7 @@ class ElevateUserCommand extends Command
return Command::INVALID; return Command::INVALID;
} }
/** @var User|null $user */
$user = $this->entityManager->getRepository(User::class)->findOneBy(['npub' => $npub]); $user = $this->entityManager->getRepository(User::class)->findOneBy(['npub' => $npub]);
if (!$user) { if (!$user) {
return Command::FAILURE; return Command::FAILURE;
@ -46,6 +47,7 @@ class ElevateUserCommand extends Command
$user->addRole($role); $user->addRole($role);
$this->entityManager->persist($user); $this->entityManager->persist($user);
$this->entityManager->flush(); $this->entityManager->flush();
$output->writeln(sprintf('User %s elevated to role %s', $npub, $role));
return Command::SUCCESS; return Command::SUCCESS;
} }

19
src/Controller/AuthorController.php

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Service\NostrClient;
use App\Service\RedisCacheService; use App\Service\RedisCacheService;
use Elastica\Query\Terms; use Elastica\Query\Terms;
use FOS\ElasticaBundle\Finder\FinderInterface; use FOS\ElasticaBundle\Finder\FinderInterface;
@ -20,18 +21,21 @@ class AuthorController extends AbstractController
* @throws InvalidArgumentException * @throws InvalidArgumentException
*/ */
#[Route('/p/{npub}', name: 'author-profile', requirements: ['npub' => '^npub1.*'])] #[Route('/p/{npub}', name: 'author-profile', requirements: ['npub' => '^npub1.*'])]
public function index($npub, RedisCacheService $redisCacheService, FinderInterface $finder): Response public function index($npub, NostrClient $nostrClient, RedisCacheService $redisCacheService, FinderInterface $finder): Response
{ {
$keys = new Key(); $keys = new Key();
$pubkey = $keys->convertToHex($npub); $pubkey = $keys->convertToHex($npub);
$author = $redisCacheService->getMetadata($npub); $author = $redisCacheService->getMetadata($npub);
$relays = $redisCacheService->getRelays($npub); // Retrieve long-form content for the author
try {
// Look for articles in index, assume indexing is done regularly $list = $nostrClient->getLongFormContentForPubkey($npub);
// TODO give users an option to reindex } catch (\Exception $e) {
$list = [];
}
// Also look for articles in the Elastica index
$query = new Terms('pubkey', [$pubkey]); $query = new Terms('pubkey', [$pubkey]);
$list = $finder->find($query, 25); $list = array_merge($list, $finder->find($query, 25));
// deduplicate by slugs // deduplicate by slugs
$articles = []; $articles = [];
@ -44,8 +48,7 @@ class AuthorController extends AbstractController
return $this->render('Pages/author.html.twig', [ return $this->render('Pages/author.html.twig', [
'author' => $author, 'author' => $author,
'npub' => $npub, 'npub' => $npub,
'articles' => $articles, 'articles' => $articles
'relays' => $relays
]); ]);
} }

51
src/Controller/DefaultController.php

@ -11,6 +11,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CacheInterface;
use App\Service\NostrClient;
use App\Factory\ArticleFactory;
use Psr\Log\LoggerInterface;
class DefaultController extends AbstractController class DefaultController extends AbstractController
{ {
@ -47,7 +50,10 @@ class DefaultController extends AbstractController
*/ */
#[Route('/cat/{slug}', name: 'magazine-category')] #[Route('/cat/{slug}', name: 'magazine-category')]
public function magCategory($slug, CacheInterface $redisCache, public function magCategory($slug, CacheInterface $redisCache,
FinderInterface $finder): Response FinderInterface $finder,
NostrClient $nostrClient,
ArticleFactory $articleFactory,
LoggerInterface $logger): Response
{ {
$catIndex = $redisCache->get('magazine-' . $slug, function (){ $catIndex = $redisCache->get('magazine-' . $slug, function (){
throw new \Exception('Not found'); throw new \Exception('Not found');
@ -55,6 +61,7 @@ class DefaultController extends AbstractController
$list = []; $list = [];
$slugs = []; $slugs = [];
$coordinates = []; // Store full coordinates (kind:author:slug)
$category = []; $category = [];
foreach ($catIndex->getTags() as $tag) { foreach ($catIndex->getTags() as $tag) {
@ -68,12 +75,12 @@ class DefaultController extends AbstractController
$parts = explode(':', $tag[1]); $parts = explode(':', $tag[1]);
if (count($parts) === 3) { if (count($parts) === 3) {
$slugs[] = $parts[2]; $slugs[] = $parts[2];
$coordinates[] = $tag[1]; // Store the full coordinate
} }
} }
} }
if (!empty($slugs)) { if (!empty($slugs)) {
$query = new Terms('slug', array_values($slugs)); $query = new Terms('slug', array_values($slugs));
$articles = $finder->find($query); $articles = $finder->find($query);
@ -88,16 +95,44 @@ class DefaultController extends AbstractController
} }
} }
if (!empty($res)) { // Find missing articles based on coordinates
foreach ($res as $result) { $missingCoordinates = [];
if (!isset($slugMap[$result->getSlug()])) { $missingIndexes = [];
$slugMap[$result->getSlug()] = $result;
} for ($i = 0; $i < count($slugs); $i++) {
$slug = $slugs[$i];
if (!isset($slugMap[$slug])) {
$missingCoordinates[] = $coordinates[$i];
$missingIndexes[$coordinates[$i]] = $i; // Track original position
} }
} }
// If we have missing articles, fetch them from nostr
if (!empty($missingCoordinates)) {
$logger->info('Fetching missing articles', [
'missing' => $missingCoordinates
]);
try {
$nostrArticles = $nostrClient->getArticlesByCoordinates($missingCoordinates);
foreach ($nostrArticles as $coordinate => $event) {
$parts = explode(':', $coordinate);
if (count($parts) === 3) {
$article = $articleFactory->createFromLongFormContentEvent($event);
// Add to the slugMap
$slugMap[$article->getSlug()] = $article;
}
}
} catch (\Exception $e) {
$logger->error('Error fetching missing articles', [
'error' => $e->getMessage()
]);
}
}
// Reorder by the original $slugs // Reorder by the original $slugs to maintain order
$results = []; $results = [];
foreach ($slugs as $slug) { foreach ($slugs as $slug) {
if (isset($slugMap[$slug])) { if (isset($slugMap[$slug])) {

314
src/Service/NostrClient.php

@ -3,25 +3,20 @@
namespace App\Service; namespace App\Service;
use App\Entity\Article; use App\Entity\Article;
use App\Entity\User;
use App\Enum\KindsEnum; use App\Enum\KindsEnum;
use App\Factory\ArticleFactory; use App\Factory\ArticleFactory;
use App\Repository\UserEntityRepository;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use swentel\nostr\Event\Event; use swentel\nostr\Event\Event;
use swentel\nostr\Filter\Filter; use swentel\nostr\Filter\Filter;
use swentel\nostr\Key\Key;
use swentel\nostr\Message\EventMessage; use swentel\nostr\Message\EventMessage;
use swentel\nostr\Message\RequestMessage; use swentel\nostr\Message\RequestMessage;
use swentel\nostr\Relay\Relay; use swentel\nostr\Relay\Relay;
use swentel\nostr\Relay\RelaySet; use swentel\nostr\Relay\RelaySet;
use swentel\nostr\Request\Request; use swentel\nostr\Request\Request;
use swentel\nostr\Subscription\Subscription; use swentel\nostr\Subscription\Subscription;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Serializer\SerializerInterface;
class NostrClient class NostrClient
{ {
@ -48,6 +43,7 @@ class NostrClient
{ {
$this->defaultRelaySet = new RelaySet(); $this->defaultRelaySet = new RelaySet();
$this->defaultRelaySet->addRelay(new Relay('wss://theforest.nostr1.com')); // public aggregator relay $this->defaultRelaySet->addRelay(new Relay('wss://theforest.nostr1.com')); // public aggregator relay
$this->defaultRelaySet->addRelay(new Relay('wss://thecitadel.nostr1.com')); // public aggregator relay
} }
/** /**
@ -55,7 +51,7 @@ class NostrClient
*/ */
private function createRelaySet(array $relayUrls): RelaySet private function createRelaySet(array $relayUrls): RelaySet
{ {
$relaySet = new RelaySet(); $relaySet = $this->defaultRelaySet;
foreach ($relayUrls as $relayUrl) { foreach ($relayUrls as $relayUrl) {
$relaySet->addRelay(new Relay($relayUrl)); $relaySet->addRelay(new Relay($relayUrl));
} }
@ -215,39 +211,45 @@ class NostrClient
} }
} }
/**
* @throws \Exception
*/
public function getLongFormFromNaddr($slug, $relayList, $author, $kind): void public function getLongFormFromNaddr($slug, $relayList, $author, $kind): void
{ {
$subscription = new Subscription(); if (!empty($relayList)) {
$subscriptionId = $subscription->setId(); // Filter out relays that exist in the REPUTABLE_RELAYS list
$filter = new Filter(); $relayList = array_filter($relayList, function ($relay) {
$filter->setKinds([$kind]); // in array REPUTABLE_RELAYS
$filter->setAuthors([$author]); return in_array($relay, self::REPUTABLE_RELAYS) && str_starts_with($relay, 'wss:') && !str_contains($relay, 'localhost');
$filter->setTag('#d', [$slug]); });
$requestMessage = new RequestMessage($subscriptionId, [$filter]); $relaySet = $this->createRelaySet($relayList);
// First try with theforest relay and any relays in $relayList
// Add theforest relay to the list, if not already present
if (!in_array('wss://theforest.nostr1.com', $relayList)) {
$relayList[] = 'wss://theforest.nostr1.com';
} }
$forestRelaySet = $this->createRelaySet($relayList);
$response = null;
$hasEvents = false; $hasEvents = false;
try { try {
$request = new Request($forestRelaySet, $requestMessage); // Create request using the helper method for forest relay set
$response = $request->send(); $request = $this->createNostrRequest(
kinds: [$kind],
filters: [
'authors' => [$author],
'tag' => ['#d', [$slug]]
],
relaySet: $relaySet ?? $this->defaultRelaySet
);
// Process the response
$events = $this->processResponse($request->send(), function($event) {
return $event;
});
// Check if we got any events if (!empty($events)) {
foreach ($response as $relayRes) { $this->saveLongFormContent(array_map(function($event) {
$filtered = array_filter($relayRes, function ($item) { $wrapper = new \stdClass();
return $item->type === 'EVENT'; $wrapper->type = 'EVENT';
}); $wrapper->event = $event;
if (count($filtered) > 0) { return $wrapper;
$this->saveLongFormContent($filtered); }, $events));
$hasEvents = true; $hasEvents = true;
break;
}
} }
// If no events found in theforest, try author's reputable relays // If no events found in theforest, try author's reputable relays
@ -255,21 +257,32 @@ class NostrClient
$topAuthorRelays = $this->getTopReputableRelaysForAuthor($author); $topAuthorRelays = $this->getTopReputableRelaysForAuthor($author);
$authorRelaySet = $this->createRelaySet($topAuthorRelays); $authorRelaySet = $this->createRelaySet($topAuthorRelays);
$this->logger->info('No results from theforest, trying author relays', [ $this->logger->info('No results, trying author relays', [
'relays' => $topAuthorRelays 'relays' => $topAuthorRelays
]); ]);
$request = new Request($authorRelaySet, $requestMessage); // Create request using the helper method for author relay set
$response = $request->send(); $request = $this->createNostrRequest(
kinds: [$kind],
filters: [
'authors' => [$author],
'tag' => ['#d', [$slug]]
],
relaySet: $authorRelaySet
);
// Process the response
$events = $this->processResponse($request->send(), function($event) {
return $event;
});
foreach ($response as $relayRes) { if (!empty($events)) {
$filtered = array_filter($relayRes, function ($item) { $this->saveLongFormContent(array_map(function($event) {
return $item->type === 'EVENT'; $wrapper = new \stdClass();
}); $wrapper->type = 'EVENT';
if (count($filtered) > 0) { $wrapper->event = $event;
$this->saveLongFormContent($filtered); return $wrapper;
break; }, $events));
}
} }
} }
} catch (\Exception $e) { } catch (\Exception $e) {
@ -277,17 +290,7 @@ class NostrClient
$this->logger->error('Error querying relays, falling back to defaults', [ $this->logger->error('Error querying relays, falling back to defaults', [
'error' => $e->getMessage() 'error' => $e->getMessage()
]); ]);
$request = new Request($this->defaultRelaySet, $requestMessage); throw new \Exception('Error querying relays', 0, $e);
$response = $request->send();
foreach ($response as $relayRes) {
$filtered = array_filter($relayRes, function ($item) {
return $item->type === 'EVENT';
});
if (count($filtered) > 0) {
$this->saveLongFormContent($filtered);
}
}
} }
} }
@ -296,17 +299,7 @@ class NostrClient
foreach ($filtered as $wrapper) { foreach ($filtered as $wrapper) {
$article = $this->articleFactory->createFromLongFormContentEvent($wrapper->event); $article = $this->articleFactory->createFromLongFormContentEvent($wrapper->event);
// check if event with same eventId already in DB // check if event with same eventId already in DB
$saved = $this->entityManager->getRepository(Article::class)->findOneBy(['eventId' => $article->getEventId()]); $this->saveEachArticleToTheDatabase($article);
if (!$saved) {
try {
$this->logger->info('Saving article', ['article' => $article]);
$this->entityManager->persist($article);
$this->entityManager->flush();
} catch (\Exception $e) {
$this->logger->error($e->getMessage());
$this->managerRegistry->resetManager();
}
}
} }
} }
@ -315,13 +308,10 @@ class NostrClient
*/ */
public function getNpubRelays($npub): array public function getNpubRelays($npub): array
{ {
// Convert npub to hex
$keys = new Key();
$pubkey = $keys->convertToHex($npub);
// Get relays // Get relays
$request = $this->createNostrRequest( $request = $this->createNostrRequest(
kinds: [KindsEnum::RELAY_LIST], kinds: [KindsEnum::RELAY_LIST],
filters: ['authors' => [$pubkey]], filters: ['authors' => [$npub]],
relaySet: $this->defaultRelaySet relaySet: $this->defaultRelaySet
); );
$response = $this->processResponse($request->send(), function($received) { $response = $this->processResponse($request->send(), function($received) {
@ -387,43 +377,32 @@ class NostrClient
/** /**
* @throws \Exception * @throws \Exception
*/ */
public function getLongFormContentForPubkey(string $pubkey): array public function getLongFormContentForPubkey(string $ident): array
{ {
$articles = []; // Add user relays to the default set
$authorRelays = $this->getTopReputableRelaysForAuthor($ident);
// Create a RelaySet from the author's relays
$relaySet = $this->defaultRelaySet; $relaySet = $this->defaultRelaySet;
if (!empty($authorRelays)) {
$relaySet = $this->createRelaySet($authorRelays);
}
// look for last months long-form notes // Create request using the helper method
$subscription = new Subscription(); $request = $this->createNostrRequest(
$subscriptionId = $subscription->setId(); kinds: [KindsEnum::LONGFORM],
$filter = new Filter(); filters: [
$filter->setKinds([KindsEnum::LONGFORM]); 'authors' => [$ident],
$filter->setLimit(10); 'limit' => 10
$filter->setAuthors([$pubkey]); ],
$requestMessage = new RequestMessage($subscriptionId, [$filter]); relaySet: $relaySet
$request = new Request($relaySet, $requestMessage); );
$response = $request->send();
// response is an array of arrays // Process the response using the helper method
foreach ($response as $value) { return $this->processResponse($request->send(), function($event) {
foreach ($value as $item) { $article = $this->articleFactory->createFromLongFormContentEvent($event);
if (is_array($item)) continue; // Save each article to the database
switch ($item->type) { $this->saveEachArticleToTheDatabase($article);
case 'EVENT': });
$article = $this->articleFactory->createFromLongFormContentEvent($item->event);
$articles[] = $article;
break;
case 'AUTH':
// throw new UnauthorizedHttpException('', 'Relay requires authentication');
case 'ERROR':
case 'NOTICE':
// throw new \Exception('An error occurred');
default:
// nothing to do here
}
}
}
return $articles;
} }
public function getArticles(array $slugs): array public function getArticles(array $slugs): array
@ -495,6 +474,108 @@ class NostrClient
return $articles; return $articles;
} }
/**
* Fetch articles by coordinates (kind:author:slug)
* Returns a map of coordinate => event for successful fetches
*
* @param array $coordinates Array of coordinates in format kind:author:slug
* @return array Map of coordinate => event
* @throws \Exception
*/
public function getArticlesByCoordinates(array $coordinates): array
{
$articlesMap = [];
foreach ($coordinates as $coordinate) {
$parts = explode(':', $coordinate);
if (count($parts) !== 3) {
$this->logger->warning('Invalid coordinate format', ['coordinate' => $coordinate]);
continue;
}
$kind = (int)$parts[0];
$pubkey = $parts[1];
$slug = $parts[2];
// Try to get relays associated with the author first
$relayList = [];
try {
// Get relays where the author publishes
$authorRelays = $this->getTopReputableRelaysForAuthor($pubkey);
if (!empty($authorRelays)) {
$relayList = $authorRelays;
}
} catch (\Exception $e) {
$this->logger->warning('Failed to get author relays', [
'pubkey' => $pubkey,
'error' => $e->getMessage()
]);
// Continue with default relays
}
// If no author relays found, add default relay
if (empty($relayList)) {
$relayList = [self::REPUTABLE_RELAYS[0]];
}
// Ensure we use a RelaySet
$relaySet = $this->createRelaySet($relayList);
// Create subscription and filter
$subscription = new Subscription();
$subscriptionId = $subscription->setId();
$filter = new Filter();
$filter->setKinds([$kind]);
$filter->setAuthors([$pubkey]);
$filter->setTag('#d', [$slug]);
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
try {
$request = new Request($relaySet, $requestMessage);
$response = $request->send();
$found = false;
// Check responses from each relay
foreach ($response as $value) {
foreach ($value as $item) {
if ($item->type === 'EVENT') {
$articlesMap[$coordinate] = $item->event;
$found = true;
break 2; // Found what we need, exit both loops
}
}
}
// If still not found, try with default relay set as fallback
if (!$found) {
$this->logger->info('Article not found in author relays, trying default relays', [
'coordinate' => $coordinate
]);
$request = new Request($this->defaultRelaySet, $requestMessage);
$response = $request->send();
foreach ($response as $value) {
foreach ($value as $item) {
if ($item->type === 'EVENT') {
$articlesMap[$coordinate] = $item->event;
break 2;
}
}
}
}
} catch (\Exception $e) {
$this->logger->error('Error fetching article', [
'coordinate' => $coordinate,
'error' => $e->getMessage()
]);
}
}
return $articlesMap;
}
private function createNostrRequest(array $kinds, array $filters = [], ?RelaySet $relaySet = null): Request private function createNostrRequest(array $kinds, array $filters = [], ?RelaySet $relaySet = null): Request
{ {
$subscription = new Subscription(); $subscription = new Subscription();
@ -504,7 +585,13 @@ class NostrClient
foreach ($filters as $key => $value) { foreach ($filters as $key => $value) {
$method = 'set' . ucfirst($key); $method = 'set' . ucfirst($key);
if (method_exists($filter, $method)) { if (method_exists($filter, $method)) {
$filter->$method($value); // If it's tags, we need to handle it differently
if ($key === 'tag') {
$filter->setTag($value[0], $value[1]);
} else {
// Call the method with the value
$filter->$method($value);
}
} }
} }
@ -516,10 +603,12 @@ class NostrClient
{ {
$results = []; $results = [];
foreach ($response as $relayRes) { foreach ($response as $relayRes) {
$this->logger->warning('Response from relay', $response);
foreach ($relayRes as $item) { foreach ($relayRes as $item) {
try { try {
switch ($item->type) { switch ($item->type) {
case 'EVENT': case 'EVENT':
$this->logger->info('Processing event', ['event' => $item->event]);
$result = $eventHandler($item->event); $result = $eventHandler($item->event);
if ($result !== null) { if ($result !== null) {
$results[] = $result; $results[] = $result;
@ -543,4 +632,23 @@ class NostrClient
} }
return $results; return $results;
} }
/**
* @param Article $article
* @return void
*/
public function saveEachArticleToTheDatabase(Article $article): void
{
$saved = $this->entityManager->getRepository(Article::class)->findOneBy(['eventId' => $article->getEventId()]);
if (!$saved) {
try {
$this->logger->info('Saving article', ['article' => $article]);
$this->entityManager->persist($article);
$this->entityManager->flush();
} catch (\Exception $e) {
$this->logger->error($e->getMessage());
$this->managerRegistry->resetManager();
}
}
}
} }

5
templates/components/UserMenu.html.twig

@ -2,8 +2,8 @@
{% if app.user %} {% if app.user %}
<div class="notice info"> <div class="notice info">
<twig:Molecules:UserFromNpub ident="{{ app.user.npub }}" /> <twig:Molecules:UserFromNpub ident="{{ app.user.npub }}" />
{% if is_granted('ROLE_ADMIN') %}<span class="badge">Admin</span>{% endif %}
</div> </div>
{# <p>Hello, {{ app.user.name }}</p>#}
{% if is_granted('ROLE_ADMIN') %} {% if is_granted('ROLE_ADMIN') %}
{# <ul>#} {# <ul>#}
{# <li>#} {# <li>#}
@ -12,9 +12,6 @@
{# </ul>#} {# </ul>#}
{% endif %} {% endif %}
<ul class="user-nav"> <ul class="user-nav">
<li>
<a href="{{ path('author-profile', {npub: app.user.npub }) }}">Profile</a>
</li>
{# <li>#} {# <li>#}
{# <a href="{{ path('editor-create') }}">Write an article</a>#} {# <a href="{{ path('editor-create') }}">Write an article</a>#}
{# </li>#} {# </li>#}

11
templates/pages/article.html.twig

@ -12,6 +12,14 @@
{% endblock %} {% endblock %}
{% block body %} {% block body %}
{% if is_granted('ROLE_ADMIN') %}
<button class="btn btn-primary" onclick="navigator.clipboard.writeText('30023:{{ article.pubkey }}:{{ article.slug }}')">
Copy coordinates
</button>
{% endif %}
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h1 class="card-title">{{ article.title }}</h1> <h1 class="card-title">{{ article.title }}</h1>
@ -66,9 +74,6 @@
{% endblock %} {% endblock %}
{% block aside %} {% block aside %}
{% if is_granted('ROLE_ADMIN') %}
<p>30032:{{ article.pubkey }}:{{ article.slug }}</p>
{% endif %}
{# <h1>Suggestions</h1>#} {# <h1>Suggestions</h1>#}
{# <twig:Organisms:CardList :list="suggestions" />#} {# <twig:Organisms:CardList :list="suggestions" />#}
{% endblock %} {% endblock %}

Loading…
Cancel
Save