Files
BelgianBitcoinEmbassy/frontend/app/blog/[slug]/BlogPostClient.tsx
T
bbeandCursor 495289232b 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>
2026-06-29 04:36:10 +02:00

477 lines
17 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { ArrowLeft, Heart, Send } from "lucide-react";
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
import remarkGfm from "remark-gfm";
import { api } from "@/lib/api";
import { formatDate } from "@/lib/utils";
import {
hasNostrExtension,
getPublicKey,
signEvent,
publishEvent,
shortenNpub,
fetchNostrProfile,
fetchLongformFromRelays,
fetchEventFromRelays,
resolveEventFromRelays,
type NostrProfile,
} from "@/lib/nostr";
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;
slug: string;
title: string;
content: string;
excerpt?: string;
image?: string;
authorName?: string;
authorPubkey?: string;
publishedAt?: string;
createdAt?: string;
nostrEventId?: string;
naddr?: string;
categories?: { category: { id: string; name: string; slug: string } }[];
}
interface NostrReply {
id: string;
pubkey: string;
content: string;
created_at: number;
}
// Builds a renderable Post from a raw long-form Nostr event, used when a slug is
// a NIP-19 reference (naddr/nevent/note) that was never indexed by the backend.
function postFromEvent(event: any, slug: string): Post {
const tag = (name: string): string | undefined =>
event.tags?.find((t: string[]) => t[0] === name)?.[1];
const publishedAtSec = Number(tag("published_at")) || event.created_at;
return {
id: event.id,
slug,
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,
naddr: slug.startsWith("naddr") ? slug : undefined,
categories: (event.tags || [])
.filter((t: string[]) => t[0] === "t" && t[1])
.map((t: string[]) => ({ category: { id: t[1], name: t[1], slug: t[1] } })),
};
}
function ArticleSkeleton() {
const widths = [85, 92, 78, 95, 88, 72, 90, 83];
return (
<div className="animate-pulse max-w-3xl mx-auto">
<div className="flex gap-2 mb-6">
<div className="h-5 w-20 bg-surface-container-high rounded-full" />
<div className="h-5 w-16 bg-surface-container-high rounded-full" />
</div>
<div className="h-12 w-3/4 bg-surface-container-high rounded mb-4" />
<div className="h-12 w-1/2 bg-surface-container-high rounded mb-8" />
<div className="h-5 w-48 bg-surface-container-high rounded mb-16" />
<div className="space-y-4">
{widths.map((w, i) => (
<div
key={i}
className="h-4 bg-surface-container-high rounded"
style={{ width: `${w}%` }}
/>
))}
</div>
</div>
);
}
function RelayLoading() {
return (
<div className="flex items-center justify-center gap-3 py-16 text-on-surface-variant">
<span
className="inline-block w-5 h-5 rounded-full border-2 border-primary/30 border-t-primary animate-spin"
aria-hidden
/>
<span className="text-sm font-medium">Fetching from Nostr relays</span>
</div>
);
}
export default function BlogPostClient({ slug }: { slug: string }) {
const [post, setPost] = useState<Post | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [liked, setLiked] = useState(false);
const [likeCount, setLikeCount] = useState(0);
const [comment, setComment] = useState("");
const [replies, setReplies] = useState<NostrReply[]>([]);
const [hasNostr, setHasNostr] = useState(false);
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);
const [retryKey, setRetryKey] = useState(0);
const retry = useCallback(() => setRetryKey((k) => k + 1), []);
useEffect(() => {
setHasNostr(hasNostrExtension());
}, []);
useEffect(() => {
if (!slug) return;
let cancelled = false;
setLoading(true);
setError(null);
setLiveContent(null);
setLiveImage(null);
setPost(null);
setAuthorProfile(null);
setContentError(false);
setResolvingFromRelays(false);
// Loads the author's profile, then hydrates the article body from relays
// when only metadata is present (indexed posts store an empty body).
const hydrate = (data: Post) => {
if (cancelled) return;
setPost(data);
setLoading(false);
if (data?.authorPubkey) {
fetchNostrProfile(data.authorPubkey)
.then((profile) => !cancelled && setAuthorProfile(profile))
.catch(() => {});
}
if (!data?.content && (data?.naddr || data?.nostrEventId)) {
setLoadingContent(true);
setContentError(false);
const fetchPromise = data.naddr
? fetchLongformFromRelays(data.naddr)
: fetchEventFromRelays(data.nostrEventId!);
fetchPromise
.then((event) => {
if (cancelled) return;
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));
}
};
api
.getPost(slug)
.then((data) => hydrate(data))
.catch(() => {
// Not indexed: treat the slug itself as a NIP-19 reference and resolve
// the long-form note live from relays.
if (cancelled) return;
setLoading(false);
setResolvingFromRelays(true);
resolveEventFromRelays(slug)
.then((event) => {
if (cancelled) return;
setResolvingFromRelays(false);
if (event) hydrate(postFromEvent(event, slug));
else setError("Post not found");
})
.catch((err) => {
if (cancelled) return;
setResolvingFromRelays(false);
setError(err?.message || "Post not found");
});
});
return () => {
cancelled = true;
};
}, [slug, retryKey]);
useEffect(() => {
if (!slug) return;
api.getPostReactions(slug)
.then((data) => setLikeCount(data.count))
.catch(() => {});
api.getPostReplies(slug)
.then((data) => setReplies(data.replies || []))
.catch(() => {});
}, [slug]);
const handleLike = useCallback(async () => {
if (liked || !post?.nostrEventId || !hasNostr) return;
try {
const pubkey = await getPublicKey();
const reactionEvent = {
kind: 7,
created_at: Math.floor(Date.now() / 1000),
tags: [["e", post.nostrEventId], ["p", post.authorPubkey || ""]],
content: "+",
pubkey,
};
const signedReaction = await signEvent(reactionEvent);
await publishEvent(signedReaction);
setLiked(true);
setLikeCount((c) => c + 1);
} catch {
// User rejected or extension unavailable
}
}, [liked, post, hasNostr]);
const handleComment = useCallback(async () => {
if (!comment.trim() || !post?.nostrEventId || !hasNostr) return;
setSubmitting(true);
try {
const pubkey = await getPublicKey();
const replyEvent = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [["e", post.nostrEventId, "", "reply"], ["p", post.authorPubkey || ""]],
content: comment.trim(),
pubkey,
};
const signed = await signEvent(replyEvent);
await publishEvent(signed);
setReplies((prev) => [
...prev,
{
id: signed.id || Date.now().toString(),
pubkey,
content: comment.trim(),
created_at: Math.floor(Date.now() / 1000),
},
]);
setComment("");
} catch {
// User rejected or extension unavailable
} finally {
setSubmitting(false);
}
}, [comment, post, hasNostr]);
const categories = post?.categories?.map((c) => c.category) || [];
const headerImage = post?.image || liveImage;
return (
<>
<Navbar />
<div className="min-h-screen">
<div className="max-w-3xl mx-auto px-8 pt-12 pb-24">
<Link
href="/blog"
className="inline-flex items-center gap-2 text-on-surface-variant hover:text-primary transition-colors mb-12 text-sm font-medium"
>
<ArrowLeft size={16} />
Back to Blog
</Link>
{loading && <ArticleSkeleton />}
{resolvingFromRelays && !post && <RelayLoading />}
{error && (
<div className="bg-error-container/20 text-error rounded-xl p-6">
<p className="mb-4">Failed to load post: {error}</p>
<button
onClick={retry}
className="px-4 py-2 rounded-lg bg-surface-container-high text-on-surface hover:bg-surface-bright transition-colors text-sm font-semibold"
>
Try again
</button>
</div>
)}
{!loading && !error && post && (
<>
<header className="mb-16">
{categories.length > 0 && (
<div className="flex flex-wrap gap-2 mb-6">
{categories.map((cat) => (
<span
key={cat.id}
className="px-3 py-1 text-xs font-bold uppercase tracking-widest text-primary bg-primary/10 rounded-full"
>
{cat.name}
</span>
))}
</div>
)}
<h1 className="text-4xl md:text-5xl font-black tracking-tight leading-tight mb-6">
{post.title}
</h1>
<div className="flex items-center gap-3 text-sm text-on-surface-variant/60">
{(authorProfile || post.authorName || post.authorPubkey) && (
<div className="flex items-center gap-2.5">
{authorProfile?.picture && (
<img
src={authorProfile.picture}
alt={authorProfile.name || post.authorName || "Author"}
className="w-8 h-8 rounded-full object-cover bg-zinc-800 shrink-0"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
)}
<span className="font-medium text-on-surface-variant">
{authorProfile?.name || post.authorName || shortenNpub(post.authorPubkey!)}
</span>
</div>
)}
{(post.publishedAt || post.createdAt) && (
<>
{(authorProfile || post.authorName || post.authorPubkey) && (
<span className="text-on-surface-variant/30">·</span>
)}
<span>
{formatDate(post.publishedAt || post.createdAt!)}
</span>
</>
)}
</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 />
) : contentError && !(post.content || liveContent) ? (
<div className="text-center py-12">
<p className="text-on-surface-variant mb-4">
Couldn&apos;t fetch this article from the Nostr relays. It may be
temporarily unavailable.
</p>
<button
onClick={retry}
className="px-4 py-2 rounded-lg bg-surface-container-high text-on-surface hover:bg-surface-bright transition-colors text-sm font-semibold"
>
Try again
</button>
</div>
) : (
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkNostr]}
components={markdownComponents}
// Preserve nostr: links (react-markdown strips unknown
// schemes by default), so mentions/notes resolve correctly.
urlTransform={(url) =>
url.startsWith("nostr:") ? url : defaultUrlTransform(url)
}
>
{post.content || liveContent || ""}
</ReactMarkdown>
)}
</article>
<section className="bg-surface-container-low rounded-xl p-8 mb-16">
<div className="flex items-center gap-6 mb-8">
<button
onClick={handleLike}
disabled={!hasNostr}
title={hasNostr ? "Like this post" : "Install a Nostr extension to interact"}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
liked
? "bg-primary/20 text-primary"
: hasNostr
? "bg-surface-container-high text-on-surface hover:bg-surface-bright"
: "bg-surface-container-high text-on-surface/40 cursor-not-allowed"
}`}
>
<Heart size={18} fill={liked ? "currentColor" : "none"} />
<span className="font-semibold">{likeCount}</span>
</button>
{!hasNostr && (
<span className="text-on-surface-variant/50 text-xs">
Install a Nostr extension to like and comment
</span>
)}
</div>
<h3 className="text-lg font-bold mb-6">
Comments {replies.length > 0 && `(${replies.length})`}
</h3>
{hasNostr && (
<div className="flex gap-3 mb-8">
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Share your thoughts..."
rows={3}
className="flex-1 bg-surface-container-highest text-on-surface rounded-lg p-4 resize-none placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
<button
onClick={handleComment}
disabled={!comment.trim() || submitting}
className="self-end px-4 py-3 bg-primary text-on-primary rounded-lg font-semibold hover:scale-105 transition-transform disabled:opacity-30 disabled:cursor-not-allowed"
>
<Send size={18} />
</button>
</div>
)}
{replies.length > 0 ? (
<div className="space-y-6">
{replies.map((r) => (
<div
key={r.id}
className="bg-surface-container-high rounded-lg p-4"
>
<div className="flex items-center gap-2.5 mb-2">
<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))}
</span>
</div>
<p className="text-on-surface-variant text-sm leading-relaxed">
{r.content}
</p>
</div>
))}
</div>
) : (
<p className="text-on-surface-variant/50 text-sm">
No comments yet. Be the first to share your thoughts.
</p>
)}
</section>
</>
)}
</div>
</div>
<Footer />
</>
);
}