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>
477 lines
17 KiB
TypeScript
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'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 />
|
|
</>
|
|
);
|
|
}
|