Browse Source

fix build

add ... more-menu to all events
add magazine jump to Jumble
implement health check route
imwald
Silberengel 4 days ago
parent
commit
ba5332c0d1
  1. 4
      Dockerfile
  2. 13
      assets/controllers/article_comments_controller.js
  3. 8
      assets/styles/app.css
  4. 25
      assets/styles/article.css
  5. 6
      assets/styles/event.css
  6. 6
      assets/styles/layout.css
  7. 6
      assets/styles/nostr-previews.css
  8. 3
      compose.hub.yaml
  9. 6
      compose.yaml
  10. 3
      src/Controller/EventController.php
  11. 32
      src/Controller/HealthController.php
  12. 54
      src/Service/NostrShareMenuBuilder.php
  13. 26
      src/Twig/NostrShareMenuExtension.php
  14. 32
      templates/components/Molecules/NostrPreviewContent.html.twig
  15. 12
      templates/components/Molecules/NostrShareMenu.html.twig
  16. 20
      templates/components/Organisms/Comments.html.twig
  17. 2
      templates/event/index.html.twig
  18. 4
      templates/pages/article.html.twig

4
Dockerfile

@ -56,9 +56,9 @@ COPY --link frankenphp/Caddyfile /etc/caddy/Caddyfile @@ -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

13
assets/controllers/article_comments_controller.js

@ -62,6 +62,10 @@ export default class extends Controller { @@ -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 { @@ -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 =
'<p class="text-subtle">Comments could not be loaded.</p>';
if (this.hasContainerTarget) {
this.containerTarget.innerHTML =
'<p class="text-subtle">Comments could not be loaded.</p>';
}
}
}
}

8
assets/styles/app.css

@ -894,6 +894,14 @@ label.search { @@ -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;

25
assets/styles/article.css

@ -34,6 +34,31 @@ @@ -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;

6
assets/styles/event.css

@ -88,6 +88,12 @@ @@ -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;
}

6
assets/styles/layout.css

@ -161,6 +161,12 @@ a.nostr-share-menu__action { @@ -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%;

6
assets/styles/nostr-previews.css

@ -85,6 +85,12 @@ @@ -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;

3
compose.hub.yaml

@ -41,8 +41,9 @@ services: @@ -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

6
compose.yaml

@ -4,10 +4,10 @@ services: @@ -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

3
src/Controller/EventController.php

@ -31,6 +31,7 @@ class EventController extends AbstractController @@ -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 @@ -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 = [];

32
src/Controller/HealthController.php

@ -0,0 +1,32 @@ @@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* Liveness: no DB or Nostr work. Used by Docker / load balancers; do not use for deep dependency checks.
*/
final class HealthController
{
public const string BODY = "ok\n";
#[Route('/health', name: 'health', methods: ['GET', 'HEAD'])]
public function __invoke(Request $request): Response
{
$headers = [
'Content-Type' => '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);
}
}

54
src/Service/NostrShareMenuBuilder.php

@ -25,21 +25,28 @@ final class NostrShareMenuBuilder @@ -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 @@ -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);
}
}

26
src/Twig/NostrShareMenuExtension.php

@ -5,6 +5,7 @@ declare(strict_types=1); @@ -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 @@ -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 @@ -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, []);
}
}

32
templates/components/Molecules/NostrPreviewContent.html.twig

@ -1,5 +1,11 @@ @@ -1,5 +1,11 @@
{% if preview.type == 'naddr' %}
<div class="card nostr-address-preview">
{% set _na_share = nostr_event_share(preview) %}
{% if _na_share %}
<div class="nostr-preview-card__menu">
{% include 'components/Molecules/NostrShareMenu.html.twig' with { share: _na_share, event_menu: true } only %}
</div>
{% endif %}
{% for tag in preview.tags %}
{% if tag[0] == 'title' %}
<div class="card-header">
@ -18,7 +24,11 @@ @@ -18,7 +24,11 @@
<div>
<twig:Molecules:UserFromNpub ident="{{ preview.pubkey }}" />
</div>
<span class="ui-badge ui-badge--brand">Highlight</span>
<div class="nostr-card-header__actions">
<span class="ui-badge ui-badge--brand">Highlight</span>
{% 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 %}
</div>
</div>
<div class="card-body">
<p>{{ preview.content }}</p>
@ -56,11 +66,15 @@ @@ -56,11 +66,15 @@
<div>
<twig:Molecules:UserFromNpub ident="{{ preview.pubkey }}" />
</div>
{% if is_longform %}
<span class="ui-badge ui-badge--secondary">Article</span>
{% else %}
<span class="ui-badge ui-badge--primary">Note</span>
{% endif %}
<div class="nostr-card-header__actions">
{% if is_longform %}
<span class="ui-badge ui-badge--secondary">Article</span>
{% else %}
<span class="ui-badge ui-badge--primary">Note</span>
{% 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 %}
</div>
</div>
<div class="card-body">
{% if is_longform %}
@ -83,6 +97,12 @@ @@ -83,6 +97,12 @@
{% endif %}
{% elseif preview.type == 'nprofile' %}
<div class="card nostr-profile-preview">
{% set _pr_share = nostr_event_share(preview) %}
{% if _pr_share %}
<div class="nostr-preview-card__menu">
{% include 'components/Molecules/NostrShareMenu.html.twig' with { share: _pr_share, event_menu: true } only %}
</div>
{% endif %}
<div class="card-body nostr-profile-preview__body">
<h5 class="card-title">{{ preview.display_name ?: preview.name }} </h5>
<small class="text-subtle">@{{ preview.npub|shortenNpub }}</small>

12
templates/components/Molecules/NostrShareMenu.html.twig

@ -1,8 +1,14 @@ @@ -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 %}
<details class="nostr-share-menu">
<details class="nostr-share-menu{{ event_menu|default(false) ? ' nostr-share-menu--event' : '' }}">
<summary class="nostr-share-menu__trigger btn btn-secondary btn-sm" title="Nostr options" aria-label="Nostr options">
<span class="nostr-share-menu__label">Nostr</span><span class="nostr-share-menu__glyph" aria-hidden="true">⋯</span>
{% if event_menu|default(false) %}
<span class="nostr-share-menu__glyph" aria-hidden="true">⋯</span>
{% else %}
<span class="nostr-share-menu__label">Nostr</span><span class="nostr-share-menu__glyph" aria-hidden="true">⋯</span>
{% endif %}
</summary>
<ul class="nostr-share-menu__list" role="menu">
{% if share.npub is not null and share.npub is not same as('') %}

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

@ -66,7 +66,7 @@ @@ -66,7 +66,7 @@
{% set cdepth = item.unfold_depth|default(0) %}
{% set is_nip18_repost = item.kind is defined and (item.kind == 6 or item.kind == 16) %}
<div class="card comment comment--depth-{{ cdepth }}"{% if cid != '' %} data-event-id="{{ cid|e('html_attr') }}"{% endif %}>
<div class="metadata">
<div class="metadata comment-card__head">
<p>
{% if item.kind is defined and item.kind == 1 %}
<span class="ui-badge ui-badge--neutral" title="Legacy text-note reply (pre–NIP-22)">kind 1</span>
@ -77,7 +77,11 @@ @@ -77,7 +77,11 @@
{% endif %}
{% if cpk != '' %}<twig:Molecules:UserFromNpub ident="{{ cpk }}" />{% else %}<span class="text-subtle">Unknown</span>{% endif %}
</p>
<small>{% if cts is not null and cts != '' %}{{ cts|date('F j Y') }}{% endif %}</small>
<div class="metadata__end">
<small>{% if cts is not null and cts != '' %}{{ cts|date('F j Y') }}{% endif %}</small>
{% set _ev_share = nostr_event_share(item) %}
{% if _ev_share %}{% include 'components/Molecules/NostrShareMenu.html.twig' with { share: _ev_share, event_menu: true } only %}{% endif %}
</div>
</div>
{% if not is_nip18_repost and item.unfold_reply_blurb|default('')|trim != '' %}
<div class="comment__reply-blurb" role="note" aria-label="Reply context">
@ -172,7 +176,7 @@ @@ -172,7 +176,7 @@
{% set cts = item.created_at|default(null) %}
{% set q_repost = item.kind is defined and (item.kind == 6 or item.kind == 16) %}
<div class="card comment comment--quote">
<div class="metadata">
<div class="metadata comment-card__head">
<p>
{% if q_repost %}
<span class="ui-badge ui-badge--neutral" title="NIP-18 repost (body omitted)">repost (kind {{ item.kind }})</span>
@ -181,9 +185,13 @@ @@ -181,9 +185,13 @@
{% endif %}
{% if cpk != '' %}<twig:Molecules:UserFromNpub ident="{{ cpk }}" />{% else %}<span class="text-subtle">Unknown</span>{% endif %}
</p>
<small>
{% if cts is not null and cts != '' %}{{ cts|date('F j Y') }}{% endif %}
</small>
<div class="metadata__end">
<small>
{% if cts is not null and cts != '' %}{{ cts|date('F j Y') }}{% endif %}
</small>
{% set _qv_share = nostr_event_share(item) %}
{% if _qv_share %}{% include 'components/Molecules/NostrShareMenu.html.twig' with { share: _qv_share, event_menu: true } only %}{% endif %}
</div>
</div>
<div class="card-body">
{% if q_repost %}

2
templates/event/index.html.twig

@ -29,6 +29,8 @@ @@ -29,6 +29,8 @@
{% endif %}
<div class="event-page__meta">
<span class="event-date">{{ event.created_at|date('F j, Y - H:i') }}</span>
{% set _ep_share = nostr_event_share(event) %}
{% if _ep_share %}{% include 'components/Molecules/NostrShareMenu.html.twig' with { share: _ep_share, event_menu: true } only %}{% endif %}
</div>
</div>
<div class="event-page__content">

4
templates/pages/article.html.twig

@ -68,8 +68,10 @@ @@ -68,8 +68,10 @@
{% endif %}
<div class="card">
<div class="card-header">
<div class="card-header card-header--article">
<h1 class="card-title">{{ article.title }}</h1>
{% set _art_share = nostr_event_share(article) %}
{% if _art_share %}{% include 'components/Molecules/NostrShareMenu.html.twig' with { share: _art_share, event_menu: true } only %}{% endif %}
</div>
{% if author %}
<div class="byline">

Loading…
Cancel
Save