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 <cursoragent@cursor.com>
This commit is contained in:
bbe
2026-06-29 04:36:10 +02:00
co-authored by Cursor
parent 2ef68222bf
commit 495289232b
4 changed files with 92 additions and 27 deletions
+28 -7
View File
@@ -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<NostrProfile | null>(null);
const [liveContent, setLiveContent] = useState<string | null>(null);
const [liveImage, setLiveImage] = useState<string | null>(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 }) {
/>
)}
<span className="font-medium text-on-surface-variant">
{authorProfile?.name || post.authorName || shortenPubkey(post.authorPubkey!)}
{authorProfile?.name || post.authorName || shortenNpub(post.authorPubkey!)}
</span>
</div>
)}
@@ -338,6 +350,17 @@ export default function BlogPostClient({ slug }: { slug: string }) {
</div>
</header>
{headerImage && (
<img
src={headerImage}
alt={post.title}
className="w-full rounded-xl mb-12 object-cover max-h-[28rem]"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
)}
<article className="mb-16">
{loadingContent ? (
<RelayLoading />
@@ -424,9 +447,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
className="bg-surface-container-high rounded-lg p-4"
>
<div className="flex items-center gap-2.5 mb-2">
<span className="font-semibold text-xs font-mono text-on-surface-variant/70">
{shortenPubkey(r.pubkey)}
</span>
<NostrAuthor pubkey={r.pubkey} />
<span className="text-on-surface-variant/30">·</span>
<span className="text-xs text-on-surface-variant/50">
{formatDate(new Date(r.created_at * 1000))}
+11 -19
View File
@@ -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 (
<a
@@ -67,8 +64,10 @@ export function NostrMention({ bech32 }: { bech32: string }) {
);
}
// Compact author line used inside an embedded note.
function NoteAuthor({ pubkey }: { pubkey: string }) {
// Reusable author line (avatar + username) resolved from the author's kind:0
// profile. Falls back to a shortened npub — never the raw hex pubkey. Shared by
// embedded notes and the blog comment list.
export function NostrAuthor({ pubkey }: { pubkey: string }) {
const [profile, setProfile] = useState<NostrProfile | null>(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 (
<span className="flex items-center gap-2">
<span className="inline-flex items-center gap-2">
{profile?.picture && (
<img
src={profile.picture}
alt=""
className="w-6 h-6 rounded-full object-cover bg-surface-container-high"
className="w-6 h-6 rounded-full object-cover bg-surface-container-high shrink-0"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
@@ -151,7 +143,7 @@ export function NostrNote({ bech32 }: { bech32: string }) {
{state === "done" && event && (
<span className="block">
<span className="flex items-center justify-between mb-3">
<NoteAuthor pubkey={event.pubkey} />
<NostrAuthor pubkey={event.pubkey} />
<a
href={njump(bech32)}
target="_blank"
+39 -1
View File
@@ -1,8 +1,16 @@
"use client";
import { useState } from "react";
import { MapPin, Clock, ArrowRight } from "lucide-react";
import Link from "next/link";
import { AddToCalendarButton } from "@/components/public/AddToCalendarDialog";
import { formatMeetupCivilDate } from "@/lib/meetupEventTime";
// Initial number of meetups shown before "Load more". Mobile is enforced via
// CSS (3) so there's no SSR/viewport mismatch; desktop shows up to 6.
const INITIAL_MOBILE = 3;
const INITIAL_DESKTOP = 6;
interface MeetupData {
id?: string;
title: string;
@@ -19,6 +27,26 @@ interface MeetupsSectionProps {
}
export function MeetupsSection({ meetups }: MeetupsSectionProps) {
// Number of extra batches revealed via "Load more". Each batch adds 3 on
// mobile and 6 on desktop, so visible counts are 3*(pages+1) / 6*(pages+1).
const [pages, setPages] = useState(0);
const mobileVisible = INITIAL_MOBILE * (pages + 1);
const desktopVisible = INITIAL_DESKTOP * (pages + 1);
// Visibility per card. Enforced with CSS so the server-rendered markup matches
// both viewports (desktopVisible >= 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 (
<section className="py-24 px-8 border-t border-zinc-800/50">
<div className="max-w-6xl mx-auto">
@@ -59,7 +87,7 @@ export function MeetupsSection({ meetups }: MeetupsSectionProps) {
<Link
key={meetup.id ?? i}
href={href}
className="group flex flex-col bg-zinc-900 border border-zinc-800 rounded-xl p-6 hover:border-zinc-700 hover:-translate-y-0.5 hover:shadow-xl transition-all duration-200"
className={`group ${cardVisibilityClass(i)} flex-col bg-zinc-900 border border-zinc-800 rounded-xl p-6 hover:border-zinc-700 hover:-translate-y-0.5 hover:shadow-xl transition-all duration-200`}
>
<div className="flex items-start gap-4 mb-4">
<div className="bg-zinc-800 rounded-lg px-3 py-2 text-center shrink-0 min-w-[52px]">
@@ -113,6 +141,16 @@ export function MeetupsSection({ meetups }: MeetupsSectionProps) {
</div>
)}
<div className={`${loadMoreClass} justify-center mt-10`}>
<button
type="button"
onClick={() => setPages((p) => p + 1)}
className="flex items-center gap-2 rounded-lg border border-zinc-700 px-6 py-2.5 text-sm font-semibold text-on-surface hover:border-primary hover:text-primary transition-colors"
>
Load more
</button>
</div>
<div className="md:hidden flex flex-col items-center gap-3 mt-8">
<Link
href="/events"
+14
View File
@@ -82,6 +82,20 @@ export function shortenPubkey(pubkey: string): string {
return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`;
}
// Shortened npub for display when no profile is available. Accepts a hex pubkey
// or an existing npub/nprofile and always renders bech32 (never raw hex).
export function shortenNpub(pubkeyOrBech32: string): string {
if (!pubkeyOrBech32) return "";
try {
const npub = pubkeyOrBech32.startsWith("npub")
? pubkeyOrBech32
: nip19.npubEncode(toHexPubkey(pubkeyOrBech32) || pubkeyOrBech32);
return shortenPubkey(npub);
} catch {
return shortenPubkey(pubkeyOrBech32);
}
}
export interface NostrProfile {
name?: string;
picture?: string;