Browse Source

Highlights

imwald
Nuša Pukšič 1 month ago
parent
commit
b3c912c76d
  1. 2
      .gitignore
  2. 4
      assets/styles/01-base/theme.css
  3. 6
      assets/styles/02-layout/layout.css
  4. 27
      assets/styles/03-components/button.css
  5. 25
      assets/styles/03-components/nostr-previews.css
  6. 2
      assets/styles/03-components/video-event.css
  7. 2
      assets/styles/04-pages/admin.css
  8. 41
      assets/styles/04-pages/highlights.css
  9. 2
      assets/styles/04-pages/landing.css
  10. 1
      assets/styles/05-utilities/utilities.css
  11. 2
      config/bundles.php
  12. 33
      migrations/Version20251130103218.php
  13. 120
      src/Command/DebugHighlightsCommand.php
  14. 2
      src/Command/ElevateUserCommand.php
  15. 55
      src/Command/ExportArticleListsCommand.php
  16. 49
      src/Command/NostrRelayPoolCleanupCommand.php
  17. 62
      src/Command/NostrRelayPoolStatsCommand.php
  18. 96
      src/Controller/ArticleController.php
  19. 10
      src/Controller/AuthorController.php
  20. 116
      src/Controller/DefaultController.php
  21. 34
      src/Controller/HighlightsController.php
  22. 2
      src/Controller/RelayAdminController.php
  23. 42
      src/Service/NostrClient.php
  24. 69
      src/Service/NostrRelayPool.php
  25. 4
      src/Service/RelayAdminService.php
  26. 149
      templates/admin/relay/index.html.twig
  27. 34
      templates/components/Molecules/ArticlePreview.html.twig
  28. 4
      templates/components/Molecules/ZapButton.html.twig
  29. 5
      templates/pages/article.html.twig
  30. 24
      templates/pages/article_disambiguation.html.twig
  31. 9
      templates/pages/highlights.html.twig

2
.gitignore vendored

@ -24,6 +24,6 @@ @@ -24,6 +24,6 @@
/publication/
###> strfry relay ###
/infra/strfry/data/
/docker/strfry/data/
###< strfry relay ###

4
assets/styles/01-base/theme.css

@ -20,10 +20,8 @@ @@ -20,10 +20,8 @@
--brand-font: 'Lobster', serif; /* A classic, refined branding font */
--brand-color: white;
--color-accent-strong: #E1B574; /* warm goldenrod (strong accent) */
--color-accent: #8FCB7E; /* fresh moss (main accent) */
--color-accent-strong: #B98BDC; /* lilac pop for headings/CTAs */
--color-accent-teal: #78C8BD; /* teal for tags/pills */
--color-accent-warm: #E1B574; /* warm highlight (badges/notes) */
--color-accent-600: #7FBF70;
--color-accent-500: #8FCB7E;
--color-accent-400: #A5D692;

6
assets/styles/02-layout/layout.css

@ -290,3 +290,9 @@ section{ position: relative; padding: var(--section-spacing) var(--spacing-3); } @@ -290,3 +290,9 @@ section{ position: relative; padding: var(--section-spacing) var(--spacing-3); }
line-height: 2;
white-space: normal !important;
}
.stat-label {
font-size: 0.9rem;
color: var(--color-text-mid);
font-weight: bold;
}

27
assets/styles/03-components/button.css

@ -4,26 +4,27 @@ @@ -4,26 +4,27 @@
*/
button, .btn, a.btn {
background-color: var(--color-primary);
background: var(--color-primary);
color: var(--color-text-contrast);
border: 2px solid var(--color-primary);
padding: var(--button-padding-y) var(--button-padding-x);
text-transform: uppercase;
font-weight: bold;
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;
transition: background-color 0.3s ease, color 0.3s ease;
border-radius: 0; /* Sharp edges */
}
button:hover, .btn:hover {
background-color: var(--color-bg);
color: var(--color-primary);
button:hover, .btn:hover, a.btn:hover {
background: var(--color-accent);
color: var(--color-text);
border: 2px solid var(--color-accent);
}
button:active, .btn:active {
background-color: var(--color-primary);
color: var(--color-text);
border-color: var(--color-text);
button:active, .btn:active, a.btn:active {
background: var(--color-primary);
border: 2px solid var(--color-primary);
}
a.btn, a.btn:hover, a.btn:active {

25
assets/styles/03-components/nostr-previews.css

@ -4,6 +4,10 @@ @@ -4,6 +4,10 @@
* Converted from SCSS to plain CSS
*/
.nostr-article-preview.card {
margin: 0;
}
.nostr-preview {
margin-top: var(--spacing-2);
}
@ -18,11 +22,6 @@ @@ -18,11 +22,6 @@
border-left-color: #00b894;
}
.nostr-preview .card-title {
margin-bottom: var(--spacing-2);
font-size: 1rem;
}
.nostr-preview .card-text {
font-size: 0.9rem;
}
@ -42,6 +41,22 @@ @@ -42,6 +41,22 @@
padding-right: var(--spacing-2);
}
/* Article preview card */
.nostr-article-preview .article-preview-image img {
max-height: 200px;
width: 100%;
object-fit: cover;
}
.nostr-article-preview .card-title a {
color: var(--color-text);
transition: color 0.2s;
}
.nostr-article-preview .card-title a:hover {
color: var(--color-primary);
}
/* Style for nostr links in text */
.nostr-link {
color: #6c5ce7;

2
assets/styles/03-components/video-event.css

@ -43,7 +43,7 @@ @@ -43,7 +43,7 @@
.content-warning {
padding: var(--spacing-3);
background-color: var(--color-accent-warm);
background-color: var(--color-accent-strong);
border: 1px solid var(--color-accent-600);
border-radius: 4px;
margin-bottom: var(--spacing-3);

2
assets/styles/04-pages/admin.css

@ -129,7 +129,7 @@ @@ -129,7 +129,7 @@
.content-warning {
padding: 1rem;
background-color: var(--color-accent-warm);
background-color: var(--color-accent-strong);
border: 1px solid var(--color-accent-600);
border-radius: 0.25rem;
margin-bottom: 1rem;

41
assets/styles/04-pages/highlights.css

@ -11,7 +11,7 @@ @@ -11,7 +11,7 @@
}
.highlight-card {
background: var(--color-bg-secondary, #f9f9f9);
background: var(--color-bg-light);
padding: var(--spacing-3);
transition: transform 0.2s;
margin-bottom: 2rem;
@ -32,18 +32,18 @@ @@ -32,18 +32,18 @@
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
color: var(--color-text-secondary, #666);
color: var(--color-text-mid);
}
.highlight-date {
font-size: 0.85rem;
color: var(--color-text-muted, #999);
color: var(--color-text-mid);
}
.highlight-content {
font-size: 1.1rem;
line-height: 1.3;
color: var(--color-text-primary, #333);
color: var(--color-text);
}
.highlight-mark {
@ -61,25 +61,29 @@ @@ -61,25 +61,29 @@
padding-top: 1rem;
}
.highlight-footer .article-preview {
margin-top: 0.5rem;
}
.article-reference {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
color: var(--color-link, #0066cc);
color: var(--color-accent-strong);
text-decoration: none;
transition: color 0.2s;
}
.article-reference:hover {
color: var(--color-link-hover, #004499);
color: var(--color-primary);
text-decoration: underline;
}
.no-highlights {
text-align: center;
padding: 4rem 2rem;
color: var(--color-text-secondary, #666);
color: var(--color-text-mid);
font-size: 1.1rem;
}
@ -95,7 +99,7 @@ @@ -95,7 +99,7 @@
justify-content: space-between;
align-items: center;
padding: 1rem;
background: var(--color-bg-secondary, #f9f9f9);
background: var(--color-bg-light);
margin-bottom: 1.5rem;
}
@ -109,27 +113,24 @@ @@ -109,27 +113,24 @@
}
.article-highlight.visible {
background: linear-gradient(to bottom,
rgba(255, 237, 74, 0.3) 0%,
rgba(255, 237, 74, 0.5) 100%);
border-bottom: 2px solid rgba(255, 200, 0, 0.6);
background: var(--color-accent-300);
border-bottom-color: var(--color-accent-300);
}
.article-highlight.visible:hover {
background: linear-gradient(to bottom,
rgba(255, 237, 74, 0.5) 0%,
rgba(255, 237, 74, 0.7) 100%);
border-bottom-color: rgba(255, 200, 0, 0.8);
background: var(--color-accent-300);
border-bottom-color: var(--color-accent);
}
/* Toggle button active state */
.btn[aria-pressed="true"] {
background-color: rgba(255, 237, 74, 0.3);
border-color: rgba(255, 200, 0, 0.6);
background-color: var(--color-accent-300);
border-color: var(--color-accent-300);
}
.btn[aria-pressed="true"]:hover {
background-color: rgba(255, 237, 74, 0.5);
color: var(--color-text);
background-color: var(--color-accent-400);
}
@media (max-width: 768px) {
@ -142,7 +143,7 @@ @@ -142,7 +143,7 @@
}
}
@media (min-width: 769px) and (max-width: 1200px) {
@media (min-width: 769px) and (max-width: 1400px) {
.highlights-grid {
column-count: 2;
}

2
assets/styles/04-pages/landing.css

@ -56,7 +56,7 @@ @@ -56,7 +56,7 @@
background: color-mix(in oklab, var(--color-primary) 18%, var(--color-bg));
}
.ln-section--unfold{
background: color-mix(in oklab, var(--color-accent-warm) 60%, var(--color-bg));
background: color-mix(in oklab, var(--color-accent-strong) 60%, var(--color-bg));
}

1
assets/styles/05-utilities/utilities.css

@ -67,4 +67,5 @@ @@ -67,4 +67,5 @@
details>summary{cursor:pointer}
/* Text truncation with ellipsis */
.line-clamp-3{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden;text-overflow:ellipsis}
.line-clamp-5{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:5;overflow:hidden;text-overflow:ellipsis}

2
config/bundles.php

@ -10,7 +10,7 @@ return [ @@ -10,7 +10,7 @@ return [
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true],
Symfony\UX\LiveComponent\LiveComponentBundle::class => ['all' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true, 'local' => true],
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['local' => true, 'dev' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Symfony\UX\Icons\UXIconsBundle::class => ['all' => true],

33
migrations/Version20251130103218.php

@ -0,0 +1,33 @@ @@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251130103218 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE article ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('DROP INDEX idx_event_kind');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE article ALTER id DROP IDENTITY');
$this->addSql('CREATE INDEX idx_event_kind ON event (kind)');
}
}

120
src/Command/DebugHighlightsCommand.php

@ -0,0 +1,120 @@ @@ -0,0 +1,120 @@
<?php
namespace App\Command;
use App\Repository\HighlightRepository;
use App\Service\HighlightService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:debug-highlights',
description: 'Debug highlights for an article coordinate',
)]
class DebugHighlightsCommand extends Command
{
public function __construct(
private readonly HighlightRepository $highlightRepository,
private readonly HighlightService $highlightService
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('coordinate', InputArgument::OPTIONAL, 'Article coordinate (kind:pubkey:slug)')
->setHelp('Debug highlights storage and retrieval. Run without arguments to see all stored coordinates.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$coordinate = $input->getArgument('coordinate');
if (!$coordinate) {
// List all stored coordinates
$coordinates = $this->highlightRepository->getAllArticleCoordinates();
$io->title('All Article Coordinates in Database');
$io->writeln(sprintf('Found %d unique coordinates:', count($coordinates)));
$io->newLine();
foreach ($coordinates as $coord) {
$count = count($this->highlightRepository->findByArticleCoordinate($coord));
$io->writeln(sprintf(' %s (%d highlights)', $coord, $count));
}
$io->newLine();
$io->info('Run with a coordinate argument to see details: app:debug-highlights "30023:pubkey:slug"');
return Command::SUCCESS;
}
// Debug specific coordinate
$io->title('Highlight Debug for: ' . $coordinate);
// Check database
$io->section('Database Check');
$dbHighlights = $this->highlightRepository->findByArticleCoordinate($coordinate);
$io->writeln(sprintf('Found %d highlights in database', count($dbHighlights)));
if (count($dbHighlights) > 0) {
$io->table(
['Event ID', 'Content Preview', 'Created At', 'Cached At'],
array_map(function($h) {
return [
substr($h->getEventId(), 0, 16) . '...',
substr($h->getContent(), 0, 50) . '...',
date('Y-m-d H:i:s', $h->getCreatedAt()),
$h->getCachedAt()->format('Y-m-d H:i:s'),
];
}, array_slice($dbHighlights, 0, 5))
);
if (count($dbHighlights) > 5) {
$io->writeln(sprintf('... and %d more', count($dbHighlights) - 5));
}
}
// Check cache status
$io->section('Cache Status');
$needsRefresh = $this->highlightRepository->needsRefresh($coordinate, 24);
$lastCache = $this->highlightRepository->getLastCacheTime($coordinate);
$io->writeln(sprintf('Needs refresh (24h): %s', $needsRefresh ? 'YES' : 'NO'));
$io->writeln(sprintf('Last cached: %s', $lastCache ? $lastCache->format('Y-m-d H:i:s') : 'Never'));
// Try to fetch through service
$io->section('Service Fetch Test');
$io->writeln('Fetching highlights through HighlightService...');
try {
$highlights = $this->highlightService->getHighlightsForArticle($coordinate);
$io->success(sprintf('Successfully fetched %d highlights', count($highlights)));
if (count($highlights) > 0) {
$io->table(
['Content Preview', 'Created At', 'Pubkey'],
array_map(function($h) {
return [
substr($h['content'], 0, 50) . '...',
date('Y-m-d H:i:s', $h['created_at']),
substr($h['pubkey'], 0, 16) . '...',
];
}, array_slice($highlights, 0, 5))
);
}
} catch (\Exception $e) {
$io->error('Failed to fetch highlights: ' . $e->getMessage());
return Command::FAILURE;
}
return Command::SUCCESS;
}
}

2
src/Command/ElevateUserCommand.php

@ -26,7 +26,7 @@ class ElevateUserCommand extends Command @@ -26,7 +26,7 @@ class ElevateUserCommand extends Command
protected function configure(): void
{
$this
->addArgument('arg1', InputArgument::REQUIRED, 'User npub')
->addArgument('arg1', InputArgument::REQUIRED, 'User pubkey')
->addArgument('arg2', InputArgument::REQUIRED, 'Role to set');
}

55
src/Command/ExportArticleListsCommand.php

@ -0,0 +1,55 @@ @@ -0,0 +1,55 @@
<?php
namespace App\Command;
use App\Entity\Article;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(
name: 'app:export-article-lists',
description: 'Export article event IDs and coordinates for relay ingest.'
)]
class ExportArticleListsCommand extends Command
{
public function __construct(private readonly EntityManagerInterface $entityManager)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
// Query recent articles (last 7 days, kind 30023)
$since = (new \DateTimeImmutable('-7 days'));
$repo = $this->entityManager->getRepository(Article::class);
$qb = $repo->createQueryBuilder('a')
->where('a.kind = :kind')
->andWhere('a.createdAt >= :since')
->setParameter('kind', 30023)
->setParameter('since', $since);
$articles = $qb->getQuery()->getResult();
$eventIds = [];
$coordinates = [];
foreach ($articles as $article) {
if (method_exists($article, 'getEventId')) {
$eventIds[] = $article->getEventId();
}
// Build coordinate: "30023:<pubkey>:<d>"
if (method_exists($article, 'getPubkey') && method_exists($article, 'getSlug')) {
$coordinates[] = '30023:' . $article->getPubkey() . ':' . $article->getSlug();
}
}
// Output event IDs (first line)
$output->writeln(json_encode($eventIds));
// Output coordinates (second line)
$output->writeln(json_encode($coordinates));
return Command::SUCCESS;
}
}

49
src/Command/NostrRelayPoolCleanupCommand.php

@ -0,0 +1,49 @@ @@ -0,0 +1,49 @@
<?php
namespace App\Command;
use App\Service\NostrRelayPool;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'nostr:pool:cleanup',
description: 'Clean up stale relay connections from the pool',
)]
class NostrRelayPoolCleanupCommand extends Command
{
public function __construct(
private readonly NostrRelayPool $relayPool
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('max-age', null, InputOption::VALUE_REQUIRED, 'Maximum age of connections in seconds', 300)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$maxAge = (int) $input->getOption('max-age');
$io->info(sprintf('Cleaning up connections older than %d seconds...', $maxAge));
$cleaned = $this->relayPool->cleanupStaleConnections($maxAge);
if ($cleaned > 0) {
$io->success(sprintf('Cleaned up %d stale connection(s).', $cleaned));
} else {
$io->info('No stale connections found.');
}
return Command::SUCCESS;
}
}

62
src/Command/NostrRelayPoolStatsCommand.php

@ -0,0 +1,62 @@ @@ -0,0 +1,62 @@
<?php
namespace App\Command;
use App\Service\NostrRelayPool;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'nostr:pool:stats',
description: 'Display statistics about the Nostr relay connection pool',
)]
class NostrRelayPoolStatsCommand extends Command
{
public function __construct(
private readonly NostrRelayPool $relayPool
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$stats = $this->relayPool->getStats();
$io->title('Nostr Relay Pool Statistics');
$io->section('Overview');
$io->table(
['Metric', 'Value'],
[
['Active Connections', $stats['active_connections']],
]
);
if (!empty($stats['relays'])) {
$io->section('Relay Details');
$rows = [];
foreach ($stats['relays'] as $relay) {
$rows[] = [
$relay['url'],
$relay['attempts'],
$relay['last_connected'] ? date('Y-m-d H:i:s', $relay['last_connected']) : 'Never',
$relay['age'] . 's',
];
}
$io->table(
['Relay URL', 'Failed Attempts', 'Last Connected', 'Age'],
$rows
);
} else {
$io->info('No active relay connections.');
}
return Command::SUCCESS;
}
}

96
src/Controller/ArticleController.php

@ -36,7 +36,7 @@ class ArticleController extends AbstractController @@ -36,7 +36,7 @@ class ArticleController extends AbstractController
* @throws \Exception
*/
#[Route('/article/{naddr}', name: 'article-naddr', requirements: ['naddr' => '^(naddr1[0-9a-zA-Z]+)$'])]
public function naddr(NostrClient $nostrClient, $naddr)
public function naddr(NostrClient $nostrClient, EntityManagerInterface $em, $naddr)
{
set_time_limit(120); // 2 minutes
$decoded = new Bech32($naddr);
@ -57,18 +57,53 @@ class ArticleController extends AbstractController @@ -57,18 +57,53 @@ class ArticleController extends AbstractController
}
$nostrClient->getLongFormFromNaddr($slug, $relays, $author, $kind);
if ($slug) {
// It's important to actually find the article
// Check if anything is in the database now
$repository = $em->getRepository(Article::class);
$article = $repository->findOneBy(['slug' => $slug, 'pubkey' => $author]);
// If found, redirect to the article page
if ($slug && $article) {
return $this->redirectToRoute('article-slug', ['slug' => $slug]);
}
throw new \Exception('No article.');
throw new \Exception('No article found.');
}
/**
* @throws InvalidArgumentException|CommonMarkException
*/
#[Route('/article/d/{slug}', name: 'article-slug', requirements: ['slug' => '.+'])]
public function article(
public function disambiguation($slug, EntityManagerInterface $entityManager): Response
{
$slug = urldecode($slug);
$repository = $entityManager->getRepository(Article::class);
$articles = $repository->findBy(['slug' => $slug]);
$count = count($articles);
if ($count === 0) {
throw $this->createNotFoundException('No articles found for this slug');
}
if ($count === 1) {
$key = new Key();
$npub = $key->convertPublicKeyToBech32($articles[0]->getPubkey());
return $this->redirectToRoute('author-article-slug', ['npub' => $npub, 'slug' => $slug]);
}
$authors = [];
$key = new Key();
foreach ($articles as $article) {
$authors[] = [
'npub' => $key->convertPublicKeyToBech32($article->getPubkey()),
'pubkey' => $article->getPubkey(),
'createdAt' => $article->getCreatedAt(),
];
}
return $this->render('pages/article_disambiguation.html.twig', [
'slug' => $slug,
'authors' => $authors,
'articles' => $articles
]);
}
#[Route('/p/{npub}/article/{slug}', name: 'author-article-slug', requirements: ['slug' => '.+'])]
public function authorArticle(
$npub,
$slug,
EntityManagerInterface $entityManager,
RedisCacheService $redisCacheService,
@ -77,44 +112,23 @@ class ArticleController extends AbstractController @@ -77,44 +112,23 @@ class ArticleController extends AbstractController
HighlightService $highlightService
): Response
{
set_time_limit(300); // 5 minutes
set_time_limit(300);
ini_set('max_execution_time', '300');
$article = null;
// check if an item with same eventId already exists in the db
$repository = $entityManager->getRepository(Article::class);
// slug might be url encoded, decode it
$slug = urldecode($slug);
$articles = $repository->findBy(['slug' => $slug]);
$revisions = count($articles);
if ($revisions === 0) {
$key = new Key();
$pubkey = $key->convertToHex($npub);
$repository = $entityManager->getRepository(Article::class);
$article = $repository->findOneBy(['slug' => $slug, 'pubkey' => $pubkey]);
if (!$article) {
throw $this->createNotFoundException('The article could not be found');
}
if ($revisions > 1) {
// sort articles by created at date
usort($articles, function ($a, $b) {
return $b->getCreatedAt() <=> $a->getCreatedAt();
});
}
$article = $articles[0];
$cacheKey = 'article_' . $article->getEventId();
$cacheItem = $articlesCache->getItem($cacheKey);
if (!$cacheItem->isHit()) {
$cacheItem->set($converter->convertToHTML($article->getContent()));
$articlesCache->save($cacheItem);
}
$key = new Key();
$npub = $key->convertPublicKeyToBech32($article->getPubkey());
$author = $redisCacheService->getMetadata($article->getPubkey());
// determine whether the logged-in user is the author
$canEdit = false;
$user = $this->getUser();
if ($user) {
@ -125,22 +139,12 @@ class ArticleController extends AbstractController @@ -125,22 +139,12 @@ class ArticleController extends AbstractController
$canEdit = false;
}
}
$canonical = $this->generateUrl('article-slug', ['slug' => $article->getSlug()], 0);
// Fetch highlights using the caching service
$canonical = $this->generateUrl('author-article-slug', ['npub' => $npub, 'slug' => $article->getSlug()], 0);
$highlights = [];
try {
$articleCoordinate = '30023:' . $article->getPubkey() . ':' . $article->getSlug();
error_log('ArticleController: Looking for highlights with coordinate: ' . $articleCoordinate);
$highlights = $highlightService->getHighlightsForArticle($articleCoordinate);
error_log('ArticleController: Found ' . count($highlights) . ' highlights');
} catch (\Exception $e) {
// Log but don't fail the page if highlights can't be fetched
// Highlights are optional enhancement
error_log('ArticleController: Failed to fetch highlights: ' . $e->getMessage());
}
} catch (\Exception $e) {}
return $this->render('pages/article.html.twig', [
'article' => $article,
'author' => $author,

10
src/Controller/AuthorController.php

@ -161,13 +161,13 @@ class AuthorController extends AbstractController @@ -161,13 +161,13 @@ class AuthorController extends AbstractController
* @throws Exception|InvalidArgumentException
*/
#[Route('/p/{npub}/media', name: 'author-media', requirements: ['npub' => '^npub1.*'])]
public function media($npub, NostrClient $nostrClient, RedisCacheService $redisCacheService, NostrKeyUtil $keyUtil): Response
public function media($npub, RedisCacheService $redisCacheService, NostrKeyUtil $keyUtil): Response
{
$author = $redisCacheService->getMetadata($keyUtil->npubToHex($npub));
$pubkey = $keyUtil->npubToHex($npub);
$author = $redisCacheService->getMetadata($pubkey);
// Use paginated cached media events - fetches 200 from relays, serves first 24
$paginatedData = $redisCacheService->getMediaEventsPaginated($keyUtil->npubToHex($npub), 1, 24);
$paginatedData = $redisCacheService->getMediaEventsPaginated($pubkey, 1, 24);
$mediaEvents = $paginatedData['events'];
// Encode event IDs as note1... for each event
@ -179,6 +179,7 @@ class AuthorController extends AbstractController @@ -179,6 +179,7 @@ class AuthorController extends AbstractController
return $this->render('profile/author-media.html.twig', [
'author' => $author,
'npub' => $npub,
'pubkey' => $pubkey,
'pictureEvents' => $mediaEvents,
'hasMore' => $paginatedData['hasMore'],
'total' => $paginatedData['total'],
@ -229,6 +230,7 @@ class AuthorController extends AbstractController @@ -229,6 +230,7 @@ class AuthorController extends AbstractController
* @throws InvalidArgumentException
*/
#[Route('/p/{npub}', name: 'author-profile', requirements: ['npub' => '^npub1.*'])]
#[Route('/p/{npub}/articles', name: 'author-articles', requirements: ['npub' => '^npub1.*'])]
public function index($npub, RedisCacheService $redisCacheService, FinderInterface $finder,
MessageBusInterface $messageBus): Response
{

116
src/Controller/DefaultController.php

@ -408,4 +408,120 @@ class DefaultController extends AbstractController @@ -408,4 +408,120 @@ class DefaultController extends AbstractController
return new Response('<div class="alert alert-warning">Unable to load OG preview for ' . htmlspecialchars($url) . '</div>', 200);
}
}
/**
* Nostr Preview endpoint for Nostr identifiers (naddr, nevent, note, npub, nprofile)
*/
#[Route('/preview/', name: 'nostr_preview', methods: ['POST'])]
public function nostrPreview(RequestStack $requestStack, EntityManagerInterface $entityManager, LoggerInterface $logger): Response
{
$request = $requestStack->getCurrentRequest();
$data = json_decode($request->getContent(), true);
$identifier = $data['identifier'] ?? null;
$type = $data['type'] ?? null;
$decoded = $data['decoded'] ?? null;
if (!$identifier || !$type) {
return new Response('<div class="alert alert-warning">Invalid preview request.</div>', 400);
}
// If decoded is a JSON string, decode it to array
if (is_string($decoded)) {
$decoded = json_decode($decoded, true);
}
// Ensure decoded is an array
if (!is_array($decoded)) {
$logger->error('Decoded data is not an array', [
'decoded' => $decoded,
'type' => gettype($decoded)
]);
return new Response('<div class="alert alert-warning">Invalid preview data format.</div>', 400);
}
try {
// Handle different Nostr identifier types
switch ($type) {
case 'naddr':
return $this->handleNaddrPreview($decoded, $entityManager, $logger);
case 'nevent':
case 'note':
return $this->handleEventPreview($decoded, $entityManager, $logger);
case 'npub':
case 'nprofile':
return $this->handleProfilePreview($decoded, $entityManager, $logger);
default:
return new Response('<div class="alert alert-warning">Unsupported preview type: ' . htmlspecialchars($type) . '</div>', 200);
}
} catch (\Exception $e) {
$logger->error('Error generating Nostr preview', [
'identifier' => $identifier,
'type' => $type,
'error' => $e->getMessage()
]);
return new Response('<div class="alert alert-warning">Unable to load preview.</div>', 200);
}
}
private function handleNaddrPreview(array $decoded, EntityManagerInterface $entityManager, LoggerInterface $logger): Response
{
$kind = $decoded['kind'] ?? null;
$pubkey = $decoded['pubkey'] ?? null;
$identifier = $decoded['identifier'] ?? null;
if ($kind === KindsEnum::LONGFORM->value) {
// Try to find article in database
$repository = $entityManager->getRepository(Article::class);
$article = $repository->findOneBy(['slug' => $identifier, 'pubkey' => $pubkey]);
if ($article) {
$key = new Key();
$npub = $key->convertPublicKeyToBech32($article->getPubkey());
return $this->render('components/Molecules/ArticlePreview.html.twig', [
'article' => $article,
'npub' => $npub
]);
}
// Article not in database yet - show a link to fetch it
// We need to construct the naddr from the decoded data
try {
$relays = $decoded['relays'] ?? [];
$naddr = \nostriphant\NIP19\Bech32::naddr(
kind: (int)$kind,
pubkey: $pubkey,
identifier: $identifier,
relays: $relays
);
return new Response(
'<div class="alert alert-info">
<strong>Article Preview</strong><br>
This article hasn\'t been fetched yet.
<a href="' . $this->generateUrl('article-naddr', ['naddr' => (string)$naddr]) . '" class="alert-link">Click here to view it</a>
</div>',
200
);
} catch (\Exception $e) {
$logger->error('Failed to generate naddr for preview', ['error' => $e->getMessage()]);
return new Response('<div class="alert alert-warning">Unable to generate article link.</div>', 200);
}
}
return new Response('<div class="alert alert-info">Preview for kind ' . $kind . ' not yet supported.</div>', 200);
}
private function handleEventPreview(array $decoded, EntityManagerInterface $entityManager, LoggerInterface $logger): Response
{
// For now, just show a basic preview
return new Response('<div class="alert alert-info">Event preview coming soon.</div>', 200);
}
private function handleProfilePreview(array $decoded, EntityManagerInterface $entityManager, LoggerInterface $logger): Response
{
// For now, just show a basic preview
return new Response('<div class="alert alert-info">Profile preview coming soon.</div>', 200);
}
}

34
src/Controller/HighlightsController.php

@ -23,6 +23,7 @@ class HighlightsController extends AbstractController @@ -23,6 +23,7 @@ class HighlightsController extends AbstractController
private readonly NostrClient $nostrClient,
private readonly HighlightService $highlightService,
private readonly LoggerInterface $logger,
private readonly \App\Service\NostrLinkParser $nostrLinkParser,
) {}
#[Route('/highlights', name: 'highlights')]
@ -31,7 +32,7 @@ class HighlightsController extends AbstractController @@ -31,7 +32,7 @@ class HighlightsController extends AbstractController
try {
// Cache key for highlights
$cacheKey = 'global_article_highlights';
// $cache->delete($cacheKey);
$cache->delete($cacheKey);
// Get highlights from cache or fetch fresh
$highlights = $cache->get($cacheKey, function (ItemInterface $item) {
$item->expiresAfter(self::CACHE_TTL);
@ -41,7 +42,7 @@ class HighlightsController extends AbstractController @@ -41,7 +42,7 @@ class HighlightsController extends AbstractController
$events = $this->nostrClient->getArticleHighlights(self::MAX_DISPLAY_HIGHLIGHTS);
// Save raw events to database first (group by article)
$this->saveHighlightsToDatabase($events);
//$this->saveHighlightsToDatabase($events);
// Process and enrich the highlights for display
return $this->processHighlights($events);
@ -183,6 +184,12 @@ class HighlightsController extends AbstractController @@ -183,6 +184,12 @@ class HighlightsController extends AbstractController
if ($highlight['article_ref'] && str_starts_with($highlight['article_ref'], '30023:')) {
// Generate naddr from the coordinate
$highlight['naddr'] = $this->generateNaddr($highlight['article_ref'], $relayHints);
// Parse naddr to create preview data for NostrPreview component
if ($highlight['naddr']) {
$highlight['preview'] = $this->createPreviewData($highlight['naddr']);
}
$processed[] = $highlight;
}
}
@ -235,5 +242,28 @@ class HighlightsController extends AbstractController @@ -235,5 +242,28 @@ class HighlightsController extends AbstractController
return null;
}
}
/**
* Create preview data structure for NostrPreview component
*/
private function createPreviewData(string $naddr): ?array
{
try {
// Use NostrLinkParser to parse the naddr identifier
$links = $this->nostrLinkParser->parseLinks("nostr:$naddr");
if (!empty($links)) {
return $links[0];
}
return null;
} catch (\Exception $e) {
$this->logger->debug('Failed to create preview data', [
'naddr' => $naddr,
'error' => $e->getMessage()
]);
return null;
}
}
}

2
src/Controller/RelayAdminController.php

@ -25,7 +25,7 @@ class RelayAdminController extends AbstractController @@ -25,7 +25,7 @@ class RelayAdminController extends AbstractController
$config = $this->relayAdminService->getConfiguration();
$containerStatus = $this->relayAdminService->getContainerStatus();
$connectivity = $this->relayAdminService->testConnectivity();
$recentEvents = $this->relayAdminService->getRecentEvents(5);
$recentEvents = $this->relayAdminService->getRecentEvents(50);
return $this->render('admin/relay/index.html.twig', [
'stats' => $stats,

42
src/Service/NostrClient.php

@ -102,7 +102,7 @@ class NostrClient @@ -102,7 +102,7 @@ class NostrClient
$authorRelays = [];
}
if (empty($authorRelays)) {
return [self::REPUTABLE_RELAYS[0]]; // Default to theforest if no author relays
return self::REPUTABLE_RELAYS; // Default to theforest if no author relays
}
$reputableAuthorRelays = [];
@ -205,17 +205,21 @@ class NostrClient @@ -205,17 +205,21 @@ class NostrClient
}
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
$request = new Request($this->defaultRelaySet, $requestMessage);
// Create relay set from all reputable relays on record
$relaySet = $this->createRelaySet(self::REPUTABLE_RELAYS);
$response = $request->send();
// response is an n-dimensional array, where n is the number of relays in the set
// check that response has events in the results
foreach ($response as $relayRes) {
$filtered = array_filter($relayRes, function ($item) {
return $item->type === 'EVENT';
});
if (count($filtered) > 0) {
$this->saveLongFormContent($filtered);
$request = new Request($relaySet, $requestMessage);
// Process the response
$events = $this->processResponse($request->send(), function($event) {
return $event;
});
if (!empty($events)) {
foreach ($events as $event) {
$article = $this->articleFactory->createFromLongFormContentEvent($event);
// check if event with same eventId already in DB
$this->saveEachArticleToTheDatabase($article);
}
}
}
@ -437,6 +441,7 @@ class NostrClient @@ -437,6 +441,7 @@ class NostrClient
{
$cacheKey = 'npub_relays_' . $npub;
try {
// $this->npubCache->deleteItem($cacheKey);
$cachedItem = $this->npubCache->getItem($cacheKey);
if ($cachedItem->isHit()) {
$this->logger->debug('Using cached relays for npub', ['npub' => $npub]);
@ -448,7 +453,7 @@ class NostrClient @@ -448,7 +453,7 @@ class NostrClient
// Get relays
$request = $this->createNostrRequest(
kinds: [KindsEnum::RELAY_LIST],
kinds: [KindsEnum::RELAY_LIST->value],
filters: ['authors' => [$npub]],
relaySet: $this->defaultRelaySet
);
@ -967,7 +972,7 @@ class NostrClient @@ -967,7 +972,7 @@ class NostrClient
*/
public function getArticleHighlights(int $limit = 50): array
{
$this->logger->info('Fetching article highlights from default relays');
$this->logger->info('Fetching article highlights from default relay');
// Use relay pool to send request
$subscription = new Subscription();
@ -979,10 +984,12 @@ class NostrClient @@ -979,10 +984,12 @@ class NostrClient
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
// Get default relay URLs
$relayUrls = $this->relayPool->getDefaultRelays();
// Use only the configured default relay
$relayUrls = $this->nostrDefaultRelay
? [$this->nostrDefaultRelay]
: [($this->relayPool->getDefaultRelays()[0] ?? null)];
$relayUrls = array_filter($relayUrls); // Remove nulls if fallback fails
// Use the relay pool to send the request
$responses = $this->relayPool->sendToRelays(
$relayUrls,
fn() => $requestMessage,
@ -1032,7 +1039,7 @@ class NostrClient @@ -1032,7 +1039,7 @@ class NostrClient
$filter->setKinds([9802]); // NIP-84 highlights
$filter->setLimit($limit);
// Add tag filter for the specific article coordinate
$filter->setTags(['a' => [$articleCoordinate]]);
$filter->setTags(['#a' => [$articleCoordinate]]);
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
@ -1078,7 +1085,6 @@ class NostrClient @@ -1078,7 +1085,6 @@ class NostrClient
}
}
}
$this->logger->info('Relay set for request', ['relays' => $relaySet ? $relaySet->getRelays() : 'default']);
$requestMessage = new RequestMessage($subscription->getId(), [$filter]);

69
src/Service/NostrRelayPool.php

@ -5,6 +5,7 @@ namespace App\Service; @@ -5,6 +5,7 @@ namespace App\Service;
use Psr\Log\LoggerInterface;
use swentel\nostr\Relay\Relay;
use swentel\nostr\RelayResponse\RelayResponse;
use WebSocket\Exception\Exception;
/**
* Manages persistent WebSocket connections to Nostr relays
@ -21,6 +22,13 @@ class NostrRelayPool @@ -21,6 +22,13 @@ class NostrRelayPool
/** @var array<string, int> Track last connection time */
private array $lastConnected = [];
/** @var array<string> */
private array $defaultRelays = [
'wss://theforest.nostr1.com',
'wss://nostr.land',
'wss://relay.primal.net',
];
private const MAX_RETRIES = 3;
private const RETRY_DELAY = 5; // seconds
private const CONNECTION_TIMEOUT = 30; // seconds
@ -28,12 +36,15 @@ class NostrRelayPool @@ -28,12 +36,15 @@ class NostrRelayPool
public function __construct(
private readonly LoggerInterface $logger,
private readonly array $defaultRelays = [
'wss://theforest.nostr1.com',
'wss://nostr.land',
'wss://relay.primal.net',
]
) {}
private readonly string $nostrDefaultRelay,
array $defaultRelays = []
) {
$relayList = $defaultRelays ?: $this->defaultRelays;
if ($this->nostrDefaultRelay && !in_array($this->nostrDefaultRelay, $relayList, true)) {
array_unshift($relayList, $this->nostrDefaultRelay);
}
$this->defaultRelays = $relayList;
}
/**
* Normalize relay URL to ensure consistency
@ -77,7 +88,7 @@ class NostrRelayPool @@ -77,7 +88,7 @@ class NostrRelayPool
}
/**
* Get multiple relay connections
* Get multiple relay connections, prioritizing default relay
*
* @param array $relayUrls
* @return array<Relay>
@ -85,7 +96,27 @@ class NostrRelayPool @@ -85,7 +96,27 @@ class NostrRelayPool
public function getRelays(array $relayUrls): array
{
$relays = [];
foreach ($relayUrls as $url) {
$defaultRelay = $this->defaultRelays[0] ?? null;
$relayUrlsNormalized = array_map([$this, 'normalizeRelayUrl'], $relayUrls);
$defaultRelayNormalized = $defaultRelay ? $this->normalizeRelayUrl($defaultRelay) : null;
// Try default relay first if present in requested URLs
if ($defaultRelayNormalized && in_array($defaultRelayNormalized, $relayUrlsNormalized, true)) {
try {
$relays[] = $this->getRelay($defaultRelayNormalized);
} catch (\Throwable $e) {
$this->logger->warning('Default relay unavailable, falling back to others', [
'relay' => $defaultRelayNormalized,
'error' => $e->getMessage()
]);
}
}
// Add other relays except the default
foreach ($relayUrlsNormalized as $url) {
if ($url === $defaultRelayNormalized) {
continue;
}
try {
$relays[] = $this->getRelay($url);
} catch (\Throwable $e) {
@ -99,7 +130,7 @@ class NostrRelayPool @@ -99,7 +130,7 @@ class NostrRelayPool
}
/**
* Send a request to multiple relays and collect responses
* Send a request to multiple relays and collect responses, prioritizing default relay
*
* @param array $relayUrls Array of relay URLs to query
* @param callable $messageBuilder Function that builds the message to send
@ -198,14 +229,17 @@ class NostrRelayPool @@ -198,14 +229,17 @@ class NostrRelayPool
// Update last connected time on successful send
$this->lastConnected[$relay->getUrl()] = time();
} catch (\WebSocket\TimeoutException $e) {
// Timeout is normal - relay has sent all events
$this->logger->debug('Relay timeout (normal - all events received)', [
'relay' => $relay->getUrl()
]);
$responses[$relay->getUrl()] = $relayResponses;
$this->lastConnected[$relay->getUrl()] = time();
} catch (Exception $e) {
// If this is a timeout, treat as normal; otherwise, rethrow or handle
if (stripos($e->getMessage(), 'timeout') !== false) {
$this->logger->debug('Relay timeout (normal - all events received)', [
'relay' => $relay->getUrl()
]);
$responses[$relay->getUrl()] = $relayResponses;
$this->lastConnected[$relay->getUrl()] = time();
} else {
throw $e;
}
} catch (\Throwable $e) {
$this->logger->error('Error sending to relay', [
'relay' => $relay->getUrl(),
@ -307,4 +341,3 @@ class NostrRelayPool @@ -307,4 +341,3 @@ class NostrRelayPool
return $this->defaultRelays;
}
}

4
src/Service/RelayAdminService.php

@ -80,7 +80,7 @@ class RelayAdminService @@ -80,7 +80,7 @@ class RelayAdminService
// Create filter for recent events (kind 30023 - articles)
$filter = new Filter();
$filter->setKinds([30023, 1, 7, 0]); // Articles, notes, reactions, profiles
$filter->setKinds([30023, 9802]); // Articles, highlights
$filter->setLimit($limit);
// Create and send request
@ -153,11 +153,9 @@ class RelayAdminService @@ -153,11 +153,9 @@ class RelayAdminService
public function getContainerStatus(): array
{
$strfryStatus = $this->checkServiceHealth('strfry', 7777);
$ingestStatus = ['status' => 'unknown', 'health' => 'Cannot check from inside container'];
return [
'strfry' => $strfryStatus,
'ingest' => $ingestStatus,
];
}

149
templates/admin/relay/index.html.twig

@ -2,139 +2,13 @@ @@ -2,139 +2,13 @@
{% block title %}Relay Administration{% endblock %}
{% block layout %}
<div class="relay-admin">
<style>
.relay-admin {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}
.relay-header {
margin-bottom: 2rem;
}
.relay-header h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.status-card {
background: white;
border: 1px solid #ddd;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.status-card h2 {
font-size: 1.2rem;
margin-bottom: 1rem;
color: #333;
}
.status-indicator {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 8px;
}
.status-indicator.healthy { background: #22c55e; }
.status-indicator.warning { background: #f59e0b; }
.status-indicator.error { background: #ef4444; }
.status-indicator.unknown { background: #6b7280; }
{% block body %}
<twig:Atoms:PageHeading
heading="Relay"
tagline="local Nostr relay"
/>
.stat-row {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid #f0f0f0;
}
.stat-row:last-child {
border-bottom: none;
}
.stat-label {
font-weight: 500;
color: #666;
}
.stat-value {
color: #333;
font-family: monospace;
}
.events-section {
margin-top: 2rem;
}
.event-card {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 1rem;
margin-bottom: 1rem;
font-family: monospace;
font-size: 0.85rem;
}
.event-header {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
font-weight: 600;
}
.event-kind {
background: #dbeafe;
color: #1e40af;
padding: 2px 8px;
border-radius: 4px;
}
.event-content {
color: #4b5563;
white-space: pre-wrap;
word-break: break-word;
}
.logs-section {
margin-top: 2rem;
}
.logs-container {
background: #1f2937;
color: #e5e7eb;
padding: 1rem;
border-radius: 6px;
font-family: monospace;
font-size: 0.85rem;
max-height: 400px;
overflow-y: auto;
}
.alert {
padding: 1rem;
border-radius: 6px;
margin-bottom: 1rem;
}
.alert-warning {
background: #fef3c7;
color: #92400e;
border: 1px solid #fbbf24;
}
.alert-success {
background: #d1fae5;
color: #065f46;
border: 1px solid #10b981;
}
.alert-error {
background: #fee2e2;
color: #991b1b;
border: 1px solid #ef4444;
}
</style>
<div class="relay-header">
<h1>🛰 Relay Administration</h1>
<p>Monitor and manage your local Nostr relay</p>
</div>
<div class="w-container">
{# Status Overview #}
<div class="status-grid">
@ -153,17 +27,6 @@ @@ -153,17 +27,6 @@
</span>
</div>
<div class="stat-row">
<span class="stat-label">Ingest Service</span>
<span class="stat-value">
{% if container_status.ingest.status == 'running' %}
<span class="status-indicator healthy"></span> Running
{% else %}
<span class="status-indicator warning"></span> {{ container_status.ingest.status|default('Not Running') }}
{% endif %}
</span>
</div>
<div class="stat-row">
<span class="stat-label">Port 7777</span>
<span class="stat-value">

34
templates/components/Molecules/ArticlePreview.html.twig

@ -0,0 +1,34 @@ @@ -0,0 +1,34 @@
<div class="nostr-article-preview card">
<div class="card-body">
{% if article.image %}
<div class="article-preview-image">
<img src="{{ article.image }}" alt="{{ article.title }}" class="img-fluid rounded">
</div>
{% endif %}
<div class="article-preview-meta d-flex flex-row justify-content-between align-items-center px-2">
<div class="author-info">
<twig:Molecules:UserFromNpub
ident="{{ article.pubkey }}"
:compact="true"
/>
</div>
<div class="article-date text-muted small">
{{ article.createdAt|date('M j, Y') }}
</div>
</div>
<div class="px-2">
<h5 class="card-title">
<a href="{{ path('author-article-slug', {npub: npub, slug: article.slug}) }}" class="text-decoration-none">
{{ article.title }}
</a>
</h5>
{% if article.summary %}
<p class="card-text text-muted small line-clamp-3">{{ article.summary }}</p>
{% endif %}
</div>
</div>
</div>

4
templates/components/Molecules/ZapButton.html.twig

@ -7,7 +7,7 @@ @@ -7,7 +7,7 @@
data-live-action-param="openDialog"
title="Send a zap"
>
Zap
Zap
</button>
{# Modal Dialog #}
@ -17,7 +17,7 @@ @@ -17,7 +17,7 @@
<div class="card-body">
{# Header #}
<div class="d-flex flex-row justify-content-between align-items-center mb-3">
<h5 class="m-0">Send Zap</h5>
<h5 class="m-0">Send Zap</h5>
<button
type="button"
class="btn btn-secondary"

5
templates/pages/article.html.twig

@ -62,6 +62,7 @@ @@ -62,6 +62,7 @@
{# Zap Button with split payment support #}
{% set zapSplits = article.advancedMetadata.zapSplits|default([]) %}
{% if zapSplits|length > 0 %}
<twig:Molecules:ZapButton
recipientPubkey="{{ article.pubkey }}"
recipientLud16="{{ author.lud16 is iterable ? author.lud16|first : author.lud16 }}"
@ -73,6 +74,7 @@ @@ -73,6 +74,7 @@
<em>(Split between {{ zapSplits|length + 1 }} recipients)</em>
</small>
{% endif %}
{% endif %}
{# Highlights toggle button #}
{% if highlights is defined and highlights|length > 0 %}
@ -82,9 +84,6 @@ @@ -82,9 +84,6 @@
type="button"
aria-pressed="false"
title="Toggle highlights">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" style="vertical-align:middle;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
Highlights ({{ highlights|length }})
</button>
{% endif %}

24
templates/pages/article_disambiguation.html.twig

@ -0,0 +1,24 @@ @@ -0,0 +1,24 @@
{% extends 'layout.html.twig' %}
{% block body %}
<h1>Disambiguation: Multiple Articles for "{{ slug }}"</h1>
<p>There are multiple articles with the slug <strong>{{ slug }}</strong>. Please select the author:</p>
<ul>
{% for author, article in authors|zip(articles) %}
<li>
<a href="{{ path('author-article-slug', { npub: author.npub, slug: slug }) }}">
Author: {{ author.npub }}
</a>
<span>Published: {{ article.createdAt|date('Y-m-d H:i') }}</span>
{% if article.title %} - <strong>{{ article.title }}</strong>{% endif %}
</li>
{% endfor %}
</ul>
{% endblock %}
{% block aside %}
<div class="aside-box">
<h3>What is this?</h3>
<p>This page lists all articles with the same slug. Click an author to view their version.</p>
</div>
{% endblock %}

9
templates/pages/highlights.html.twig

@ -57,8 +57,13 @@ @@ -57,8 +57,13 @@
</div>
<div class="highlight-footer">
{% if highlight.naddr is defined and highlight.naddr %}
{# Use naddr to link to article so it gets fetched from relays #}
{% if highlight.preview is defined and highlight.preview %}
{# Show article preview using NostrPreview component #}
<div class="article-preview">
<twig:Molecules:NostrPreview :preview="highlight.preview" />
</div>
{% elseif highlight.naddr is defined and highlight.naddr %}
{# Fallback: Use naddr to link to article so it gets fetched from relays #}
<a href="{{ path('article-naddr', {naddr: highlight.naddr}) }}"
class="article-reference">
{% if highlight.article_title %}

Loading…
Cancel
Save