feat: user relay management, blog naddr sync, and OG image update

Add UserRelay API and dashboard relays tab with NIP-65 import, store post
naddr for Nostr articles, and ship Prisma migrations for board tables,
user relays, and post metadata.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
bbe
2026-06-23 05:54:34 +02:00
co-authored by Cursor
parent 78271ea110
commit 3bdd01dedf
19 changed files with 896 additions and 132 deletions
+56 -15
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { cn } from "@/lib/utils";
import { fetchEventFromRelays, fetchLongformFromRelays } from "@/lib/nostr";
import {
Pencil,
Trash2,
@@ -56,13 +57,24 @@ export default function BlogPage() {
setFetching(true);
setError("");
try {
const isNaddr = importInput.startsWith("naddr");
const data = await api.fetchNostrEvent(
isNaddr ? { naddr: importInput } : { eventId: importInput }
);
setImportPreview(data);
const isNaddr = importInput.trim().startsWith("naddr");
const event = isNaddr
? await fetchLongformFromRelays(importInput.trim())
: await fetchEventFromRelays(importInput.trim());
if (!event) {
setError("Event not found on relays");
setImportPreview(null);
return;
}
const titleTag = event.tags?.find((t: string[]) => t[0] === "title");
setImportPreview({
...event,
title: titleTag?.[1] || "Untitled",
});
} catch (err: any) {
setError(err.message);
setError(err.message || "Failed to fetch event");
setImportPreview(null);
} finally {
setFetching(false);
@@ -70,14 +82,29 @@ export default function BlogPage() {
};
const handleImport = async () => {
if (!importInput.trim()) return;
if (!importPreview) return;
setImporting(true);
setError("");
try {
const isNaddr = importInput.startsWith("naddr");
await api.importPost(
isNaddr ? { naddr: importInput } : { eventId: importInput }
);
const isNaddr = importInput.trim().startsWith("naddr");
const excerpt = (importPreview.content || "")
.slice(0, 200)
.replace(/[#*_\n]/g, "")
.trim();
const tags: string[] = (importPreview.tags || [])
.filter((t: string[]) => t[0] === "t" && t[1])
.map((t: string[]) => t[1].toLowerCase());
await api.importPost({
nostrEventId: importPreview.id,
naddr: isNaddr ? importInput.trim() : undefined,
title: importPreview.title || "Untitled",
excerpt: excerpt || undefined,
authorPubkey: importPreview.pubkey,
publishedAt: importPreview.created_at,
tags: tags.length > 0 ? tags : undefined,
});
setImportInput("");
setImportPreview(null);
setImportOpen(false);
@@ -95,7 +122,7 @@ export default function BlogPage() {
title: post.title || "",
slug: post.slug || "",
excerpt: post.excerpt || "",
categories: post.categories?.map((c: any) => c.id || c) || [],
categories: post.categories?.map((c: any) => c.categoryId || c.category?.id || c) || [],
featured: post.featured || false,
visible: post.visible !== false,
});
@@ -184,6 +211,20 @@ export default function BlogPage() {
<p className="text-on-surface/60 text-sm mt-1 line-clamp-3">
{importPreview.content?.slice(0, 300)}...
</p>
{(() => {
const previewTags = (importPreview.tags || [])
.filter((t: string[]) => t[0] === "t" && t[1])
.map((t: string[]) => t[1]);
return previewTags.length > 0 ? (
<div className="flex flex-wrap gap-1.5 mt-2">
{previewTags.map((tag: string) => (
<span key={tag} className="rounded-full px-2 py-0.5 text-xs bg-primary/10 text-primary font-medium">
{tag}
</span>
))}
</div>
) : null;
})()}
<button
onClick={handleImport}
disabled={importing}
@@ -303,12 +344,12 @@ export default function BlogPage() {
<p className="text-on-surface/50 text-sm truncate">/{post.slug}</p>
{post.categories?.length > 0 && (
<div className="flex gap-2 mt-2">
{post.categories.map((cat: any) => (
{post.categories.map((pc: any) => (
<span
key={cat.id || cat}
key={pc.categoryId || pc.category?.id}
className="rounded-full px-2 py-0.5 text-xs bg-surface-container-highest text-on-surface/60"
>
{cat.name || cat}
{pc.category?.name || pc.categoryId}
</span>
))}
</div>
+42 -9
View File
@@ -7,7 +7,7 @@ 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 { hasNostrExtension, getPublicKey, signEvent, publishEvent, shortenPubkey, fetchNostrProfile, fetchLongformFromRelays, fetchEventFromRelays, type NostrProfile } from "@/lib/nostr";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import type { Components } from "react-markdown";
@@ -23,6 +23,7 @@ interface Post {
publishedAt?: string;
createdAt?: string;
nostrEventId?: string;
naddr?: string;
categories?: { category: { id: string; name: string; slug: string } }[];
}
@@ -147,6 +148,8 @@ export default function BlogPostClient({ slug }: { slug: string }) {
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 [loadingContent, setLoadingContent] = useState(false);
useEffect(() => {
setHasNostr(hasNostrExtension());
@@ -156,18 +159,36 @@ export default function BlogPostClient({ slug }: { slug: string }) {
if (!slug) return;
setLoading(true);
setError(null);
setLiveContent(null);
api
.getPost(slug)
.then((data) => {
setPost(data);
setLoading(false);
if (data?.authorPubkey) {
fetchNostrProfile(data.authorPubkey)
.then((profile) => setAuthorProfile(profile))
.catch(() => {});
}
if (!data?.content && (data?.naddr || data?.nostrEventId)) {
setLoadingContent(true);
const fetchPromise = data.naddr
? fetchLongformFromRelays(data.naddr)
: fetchEventFromRelays(data.nostrEventId!);
fetchPromise
.then((event) => {
if (event?.content) {
setLiveContent(event.content);
}
})
.catch(() => {})
.finally(() => setLoadingContent(false));
}
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
.catch((err) => {
setError(err.message);
setLoading(false);
});
}, [slug]);
useEffect(() => {
@@ -306,12 +327,24 @@ export default function BlogPostClient({ slug }: { slug: string }) {
</header>
<article className="mb-16">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownComponents}
>
{post.content}
</ReactMarkdown>
{loadingContent ? (
<div className="animate-pulse space-y-4">
{[85, 92, 78, 95, 88, 72, 90, 83].map((w, i) => (
<div
key={i}
className="h-4 bg-surface-container-high rounded"
style={{ width: `${w}%` }}
/>
))}
</div>
) : (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownComponents}
>
{post.content || liveContent || ""}
</ReactMarkdown>
)}
</article>
<section className="bg-surface-container-low rounded-xl p-8 mb-16">
+4 -4
View File
@@ -18,7 +18,7 @@ interface Post {
authorPubkey?: string;
publishedAt?: string;
createdAt?: string;
categories?: { id: string; name: string; slug: string }[];
categories?: { category: { id: string; name: string; slug: string } }[];
featured?: boolean;
}
@@ -212,12 +212,12 @@ export default function BlogPage() {
>
{post.categories && post.categories.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{post.categories.map((cat) => (
{post.categories.map((pc) => (
<span
key={cat.id}
key={pc.category.id}
className="text-primary text-[10px] uppercase tracking-widest font-bold"
>
{cat.name}
{pc.category.name}
</span>
))}
</div>
+215 -3
View File
@@ -2,7 +2,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import Image from "next/image";
import { Send, FileText, Clock, CheckCircle, XCircle, Plus, User, Loader2, AtSign } from "lucide-react";
import { Send, FileText, Clock, CheckCircle, XCircle, Plus, User, Loader2, AtSign, Radio, Trash2, Download, Eye, Pencil } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { api } from "@/lib/api";
import { shortenPubkey } from "@/lib/nostr";
@@ -37,7 +37,15 @@ const STATUS_CONFIG: Record<string, { label: string; icon: typeof Clock; classNa
},
};
type Tab = "submissions" | "profile";
interface UserRelay {
id: string;
url: string;
read: boolean;
write: boolean;
createdAt: string;
}
type Tab = "submissions" | "profile" | "relays";
type UsernameStatus =
| { state: "idle" }
@@ -69,6 +77,15 @@ export default function DashboardPage() {
const [hostname, setHostname] = useState("");
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Relay state
const [userRelays, setUserRelays] = useState<UserRelay[]>([]);
const [loadingRelays, setLoadingRelays] = useState(true);
const [newRelayUrl, setNewRelayUrl] = useState("");
const [addingRelay, setAddingRelay] = useState(false);
const [importingNip65, setImportingNip65] = useState(false);
const [relayError, setRelayError] = useState("");
const [relaySuccess, setRelaySuccess] = useState("");
const displayName = user?.name || user?.displayName || shortenPubkey(user?.pubkey || "");
useEffect(() => {
@@ -92,9 +109,21 @@ export default function DashboardPage() {
}
}, []);
const loadRelays = useCallback(async () => {
try {
const data = await api.getUserRelays();
setUserRelays(data);
} catch {
// Silently handle
} finally {
setLoadingRelays(false);
}
}, []);
useEffect(() => {
loadSubmissions();
}, [loadSubmissions]);
loadRelays();
}, [loadSubmissions, loadRelays]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -130,6 +159,63 @@ export default function DashboardPage() {
}
};
const handleAddRelay = async (e: React.FormEvent) => {
e.preventDefault();
setRelayError("");
setRelaySuccess("");
const url = newRelayUrl.trim();
if (!url) {
setRelayError("Relay URL is required");
return;
}
if (!url.startsWith("wss://") && !url.startsWith("ws://")) {
setRelayError("URL must start with wss:// or ws://");
return;
}
setAddingRelay(true);
try {
await api.addUserRelay({ url });
setNewRelayUrl("");
setRelaySuccess("Relay added");
await loadRelays();
} catch (err: any) {
setRelayError(err.message || "Failed to add relay");
} finally {
setAddingRelay(false);
}
};
const handleRemoveRelay = async (id: string) => {
setRelayError("");
try {
await api.removeUserRelay(id);
setUserRelays((prev) => prev.filter((r) => r.id !== id));
} catch (err: any) {
setRelayError(err.message || "Failed to remove relay");
}
};
const handleImportNip65 = async () => {
setRelayError("");
setRelaySuccess("");
setImportingNip65(true);
try {
const result = await api.importNip65Relays();
if (result.imported > 0) {
setRelaySuccess(`Imported ${result.imported} relay(s) from your NIP-65 list`);
await loadRelays();
} else {
setRelayError(result.message || "No NIP-65 relay list found for your pubkey");
}
} catch (err: any) {
setRelayError(err.message || "Failed to import NIP-65 relays");
} finally {
setImportingNip65(false);
}
};
const handleUsernameChange = (value: string) => {
setUsername(value);
setSaveError("");
@@ -247,6 +333,17 @@ export default function DashboardPage() {
<User size={16} />
Profile
</button>
<button
onClick={() => setActiveTab("relays")}
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-colors ${
activeTab === "relays"
? "border-primary text-primary"
: "border-transparent text-on-surface-variant hover:text-on-surface"
}`}
>
<Radio size={16} />
Relays
</button>
</div>
{/* Submissions tab */}
@@ -516,6 +613,121 @@ export default function DashboardPage() {
</form>
</section>
)}
{/* Relays tab */}
{activeTab === "relays" && (
<section>
<h2 className="text-xl font-bold text-on-surface mb-2">Your Relays</h2>
<p className="text-on-surface-variant text-sm mb-8">
Manage the Nostr relays used for your interactions on this site.
These relays are used when publishing events and fetching your content.
You can also import your relay list from your Nostr profile (NIP-65).
</p>
{relaySuccess && (
<div className="bg-green-400/10 text-green-400 rounded-lg px-4 py-3 text-sm mb-6">
{relaySuccess}
</div>
)}
{relayError && (
<div className="bg-error/10 text-error rounded-lg px-4 py-3 text-sm mb-6">
{relayError}
</div>
)}
<div className="flex flex-col sm:flex-row gap-3 mb-8">
<form onSubmit={handleAddRelay} className="flex-1 flex gap-3">
<input
type="text"
value={newRelayUrl}
onChange={(e) => setNewRelayUrl(e.target.value)}
placeholder="wss://relay.example.com"
className="flex-1 bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 font-mono text-sm placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
<Button
variant="primary"
size="md"
type="submit"
disabled={addingRelay}
>
<span className="flex items-center gap-2">
<Plus size={16} />
{addingRelay ? "Adding…" : "Add"}
</span>
</Button>
</form>
<Button
variant="secondary"
size="md"
type="button"
onClick={handleImportNip65}
disabled={importingNip65}
>
<span className="flex items-center gap-2">
{importingNip65 ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Download size={16} />
)}
{importingNip65 ? "Importing…" : "Import from NIP-65"}
</span>
</Button>
</div>
{loadingRelays ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="animate-pulse bg-surface-container-low rounded-xl p-5">
<div className="h-5 w-2/3 bg-surface-container-high rounded" />
</div>
))}
</div>
) : userRelays.length === 0 ? (
<div className="bg-surface-container-low rounded-xl p-8 text-center">
<Radio size={32} className="text-on-surface-variant/30 mx-auto mb-3" />
<p className="text-on-surface-variant/60 text-sm">
No relays configured. Add a relay manually or import from your NIP-65 profile.
</p>
</div>
) : (
<div className="space-y-3">
{userRelays.map((relay) => (
<div
key={relay.id}
className="bg-surface-container-low rounded-xl px-5 py-4 flex items-center justify-between gap-4"
>
<div className="flex-1 min-w-0">
<p className="font-mono text-sm text-on-surface truncate">
{relay.url}
</p>
<div className="flex gap-2 mt-1.5">
{relay.read && (
<span className="flex items-center gap-1 text-xs font-semibold text-primary bg-primary/10 px-2 py-0.5 rounded-full">
<Eye size={12} />
Read
</span>
)}
{relay.write && (
<span className="flex items-center gap-1 text-xs font-semibold text-green-400 bg-green-400/10 px-2 py-0.5 rounded-full">
<Pencil size={12} />
Write
</span>
)}
</div>
</div>
<button
onClick={() => handleRemoveRelay(relay.id)}
className="text-on-surface-variant/50 hover:text-error transition-colors p-2 rounded-lg hover:bg-error/10"
title="Remove relay"
>
<Trash2 size={16} />
</button>
</div>
))}
</div>
)}
</section>
)}
</div>
);
}
+3 -3
View File
@@ -23,7 +23,7 @@ export const metadata: Metadata = {
template: "%s | Belgian Bitcoin Embassy",
},
description:
"Belgium's sovereign Bitcoin community. Monthly meetups in Antwerp, Bitcoin education, and curated Nostr content. No hype, just signal.",
"Discover Bitcoin meetups across Belgium. Real conversations, education, and a strong local community.",
keywords: [
"Bitcoin",
"Belgium",
@@ -44,7 +44,7 @@ export const metadata: Metadata = {
siteName: "Belgian Bitcoin Embassy",
title: "Belgian Bitcoin Embassy | Bitcoin Meetups & Education in Belgium",
description:
"Belgium's sovereign Bitcoin community. Monthly meetups, education, and curated Nostr content.",
"Discover Bitcoin meetups across Belgium. Real conversations, education, and a strong local community.",
images: [
{
url: "/og-default.png",
@@ -58,7 +58,7 @@ export const metadata: Metadata = {
card: "summary_large_image",
title: "Belgian Bitcoin Embassy",
description:
"Belgium's sovereign Bitcoin community. Monthly meetups, education, and curated Nostr content.",
"Discover Bitcoin meetups across Belgium. Real conversations, education, and a strong local community.",
images: ["/og-default.png"],
},
robots: {