401 lines
14 KiB
TypeScript
401 lines
14 KiB
TypeScript
"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 />
|
|
</>
|
|
);
|
|
}
|