7 changed files with 449 additions and 5 deletions
@ -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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
{% 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