7 changed files with 449 additions and 5 deletions
@ -0,0 +1,112 @@
@@ -0,0 +1,112 @@
|
||||
.highlights-container { |
||||
max-width: 1200px; |
||||
margin: 0 auto; |
||||
padding: 2rem 1rem; |
||||
} |
||||
|
||||
.highlights-grid { |
||||
column-count: 3; |
||||
column-gap: 2rem; |
||||
margin-top: 2rem; |
||||
} |
||||
|
||||
.highlight-card { |
||||
background: var(--color-bg-secondary, #f9f9f9); |
||||
padding: var(--spacing-3); |
||||
transition: transform 0.2s; |
||||
margin-bottom: 2rem; |
||||
display: inline-block; |
||||
width: 100%; |
||||
} |
||||
|
||||
.highlight-header { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
margin-bottom: 1rem; |
||||
padding-bottom: 0.75rem; |
||||
} |
||||
|
||||
.highlight-author { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.5rem; |
||||
font-size: 0.9rem; |
||||
color: var(--color-text-secondary, #666); |
||||
} |
||||
|
||||
.highlight-date { |
||||
font-size: 0.85rem; |
||||
color: var(--color-text-muted, #999); |
||||
} |
||||
|
||||
.highlight-content { |
||||
font-size: 1.1rem; |
||||
line-height: 1.3; |
||||
color: var(--color-text-primary, #333); |
||||
} |
||||
|
||||
.highlight-mark { |
||||
background: var(--color-accent-300); |
||||
padding: 0.1em 0.2em; |
||||
font-weight: 500; |
||||
} |
||||
.context-text { |
||||
margin-bottom: 0.5rem; |
||||
font-style: normal; |
||||
} |
||||
|
||||
.highlight-footer { |
||||
margin-top: 1rem; |
||||
padding-top: 1rem; |
||||
} |
||||
|
||||
.article-reference { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.5rem; |
||||
font-size: 0.9rem; |
||||
color: var(--color-link, #0066cc); |
||||
text-decoration: none; |
||||
transition: color 0.2s; |
||||
} |
||||
|
||||
.article-reference:hover { |
||||
color: var(--color-link-hover, #004499); |
||||
text-decoration: underline; |
||||
} |
||||
|
||||
.no-highlights { |
||||
text-align: center; |
||||
padding: 4rem 2rem; |
||||
color: var(--color-text-secondary, #666); |
||||
font-size: 1.1rem; |
||||
} |
||||
|
||||
.error-message { |
||||
background: #fee; |
||||
border: 1px solid #fcc; |
||||
padding: 1rem; |
||||
margin: 1rem 0; |
||||
color: #c00; |
||||
} |
||||
.highlights-stats { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
padding: 1rem; |
||||
background: var(--color-bg-secondary, #f9f9f9); |
||||
margin-bottom: 1.5rem; |
||||
} |
||||
|
||||
@media (max-width: 768px) { |
||||
.highlight-card { |
||||
margin-bottom: 1.5rem; |
||||
} |
||||
} |
||||
|
||||
@media (min-width: 769px) and (max-width: 1200px) { |
||||
.highlights-grid { |
||||
column-count: 2; |
||||
} |
||||
} |
||||
@ -0,0 +1,179 @@
@@ -0,0 +1,179 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Controller; |
||||
|
||||
use App\Service\NostrClient; |
||||
use nostriphant\NIP19\Bech32; |
||||
use Psr\Log\LoggerInterface; |
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; |
||||
use Symfony\Component\HttpFoundation\Response; |
||||
use Symfony\Component\Routing\Attribute\Route; |
||||
use Symfony\Contracts\Cache\CacheInterface; |
||||
use Symfony\Contracts\Cache\ItemInterface; |
||||
|
||||
class HighlightsController extends AbstractController |
||||
{ |
||||
private const CACHE_TTL = 3600; // 1 hour in seconds |
||||
private const MAX_DISPLAY_HIGHLIGHTS = 50; |
||||
|
||||
public function __construct( |
||||
private readonly NostrClient $nostrClient, |
||||
private readonly LoggerInterface $logger, |
||||
) {} |
||||
|
||||
#[Route('/highlights', name: 'highlights')] |
||||
public function index(CacheInterface $cache): Response |
||||
{ |
||||
try { |
||||
// Cache key for highlights |
||||
$cacheKey = 'global_article_highlights'; |
||||
// $cache->delete($cacheKey); |
||||
// Get highlights from cache or fetch fresh |
||||
$highlights = $cache->get($cacheKey, function (ItemInterface $item) { |
||||
$item->expiresAfter(self::CACHE_TTL); |
||||
|
||||
try { |
||||
// Fetch highlights that reference articles (kind 30023) |
||||
$events = $this->nostrClient->getArticleHighlights(self::MAX_DISPLAY_HIGHLIGHTS); |
||||
|
||||
// Process and enrich the highlights |
||||
return $this->processHighlights($events); |
||||
} catch (\Exception $e) { |
||||
$this->logger->error('Failed to fetch highlights', [ |
||||
'error' => $e->getMessage() |
||||
]); |
||||
return []; |
||||
} |
||||
}); |
||||
|
||||
return $this->render('pages/highlights.html.twig', [ |
||||
'highlights' => $highlights, |
||||
'total' => count($highlights), |
||||
]); |
||||
|
||||
} catch (\Exception $e) { |
||||
$this->logger->error('Error loading highlights page', [ |
||||
'error' => $e->getMessage() |
||||
]); |
||||
|
||||
return $this->render('pages/highlights.html.twig', [ |
||||
'highlights' => [], |
||||
'total' => 0, |
||||
'error' => 'Unable to load highlights at this time. Please try again later.', |
||||
]); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Process highlights to extract metadata |
||||
*/ |
||||
private function processHighlights(array $events): array |
||||
{ |
||||
$processed = []; |
||||
|
||||
foreach ($events as $event) { |
||||
$highlight = [ |
||||
'id' => $event->id ?? null, |
||||
'content' => $event->content ?? '', |
||||
'created_at' => $event->created_at ?? time(), |
||||
'pubkey' => $event->pubkey ?? null, |
||||
'tags' => $event->tags ?? [], |
||||
'article_ref' => null, |
||||
'article_title' => null, |
||||
'article_author' => null, |
||||
'context' => null, |
||||
'url' => null, |
||||
'naddr' => null, |
||||
]; |
||||
|
||||
$relayHints = []; |
||||
|
||||
// Extract metadata from tags |
||||
foreach ($event->tags ?? [] as $tag) { |
||||
if (!is_array($tag) || count($tag) < 2) { |
||||
continue; |
||||
} |
||||
|
||||
switch ($tag[0]) { |
||||
case 'a': // Article reference (kind:pubkey:identifier) |
||||
case 'A': |
||||
$highlight['article_ref'] = $tag[1] ?? null; |
||||
// Get relay hint if available |
||||
if (isset($tag[2]) && str_starts_with($tag[2], 'wss://')) { |
||||
$relayHints[] = $tag[2]; |
||||
} |
||||
// Parse to check if it's an article (kind 30023) |
||||
$parts = explode(':', $tag[1] ?? '', 3); |
||||
if (count($parts) === 3 && $parts[0] === '30023') { |
||||
$highlight['article_author'] = $parts[1]; |
||||
} |
||||
break; |
||||
case 'context': |
||||
$highlight['context'] = $tag[1] ?? null; |
||||
break; |
||||
case 'r': // URL reference |
||||
if (!$highlight['url']) { |
||||
$highlight['url'] = $tag[1] ?? null; |
||||
} |
||||
// Also collect relay hints from r tags |
||||
if (isset($tag[1]) && str_starts_with($tag[1], 'wss://')) { |
||||
$relayHints[] = $tag[1]; |
||||
} |
||||
break; |
||||
case 'title': |
||||
$highlight['article_title'] = $tag[1] ?? null; |
||||
break; |
||||
} |
||||
} |
||||
|
||||
// Only include highlights that reference articles (kind 30023) |
||||
if ($highlight['article_ref'] && str_starts_with($highlight['article_ref'], '30023:')) { |
||||
// Generate naddr from the coordinate |
||||
$highlight['naddr'] = $this->generateNaddr($highlight['article_ref'], $relayHints); |
||||
$processed[] = $highlight; |
||||
} |
||||
} |
||||
|
||||
// Sort by created_at descending (newest first) |
||||
usort($processed, fn($a, $b) => $b['created_at'] <=> $a['created_at']); |
||||
|
||||
return $processed; |
||||
} |
||||
|
||||
/** |
||||
* Generate naddr from coordinate (kind:pubkey:identifier) and relay hints |
||||
*/ |
||||
private function generateNaddr(string $coordinate, array $relayHints = []): ?string |
||||
{ |
||||
$parts = explode(':', $coordinate, 3); |
||||
if (count($parts) !== 3) { |
||||
$this->logger->debug('Invalid coordinate format', ['coordinate' => $coordinate]); |
||||
return null; |
||||
} |
||||
|
||||
try { |
||||
$kind = (int)$parts[0]; |
||||
$pubkey = $parts[1]; |
||||
$identifier = $parts[2]; |
||||
|
||||
$naddr = Bech32::naddr( |
||||
kind: $kind, |
||||
pubkey: $pubkey, |
||||
identifier: $identifier, |
||||
relays: $relayHints |
||||
); |
||||
|
||||
return (string)$naddr; |
||||
|
||||
} catch (\Throwable $e) { |
||||
$this->logger->warning('Failed to generate naddr', [ |
||||
'coordinate' => $coordinate, |
||||
'error' => $e->getMessage() |
||||
]); |
||||
return null; |
||||
} |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,98 @@
@@ -0,0 +1,98 @@
|
||||
{% extends 'layout.html.twig' %} |
||||
|
||||
{% block body %} |
||||
<twig:Atoms:PageHeading |
||||
heading="Article Highlights" |
||||
tagline="Noteworthy passages highlighted by the community" |
||||
/> |
||||
|
||||
<div class="highlights-container w-container"> |
||||
{% if error is defined %} |
||||
<div class="error-message"> |
||||
<p>{{ error }}</p> |
||||
</div> |
||||
{% endif %} |
||||
|
||||
{% if highlights|length > 0 %} |
||||
<div class="highlights-stats hidden"> |
||||
<span><strong>{{ total }}</strong> highlights found</span> |
||||
<span>From the last 7 days</span> |
||||
</div> |
||||
|
||||
<div class="highlights-grid"> |
||||
{% for highlight in highlights %} |
||||
<div class="highlight-card"> |
||||
<div class="highlight-header"> |
||||
<div class="highlight-author"> |
||||
{% if highlight.pubkey %} |
||||
<twig:Molecules:UserFromNpub |
||||
ident="{{ highlight.pubkey }}" |
||||
:compact="true" |
||||
/> |
||||
{% else %} |
||||
<span>Anonymous</span> |
||||
{% endif %} |
||||
</div> |
||||
<div class="highlight-date"> |
||||
{{ highlight.created_at|date('M j, Y') }} |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="highlight-content"> |
||||
{% if highlight.context %} |
||||
{# Render markdown to HTML first, then wrap highlight substring in a span. #} |
||||
{% set htmlContext = highlight.context|markdown_to_html %} |
||||
{% if highlight.content in highlight.context %} |
||||
{% set wrapper = '<span class="highlight-mark">' ~ highlight.content ~ '</span>' %} |
||||
{# Replace occurrences in rendered HTML and output raw HTML #} |
||||
{% set rendered = htmlContext|replace({ (highlight.content): wrapper }) %} |
||||
{{ rendered|raw }} |
||||
{% else %} |
||||
<div class="context-text">{{ htmlContext|raw }}</div> |
||||
<div><span class="highlight-mark">{{ highlight.content }}</span></div> |
||||
{% endif %} |
||||
{% else %} |
||||
<span class="highlight-mark">{{ highlight.content }}</span> |
||||
{% endif %} |
||||
</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 #} |
||||
<a href="{{ path('article-naddr', {naddr: highlight.naddr}) }}" |
||||
class="article-reference"> |
||||
{% if highlight.article_title %} |
||||
{{ highlight.article_title }} |
||||
{% else %} |
||||
View original article |
||||
{% endif %} |
||||
</a> |
||||
{% elseif highlight.article_ref %} |
||||
{# Fallback: show article reference but no link if naddr generation failed #} |
||||
<span class="article-reference" style="opacity: 0.6; cursor: default;"> |
||||
{% if highlight.article_title %} |
||||
{{ highlight.article_title }} |
||||
{% else %} |
||||
Article reference: {{ highlight.article_ref }} |
||||
{% endif %} |
||||
</span> |
||||
{% elseif highlight.url %} |
||||
<a href="{{ highlight.url }}" |
||||
class="article-reference" |
||||
target="_blank" |
||||
rel="noopener noreferrer"> |
||||
View source |
||||
</a> |
||||
{% endif %} |
||||
</div> |
||||
</div> |
||||
{% endfor %} |
||||
</div> |
||||
{% else %} |
||||
<div class="no-highlights"> |
||||
<p>No highlights found. Check back later!</p> |
||||
</div> |
||||
{% endif %} |
||||
</div> |
||||
{% endblock %} |
||||
|
||||
Loading…
Reference in new issue