Browse Source

Overhaul converter

imwald
Nuša Pukšič 3 months ago
parent
commit
4eef42e975
  1. 202
      assets/styles/03-components/nostr-previews.css
  2. 4
      src/Controller/ArticleController.php
  3. 14
      src/Service/NostrClient.php
  4. 80
      src/Util/CommonMark/Converter.php
  5. 2
      templates/components/Organisms/Comments.html.twig
  6. 158
      templates/components/event_card.html.twig
  7. 175
      templates/event/index.html.twig
  8. 4
      templates/pages/article.html.twig
  9. 1
      templates/partial/_gallery.html.twig

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

@ -54,3 +54,205 @@
.nostr-link:hover { .nostr-link:hover {
background-color: rgba(108, 92, 231, 0.2); background-color: rgba(108, 92, 231, 0.2);
} }
.event-container {
max-width: 800px;
margin: 2rem auto;
background: #fff;
border-radius: 8px;
}
.event-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #eee;
padding: 1rem;
}
.event-content {
padding: 1rem;
font-size: 1.1rem;
line-height: 1.6;
}
.event-content img {
max-width: 100%;
}
/* NIP-68 Picture Event Styles */
.content-warning {
background-color: #fff3cd;
border: 2px solid #ffc107;
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
text-align: center;
}
.btn-show-nsfw {
margin-top: 0.5rem;
padding: 0.5rem 1rem;
background-color: #ffc107;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
}
.btn-show-nsfw:hover {
background-color: #e0a800;
}
.picture-location {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
color: #555;
font-size: 0.95rem;
}
.location-icon {
font-size: 1.2rem;
}
.geohash {
background-color: #e9ecef;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-family: monospace;
font-size: 0.85rem;
cursor: help;
}
.picture-source {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
font-size: 0.95rem;
}
.source-label {
font-weight: 600;
color: #495057;
flex-shrink: 0;
}
.source-link {
color: #0066cc;
text-decoration: none;
word-break: break-all;
}
.source-link:hover {
text-decoration: underline;
}
.nostr-links {
margin: 1.5rem 0;
padding: 1rem;
background-color: #f9f9f9;
border-radius: 4px;
}
.link-list {
list-style: none;
padding-left: 0;
}
.link-list li {
word-break: break-all;
}
.link-type {
color: #6c757d;
font-size: 0.9rem;
margin-left: 0.5rem;
}
.event-footer {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 1rem;
border-top: 1px solid #eee;
}
.event-tags {
flex: 1;
}
.event-tags ul, .event-references ul {
list-style-type: none;
padding-left: 0;
}
.event-tags li, .event-references li {
margin-bottom: 0.5rem;
}
.error {
color: #dc3545;
padding: 1rem;
text-align: center;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.picture-gallery {
grid-template-columns: 1fr;
}
}
.embedded-event-card {
border: none;
border-radius: 0;
padding: 12px;
margin: 8px 0;
background: #f8f9fa;
}
.embedded-event-card .event-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.embedded-event-card .avatar-small {
width: 24px;
height: 24px;
border-radius: 50%;
}
.embedded-event-card .author-info {
flex: 1;
}
.embedded-event-card .nip05 {
color: #6c757d;
font-size: 0.85em;
}
.embedded-event-card .event-meta {
color: #6c757d;
font-size: 0.8em;
}
.embedded-event-card .event-content {
margin: 8px 0;
line-height: 1.4;
}
.embedded-event-card .event-footer {
text-align: right;
}
.embedded-event-card .view-full {
color: #007bff;
text-decoration: none;
font-size: 0.85em;
}

4
src/Controller/ArticleController.php

@ -99,10 +99,10 @@ class ArticleController extends AbstractController
$cacheKey = 'article_' . $article->getEventId(); $cacheKey = 'article_' . $article->getEventId();
$cacheItem = $articlesCache->getItem($cacheKey); $cacheItem = $articlesCache->getItem($cacheKey);
if (!$cacheItem->isHit()) { //if (!$cacheItem->isHit()) {
$cacheItem->set($converter->convertToHTML($article->getContent())); $cacheItem->set($converter->convertToHTML($article->getContent()));
$articlesCache->save($cacheItem); $articlesCache->save($cacheItem);
} //}
$key = new Key(); $key = new Key();
$npub = $key->convertPublicKeyToBech32($article->getPubkey()); $npub = $key->convertPublicKeyToBech32($article->getPubkey());

14
src/Service/NostrClient.php

@ -48,7 +48,8 @@ class NostrClient
{ {
$this->defaultRelaySet = new RelaySet(); $this->defaultRelaySet = new RelaySet();
$this->defaultRelaySet->addRelay(new Relay('wss://theforest.nostr1.com')); // public aggregator relay $this->defaultRelaySet->addRelay(new Relay('wss://theforest.nostr1.com')); // public aggregator relay
$this->defaultRelaySet->addRelay(new Relay('wss://aggr.nostr.land')); // aggregator relay, has AUTH $this->defaultRelaySet->addRelay(new Relay('wss://nostr.land')); // aggregator relay, has AUTH
$this->defaultRelaySet->addRelay(new Relay('wss://relay.primal.net')); // profile aggregator
} }
/** /**
@ -107,7 +108,7 @@ class NostrClient
public function getPubkeyMetadata($pubkey): \stdClass public function getPubkeyMetadata($pubkey): \stdClass
{ {
$relaySet = $this->defaultRelaySet; $relaySet = $this->defaultRelaySet;
$relaySet->addRelay(new Relay('wss://profiles.nostr1.com')); // profile aggregator // $relaySet->addRelay(new Relay('wss://profiles.nostr1.com')); // profile aggregator
$relaySet->addRelay(new Relay('wss://purplepag.es')); // profile aggregator $relaySet->addRelay(new Relay('wss://purplepag.es')); // profile aggregator
$this->logger->info('Getting metadata for pubkey ' . $pubkey ); $this->logger->info('Getting metadata for pubkey ' . $pubkey );
$request = $this->createNostrRequest( $request = $this->createNostrRequest(
@ -476,8 +477,11 @@ class NostrClient
// Create request using the helper method // Create request using the helper method
$request = $this->createNostrRequest( $request = $this->createNostrRequest(
kinds: [KindsEnum::COMMENTS->value, KindsEnum::ZAP_RECEIPT->value], kinds: [
filters: ['tag' => ['#a', [$coordinate]]], KindsEnum::COMMENTS->value,
// KindsEnum::ZAP_RECEIPT->value // Not yet
],
filters: ['tag' => ['#A', [$coordinate]]], // #A means root event
relaySet: $relaySet relaySet: $relaySet
); );
@ -961,7 +965,7 @@ class NostrClient
foreach ($relayRes as $item) { foreach ($relayRes as $item) {
try { try {
if (!is_object($item)) { if (!is_object($item)) {
$this->logger->warning('Invalid response item', [ $this->logger->warning('Invalid response item from ' . $relayUrl , [
'relay' => $relayUrl, 'relay' => $relayUrl,
'item' => $item 'item' => $item
]); ]);

80
src/Util/CommonMark/Converter.php

@ -2,6 +2,7 @@
namespace App\Util\CommonMark; namespace App\Util\CommonMark;
use App\Enum\KindsEnum;
use App\Service\NostrClient; use App\Service\NostrClient;
use App\Service\RedisCacheService; use App\Service\RedisCacheService;
use App\Util\CommonMark\ImagesExtension\RawImageLinkExtension; use App\Util\CommonMark\ImagesExtension\RawImageLinkExtension;
@ -45,9 +46,6 @@ readonly class Converter
*/ */
public function convertToHTML(string $markdown): string public function convertToHTML(string $markdown): string
{ {
// Preprocess nostr: links for batching
$markdown = $this->preprocessNostrLinks($markdown);
// Check if the article has more than three headings // Check if the article has more than three headings
// Match all headings (from level 1 to 6) // Match all headings (from level 1 to 6)
preg_match_all('/^#+\s.*$/m', $markdown, $matches); preg_match_all('/^#+\s.*$/m', $markdown, $matches);
@ -68,7 +66,7 @@ readonly class Converter
], ],
'embed' => [ 'embed' => [
'adapter' => new OscaroteroEmbedAdapter(), // See the "Adapter" documentation below 'adapter' => new OscaroteroEmbedAdapter(), // See the "Adapter" documentation below
'allowed_domains' => ['youtube.com', 'x.com', 'github.com', 'fountain.fm'], 'allowed_domains' => ['youtube.com', 'x.com', 'github.com', 'fountain.fm', 'blossom.primal.net', 'i.nostr.build'],
'fallback' => 'link' 'fallback' => 'link'
], ],
]; ];
@ -92,16 +90,19 @@ readonly class Converter
$converter = new MarkdownConverter($environment); $converter = new MarkdownConverter($environment);
$content = html_entity_decode($markdown); $content = html_entity_decode($markdown);
return $converter->convert($content); $html = $converter->convert($content);
// Process nostr links after conversion to avoid re-processing HTML
return $this->processNostrLinks($html);
} }
private function preprocessNostrLinks(string $markdown): string private function processNostrLinks(string $content): string
{ {
// Find all nostr: links // Find all nostr: links
preg_match_all('/nostr:(?:npub1|nprofile1|note1|nevent1|naddr1)[^\\s<>()\\[\\]{}"\'`.,;:!?]*/', $markdown, $matches); preg_match_all('/nostr:(?:npub1|nprofile1|note1|nevent1|naddr1)[^\\s<>()\\[\\]{}"\'`.,;:!?]*/', $content, $matches);
if (empty($matches[0])) { if (empty($matches[0])) {
return $markdown; return $content;
} }
$links = array_unique($matches[0]); $links = array_unique($matches[0]);
@ -125,7 +126,7 @@ readonly class Converter
case 'nprofile': case 'nprofile':
/** @var NProfile $object */ /** @var NProfile $object */
$object = $decoded->data; $object = $decoded->data;
$pubkeys[$object->pubkey] = $bechEncoded; $pubkeys[$object->pubkey] = $this->nostrKeyUtil->hexToNpub($object->pubkey);
break; break;
case 'note': case 'note':
/** @var Note $object */ /** @var Note $object */
@ -147,16 +148,6 @@ readonly class Converter
} }
} }
// Fetch metadata in batch (actually, getMetadata is cached, so just prepare)
$metadata = [];
foreach (array_keys($pubkeys) as $hex) {
try {
$metadata[$hex] = $this->redisCacheService->getMetadata($hex);
} catch (\Exception $e) {
$metadata[$hex] = null;
}
}
// Fetch events in batch // Fetch events in batch
$events = []; $events = [];
if (!empty($eventIds)) { if (!empty($eventIds)) {
@ -167,6 +158,26 @@ readonly class Converter
} }
} }
// Collect pubkeys from events for metadata fetching
$eventPubkeys = [];
foreach ($events as $event) {
$eventPubkeys[$event->pubkey] = true;
}
// Fetch metadata in batch
$allHexes = array_unique(array_merge(array_keys($pubkeys), array_keys($eventPubkeys)));
$metadata = [];
try {
$fetchedMetadata = $this->redisCacheService->getMultipleMetadata($allHexes);
foreach ($allHexes as $hex) {
$metadata[$hex] = $fetchedMetadata[$hex] ?? null;
}
} catch (\Exception $e) {
foreach ($allHexes as $hex) {
$metadata[$hex] = null;
}
}
// Now, render each link // Now, render each link
foreach ($links as $link) { foreach ($links as $link) {
$bechEncoded = substr($link, 6); $bechEncoded = substr($link, 6);
@ -180,8 +191,8 @@ readonly class Converter
} }
} }
// Replace in markdown // Replace in content
return str_replace(array_keys($replacements), array_values($replacements), $markdown); return str_replace(array_keys($replacements), array_values($replacements), $content);
} }
private function renderNostrLink(Bech32 $decoded, string $bechEncoded, array $metadata, array $events): string private function renderNostrLink(Bech32 $decoded, string $bechEncoded, array $metadata, array $events): string
@ -190,14 +201,12 @@ readonly class Converter
case 'npub': case 'npub':
$hex = $this->nostrKeyUtil->npubToHex($bechEncoded); $hex = $this->nostrKeyUtil->npubToHex($bechEncoded);
$profile = $metadata[$hex] ?? null; $profile = $metadata[$hex] ?? null;
if ($profile && isset($profile->name)) { $label = $profile && isset($profile->name) ? $profile->name : $this->labelFromKey($bechEncoded);
return '<a href="/profile/' . $bechEncoded . '" class="nostr-mention">@' . htmlspecialchars($profile->name) . '</a>'; return '<a href="/p/' . $bechEncoded . '" class="nostr-mention">@' . htmlspecialchars($label) . '</a>';
} else {
return '<a href="/profile/' . $bechEncoded . '" class="nostr-mention">@' . substr($bechEncoded, 5, 8) . '…</a>';
}
case 'nprofile': case 'nprofile':
$object = $decoded->data; $object = $decoded->data;
return '<a href="/profile/' . $bechEncoded . '" class="nostr-mention">@' . substr($bechEncoded, 9, 8) . '…</a>'; $label = $this->labelFromKey($bechEncoded);
return '<a href="/p/' . $bechEncoded . '" class="nostr-mention">@' . htmlspecialchars($label) . '</a>';
case 'note': case 'note':
$object = $decoded->data; $object = $decoded->data;
$event = $events[$object->data] ?? null; $event = $events[$object->data] ?? null;
@ -208,7 +217,7 @@ readonly class Converter
]); ]);
return $pictureCardHtml; return $pictureCardHtml;
} else { } else {
return '<a href="/event/' . $bechEncoded . '" class="nostr-link">' . $bechEncoded . '</a>'; return '<a href="/e/' . $bechEncoded . '" class="nostr-link">' . $bechEncoded . '</a>';
} }
case 'nevent': case 'nevent':
$object = $decoded->data; $object = $decoded->data;
@ -222,13 +231,24 @@ readonly class Converter
]); ]);
return $eventCardHtml; return $eventCardHtml;
} else { } else {
return '<a href="/event/' . $bechEncoded . '" class="nostr-link">' . $bechEncoded . '</a>'; return '<a href="/e/' . $bechEncoded . '" class="nostr-link">' . $bechEncoded . '</a>';
} }
case 'naddr': case 'naddr':
return '<a href="/event/' . $bechEncoded . '" class="nostr-link">' . $bechEncoded . '</a>'; if ($decoded->kind === KindsEnum::LONGFORM->value) {
return '<a href="/article/' . $bechEncoded . '" class="nostr-link">' . $bechEncoded . '</a>';
} else {
return '<a href="/e/' . $bechEncoded . '" class="nostr-link">' . $bechEncoded . '</a>';
}
default: default:
return $bechEncoded; return $bechEncoded;
} }
} }
private function labelFromKey(string $npub): string
{
$start = substr($npub, 0, 5);
$end = substr($npub, -5);
return $start . '...' . $end;
}
} }

2
templates/components/Organisms/Comments.html.twig

@ -7,7 +7,7 @@
{% if loading %} {% if loading %}
<div class="comments-loading" data-comments-mercure-target="loading">Loading comments…</div> <div class="comments-loading" data-comments-mercure-target="loading">Loading comments…</div>
{% endif %} {% endif %}
<div class="comments-list" data-comments-mercure-target="list" {% if loading %}style="display:none"{% endif %}> <div class="comments" data-comments-mercure-target="list" {% if loading %}style="display:none"{% endif %}>
{% for item in list %} {% for item in list %}
<div class="card comment {% if item.kind is defined and item.kind == '9735' %}zap-comment{% endif %}"> <div class="card comment {% if item.kind is defined and item.kind == '9735' %}zap-comment{% endif %}">
<div class="metadata"> <div class="metadata">

158
templates/components/event_card.html.twig

@ -2,9 +2,6 @@
<div class="embedded-event-card" data-nevent="{{ nevent }}"> <div class="embedded-event-card" data-nevent="{{ nevent }}">
<div class="event-header"> <div class="event-header">
{% if author %} {% if author %}
{% if author.image is defined %}
<img src="{{ author.image }}" class="avatar-small" alt="{{ author.name }}" onerror="this.style.display = 'none'" />
{% endif %}
<div class="author-info"> <div class="author-info">
<strong>{{ author.name ?? 'Anonymous' }}</strong> <strong>{{ author.name ?? 'Anonymous' }}</strong>
</div> </div>
@ -13,162 +10,13 @@
<span class="event-date">{{ event.created_at|date('M j, Y H:i') }}</span> <span class="event-date">{{ event.created_at|date('M j, Y H:i') }}</span>
</div> </div>
</div> </div>
<div class="event-content"> <div class="event-content">
{{ event.content|markdown_to_html|mentionify }} <twig:Atoms:Content :content="event.content" />
</div> </div>
{% include 'partial/_gallery.html.twig' with {event: event, isEmbed: true} %}
{# Collect all imeta images into an array #}
{% set images = [] %}
{% for tag in event.tags %}
{% if tag[0] == 'imeta' %}
{% set imageUrl = null %}
{% set mimeType = null %}
{% set blurhash = null %}
{% set dimensions = null %}
{% set altText = null %}
{% set fallbacks = [] %}
{% set annotatedUsers = [] %}
{# Parse imeta tag parameters #}
{% for i in 1..(tag|length - 1) %}
{% set param = tag[i] %}
{% if param starts with 'url ' %}
{% set imageUrl = param[4:] %}
{% elseif param starts with 'm ' %}
{% set mimeType = param[2:] %}
{% elseif param starts with 'blurhash ' %}
{% set blurhash = param[9:] %}
{% elseif param starts with 'dim ' %}
{% set dimensions = param[4:] %}
{% elseif param starts with 'alt ' %}
{% set altText = param[4:] %}
{% elseif param starts with 'fallback ' %}
{% set fallbacks = fallbacks|merge([param[9:]]) %}
{% elseif param starts with 'annotate-user ' %}
{% set annotatedUsers = annotatedUsers|merge([param[14:]]) %}
{% endif %}
{% endfor %}
{# Alt is also own tag #}
{% for altTag in event.tags %}
{% if altTag[0] == 'alt' %}
{% set altText = altTag[1] %}
{% endif %}
{% endfor %}
{% set images = images|merge([{
'url': imageUrl,
'mimeType': mimeType,
'blurhash': blurhash,
'dimensions': dimensions,
'altText': altText,
'fallbacks': fallbacks,
'annotatedUsers': annotatedUsers
}]) %}
{% endif %}
{% endfor %}
{% if images|length > 0 %}
<div class="gallery-view" data-controller="gallery">
<div class="main-image-wrapper">
{% set main = images[0] %}
<figure class="media">
<picture>
<img src="{{ main.url }}"
alt="{{ main.altText|default('Picture') }}"
{% if main.dimensions %}data-dimensions="{{ main.dimensions }}"{% endif %}
{% if main.blurhash %}data-blurhash="{{ main.blurhash }}"{% endif %}
class="picture-image main-image"
data-gallery-target="mainImage"
/>
{% for fallback in main.fallbacks %}
<source srcset="{{ fallback }}" />
{% endfor %}
</picture>
{% if images|length > 1 %}
<div class="thumbnails" data-gallery-target="thumbnails">
{% for img in images %}
<img src="{{ img.url }}"
alt="{{ img.altText|default('Picture') }}"
class="thumbnail{% if loop.first %} selected{% endif %}"
data-gallery-target="thumbnail"
data-action="click->gallery#switch"
data-gallery-index="{{ loop.index0 }}"
{% if img.dimensions %}data-dimensions="{{ img.dimensions }}"{% endif %}
{% if img.blurhash %}data-blurhash="{{ img.blurhash }}"{% endif %}
/>
{% endfor %}
</div>
{% endif %}
</figure>
{# Display annotated users for main image #}
{% if main.annotatedUsers|length > 0 %}
<div class="annotated-users">
{% for userAnnotation in main.annotatedUsers %}
{% set parts = userAnnotation|split(':') %}
{% if parts|length == 3 %}
<div class="user-tag" data-left="{{ parts[1] }}" data-top="{{ parts[2] }}" style="left: {{ parts[1] }}%; top: {{ parts[2] }}%;">
<twig:Molecules:UserFromNpub ident="{{ parts[0] }}" />
</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endif %}
<div class="event-footer"> <div class="event-footer">
<a href="/e/{{ nevent }}" class="view-full">View full event</a> <a href="/e/{{ nevent }}" class="view-full">View full event</a>
</div> </div>
</div> </div>
<style>
.embedded-event-card {
border: none;
border-radius: 0;
padding: 12px;
margin: 8px 0;
background: #f8f9fa;
}
.embedded-event-card .event-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.embedded-event-card .avatar-small {
width: 24px;
height: 24px;
border-radius: 50%;
}
.embedded-event-card .author-info {
flex: 1;
}
.embedded-event-card .nip05 {
color: #6c757d;
font-size: 0.85em;
}
.embedded-event-card .event-meta {
color: #6c757d;
font-size: 0.8em;
}
.embedded-event-card .event-content {
margin: 8px 0;
line-height: 1.4;
}
.embedded-event-card .event-footer {
text-align: right;
}
.embedded-event-card .view-full {
color: #007bff;
text-decoration: none;
font-size: 0.85em;
}
</style>

175
templates/event/index.html.twig

@ -171,182 +171,7 @@
</div> </div>
{% endif %} {% endif %}
{% if is_granted('ROLE_ADMIN') %}
<h2>Raw Event Data</h2>
<pre>
{{ event|json_encode(constant('JSON_PRETTY_PRINT')) }}
</pre>
<div class="event-tags">
{% if event.tags is defined and event.tags|length > 0 %}
<ul>
{% for tag in event.tags %}
<li>
<strong>{{ tag[0] }}:</strong> {{ tag[1] }}
{% if tag[2] is defined %}
<span>{{ tag[2] }}</span>
{% endif %}
{% if tag[3] is defined %}
<span>{{ tag[3] }}</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endif %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block stylesheets %}
{{ parent() }}
<style>
.event-container {
max-width: 800px;
margin: 2rem auto;
background: #fff;
border-radius: 8px;
}
.event-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #eee;
padding: 1rem;
}
.event-content {
padding: 1rem;
font-size: 1.1rem;
line-height: 1.6;
}
/* NIP-68 Picture Event Styles */
.content-warning {
background-color: #fff3cd;
border: 2px solid #ffc107;
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
text-align: center;
}
.btn-show-nsfw {
margin-top: 0.5rem;
padding: 0.5rem 1rem;
background-color: #ffc107;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
}
.btn-show-nsfw:hover {
background-color: #e0a800;
}
.picture-location {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
color: #555;
font-size: 0.95rem;
}
.location-icon {
font-size: 1.2rem;
}
.geohash {
background-color: #e9ecef;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-family: monospace;
font-size: 0.85rem;
cursor: help;
}
.picture-source {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
font-size: 0.95rem;
}
.source-label {
font-weight: 600;
color: #495057;
flex-shrink: 0;
}
.source-link {
color: #0066cc;
text-decoration: none;
word-break: break-all;
}
.source-link:hover {
text-decoration: underline;
}
.nostr-links {
margin: 1.5rem 0;
padding: 1rem;
background-color: #f9f9f9;
border-radius: 4px;
}
.link-list {
list-style: none;
padding-left: 0;
}
.link-list li {
word-break: break-all;
}
.link-type {
color: #6c757d;
font-size: 0.9rem;
margin-left: 0.5rem;
}
.event-footer {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 1rem;
border-top: 1px solid #eee;
}
.event-tags {
flex: 1;
}
.event-tags ul, .event-references ul {
list-style-type: none;
padding-left: 0;
}
.event-tags li, .event-references li {
margin-bottom: 0.5rem;
}
.error {
color: #dc3545;
padding: 1rem;
text-align: center;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.picture-gallery {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}

4
templates/pages/article.html.twig

@ -16,10 +16,6 @@
<twig:Organisms:MagazineHero :mag="mag" :magazine="magazine" /> <twig:Organisms:MagazineHero :mag="mag" :magazine="magazine" />
{% endif %} {% endif %}
<pre>
{{ article.topics|json_encode(constant('JSON_PRETTY_PRINT')) }}
</pre>
<div class="article-actions"> <div class="article-actions">
{% if canEdit %} {% if canEdit %}
<a class="btn btn-primary" href="{{ path('editor-edit-slug', {'slug': article.slug}) }}">Edit article</a> <a class="btn btn-primary" href="{{ path('editor-edit-slug', {'slug': article.slug}) }}">Edit article</a>

1
templates/partial/_gallery.html.twig

@ -129,3 +129,4 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
</div>

Loading…
Cancel
Save