Browse Source

Redis and categories

imwald
Nuša Pukšič 10 months ago
parent
commit
b43313e738
  1. 2
      Dockerfile
  2. 37
      assets/styles/app.css
  3. 8
      assets/styles/card.css
  4. 10
      assets/styles/layout.css
  5. 2
      assets/styles/theme.css
  6. 1
      config/bundles.php
  7. 12
      config/packages/cache.yaml
  8. 2
      config/packages/fos_elastica.yaml
  9. 2
      config/packages/framework.yaml
  10. 36
      config/services.yaml
  11. 23
      src/Controller/ArticleController.php
  12. 17
      src/Controller/AuthorController.php
  13. 164
      src/Controller/DefaultController.php
  14. 5
      src/Entity/Article.php
  15. 30
      src/EventListener/PopulateListener.php
  16. 27
      src/Provider/ArticleProvider.php
  17. 1
      src/Repository/ArticleRepository.php
  18. 5
      src/Service/NostrClient.php
  19. 13
      src/Twig/Components/SearchComponent.php
  20. 14
      src/Util/IndexableArticleChecker.php
  21. 21
      templates/components/Header.html.twig
  22. 13
      templates/components/Molecules/Card.html.twig
  23. 28
      templates/components/SearchComponent.html.twig
  24. 2
      templates/home.html.twig
  25. 9
      templates/pages/article.html.twig
  26. 32
      templates/pages/author.html.twig
  27. 13
      templates/pages/category.html.twig

2
Dockerfile

@ -34,6 +34,8 @@ RUN set -eux; \
opcache \ opcache \
zip \ zip \
gmp \ gmp \
gd \
redis \
; ;
# https://getcomposer.org/doc/03-cli.md#composer-allow-superuser # https://getcomposer.org/doc/03-cli.md#composer-allow-superuser

37
assets/styles/app.css

@ -25,6 +25,15 @@ h1 {
font-weight: 600; font-weight: 600;
} }
h1.brand {
font-family: var(--brand-font), serif;
font-size: 3.6rem;
}
h1.brand a {
color: white;
}
h2 { h2 {
font-size: 3rem; font-size: 3rem;
} }
@ -49,6 +58,18 @@ p {
margin: 0 0 15px; margin: 0 0 15px;
} }
aside h1 {
font-size: 2rem;
}
aside h2 {
font-size: 1.7rem;
}
aside p.lede {
font-size: 1.2rem;
}
.lede { .lede {
font-family: var(--main-body-font), serif; font-family: var(--main-body-font), serif;
font-size: 1.6rem; font-size: 1.6rem;
@ -102,7 +123,7 @@ svg.icon {
background-color: var(--color-bg); background-color: var(--color-bg);
color: var(--color-text); color: var(--color-text);
padding: 0; padding: 0;
margin: 20px 0; margin: 20px 0 50px 0;
border-radius: 0; /* Sharp edges */ border-radius: 0; /* Sharp edges */
} }
@ -112,8 +133,6 @@ svg.icon {
.card-header { .card-header {
font-size: 1.5rem; font-size: 1.5rem;
margin-bottom: 10px;
padding-bottom: 10px;
} }
.header__image { .header__image {
@ -165,16 +184,26 @@ svg.icon {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 2em; gap: 2em;
padding: 0;
} }
.header__categories li { .header__categories li {
list-style: none; list-style: none;
} }
.header__logo h1 { .header__categories li a:hover {
text-decoration: none;
font-weight: bold;
}
.header__categories li.active a {
font-weight: bold; font-weight: bold;
} }
.header__logo h1 {
font-weight: normal;
}
.header__logo img { .header__logo img {
height: 40px; /* Adjust the height as needed */ height: 40px; /* Adjust the height as needed */
} }

8
assets/styles/card.css

@ -1 +1,7 @@
.card-header img {
max-width: 100%;
height: auto;
max-height: 200px;
width: 100%;
object-fit: cover;
}

10
assets/styles/layout.css

@ -23,6 +23,7 @@
nav { nav {
width: auto; width: auto;
min-width: 150px;
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 */
@ -56,7 +57,10 @@ main {
/* Right sidebar */ /* Right sidebar */
aside { aside {
width: 180px; /* Adjust the width based on the left menu */ width: 190px;
min-width: 150px;
flex-shrink: 0;
flex-grow: 0;
padding: 1em; padding: 1em;
} }
@ -92,3 +96,7 @@ footer {
position: relative; position: relative;
width: 100%; width: 100%;
} }
.search input {
flex-grow: 1;
}

2
assets/styles/theme.css

@ -1,6 +1,7 @@
@import url('https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,200..800;1,6..72,200..800&family=Playfair+Display:ital,wght@0,400..900;1,400..900&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,200..800;1,6..72,200..800&family=Playfair+Display:ital,wght@0,400..900;1,400..900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Montserrat&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Montserrat&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Josefin+Slab:ital,wght@0,100..700;1,100..700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Josefin+Slab:ital,wght@0,100..700;1,100..700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Lobster&display=swap');
:root { :root {
--color-bg: #222; /* Black background */ --color-bg: #222; /* Black background */
@ -15,4 +16,5 @@
--font-family: 'Montserrat'; /* Set the Montserrat font as default */ --font-family: 'Montserrat'; /* Set the Montserrat font as default */
--main-body-font: 'Newsreader'; /* Set the font for the main body */ --main-body-font: 'Newsreader'; /* Set the font for the main body */
--heading-font: 'Josefin Slab'; /* Set the font for headings */ --heading-font: 'Josefin Slab'; /* Set the font for headings */
--brand-font: 'Lobster';
} }

1
config/bundles.php

@ -15,4 +15,5 @@ return [
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Symfony\UX\Icons\UXIconsBundle::class => ['all' => true], Symfony\UX\Icons\UXIconsBundle::class => ['all' => true],
FOS\ElasticaBundle\FOSElasticaBundle::class => ['all' => true], FOS\ElasticaBundle\FOSElasticaBundle::class => ['all' => true],
Endroid\QrCodeBundle\EndroidQrCodeBundle::class => ['all' => true],
]; ];

12
config/packages/cache.yaml

@ -1,19 +1,17 @@
framework: framework:
cache: cache:
# Unique name of your app: used to compute stable namespaces for cache keys. # Unique name of your app: used to compute stable namespaces for cache keys.
#prefix_seed: your_vendor_name/app_name prefix_seed: newsroom/app
# The "app" cache stores to the filesystem by default. # The "app" cache stores to the filesystem by default.
# The data in this cache should persist between deploys. # The data in this cache should persist between deploys.
# Other options include: # Other options include:
# Redis # Redis
#app: cache.adapter.redis app: cache.adapter.redis
#default_redis_provider: redis://localhost default_redis_provider: Redis
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
#app: cache.adapter.apcu
# Namespaced pools use the above "app" backend by default # Namespaced pools use the above "app" backend by default
#pools: pools:
#my.dedicated.cache: null #my.dedicated.cache: null
subscriptions.cache: null

2
config/packages/fos_elastica.yaml

@ -8,6 +8,7 @@ fos_elastica:
indexes: indexes:
# create the index by running php bin/console fos:elastica:populate # create the index by running php bin/console fos:elastica:populate
articles: articles:
indexable_callback: [ 'App\Util\IndexableArticleChecker', 'isIndexable' ]
properties: properties:
title: ~ title: ~
summary: ~ summary: ~
@ -20,4 +21,3 @@ fos_elastica:
provider: ~ provider: ~
listener: ~ listener: ~
finder: ~ finder: ~

2
config/packages/framework.yaml

@ -5,7 +5,7 @@ framework:
# Note that the session will be started ONLY if you read or write from it. # Note that the session will be started ONLY if you read or write from it.
session: session:
handler_id: Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler handler_id: Symfony\Component\HttpFoundation\Session\Storage\Handler\RedisSessionHandler
cookie_secure: auto cookie_secure: auto
cookie_samesite: lax cookie_samesite: lax
cookie_lifetime: 0 # integer, lifetime in seconds, 0 means 'valid for the length of the browser session' cookie_lifetime: 0 # integer, lifetime in seconds, 0 means 'valid for the length of the browser session'

36
config/services.yaml

@ -30,3 +30,39 @@ services:
# #
FOS\ElasticaBundle\Finder\FinderInterface: FOS\ElasticaBundle\Finder\FinderInterface:
alias: fos_elastica.finder.articles alias: fos_elastica.finder.articles
# Redis
Symfony\Component\Cache\Adapter\RedisAdapter:
arguments:
- '@Redis'
Symfony\Component\HttpFoundation\Session\Storage\Handler\RedisSessionHandler:
arguments:
- '@Redis'
# you can optionally pass an array of options. The only options are 'prefix' and 'ttl',
# which define the prefix to use for the keys to avoid collision on the Redis server
# and the expiration time for any given entry (in seconds), defaults are 'sf_s' and null:
# - { 'prefix': 'my_prefix', 'ttl': 600 }
Redis:
# you can also use \RedisArray, \RedisCluster, \Relay\Relay or \Predis\Client classes
class: Redis
calls:
- connect:
- '%env(REDIS_HOST)%'
- auth:
- '%env(REDIS_PASSWORD)%'
App\Service\LnBitsService:
arguments:
$lnbitsUrl: '%env(LNBITS_URL)%'
$apiKey: '%env(LNBITS_API_KEY)%'
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' }

23
src/Controller/ArticleController.php

@ -54,13 +54,32 @@ class ArticleController extends AbstractController
$articlesCache->save($cacheItem); $articlesCache->save($cacheItem);
} }
// // suggestions
// $suggestions = $repository->findBy(['pubkey' => $article->getPubkey()], ['createdAt' => 'DESC'], 3);
// // skip current, if listed in suggestions
// $suggestions = array_filter($suggestions, function ($s) use ($article) {
// return $s->getId() !== $article->getId();
// });
// $suggestions = array_merge($suggestions, $repository->findBy([], ['createdAt' => 'DESC'], 6 - count($suggestions)));
// // sort by date
// usort($suggestions, function ($a, $b) {
// return $b->getCreatedAt() <=> $a->getCreatedAt();
// });
$meta = $nostrClient->getNpubMetadata($article->getPubkey()); $meta = $nostrClient->getNpubMetadata($article->getPubkey());
$author = (array) json_decode($meta->content); if ($meta?->content) {
$author = (array) json_decode($meta->content);
} else {
$author = [
'name' => '<anonymous>'
];
}
return $this->render('Pages/article.html.twig', [ return $this->render('Pages/article.html.twig', [
'article' => $article, 'article' => $article,
'author' => $author, 'author' => $author,
'content' => $cacheItem->get() 'content' => $cacheItem->get(),
//'suggestions' => $suggestions
]); ]);
} }

17
src/Controller/AuthorController.php

@ -7,7 +7,6 @@ namespace App\Controller;
use App\Entity\Article; use App\Entity\Article;
use App\Entity\Event; use App\Entity\Event;
use App\Entity\Nzine; use App\Entity\Nzine;
use App\Entity\User;
use App\Enum\KindsEnum; use App\Enum\KindsEnum;
use App\Service\NostrClient; use App\Service\NostrClient;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@ -25,12 +24,23 @@ class AuthorController extends AbstractController
#[Route('/p/{npub}', name: 'author-profile')] #[Route('/p/{npub}', name: 'author-profile')]
public function index($npub, EntityManagerInterface $entityManager, NostrClient $client): Response public function index($npub, EntityManagerInterface $entityManager, NostrClient $client): Response
{ {
$meta = $client->getNpubMetadata($npub); $meta = $client->getNpubMetadata($npub);
$author = (array) json_decode($meta->content ?? '{}'); $author = (array) json_decode($meta->content ?? '{}');
$client->getNpubLongForm($npub); // $client->getNpubLongForm($npub);
$list = $entityManager->getRepository(Article::class)->findBy(['pubkey' => $npub, 'kind' => KindsEnum::LONGFORM], ['createdAt' => 'DESC']);
$articles = $entityManager->getRepository(Article::class)->findBy(['pubkey' => $npub, 'kind' => KindsEnum::LONGFORM], ['createdAt' => 'DESC']); // deduplicate by slugs
$articles = [];
foreach ($list as $item) {
if (!key_exists((string) $item->getSlug(), $articles)) {
$articles[(string) $item->getSlug()] = $item;
}
}
$indices = $entityManager->getRepository(Event::class)->findBy(['pubkey' => $npub, 'kind' => KindsEnum::PUBLICATION_INDEX]); $indices = $entityManager->getRepository(Event::class)->findBy(['pubkey' => $npub, 'kind' => KindsEnum::PUBLICATION_INDEX]);
@ -40,6 +50,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,
'articles' => $articles, 'articles' => $articles,
'nzine' => $nzine, 'nzine' => $nzine,
'nzines' => $nzines, 'nzines' => $nzines,

164
src/Controller/DefaultController.php

@ -5,15 +5,22 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Entity\Article; use App\Entity\Article;
use App\Enum\IndexStatusEnum;
use App\Service\NostrClient; use App\Service\NostrClient;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use FOS\ElasticaBundle\Finder\FinderInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
class DefaultController extends AbstractController class DefaultController extends AbstractController
{ {
public function __construct(private readonly EntityManagerInterface $entityManager, public function __construct(
private readonly FinderInterface $esFinder,
private readonly EntityManagerInterface $entityManager,
private readonly NostrClient $nostrClient) private readonly NostrClient $nostrClient)
{ {
} }
@ -24,11 +31,7 @@ class DefaultController extends AbstractController
#[Route('/', name: 'default')] #[Route('/', name: 'default')]
public function index(): Response public function index(): Response
{ {
$original = $this->entityManager->getRepository(Article::class)->findBy([], ['createdAt' => 'DESC'], 20); $list = $this->entityManager->getRepository(Article::class)->findBy(['indexStatus' => IndexStatusEnum::INDEXED], ['createdAt' => 'DESC'], 5);
$list = array_filter($original, function ($obj) {
return !empty($obj->getSlug());
});
// deduplicate by slugs // deduplicate by slugs
$deduplicated = []; $deduplicated = [];
@ -42,10 +45,157 @@ class DefaultController extends AbstractController
return $obj->getPubkey(); return $obj->getPubkey();
}, $list); }, $list);
$this->nostrClient->getMetadata(array_unique($npubs)); // $this->nostrClient->getMetadata(array_unique($npubs));
return $this->render('home.html.twig', [ return $this->render('home.html.twig', [
'list' => array_values($deduplicated) 'list' => array_values($deduplicated)
]); ]);
} }
/**
* @throws InvalidArgumentException
*/
#[Route('/business', name: 'business')]
public function business(CacheInterface $redisCache): Response
{
$articles = $redisCache->get('main_category_business', function (ItemInterface $item): array {
$item->expiresAfter(36000);
$search = [
'finance business',
'trading stock commodity',
's&p500 gold oil',
'currency bitcoin',
'international military incident'
];
return $this->getArticles($search);
});
return $this->render('pages/category.html.twig', [
'list' => array_slice($articles, 0, 9)
]);
}
/**
* @throws InvalidArgumentException
*/
#[Route('/technology', name: 'technology')]
public function technology(CacheInterface $redisCache): Response
{
$articles = $redisCache->get('main_category_technology', function (ItemInterface $item): array {
$item->expiresAfter(36000);
$search = [
'technology innovation',
'ai llm chatgpt claude agent',
'blockchain mining cryptography',
'cypherpunk nostr',
'server hosting'
];
return $this->getArticles($search);
});
return $this->render('pages/category.html.twig', [
'list' => array_slice($articles, 0, 9)
]);
}
/**
* @throws InvalidArgumentException
*/
#[Route('/world', name: 'world')]
public function world(CacheInterface $redisCache): Response
{
$articles = $redisCache->get('main_category_world', function (ItemInterface $item): array {
$item->expiresAfter(36000);
$search = [
'politics policy president',
'agreement law resolution',
'tariffs taxes trade',
'international military incident'
];
return $this->getArticles($search);
});
return $this->render('pages/category.html.twig', [
'list' => array_slice($articles, 0, 9)
]);
}
/**
* @throws InvalidArgumentException
*/
#[Route('/lifestyle', name: 'lifestyle')]
public function lifestyle(CacheInterface $redisCache): Response
{
$articles = $redisCache->get('main_category_lifestyle', function (ItemInterface $item): array {
$item->expiresAfter(36000);
$search = [
'touch grass',
'health healthy',
'lifestyle wellness diet sunshine'
];
return $this->getArticles($search);
});
return $this->render('pages/category.html.twig', [
'list' => array_slice($articles, 0, 9)
]);
}
/**
* @throws InvalidArgumentException
*/
#[Route('/art', name: 'art')]
public function art(CacheInterface $redisCache): Response
{
$articles = $redisCache->get('main_category_art', function (ItemInterface $item): array {
$item->expiresAfter(36000);
$search = [
'photo photography',
'travel',
'art painting'
];
return $this->getArticles($search);
});
return $this->render('pages/category.html.twig', [
'list' => array_slice($articles, 0, 9)
]);
}
/**
* @param $search
* @return array
*/
public function getArticles($search): array
{
$articles = [];
foreach ($search as $q) {
$articles = array_merge($articles, $this->esFinder->find($q, 10));
}
// sort articles by created at date
usort($articles, function ($a, $b) {
return $b->getCreatedAt() <=> $a->getCreatedAt();
});
// deduplicate by slugs
$deduplicated = [];
foreach ($articles as $item) {
if (!key_exists((string)$item->getSlug(), $deduplicated)) {
$deduplicated[(string)$item->getSlug()] = $item;
}
// keep 10
if (count($deduplicated) > 9) {
break;
}
}
return $deduplicated;
}
} }

5
src/Entity/Article.php

@ -8,6 +8,7 @@ use App\Enum\KindsEnum;
use App\Repository\ArticleRepository; use App\Repository\ArticleRepository;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use FOS\ElasticaBundle\Provider\IndexableInterface;
/** /**
* Entity storing long-form articles * Entity storing long-form articles
@ -313,12 +314,12 @@ class Article
return $this->eventStatus === EventStatusEnum::PREVIEW; return $this->eventStatus === EventStatusEnum::PREVIEW;
} }
public function getRaw(): null public function getRaw()
{ {
return $this->raw; return $this->raw;
} }
public function setRaw(object $raw): void public function setRaw($raw): void
{ {
$this->raw = $raw; $this->raw = $raw;
} }

30
src/EventListener/PopulateListener.php

@ -0,0 +1,30 @@
<?php
namespace App\EventListener;
use App\Entity\Article;
use App\Enum\IndexStatusEnum;
use Doctrine\ORM\EntityManagerInterface;
use FOS\ElasticaBundle\Event\PostIndexPopulateEvent;
class PopulateListener
{
public function __construct( private readonly EntityManagerInterface $entityManager)
{
}
public function postIndexPopulate(PostIndexPopulateEvent $event): void
{
$articles = $this->entityManager->getRepository(Article::class)->findBy(['indexStatus' => IndexStatusEnum::TO_BE_INDEXED]);
foreach ($articles as $article) {
if ($article instanceof Article) {
$article->setIndexStatus(IndexStatusEnum::INDEXED);
$this->entityManager->persist($article);
}
}
$this->entityManager->flush();
}
}

27
src/Provider/ArticleProvider.php

@ -0,0 +1,27 @@
<?php
namespace App\Provider;
use App\Entity\Article;
use App\Enum\IndexStatusEnum;
use Doctrine\ORM\EntityManagerInterface;
use FOS\ElasticaBundle\Provider\PagerfantaPager;
use FOS\ElasticaBundle\Provider\PagerInterface;
use FOS\ElasticaBundle\Provider\PagerProviderInterface;
use Pagerfanta\Adapter\ArrayAdapter;
use Pagerfanta\Pagerfanta;
class ArticleProvider implements PagerProviderInterface
{
public function __construct(private readonly EntityManagerInterface $entityManager)
{
}
public function provide(array $options = []): PagerInterface
{
$articles = $this->entityManager->getRepository(Article::class)->findBy(['indexStatus' => IndexStatusEnum::TO_BE_INDEXED],['createdAt' => 'ASC'],200);
return new PagerfantaPager(new Pagerfanta(new ArrayAdapter($articles)));
}
}

1
src/Repository/ArticleRepository.php

@ -3,6 +3,7 @@
namespace App\Repository; namespace App\Repository;
use App\Entity\Article; use App\Entity\Article;
use App\Enum\IndexStatusEnum;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\DBAL\Exception; use Doctrine\DBAL\Exception;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;

5
src/Service/NostrClient.php

@ -7,7 +7,6 @@ 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 App\Repository\UserEntityRepository;
use App\Security\UserDTO;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
@ -90,6 +89,8 @@ class NostrClient
$requestMessage = new RequestMessage($subscriptionId, [$filter]); $requestMessage = new RequestMessage($subscriptionId, [$filter]);
$relays = new RelaySet(); $relays = new RelaySet();
$relays->addRelay(new Relay('wss://purplepag.es')); // default metadata aggregator $relays->addRelay(new Relay('wss://purplepag.es')); // default metadata aggregator
$relays->addRelay(new Relay('wss://relay.damus.io')); // major public
$relays->addRelay(new Relay('wss://relay.snort.social')); // major public
$request = new Request($relays, $requestMessage); $request = new Request($relays, $requestMessage);
@ -125,7 +126,7 @@ class NostrClient
$requestMessage = new RequestMessage($subscriptionId, [$filter]); $requestMessage = new RequestMessage($subscriptionId, [$filter]);
// if user is logged in, use their settings // if user is logged in, use their settings
/* @var UserDTO $user */ /* @var $user */
$user = $this->tokenStorage->getToken()?->getUser(); $user = $this->tokenStorage->getToken()?->getUser();
$relays = $this->defaultRelaySet; $relays = $this->defaultRelaySet;
if ($user && $user->getRelays()) { if ($user && $user->getRelays()) {

13
src/Twig/Components/SearchComponent.php

@ -15,6 +15,8 @@ final class SearchComponent
#[LiveProp(writable: true)] #[LiveProp(writable: true)]
public string $query = ''; public string $query = '';
public bool $interactive = true;
private FinderInterface $finder; private FinderInterface $finder;
public function __construct(FinderInterface $finder) public function __construct(FinderInterface $finder)
@ -27,8 +29,15 @@ final class SearchComponent
if (empty($this->query)) { if (empty($this->query)) {
return []; return [];
} }
$res = $this->finder->find($this->query, 10); // Limit to 10 results $res = $this->finder->find($this->query, 12); // Limit to 10 results
return $res; // Limit to 10 results
// filter out items with bad slugs
$filtered = array_filter($res, function($r) {
return !str_contains($r->getSlug(), '/');
});
return $filtered;
} }
} }

14
src/Util/IndexableArticleChecker.php

@ -0,0 +1,14 @@
<?php
namespace App\Util;
use App\Entity\Article;
use App\Enum\IndexStatusEnum;
class IndexableArticleChecker
{
public static function isIndexable(Article $article): bool
{
return $article->getIndexStatus() !== IndexStatusEnum::DO_NOT_INDEX;
}
}

21
templates/components/Header.html.twig

@ -1,3 +1,22 @@
<header class="header"> <header class="header">
<div class="header__logo"><h1><a href="/">Newsroom</a></h1></div> <div class="header__logo"><h1 class="brand"><a href="/">newsroom</a></h1></div>
<div class="header__categories">
<ul>
<li {% if app.current_route is same as("world") %}class="active"{% endif %}>
<a href="/world">World</a>
</li>
<li {% if app.current_route is same as("business") %}class="active"{% endif %}>
<a href="/business">Business</a>
</li>
<li {% if app.current_route is same as("technology") %}class="active"{% endif %}>
<a href="/technology">Technology</a>
</li>
<li {% if app.current_route is same as("lifestyle") %}class="active"{% endif %}>
<a href="/lifestyle">Lifestyle</a>
</li>
<li {% if app.current_route is same as("art") %}class="active"{% endif %}>
<a href="/art">Art</a>
</li>
</ul>
</div>
</header> </header>

13
templates/components/Molecules/Card.html.twig

@ -1,6 +1,11 @@
{% if article is defined %} {% if article is defined %}
<{{ tag }} {{ attributes }}> <{{ tag }} {{ attributes }}>
<div class="card-header"><small class="text-uppercase">{{ category }}</small></div> <div class="card-header">
{% if category %}<small class="text-uppercase">{{ category }}</small>{% endif %}
{% if article.image %}
<img src="{{ article.image }}" alt="">
{% endif %}
</div>
<div class="card-body"> <div class="card-body">
<small>{{ article.createdAt|date('F j') }}</small> <small>{{ article.createdAt|date('F j') }}</small>
<h2 class="card-title">{{ article.title }}</h2> <h2 class="card-title">{{ article.title }}</h2>
@ -8,10 +13,10 @@
{{ article.summary }} {{ article.summary }}
</p> </p>
</div> </div>
<div class="card-footer">
<a href="{{ path('author-profile', { npub: article.pubkey })}}"><twig:Molecules:UserFromNpub npub="{{ article.pubkey }}" /></a>
</div>
</{{ tag }}> </{{ tag }}>
<div class="card card-footer">
<a href="{{ path('author-profile', { npub: article.pubkey })}}"><twig:Molecules:UserFromNpub npub="{{ article.pubkey }}" /></a>
</div>
{% endif %} {% endif %}
{% if user is defined %} {% if user is defined %}
<{{ tag }} {{ attributes }}> <{{ tag }} {{ attributes }}>

28
templates/components/SearchComponent.html.twig

@ -1,16 +1,22 @@
<div {{ attributes }}> <div {{ attributes }}>
<label class="search"> {% if interactive %}
<input type="search" <label class="search">
placeholder="{{ 'text.search'|trans }}" <input type="search"
data-model="norender|query" placeholder="{{ 'text.search'|trans }}"
/> data-model="norender|query"
<button type="submit" data-action="live#$render"><twig:ux:icon name="iconoir:search" class="icon" /></button> />
</label> <button type="submit" data-action="live#$render"><twig:ux:icon name="iconoir:search" class="icon" /></button>
</label>
<!-- -->
<div style="text-align: right">
<small class="help-text"><em>Powered by Silk</em></small>
</div>
<!-- Loading Indicator --> <!-- Loading Indicator -->
<div style="text-align: center"> <div style="text-align: center">
<span data-loading>{{ 'text.searching'|trans }}</span> <span data-loading>{{ 'text.searching'|trans }}</span>
</div> </div>
{% endif %}
<!-- Results --> <!-- Results -->
{% if this.results is not empty %} {% if this.results is not empty %}

2
templates/home.html.twig

@ -5,6 +5,8 @@
{% block body %} {% block body %}
{# content #} {# content #}
{# replace list with featured #}
<twig:Organisms:CardList :list="list" /> <twig:Organisms:CardList :list="list" />
{% endblock %} {% endblock %}

9
templates/pages/article.html.twig

@ -45,4 +45,13 @@
</div> </div>
</div> </div>
{# <pre>#}
{# {{ article.content }}#}
{# </pre>#}
{% endblock %}
{% block aside %}
{# <h1>Suggestions</h1>#}
{# <twig:Organisms:CardList :list="suggestions" />#}
{% endblock %} {% endblock %}

32
templates/pages/author.html.twig

@ -13,6 +13,38 @@
</p> </p>
{% endif %} {% endif %}
{% if app.user and app.user.userIdentifier is same as npub %}
<div id="invoice-component" class="p-4 bg-gray-100 rounded-lg">
<h3 class="text-xl font-semibold mb-4">Purchase Search Credits</h3>
<div class="mb-4">
<p>Amount: {{ amount ?? 0 }} sats</p>
<p>Status: <span id="payment-status">Pending</span></p>
</div>
{# You can access the width and height via the matrix #}
{# Replace the string with the invoice #}
{% set qrCode = qr_code_result('My QR Code') %}
<img src="{{ qrCode.dataUri }}" width="{{ qrCode.matrix.outerSize }}" alt="invoice-qr" />
<br>
<button id="check-payment" class="px-4 py-2 bg-blue-500 text-white rounded">Check Payment Status</button>
<script>
document.getElementById('check-payment').addEventListener('click', async () => {
const response = await fetch('/payment-status/{{ payment_hash ?? '' }}');
const data = await response.json();
if (data.status === 'paid') {
document.getElementById('payment-status').innerText = 'Paid';
} else {
document.getElementById('payment-status').innerText = 'Pending';
}
});
</script>
</div>
{% endif %}
{% if nzine %} {% if nzine %}
<a href="{{ path('nzine_view', {npub: author.npub}) }}">View as N-Zine</a> <a href="{{ path('nzine_view', {npub: author.npub}) }}">View as N-Zine</a>

13
templates/pages/category.html.twig

@ -0,0 +1,13 @@
{% extends 'base.html.twig' %}
{% block nav %}
{% endblock %}
{% block body %}
<twig:Organisms:CardList :list="list" />
{% endblock %}
{% block aside %}
{# <h6>Magazines</h6>#}
{# <twig:Organisms:ZineList />#}
{% endblock %}
Loading…
Cancel
Save