From ba5332c0d1baa42f875621e256d40b4b077f1393 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 24 Apr 2026 08:29:24 +0200 Subject: [PATCH] fix build add ... more-menu to all events add magazine jump to Jumble implement health check route --- Dockerfile | 4 +- .../article_comments_controller.js | 13 ++++- assets/styles/app.css | 8 +++ assets/styles/article.css | 25 +++++++++ assets/styles/event.css | 6 +++ assets/styles/layout.css | 6 +++ assets/styles/nostr-previews.css | 6 +++ compose.hub.yaml | 3 +- compose.yaml | 6 +-- src/Controller/EventController.php | 3 +- src/Controller/HealthController.php | 32 +++++++++++ src/Service/NostrShareMenuBuilder.php | 54 ++++++++++++++++--- src/Twig/NostrShareMenuExtension.php | 26 +++++++++ .../Molecules/NostrPreviewContent.html.twig | 32 ++++++++--- .../Molecules/NostrShareMenu.html.twig | 12 +++-- .../components/Organisms/Comments.html.twig | 20 ++++--- templates/event/index.html.twig | 2 + templates/pages/article.html.twig | 4 +- 18 files changed, 231 insertions(+), 31 deletions(-) create mode 100644 src/Controller/HealthController.php diff --git a/Dockerfile b/Dockerfile index 05efd10..119d258 100644 --- a/Dockerfile +++ b/Dockerfile @@ -56,9 +56,9 @@ COPY --link frankenphp/Caddyfile /etc/caddy/Caddyfile ENTRYPOINT ["docker-entrypoint"] -# Hit the public HTTP server, not Caddy :2019 admin (not always available the same way in all setups). +# App liveness: GET /health (no DB/Nostr; see HealthController) HEALTHCHECK --interval=10s --timeout=5s --retries=6 --start-period=120s \ - CMD curl -fsS http://127.0.0.1/ -o /dev/null || exit 1 + CMD curl -fsS http://127.0.0.1/health -o /dev/null || exit 1 CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile" ] # Dev FrankenPHP image diff --git a/assets/controllers/article_comments_controller.js b/assets/controllers/article_comments_controller.js index ccffddf..5c4d9e9 100644 --- a/assets/controllers/article_comments_controller.js +++ b/assets/controllers/article_comments_controller.js @@ -62,6 +62,10 @@ export default class extends Controller { throw new Error(`HTTP ${res.status}`); } const html = await res.text(); + if (!this.hasContainerTarget) { + window.clearTimeout(timer); + return; + } this.containerTarget.innerHTML = html; const ms = Math.round(performance.now() - t0); if (attempt > 1) { @@ -76,12 +80,17 @@ export default class extends Controller { if (attempt < maxAttempts) { const delay = 1_200 * 2 ** (attempt - 1); await new Promise((r) => setTimeout(r, delay)); + if (!this.hasContainerTarget) { + return; + } continue; } const ms = Math.round(performance.now() - t0); console.warn(`[article-comments] fragment failed after ${ms}ms`, this.urlValue, err); - this.containerTarget.innerHTML = - '

Comments could not be loaded.

'; + if (this.hasContainerTarget) { + this.containerTarget.innerHTML = + '

Comments could not be loaded.

'; + } } } } diff --git a/assets/styles/app.css b/assets/styles/app.css index 9816d35..3694a41 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -894,6 +894,14 @@ label.search { gap: 0.5rem; } +.nostr-card-header__actions { + display: inline-flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; + justify-content: flex-end; +} + .ui-badge { display: inline-block; padding: 0.2rem 0.55rem; diff --git a/assets/styles/article.css b/assets/styles/article.css index bbb4754..8ce00f6 100644 --- a/assets/styles/article.css +++ b/assets/styles/article.css @@ -34,6 +34,31 @@ border-bottom: 1px solid var(--color-text); } +.card-header--article { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 0.75rem; + flex-wrap: wrap; +} + +.card-header--article .card-title { + flex: 1 1 12rem; + min-width: 0; + margin: 0; +} + +.card.comment .metadata.comment-card__head { + align-items: flex-start; +} + +.card.comment .metadata__end { + display: flex; + align-items: center; + gap: 0.4rem; + flex-shrink: 0; +} + .byline { display: flex; justify-content: space-between; diff --git a/assets/styles/event.css b/assets/styles/event.css index b10cc49..c859c8d 100644 --- a/assets/styles/event.css +++ b/assets/styles/event.css @@ -88,6 +88,12 @@ } .event-page__meta { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.5rem; + width: 100%; color: var(--color-text-mid); font-size: 0.95rem; } diff --git a/assets/styles/layout.css b/assets/styles/layout.css index eeda26f..454bbbf 100644 --- a/assets/styles/layout.css +++ b/assets/styles/layout.css @@ -161,6 +161,12 @@ a.nostr-share-menu__action { color: var(--color-primary, inherit); } +.nostr-share-menu--event .nostr-share-menu__trigger { + min-width: auto; + padding: 0.15rem 0.4rem; + font-size: 1rem; +} + .header__logo { display: flex; width: 100%; diff --git a/assets/styles/nostr-previews.css b/assets/styles/nostr-previews.css index 2dba82d..55499c0 100644 --- a/assets/styles/nostr-previews.css +++ b/assets/styles/nostr-previews.css @@ -85,6 +85,12 @@ gap: 0.35rem; } +.nostr-preview-card__menu { + display: flex; + justify-content: flex-end; + margin-bottom: 0.35rem; +} + .nostr-previews h6 { font-size: 0.9rem; margin-bottom: 1rem; diff --git a/compose.hub.yaml b/compose.hub.yaml index 535a9c4..0523acb 100644 --- a/compose.hub.yaml +++ b/compose.hub.yaml @@ -41,8 +41,9 @@ services: - "${HTTP_PUBLISH:-9080}:80/tcp" # Caddy/FrankenPHP only listen after the entrypoint finishes DB wait + migrations — allow a slow # first MySQL + migrate on a small host (avoids "unhealthy" + failed `up` for dependents). + # Liveness: GET /health (see HealthController), not /. healthcheck: - test: ["CMD", "curl", "-fsS", "http://127.0.0.1/", "-o", "/dev/null"] + test: ["CMD", "curl", "-fsS", "http://127.0.0.1/health", "-o", "/dev/null"] interval: 10s timeout: 5s retries: 10 diff --git a/compose.yaml b/compose.yaml index 4f16977..6e2c1f6 100644 --- a/compose.yaml +++ b/compose.yaml @@ -4,10 +4,10 @@ services: build: context: . dockerfile: Dockerfile - # Overrides Dockerfile HEALTHCHECK: verify Caddy/FrankenPHP serves the app (not Caddy :2019 admin, - # which is unreliable for “ready”). `docker compose up --wait` requires this to pass. + # Overrides Dockerfile HEALTHCHECK: lightweight app route (see HealthController), not / (magazine + relays). + # `docker compose up --wait` requires this to pass. healthcheck: - test: ["CMD", "curl", "-fsS", "http://127.0.0.1/", "-o", "/dev/null"] + test: ["CMD", "curl", "-fsS", "http://127.0.0.1/health", "-o", "/dev/null"] interval: 10s timeout: 5s retries: 6 diff --git a/src/Controller/EventController.php b/src/Controller/EventController.php index f65a071..fccb4a2 100644 --- a/src/Controller/EventController.php +++ b/src/Controller/EventController.php @@ -31,6 +31,7 @@ class EventController extends AbstractController NostrClient $nostrClient, CacheService $cacheService, NostrLinkParser $nostrLinkParser, + NostrShareMenuBuilder $nostrShareMenuBuilder, LoggerInterface $logger, ): Response { $logger->info('Accessing event page', ['nevent' => $nevent]); @@ -96,7 +97,7 @@ class EventController extends AbstractController throw new NotFoundHttpException('Event not found'); } - NostrShareMenuBuilder::applyWireEventToRequest($request, $event, $relays); + $nostrShareMenuBuilder->applyWireEventToRequest($request, $event, $relays); // Parse event content for Nostr links $nostrLinks = []; diff --git a/src/Controller/HealthController.php b/src/Controller/HealthController.php new file mode 100644 index 0000000..d7ceb94 --- /dev/null +++ b/src/Controller/HealthController.php @@ -0,0 +1,32 @@ + 'text/plain; charset=UTF-8', + 'Cache-Control' => 'no-store', + ]; + + if ($request->isMethod('HEAD')) { + return new Response('', Response::HTTP_OK, $headers); + } + + return new Response(self::BODY, Response::HTTP_OK, $headers); + } +} diff --git a/src/Service/NostrShareMenuBuilder.php b/src/Service/NostrShareMenuBuilder.php index 367ee5f..b3a07b2 100644 --- a/src/Service/NostrShareMenuBuilder.php +++ b/src/Service/NostrShareMenuBuilder.php @@ -25,21 +25,28 @@ final class NostrShareMenuBuilder public const string ATTR_NADDR_BECH32 = 'nostr_share_naddr_bech32'; - public static function applyWireEventToRequest(Request $request, object $event, array $relayHints = []): void + /** + * NIP-19 + Jumble href for a wire event (replies, quotes, previews, /e/ page). + */ + public function shareContextFromWireEvent(object $event, array $relayHints = []): ?NostrShareMenuContext { $pubkeyHex = strtolower((string) ($event->pubkey ?? '')); if (64 !== \strlen($pubkeyHex) || !ctype_xdigit($pubkeyHex)) { - return; + return null; } $key = new Key(); - $request->attributes->set(self::ATTR_NPUB, $key->convertPublicKeyToBech32($pubkeyHex)); + $npub = $key->convertPublicKeyToBech32($pubkeyHex); $kind = (int) ($event->kind ?? 0); $d = self::dTagFromWireEvent($event); if (Nip19Addressable::isParameterizedReplaceableKind($kind) && $d !== null) { $naddr = Nip19Addressable::naddrBech32($kind, $pubkeyHex, $d, $relayHints); - $request->attributes->set(self::ATTR_NADDR_BECH32, $naddr); - return; + return new NostrShareMenuContext( + $npub, + null, + $naddr, + $this->feedJumble($naddr), + ); } $eventIdHex = strtolower((string) ($event->id ?? '')); if (64 === \strlen($eventIdHex) && ctype_xdigit($eventIdHex)) { @@ -49,7 +56,42 @@ final class NostrShareMenuBuilder author: $pubkeyHex, kind: $kind, ); - $request->attributes->set(self::ATTR_NEVENT_BECH32, $rebuilt); + + return new NostrShareMenuContext( + $npub, + $rebuilt, + null, + $this->feedJumble($rebuilt), + ); + } + + return new NostrShareMenuContext( + $npub, + null, + null, + $this->profileJumbleUrl($npub), + ); + } + + public function shareContextForArticle(Article $article): NostrShareMenuContext + { + return $this->fromArticle($article); + } + + public function applyWireEventToRequest(Request $request, object $event, array $relayHints = []): void + { + $ctx = $this->shareContextFromWireEvent($event, $relayHints); + if (null === $ctx || null === $ctx->npub) { + return; + } + $request->attributes->set(self::ATTR_NPUB, $ctx->npub); + if ($ctx->naddrBech32 !== null && $ctx->naddrBech32 !== '') { + $request->attributes->set(self::ATTR_NADDR_BECH32, $ctx->naddrBech32); + + return; + } + if ($ctx->neventBech32 !== null && $ctx->neventBech32 !== '') { + $request->attributes->set(self::ATTR_NEVENT_BECH32, $ctx->neventBech32); } } diff --git a/src/Twig/NostrShareMenuExtension.php b/src/Twig/NostrShareMenuExtension.php index b9fbee7..ccdd753 100644 --- a/src/Twig/NostrShareMenuExtension.php +++ b/src/Twig/NostrShareMenuExtension.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Twig; use App\Dto\NostrShareMenuContext; +use App\Entity\Article; use App\Service\NostrShareMenuBuilder; use Symfony\Component\HttpFoundation\RequestStack; use Twig\Extension\AbstractExtension; @@ -22,6 +23,7 @@ final class NostrShareMenuExtension extends AbstractExtension { return [ new TwigFunction('nostr_share_menu', [$this, 'getOrBuildContext']), + new TwigFunction('nostr_event_share', [$this, 'getEventShareContext']), ]; } @@ -34,4 +36,28 @@ final class NostrShareMenuExtension extends AbstractExtension return $this->builder->buildForRequest($request); } + + /** + * Share menu for a specific event: {@see Article} row, wire object (comment / quote / preview), etc. + * + * @param mixed $data Article entity or wire-like object (id, pubkey, kind, tags) + */ + public function getEventShareContext(mixed $data): ?NostrShareMenuContext + { + if ($data instanceof Article) { + return $this->builder->shareContextForArticle($data); + } + if ($data === null) { + return null; + } + if (\is_array($data)) { + $json = json_encode($data); + $data = \is_string($json) ? json_decode($json) : null; + } + if (!\is_object($data)) { + return null; + } + + return $this->builder->shareContextFromWireEvent($data, []); + } } diff --git a/templates/components/Molecules/NostrPreviewContent.html.twig b/templates/components/Molecules/NostrPreviewContent.html.twig index d4985b1..c0b1584 100644 --- a/templates/components/Molecules/NostrPreviewContent.html.twig +++ b/templates/components/Molecules/NostrPreviewContent.html.twig @@ -1,5 +1,11 @@ {% if preview.type == 'naddr' %}
+ {% set _na_share = nostr_event_share(preview) %} + {% if _na_share %} +
+ {% include 'components/Molecules/NostrShareMenu.html.twig' with { share: _na_share, event_menu: true } only %} +
+ {% endif %} {% for tag in preview.tags %} {% if tag[0] == 'title' %}
@@ -18,7 +24,11 @@
- Highlight +
+ Highlight + {% set _hi_share = nostr_event_share(preview) %} + {% if _hi_share %}{% include 'components/Molecules/NostrShareMenu.html.twig' with { share: _hi_share, event_menu: true } only %}{% endif %} +

{{ preview.content }}

@@ -56,11 +66,15 @@
- {% if is_longform %} - Article - {% else %} - Note - {% endif %} +
+ {% if is_longform %} + Article + {% else %} + Note + {% endif %} + {% set _evp_share = nostr_event_share(preview) %} + {% if _evp_share %}{% include 'components/Molecules/NostrShareMenu.html.twig' with { share: _evp_share, event_menu: true } only %}{% endif %} +
{% if is_longform %} @@ -83,6 +97,12 @@ {% endif %} {% elseif preview.type == 'nprofile' %}
+ {% set _pr_share = nostr_event_share(preview) %} + {% if _pr_share %} +
+ {% include 'components/Molecules/NostrShareMenu.html.twig' with { share: _pr_share, event_menu: true } only %} +
+ {% endif %}
{{ preview.display_name ?: preview.name }}
@{{ preview.npub|shortenNpub }} diff --git a/templates/components/Molecules/NostrShareMenu.html.twig b/templates/components/Molecules/NostrShareMenu.html.twig index bd7fe49..2a8097d 100644 --- a/templates/components/Molecules/NostrShareMenu.html.twig +++ b/templates/components/Molecules/NostrShareMenu.html.twig @@ -1,8 +1,14 @@ -{% set share = nostr_share_menu() %} +{% if share is not defined %} + {% set share = nostr_share_menu() %} +{% endif %} {% if share is not null %} -
+
- Nostr + {% if event_menu|default(false) %} + + {% else %} + Nostr + {% endif %}