From 495289232be40ec84c4d1aca1f6b5cbff11fb8a6 Mon Sep 17 00:00:00 2001 From: bbe Date: Mon, 29 Jun 2026 04:36:10 +0200 Subject: [PATCH] feat: blog header images, npub display, and meetups load more Show longform image tags on posts, use shortened npubs for author names, and paginate the homepage meetups section with responsive load more. Co-authored-by: Cursor --- frontend/app/blog/[slug]/BlogPostClient.tsx | 35 ++++++++++++---- frontend/app/blog/[slug]/NostrEmbeds.tsx | 30 +++++--------- frontend/components/public/MeetupsSection.tsx | 40 ++++++++++++++++++- frontend/lib/nostr.ts | 14 +++++++ 4 files changed, 92 insertions(+), 27 deletions(-) diff --git a/frontend/app/blog/[slug]/BlogPostClient.tsx b/frontend/app/blog/[slug]/BlogPostClient.tsx index 85921e8..ccf00b5 100644 --- a/frontend/app/blog/[slug]/BlogPostClient.tsx +++ b/frontend/app/blog/[slug]/BlogPostClient.tsx @@ -12,7 +12,7 @@ import { getPublicKey, signEvent, publishEvent, - shortenPubkey, + shortenNpub, fetchNostrProfile, fetchLongformFromRelays, fetchEventFromRelays, @@ -23,6 +23,7 @@ import { Navbar } from "@/components/public/Navbar"; import { Footer } from "@/components/public/Footer"; import { markdownComponents } from "./markdownComponents"; import { remarkNostr } from "./remarkNostr"; +import { NostrAuthor } from "./NostrEmbeds"; interface Post { id: string; @@ -30,6 +31,7 @@ interface Post { title: string; content: string; excerpt?: string; + image?: string; authorName?: string; authorPubkey?: string; publishedAt?: string; @@ -59,6 +61,7 @@ function postFromEvent(event: any, slug: string): Post { title: tag("title") || "Untitled", content: event.content || "", excerpt: tag("summary"), + image: tag("image"), authorPubkey: event.pubkey, publishedAt: new Date(publishedAtSec * 1000).toISOString(), nostrEventId: event.id, @@ -117,6 +120,7 @@ export default function BlogPostClient({ slug }: { slug: string }) { const [submitting, setSubmitting] = useState(false); const [authorProfile, setAuthorProfile] = useState(null); const [liveContent, setLiveContent] = useState(null); + const [liveImage, setLiveImage] = useState(null); const [loadingContent, setLoadingContent] = useState(false); const [resolvingFromRelays, setResolvingFromRelays] = useState(false); const [contentError, setContentError] = useState(false); @@ -134,6 +138,7 @@ export default function BlogPostClient({ slug }: { slug: string }) { setLoading(true); setError(null); setLiveContent(null); + setLiveImage(null); setPost(null); setAuthorProfile(null); setContentError(false); @@ -159,8 +164,14 @@ export default function BlogPostClient({ slug }: { slug: string }) { fetchPromise .then((event) => { if (cancelled) return; - if (event?.content) setLiveContent(event.content); - else setContentError(true); // not found / timed out + if (event?.content) { + setLiveContent(event.content); + // Pull the longform header image (`image` tag) for display. + const img = event.tags?.find((t: string[]) => t[0] === "image")?.[1]; + if (img) setLiveImage(img); + } else { + setContentError(true); // not found / timed out + } }) .catch(() => !cancelled && setContentError(true)) .finally(() => !cancelled && setLoadingContent(false)); @@ -258,6 +269,7 @@ export default function BlogPostClient({ slug }: { slug: string }) { }, [comment, post, hasNostr]); const categories = post?.categories?.map((c) => c.category) || []; + const headerImage = post?.image || liveImage; return ( <> @@ -321,7 +333,7 @@ export default function BlogPostClient({ slug }: { slug: string }) { /> )} - {authorProfile?.name || post.authorName || shortenPubkey(post.authorPubkey!)} + {authorProfile?.name || post.authorName || shortenNpub(post.authorPubkey!)} )} @@ -338,6 +350,17 @@ export default function BlogPostClient({ slug }: { slug: string }) { + {headerImage && ( + {post.title} { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + )} +
{loadingContent ? ( @@ -424,9 +447,7 @@ export default function BlogPostClient({ slug }: { slug: string }) { className="bg-surface-container-high rounded-lg p-4" >
- - {shortenPubkey(r.pubkey)} - + · {formatDate(new Date(r.created_at * 1000))} diff --git a/frontend/app/blog/[slug]/NostrEmbeds.tsx b/frontend/app/blog/[slug]/NostrEmbeds.tsx index 18fc676..02bed66 100644 --- a/frontend/app/blog/[slug]/NostrEmbeds.tsx +++ b/frontend/app/blog/[slug]/NostrEmbeds.tsx @@ -5,7 +5,7 @@ import { nip19 } from "nostr-tools"; import { loadNostrProfile, resolveEventFromRelays, - shortenPubkey, + shortenNpub, type NostrProfile, } from "@/lib/nostr"; @@ -39,11 +39,8 @@ export function NostrMention({ bech32 }: { bech32: string }) { }, [pubkey]); // Show the username; fall back to a shortened npub only when no profile. - const npub = useMemo(() => { - if (bech32.startsWith("npub")) return bech32; - return pubkey ? nip19.npubEncode(pubkey) : bech32; - }, [bech32, pubkey]); - const name = profile?.name || profile?.displayName || shortenPubkey(npub); + const name = + profile?.name || profile?.displayName || shortenNpub(pubkey || bech32); return ( (null); useEffect(() => { let cancelled = false; @@ -79,21 +78,14 @@ function NoteAuthor({ pubkey }: { pubkey: string }) { cancelled = true; }; }, [pubkey]); - const fallback = useMemo(() => { - try { - return shortenPubkey(nip19.npubEncode(pubkey)); - } catch { - return shortenPubkey(pubkey); - } - }, [pubkey]); - const name = profile?.name || profile?.displayName || fallback; + const name = profile?.name || profile?.displayName || shortenNpub(pubkey); return ( - + {profile?.picture && ( { (e.target as HTMLImageElement).style.display = "none"; }} @@ -151,7 +143,7 @@ export function NostrNote({ bech32 }: { bech32: string }) { {state === "done" && event && ( - + = mobileVisible, so "mobile-only" never occurs). + const cardVisibilityClass = (i: number) => { + if (i < mobileVisible) return "flex"; + if (i < desktopVisible) return "hidden md:flex"; + return "hidden"; + }; + + // Show the button only when content is still hidden at the given breakpoint. + let loadMoreClass = "hidden"; + if (meetups.length > desktopVisible) loadMoreClass = "flex"; + else if (meetups.length > mobileVisible) loadMoreClass = "flex md:hidden"; + return (
@@ -59,7 +87,7 @@ export function MeetupsSection({ meetups }: MeetupsSectionProps) {
@@ -113,6 +141,16 @@ export function MeetupsSection({ meetups }: MeetupsSectionProps) {
)} +
+ +
+