"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { ArrowLeft, Heart, Send } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { api } from "@/lib/api";
import { formatDate } from "@/lib/utils";
import { hasNostrExtension, getPublicKey, signEvent, publishEvent, shortenPubkey, fetchNostrProfile, type NostrProfile } from "@/lib/nostr";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import type { Components } from "react-markdown";
interface Post {
id: string;
slug: string;
title: string;
content: string;
excerpt?: string;
authorName?: string;
authorPubkey?: string;
publishedAt?: string;
createdAt?: string;
nostrEventId?: string;
categories?: { category: { id: string; name: string; slug: string } }[];
}
interface NostrReply {
id: string;
pubkey: string;
content: string;
created_at: number;
}
const markdownComponents: Components = {
h1: ({ children }) => (
{children}
),
h2: ({ children }) => (
{children}
),
h3: ({ children }) => (
{children}
),
h4: ({ children }) => (
{children}
),
p: ({ children }) => (
{children}
),
a: ({ href, children }) => (
{children}
),
ul: ({ children }) => (
),
ol: ({ children }) => (
{children}
),
li: ({ children }) => (
{children}
),
blockquote: ({ children }) => (
{children}
),
code: ({ className, children }) => {
const isBlock = className?.includes("language-");
if (isBlock) {
return (
{children}
);
}
return (
{children}
);
},
pre: ({ children }) => (
{children}
),
img: ({ src, alt }) => (
),
hr: () =>
,
table: ({ children }) => (
),
th: ({ children }) => (
{children}
|
),
td: ({ children }) => (
{children} |
),
};
function ArticleSkeleton() {
const widths = [85, 92, 78, 95, 88, 72, 90, 83];
return (
{widths.map((w, i) => (
))}
);
}
export default function BlogPostClient({ slug }: { slug: string }) {
const [post, setPost] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [liked, setLiked] = useState(false);
const [likeCount, setLikeCount] = useState(0);
const [comment, setComment] = useState("");
const [replies, setReplies] = useState([]);
const [hasNostr, setHasNostr] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [authorProfile, setAuthorProfile] = useState(null);
useEffect(() => {
setHasNostr(hasNostrExtension());
}, []);
useEffect(() => {
if (!slug) return;
setLoading(true);
setError(null);
api
.getPost(slug)
.then((data) => {
setPost(data);
if (data?.authorPubkey) {
fetchNostrProfile(data.authorPubkey)
.then((profile) => setAuthorProfile(profile))
.catch(() => {});
}
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [slug]);
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) || [];
return (
<>
Back to Blog
{loading &&
}
{error && (
Failed to load post: {error}
)}
{!loading && !error && post && (
<>
{categories.length > 0 && (
{categories.map((cat) => (
{cat.name}
))}
)}
{post.title}
{(authorProfile || post.authorName || post.authorPubkey) && (
{authorProfile?.picture && (

{ (e.target as HTMLImageElement).style.display = "none"; }}
/>
)}
{authorProfile?.name || post.authorName || shortenPubkey(post.authorPubkey!)}
)}
{(post.publishedAt || post.createdAt) && (
<>
{(authorProfile || post.authorName || post.authorPubkey) && (
·
)}
{formatDate(post.publishedAt || post.createdAt!)}
>
)}
{post.content}
{!hasNostr && (
Install a Nostr extension to like and comment
)}
Comments {replies.length > 0 && `(${replies.length})`}
{hasNostr && (
)}
{replies.length > 0 ? (
{replies.map((r) => (
{shortenPubkey(r.pubkey)}
·
{formatDate(new Date(r.created_at * 1000))}
{r.content}
))}
) : (
No comments yet. Be the first to share your thoughts.
)}
>
)}
>
);
}