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:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user