diff --git a/Dockerfile b/Dockerfile index bd32add..e35e415 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,6 +34,8 @@ RUN set -eux; \ opcache \ zip \ gmp \ + gd \ + redis \ ; # https://getcomposer.org/doc/03-cli.md#composer-allow-superuser diff --git a/assets/styles/app.css b/assets/styles/app.css index 4431759..f87a760 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -25,6 +25,15 @@ h1 { font-weight: 600; } +h1.brand { + font-family: var(--brand-font), serif; + font-size: 3.6rem; +} + +h1.brand a { + color: white; +} + h2 { font-size: 3rem; } @@ -49,6 +58,18 @@ p { margin: 0 0 15px; } +aside h1 { + font-size: 2rem; +} + +aside h2 { + font-size: 1.7rem; +} + +aside p.lede { + font-size: 1.2rem; +} + .lede { font-family: var(--main-body-font), serif; font-size: 1.6rem; @@ -102,7 +123,7 @@ svg.icon { background-color: var(--color-bg); color: var(--color-text); padding: 0; - margin: 20px 0; + margin: 20px 0 50px 0; border-radius: 0; /* Sharp edges */ } @@ -112,8 +133,6 @@ svg.icon { .card-header { font-size: 1.5rem; - margin-bottom: 10px; - padding-bottom: 10px; } .header__image { @@ -165,16 +184,26 @@ svg.icon { display: flex; justify-content: center; gap: 2em; + padding: 0; } .header__categories li { 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; } +.header__logo h1 { + font-weight: normal; +} + .header__logo img { height: 40px; /* Adjust the height as needed */ } diff --git a/assets/styles/card.css b/assets/styles/card.css index 8b13789..6dc331a 100644 --- a/assets/styles/card.css +++ b/assets/styles/card.css @@ -1 +1,7 @@ - +.card-header img { + max-width: 100%; + height: auto; + max-height: 200px; + width: 100%; + object-fit: cover; +} diff --git a/assets/styles/layout.css b/assets/styles/layout.css index 24af931..e457c54 100644 --- a/assets/styles/layout.css +++ b/assets/styles/layout.css @@ -23,6 +23,7 @@ nav { width: auto; + min-width: 150px; flex-shrink: 0; padding: 1em; overflow-y: auto; /* Ensure the menu is scrollable if content is too long */ @@ -56,7 +57,10 @@ main { /* Right sidebar */ aside { - width: 180px; /* Adjust the width based on the left menu */ + width: 190px; + min-width: 150px; + flex-shrink: 0; + flex-grow: 0; padding: 1em; } @@ -92,3 +96,7 @@ footer { position: relative; width: 100%; } + +.search input { + flex-grow: 1; +} diff --git a/assets/styles/theme.css b/assets/styles/theme.css index bdcdac0..39eecae 100644 --- a/assets/styles/theme.css +++ b/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=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=Lobster&display=swap'); :root { --color-bg: #222; /* Black background */ @@ -15,4 +16,5 @@ --font-family: 'Montserrat'; /* Set the Montserrat font as default */ --main-body-font: 'Newsreader'; /* Set the font for the main body */ --heading-font: 'Josefin Slab'; /* Set the font for headings */ + --brand-font: 'Lobster'; } diff --git a/config/bundles.php b/config/bundles.php index 181adf4..dcbd623 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -15,4 +15,5 @@ return [ Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], Symfony\UX\Icons\UXIconsBundle::class => ['all' => true], FOS\ElasticaBundle\FOSElasticaBundle::class => ['all' => true], + Endroid\QrCodeBundle\EndroidQrCodeBundle::class => ['all' => true], ]; diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml index 6899b72..86c2e75 100644 --- a/config/packages/cache.yaml +++ b/config/packages/cache.yaml @@ -1,19 +1,17 @@ framework: cache: # 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 data in this cache should persist between deploys. # Other options include: # Redis - #app: cache.adapter.redis - #default_redis_provider: redis://localhost - - # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues) - #app: cache.adapter.apcu + app: cache.adapter.redis + default_redis_provider: Redis # Namespaced pools use the above "app" backend by default - #pools: + pools: #my.dedicated.cache: null + subscriptions.cache: null diff --git a/config/packages/fos_elastica.yaml b/config/packages/fos_elastica.yaml index 316fd1f..ded92e8 100644 --- a/config/packages/fos_elastica.yaml +++ b/config/packages/fos_elastica.yaml @@ -8,6 +8,7 @@ fos_elastica: indexes: # create the index by running php bin/console fos:elastica:populate articles: + indexable_callback: [ 'App\Util\IndexableArticleChecker', 'isIndexable' ] properties: title: ~ summary: ~ @@ -20,4 +21,3 @@ fos_elastica: provider: ~ listener: ~ finder: ~ - diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 9c865ce..b40ac9a 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\PdoSessionHandler + handler_id: Symfony\Component\HttpFoundation\Session\Storage\Handler\RedisSessionHandler cookie_secure: auto cookie_samesite: lax cookie_lifetime: 0 # 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 7fde9a1..ec4a1c1 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -30,3 +30,39 @@ services: # FOS\ElasticaBundle\Finder\FinderInterface: 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' } diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index f57dbcc..7d7f8ed 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -54,13 +54,32 @@ class ArticleController extends AbstractController $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()); - $author = (array) json_decode($meta->content); + if ($meta?->content) { + $author = (array) json_decode($meta->content); + } else { + $author = [ + 'name' => '' + ]; + } return $this->render('Pages/article.html.twig', [ 'article' => $article, 'author' => $author, - 'content' => $cacheItem->get() + 'content' => $cacheItem->get(), + //'suggestions' => $suggestions ]); } diff --git a/src/Controller/AuthorController.php b/src/Controller/AuthorController.php index 484e7b0..0730ef1 100644 --- a/src/Controller/AuthorController.php +++ b/src/Controller/AuthorController.php @@ -7,7 +7,6 @@ namespace App\Controller; use App\Entity\Article; use App\Entity\Event; use App\Entity\Nzine; -use App\Entity\User; use App\Enum\KindsEnum; use App\Service\NostrClient; use Doctrine\ORM\EntityManagerInterface; @@ -25,12 +24,23 @@ class AuthorController extends AbstractController #[Route('/p/{npub}', name: 'author-profile')] public function index($npub, EntityManagerInterface $entityManager, NostrClient $client): Response { + + + $meta = $client->getNpubMetadata($npub); $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]); @@ -40,6 +50,7 @@ class AuthorController extends AbstractController return $this->render('Pages/author.html.twig', [ 'author' => $author, + 'npub' => $npub, 'articles' => $articles, 'nzine' => $nzine, 'nzines' => $nzines, diff --git a/src/Controller/DefaultController.php b/src/Controller/DefaultController.php index eec6521..8e1f3b0 100644 --- a/src/Controller/DefaultController.php +++ b/src/Controller/DefaultController.php @@ -5,15 +5,22 @@ declare(strict_types=1); namespace App\Controller; use App\Entity\Article; +use App\Enum\IndexStatusEnum; use App\Service\NostrClient; use Doctrine\ORM\EntityManagerInterface; +use FOS\ElasticaBundle\Finder\FinderInterface; +use Psr\Cache\InvalidArgumentException; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\ItemInterface; 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) { } @@ -24,11 +31,7 @@ class DefaultController extends AbstractController #[Route('/', name: 'default')] public function index(): Response { - $original = $this->entityManager->getRepository(Article::class)->findBy([], ['createdAt' => 'DESC'], 20); - - $list = array_filter($original, function ($obj) { - return !empty($obj->getSlug()); - }); + $list = $this->entityManager->getRepository(Article::class)->findBy(['indexStatus' => IndexStatusEnum::INDEXED], ['createdAt' => 'DESC'], 5); // deduplicate by slugs $deduplicated = []; @@ -42,10 +45,157 @@ class DefaultController extends AbstractController return $obj->getPubkey(); }, $list); - $this->nostrClient->getMetadata(array_unique($npubs)); + // $this->nostrClient->getMetadata(array_unique($npubs)); return $this->render('home.html.twig', [ '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; + } } diff --git a/src/Entity/Article.php b/src/Entity/Article.php index 21743b0..27e0716 100644 --- a/src/Entity/Article.php +++ b/src/Entity/Article.php @@ -8,6 +8,7 @@ use App\Enum\KindsEnum; use App\Repository\ArticleRepository; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; +use FOS\ElasticaBundle\Provider\IndexableInterface; /** * Entity storing long-form articles @@ -313,12 +314,12 @@ class Article return $this->eventStatus === EventStatusEnum::PREVIEW; } - public function getRaw(): null + public function getRaw() { return $this->raw; } - public function setRaw(object $raw): void + public function setRaw($raw): void { $this->raw = $raw; } diff --git a/src/EventListener/PopulateListener.php b/src/EventListener/PopulateListener.php new file mode 100644 index 0000000..0938110 --- /dev/null +++ b/src/EventListener/PopulateListener.php @@ -0,0 +1,30 @@ +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(); + + } +} diff --git a/src/Provider/ArticleProvider.php b/src/Provider/ArticleProvider.php new file mode 100644 index 0000000..75588c7 --- /dev/null +++ b/src/Provider/ArticleProvider.php @@ -0,0 +1,27 @@ +entityManager->getRepository(Article::class)->findBy(['indexStatus' => IndexStatusEnum::TO_BE_INDEXED],['createdAt' => 'ASC'],200); + return new PagerfantaPager(new Pagerfanta(new ArrayAdapter($articles))); + } +} diff --git a/src/Repository/ArticleRepository.php b/src/Repository/ArticleRepository.php index 161f307..9b619b4 100644 --- a/src/Repository/ArticleRepository.php +++ b/src/Repository/ArticleRepository.php @@ -3,6 +3,7 @@ namespace App\Repository; use App\Entity\Article; +use App\Enum\IndexStatusEnum; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\DBAL\Exception; use Doctrine\Persistence\ManagerRegistry; diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index ec52ea5..4a55b97 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -7,7 +7,6 @@ use App\Entity\User; use App\Enum\KindsEnum; use App\Factory\ArticleFactory; use App\Repository\UserEntityRepository; -use App\Security\UserDTO; use Doctrine\ORM\EntityManagerInterface; use Doctrine\Persistence\ManagerRegistry; use Psr\Cache\InvalidArgumentException; @@ -90,6 +89,8 @@ class NostrClient $requestMessage = new RequestMessage($subscriptionId, [$filter]); $relays = new RelaySet(); $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); @@ -125,7 +126,7 @@ class NostrClient $requestMessage = new RequestMessage($subscriptionId, [$filter]); // if user is logged in, use their settings - /* @var UserDTO $user */ + /* @var $user */ $user = $this->tokenStorage->getToken()?->getUser(); $relays = $this->defaultRelaySet; if ($user && $user->getRelays()) { diff --git a/src/Twig/Components/SearchComponent.php b/src/Twig/Components/SearchComponent.php index f4cf1cb..3900f1c 100644 --- a/src/Twig/Components/SearchComponent.php +++ b/src/Twig/Components/SearchComponent.php @@ -15,6 +15,8 @@ final class SearchComponent #[LiveProp(writable: true)] public string $query = ''; + public bool $interactive = true; + private FinderInterface $finder; public function __construct(FinderInterface $finder) @@ -27,8 +29,15 @@ final class SearchComponent if (empty($this->query)) { return []; } - $res = $this->finder->find($this->query, 10); // Limit to 10 results - return $res; // Limit to 10 results + $res = $this->finder->find($this->query, 12); // Limit to 10 results + + // filter out items with bad slugs + $filtered = array_filter($res, function($r) { + return !str_contains($r->getSlug(), '/'); + }); + + + return $filtered; } } diff --git a/src/Util/IndexableArticleChecker.php b/src/Util/IndexableArticleChecker.php new file mode 100644 index 0000000..098fa52 --- /dev/null +++ b/src/Util/IndexableArticleChecker.php @@ -0,0 +1,14 @@ +getIndexStatus() !== IndexStatusEnum::DO_NOT_INDEX; + } +} diff --git a/templates/components/Header.html.twig b/templates/components/Header.html.twig index a5b6da3..75d2d26 100644 --- a/templates/components/Header.html.twig +++ b/templates/components/Header.html.twig @@ -1,3 +1,22 @@
- + +
+ +
diff --git a/templates/components/Molecules/Card.html.twig b/templates/components/Molecules/Card.html.twig index 1b1d4eb..22775f4 100644 --- a/templates/components/Molecules/Card.html.twig +++ b/templates/components/Molecules/Card.html.twig @@ -1,6 +1,11 @@ {% if article is defined %} <{{ tag }} {{ attributes }}> -
{{ category }}
+
+ {% if category %}{{ category }}{% endif %} + {% if article.image %} + + {% endif %} +
{{ article.createdAt|date('F j') }}

{{ article.title }}

@@ -8,10 +13,10 @@ {{ article.summary }}

- + {% endif %} {% if user is defined %} <{{ tag }} {{ attributes }}> diff --git a/templates/components/SearchComponent.html.twig b/templates/components/SearchComponent.html.twig index 3eac378..d5da0c2 100644 --- a/templates/components/SearchComponent.html.twig +++ b/templates/components/SearchComponent.html.twig @@ -1,16 +1,22 @@
- + {% if interactive %} + + +
+ Powered by Silk +
- -
- {{ 'text.searching'|trans }} -
+ +
+ {{ 'text.searching'|trans }} +
+ {% endif %} {% if this.results is not empty %} diff --git a/templates/home.html.twig b/templates/home.html.twig index b66141c..4c95422 100644 --- a/templates/home.html.twig +++ b/templates/home.html.twig @@ -5,6 +5,8 @@ {% block body %} {# content #} + + {# replace list with featured #} {% endblock %} diff --git a/templates/pages/article.html.twig b/templates/pages/article.html.twig index b06309f..6f7b9d8 100644 --- a/templates/pages/article.html.twig +++ b/templates/pages/article.html.twig @@ -45,4 +45,13 @@
+ +{#
#}
+{#        {{ article.content }}#}
+{#    
#} +{% endblock %} + +{% block aside %} +{#

Suggestions

#} +{# #} {% endblock %} diff --git a/templates/pages/author.html.twig b/templates/pages/author.html.twig index eef57da..766f5cd 100644 --- a/templates/pages/author.html.twig +++ b/templates/pages/author.html.twig @@ -13,6 +13,38 @@

{% endif %} + {% if app.user and app.user.userIdentifier is same as npub %} +
+

Purchase Search Credits

+ +
+

Amount: {{ amount ?? 0 }} sats

+

Status: Pending

+
+ + {# You can access the width and height via the matrix #} + {# Replace the string with the invoice #} + {% set qrCode = qr_code_result('My QR Code') %} + invoice-qr + +
+ + + +
+ {% endif %} + {% if nzine %} View as N-Zine diff --git a/templates/pages/category.html.twig b/templates/pages/category.html.twig new file mode 100644 index 0000000..5a933ea --- /dev/null +++ b/templates/pages/category.html.twig @@ -0,0 +1,13 @@ +{% extends 'base.html.twig' %} + +{% block nav %} +{% endblock %} + +{% block body %} + +{% endblock %} + +{% block aside %} + {#
Magazines
#} + {# #} +{% endblock %}