first commit
Made-with: Cursor
This commit is contained in:
400
frontend/app/blog/[slug]/BlogPostClient.tsx
Normal file
400
frontend/app/blog/[slug]/BlogPostClient.tsx
Normal file
@@ -0,0 +1,400 @@
|
||||
"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 }) => (
|
||||
<h1 className="text-3xl font-bold text-on-surface mb-4 mt-10">{children}</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-2xl font-bold text-on-surface mb-4 mt-8">{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-xl font-bold text-on-surface mb-3 mt-6">{children}</h3>
|
||||
),
|
||||
h4: ({ children }) => (
|
||||
<h4 className="text-lg font-semibold text-on-surface mb-2 mt-4">{children}</h4>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="text-on-surface-variant leading-relaxed mb-6">{children}</p>
|
||||
),
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="list-disc ml-6 mb-6 space-y-2 text-on-surface-variant">{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal ml-6 mb-6 space-y-2 text-on-surface-variant">{children}</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li className="leading-relaxed">{children}</li>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-primary/30 pl-4 italic text-on-surface-variant mb-6">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
code: ({ className, children }) => {
|
||||
const isBlock = className?.includes("language-");
|
||||
if (isBlock) {
|
||||
return (
|
||||
<code className={`${className} block`}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className="bg-surface-container-high px-2 py-1 rounded text-sm text-primary">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children }) => (
|
||||
<pre className="bg-surface-container-highest p-4 rounded-lg overflow-x-auto mb-6 text-sm">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
img: ({ src, alt }) => (
|
||||
<img src={src} alt={alt || ""} className="rounded-lg max-w-full mb-6" />
|
||||
),
|
||||
hr: () => <hr className="border-surface-container-high my-8" />,
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-left text-on-surface-variant">{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="px-4 py-2 font-semibold text-on-surface bg-surface-container-high">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="px-4 py-2">{children}</td>
|
||||
),
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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 (
|
||||
<>
|
||||
<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 />}
|
||||
|
||||
{error && (
|
||||
<div className="bg-error-container/20 text-error rounded-xl p-6">
|
||||
Failed to load post: {error}
|
||||
</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 || shortenPubkey(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>
|
||||
|
||||
<article className="mb-16">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{post.content}
|
||||
</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">
|
||||
<span className="font-semibold text-xs font-mono text-on-surface-variant/70">
|
||||
{shortenPubkey(r.pubkey)}
|
||||
</span>
|
||||
<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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
84
frontend/app/blog/[slug]/page.tsx
Normal file
84
frontend/app/blog/[slug]/page.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { Metadata } from "next";
|
||||
import BlogPostClient from "./BlogPostClient";
|
||||
import { BlogPostingJsonLd, BreadcrumbJsonLd } from "@/components/public/JsonLd";
|
||||
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000/api";
|
||||
|
||||
async function fetchPost(slug: string) {
|
||||
try {
|
||||
const res = await fetch(`${apiUrl}/posts/${slug}`, {
|
||||
next: { revalidate: 300 },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const post = await fetchPost(slug);
|
||||
if (!post) {
|
||||
return { title: "Post Not Found" };
|
||||
}
|
||||
|
||||
const description =
|
||||
post.excerpt ||
|
||||
`Read "${post.title}" on the Belgian Bitcoin Embassy blog.`;
|
||||
const author = post.authorName || "Belgian Bitcoin Embassy";
|
||||
const ogImageUrl = `/og?title=${encodeURIComponent(post.title)}&type=blog`;
|
||||
|
||||
return {
|
||||
title: post.title,
|
||||
description,
|
||||
openGraph: {
|
||||
type: "article",
|
||||
title: post.title,
|
||||
description,
|
||||
publishedTime: post.publishedAt || post.createdAt,
|
||||
authors: [author],
|
||||
images: [{ url: ogImageUrl, width: 1200, height: 630, alt: post.title }],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: post.title,
|
||||
description,
|
||||
images: [ogImageUrl],
|
||||
},
|
||||
alternates: { canonical: `/blog/${slug}` },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogDetailPage({ params }: Props) {
|
||||
const { slug } = await params;
|
||||
const post = await fetchPost(slug);
|
||||
|
||||
return (
|
||||
<>
|
||||
{post && (
|
||||
<>
|
||||
<BlogPostingJsonLd
|
||||
title={post.title}
|
||||
description={post.excerpt || `Read "${post.title}" on the Belgian Bitcoin Embassy blog.`}
|
||||
slug={slug}
|
||||
publishedAt={post.publishedAt || post.createdAt}
|
||||
authorName={post.authorName}
|
||||
/>
|
||||
<BreadcrumbJsonLd
|
||||
items={[
|
||||
{ name: "Home", href: "/" },
|
||||
{ name: "Blog", href: "/blog" },
|
||||
{ name: post.title, href: `/blog/${slug}` },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<BlogPostClient slug={slug} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user