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
@@ -0,0 +1,29 @@
-- CreateTable
CREATE TABLE IF NOT EXISTS "BoardInvoicePending" (
"paymentHash" TEXT NOT NULL PRIMARY KEY,
"content" TEXT NOT NULL,
"guestName" TEXT,
"pubkey" TEXT,
"profilePic" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE IF NOT EXISTS "BoardMessage" (
"id" TEXT NOT NULL PRIMARY KEY,
"paymentHash" TEXT NOT NULL,
"content" TEXT NOT NULL,
"authorName" TEXT NOT NULL,
"pubkey" TEXT,
"profilePic" TEXT,
"satsPaid" INTEGER NOT NULL,
"status" TEXT NOT NULL DEFAULT 'active',
"likeCount" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "BoardMessage_paymentHash_key" ON "BoardMessage"("paymentHash");
-- DropIndex (cleanup from organizer migration)
DROP INDEX IF EXISTS "Meetup_organizerId_idx";
@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "UserRelay" (
"id" TEXT NOT NULL PRIMARY KEY,
"pubkey" TEXT NOT NULL,
"url" TEXT NOT NULL,
"read" BOOLEAN NOT NULL DEFAULT true,
"write" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateIndex
CREATE UNIQUE INDEX "UserRelay_pubkey_url_key" ON "UserRelay"("pubkey", "url");
@@ -0,0 +1,5 @@
-- AlterTable: add naddr column to Post
ALTER TABLE "Post" ADD COLUMN "naddr" TEXT;
-- AlterTable: set default for content on Post (new rows only; existing rows keep their content)
-- SQLite doesn't support ALTER COLUMN DEFAULT, but Prisma handles this at the client level.
+13 -1
View File
@@ -61,9 +61,10 @@ model Media {
model Post {
id String @id @default(uuid())
nostrEventId String @unique
naddr String?
title String
slug String @unique
content String
content String @default("")
excerpt String?
authorPubkey String
authorName String?
@@ -117,6 +118,17 @@ model Relay {
createdAt DateTime @default(now())
}
model UserRelay {
id String @id @default(uuid())
pubkey String
url String
read Boolean @default(true)
write Boolean @default(true)
createdAt DateTime @default(now())
@@unique([pubkey, url])
}
model Setting {
id String @id @default(uuid())
key String @unique
+48 -30
View File
@@ -69,54 +69,72 @@ router.post(
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const { eventId, naddr } = req.body;
if (!eventId && !naddr) {
res.status(400).json({ error: 'eventId or naddr is required' });
const { nostrEventId, naddr, title, excerpt, authorPubkey, publishedAt, tags } = req.body;
if (!nostrEventId || !title || !authorPubkey) {
res.status(400).json({ error: 'nostrEventId, title, and authorPubkey are required' });
return;
}
let event: any = null;
if (naddr) {
event = await nostrService.fetchLongformEvent(naddr);
} else if (eventId) {
event = await nostrService.fetchEvent(eventId);
}
if (!event) {
res.status(404).json({ error: 'Event not found on relays' });
return;
}
const titleTag = event.tags?.find((t: string[]) => t[0] === 'title');
const title = titleTag?.[1] || 'Untitled';
const slugBase = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
const slug = `${slugBase}-${event.id.slice(0, 8)}`;
const excerpt = event.content.slice(0, 200).replace(/[#*_\n]/g, '').trim();
const slug = `${slugBase}-${nostrEventId.slice(0, 8)}`;
const post = await prisma.post.upsert({
where: { nostrEventId: event.id },
where: { nostrEventId },
update: {
title,
content: event.content,
excerpt,
excerpt: excerpt || undefined,
naddr: naddr || undefined,
},
create: {
nostrEventId: event.id,
nostrEventId,
naddr: naddr || null,
title,
slug,
content: event.content,
excerpt,
authorPubkey: event.pubkey,
publishedAt: new Date(event.created_at * 1000),
excerpt: excerpt || null,
authorPubkey,
publishedAt: publishedAt
? new Date(typeof publishedAt === 'number' ? publishedAt * 1000 : publishedAt)
: new Date(),
},
});
res.json(post);
if (Array.isArray(tags) && tags.length > 0) {
const categoryIds: string[] = [];
for (const tag of tags as string[]) {
const catSlug = tag.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
if (!catSlug) continue;
const category = await prisma.category.upsert({
where: { slug: catSlug },
update: {},
create: {
name: tag.charAt(0).toUpperCase() + tag.slice(1),
slug: catSlug,
},
});
categoryIds.push(category.id);
}
await prisma.postCategory.deleteMany({ where: { postId: post.id } });
if (categoryIds.length > 0) {
await prisma.postCategory.createMany({
data: categoryIds.map((categoryId) => ({
postId: post.id,
categoryId,
})),
});
}
}
const result = await prisma.post.findUnique({
where: { id: post.id },
include: { categories: { include: { category: true } } },
});
res.json(result);
} catch (err) {
console.error('Import post error:', err);
res.status(500).json({ error: 'Internal server error' });
+16
View File
@@ -5,6 +5,22 @@ import { requireAuth, requireRole } from '../middleware/auth';
const router = Router();
router.get(
'/public',
async (_req: Request, res: Response) => {
try {
const relays = await prisma.relay.findMany({
where: { active: true },
orderBy: { priority: 'asc' },
});
res.json({ relays: relays.map((r) => r.url) });
} catch (err) {
console.error('Public relays error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.get(
'/',
requireAuth,
+137
View File
@@ -0,0 +1,137 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth } from '../middleware/auth';
import { nostrService } from '../services/nostr';
const router = Router();
router.get(
'/',
requireAuth,
async (req: Request, res: Response) => {
try {
const relays = await prisma.userRelay.findMany({
where: { pubkey: req.user!.pubkey },
orderBy: { createdAt: 'asc' },
});
res.json(relays);
} catch (err) {
console.error('List user relays error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.post(
'/',
requireAuth,
async (req: Request, res: Response) => {
try {
const { url, read, write } = req.body;
if (!url || typeof url !== 'string') {
res.status(400).json({ error: 'url is required' });
return;
}
const normalized = url.trim().replace(/\/+$/, '');
if (!normalized.startsWith('wss://') && !normalized.startsWith('ws://')) {
res.status(400).json({ error: 'Relay URL must start with wss:// or ws://' });
return;
}
const relay = await prisma.userRelay.upsert({
where: {
pubkey_url: { pubkey: req.user!.pubkey, url: normalized },
},
update: {
read: read !== undefined ? read : true,
write: write !== undefined ? write : true,
},
create: {
pubkey: req.user!.pubkey,
url: normalized,
read: read !== undefined ? read : true,
write: write !== undefined ? write : true,
},
});
res.status(201).json(relay);
} catch (err) {
console.error('Add user relay error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.delete(
'/:id',
requireAuth,
async (req: Request, res: Response) => {
try {
const relay = await prisma.userRelay.findUnique({
where: { id: req.params.id as string },
});
if (!relay || relay.pubkey !== req.user!.pubkey) {
res.status(404).json({ error: 'Relay not found' });
return;
}
await prisma.userRelay.delete({ where: { id: req.params.id as string } });
res.json({ success: true });
} catch (err) {
console.error('Delete user relay error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.post(
'/import-nip65',
requireAuth,
async (req: Request, res: Response) => {
try {
const pubkey = req.user!.pubkey;
const nip65 = await nostrService.fetchNip65Relays(pubkey);
const allUrls = new Set<string>();
const relayEntries: { url: string; read: boolean; write: boolean }[] = [];
for (const url of nip65.write) {
if (allUrls.has(url)) continue;
allUrls.add(url);
relayEntries.push({
url,
read: nip65.read.includes(url),
write: true,
});
}
for (const url of nip65.read) {
if (allUrls.has(url)) continue;
allUrls.add(url);
relayEntries.push({ url, read: true, write: false });
}
if (relayEntries.length === 0) {
res.json({ imported: 0, message: 'No NIP-65 relay list found for your pubkey' });
return;
}
let imported = 0;
for (const entry of relayEntries) {
await prisma.userRelay.upsert({
where: { pubkey_url: { pubkey, url: entry.url } },
update: { read: entry.read, write: entry.write },
create: { pubkey, url: entry.url, read: entry.read, write: entry.write },
});
imported++;
}
res.json({ imported, total: relayEntries.length });
} catch (err) {
console.error('Import NIP-65 error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;
+2
View File
@@ -24,6 +24,7 @@ import calendarRouter from './api/calendar';
import nip05Router from './api/nip05';
import messagesRouter from './api/messages';
import adminMessagesRouter from './api/adminMessages';
import userRelaysRouter from './api/userRelays';
const app = express();
const PORT = parseInt(process.env.BACKEND_PORT || '4000', 10);
@@ -54,6 +55,7 @@ app.use('/api/calendar', calendarRouter);
app.use('/api/nip05', nip05Router);
app.use('/api/messages', messagesRouter);
app.use('/api/admin/messages', adminMessagesRouter);
app.use('/api/user-relays', userRelaysRouter);
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok' });
+101 -56
View File
@@ -11,7 +11,62 @@ async function getRelayUrls(): Promise<string[]> {
return relays.map((r) => r.url);
}
function dedupeRelays(...lists: string[][]): string[] {
return [...new Set(lists.flat().filter(Boolean))];
}
async function cacheEvent(event: any) {
await prisma.nostrEventCache.upsert({
where: { eventId: event.id },
update: {
content: event.content,
tags: JSON.stringify(event.tags),
},
create: {
eventId: event.id,
kind: event.kind,
pubkey: event.pubkey,
content: event.content,
tags: JSON.stringify(event.tags),
createdAt: event.created_at,
},
});
}
export const nostrService = {
async fetchNip65Relays(pubkey: string): Promise<{ write: string[]; read: string[] }> {
const siteRelays = await getRelayUrls();
if (siteRelays.length === 0) return { write: [], read: [] };
try {
const event = await pool.get(siteRelays, {
kinds: [10002],
authors: [pubkey],
});
if (!event) return { write: [], read: [] };
const write: string[] = [];
const read: string[] = [];
for (const tag of event.tags) {
if (tag[0] !== 'r' || !tag[1]) continue;
const url = tag[1];
const marker = tag[2];
if (marker === 'write') {
write.push(url);
} else if (marker === 'read') {
read.push(url);
} else {
write.push(url);
read.push(url);
}
}
return { write, read };
} catch (err) {
console.error('Failed to fetch NIP-65 relays for', pubkey, err);
return { write: [], read: [] };
}
},
async fetchEvent(eventId: string, skipCache = false) {
if (!skipCache) {
const cached = await prisma.nostrEventCache.findUnique({
@@ -29,34 +84,20 @@ export const nostrService = {
}
}
const relays = await getRelayUrls();
if (relays.length === 0) return null;
const siteRelays = await getRelayUrls();
if (siteRelays.length === 0) return null;
try {
const event = await pool.get(relays, { ids: [eventId] });
if (!event) return null;
await prisma.nostrEventCache.upsert({
where: { eventId: event.id },
update: {
content: event.content,
tags: JSON.stringify(event.tags),
},
create: {
eventId: event.id,
kind: event.kind,
pubkey: event.pubkey,
content: event.content,
tags: JSON.stringify(event.tags),
createdAt: event.created_at,
},
});
return event;
const event = await pool.get(siteRelays, { ids: [eventId] });
if (event) {
await cacheEvent(event);
return event;
}
} catch (err) {
console.error('Failed to fetch event:', err);
return null;
console.error('Failed to fetch event from site relays:', err);
}
return null;
},
async fetchLongformEvent(naddrStr: string) {
@@ -69,40 +110,44 @@ export const nostrService = {
return null;
}
const relays = decoded.relays?.length
? decoded.relays
: await getRelayUrls();
if (relays.length === 0) return null;
const siteRelays = await getRelayUrls();
const naddrRelays = decoded.relays || [];
const filter = {
kinds: [decoded.kind],
authors: [decoded.pubkey],
'#d': [decoded.identifier],
};
try {
const event = await pool.get(relays, {
kinds: [decoded.kind],
authors: [decoded.pubkey],
'#d': [decoded.identifier],
});
if (!event) return null;
await prisma.nostrEventCache.upsert({
where: { eventId: event.id },
update: {
content: event.content,
tags: JSON.stringify(event.tags),
},
create: {
eventId: event.id,
kind: event.kind,
pubkey: event.pubkey,
content: event.content,
tags: JSON.stringify(event.tags),
createdAt: event.created_at,
},
});
return event;
} catch (err) {
console.error('Failed to fetch longform event:', err);
return null;
// Try naddr-embedded relays first, then site relays
const firstTry = dedupeRelays(naddrRelays, siteRelays);
if (firstTry.length > 0) {
try {
const event = await pool.get(firstTry, filter);
if (event) {
await cacheEvent(event);
return event;
}
} catch (err) {
console.error('Failed to fetch longform event (first pass):', err);
}
}
// Fallback: try author's NIP-65 write relays
const nip65 = await nostrService.fetchNip65Relays(decoded.pubkey);
if (nip65.write.length > 0) {
const nip65Relays = dedupeRelays(nip65.write);
try {
const event = await pool.get(nip65Relays, filter);
if (event) {
await cacheEvent(event);
return event;
}
} catch (err) {
console.error('Failed to fetch longform event (NIP-65 fallback):', err);
}
}
return null;
},
async fetchReactions(eventId: string) {
+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: {
+2 -2
View File
@@ -24,7 +24,7 @@ export function OrganizationJsonLd() {
url: siteUrl,
logo: `${siteUrl}/og-default.png`,
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.",
sameAs: ["https://t.me/belgianbitcoinembassy"],
address: {
"@type": "PostalAddress",
@@ -45,7 +45,7 @@ export function WebSiteJsonLd() {
name: "Belgian Bitcoin Embassy",
url: siteUrl,
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.",
publisher: {
"@type": "Organization",
name: "Belgian Bitcoin Embassy",
+20 -1
View File
@@ -43,7 +43,15 @@ export const api = {
request<{ count: number; reactions: any[] }>(`/posts/${slug}/reactions`),
getPostReplies: (slug: string) =>
request<{ count: number; replies: any[] }>(`/posts/${slug}/replies`),
importPost: (data: { eventId?: string; naddr?: string }) =>
importPost: (data: {
nostrEventId: string;
naddr?: string;
title: string;
excerpt?: string;
authorPubkey: string;
publishedAt?: number;
tags?: string[];
}) =>
request<any>("/posts/import", { method: "POST", body: JSON.stringify(data) }),
updatePost: (id: string, data: any) =>
request<any>(`/posts/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
@@ -116,6 +124,7 @@ export const api = {
request<void>(`/organizers/${id}`, { method: "DELETE" }),
// Relays
getPublicRelays: () => request<{ relays: string[] }>("/relays/public"),
getRelays: () => request<any[]>("/relays"),
addRelay: (data: { url: string; priority?: number }) =>
request<any>("/relays", { method: "POST", body: JSON.stringify(data) }),
@@ -126,6 +135,16 @@ export const api = {
testRelay: (id: string) =>
request<{ success: boolean }>(`/relays/${id}/test`, { method: "POST" }),
// User Relays
getUserRelays: () =>
request<any[]>("/user-relays"),
addUserRelay: (data: { url: string; read?: boolean; write?: boolean }) =>
request<any>("/user-relays", { method: "POST", body: JSON.stringify(data) }),
removeUserRelay: (id: string) =>
request<void>(`/user-relays/${id}`, { method: "DELETE" }),
importNip65Relays: () =>
request<{ imported: number; total?: number; message?: string }>("/user-relays/import-nip65", { method: "POST" }),
// Settings
getSettings: () => request<Record<string, string>>("/settings"),
getPublicSettings: () => request<Record<string, string>>("/settings/public"),
+191 -8
View File
@@ -56,24 +56,143 @@ export interface NostrProfile {
displayName?: string;
}
const DEFAULT_RELAYS = [
const FALLBACK_RELAYS = [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.nostr.band",
];
let _siteRelaysCache: { relays: string[]; fetchedAt: number } | null = null;
const SITE_RELAY_TTL = 5 * 60 * 1000; // 5 minutes
export async function getSiteRelays(): Promise<string[]> {
if (_siteRelaysCache && Date.now() - _siteRelaysCache.fetchedAt < SITE_RELAY_TTL) {
return _siteRelaysCache.relays;
}
try {
const res = await fetch("/api/relays/public");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (Array.isArray(data.relays) && data.relays.length > 0) {
_siteRelaysCache = { relays: data.relays, fetchedAt: Date.now() };
return data.relays;
}
} catch {}
return FALLBACK_RELAYS;
}
export interface Nip65RelayList {
write: string[];
read: string[];
all: string[];
}
const _nip65Cache = new Map<string, { data: Nip65RelayList; fetchedAt: number }>();
const NIP65_TTL = 10 * 60 * 1000; // 10 minutes
export async function fetchNip65RelayList(pubkey: string): Promise<Nip65RelayList> {
const cached = _nip65Cache.get(pubkey);
if (cached && Date.now() - cached.fetchedAt < NIP65_TTL) return cached.data;
const { SimplePool } = await import("nostr-tools/pool");
const siteRelays = await getSiteRelays();
const pool = new SimplePool();
try {
const event = await pool.get(siteRelays, {
kinds: [10002],
authors: [pubkey],
});
const result: Nip65RelayList = { write: [], read: [], all: [] };
if (!event) {
_nip65Cache.set(pubkey, { data: result, fetchedAt: Date.now() });
return result;
}
for (const tag of event.tags) {
if (tag[0] !== "r" || !tag[1]) continue;
const url = tag[1];
const marker = tag[2];
result.all.push(url);
if (marker === "write") {
result.write.push(url);
} else if (marker === "read") {
result.read.push(url);
} else {
result.write.push(url);
result.read.push(url);
}
}
_nip65Cache.set(pubkey, { data: result, fetchedAt: Date.now() });
return result;
} catch {
return { write: [], read: [], all: [] };
} finally {
pool.close(siteRelays);
}
}
let _userRelaysCache: { relays: { url: string; read: boolean; write: boolean }[]; fetchedAt: number } | null = null;
const USER_RELAY_TTL = 2 * 60 * 1000; // 2 minutes
export async function getUserStoredRelays(): Promise<{ url: string; read: boolean; write: boolean }[]> {
if (_userRelaysCache && Date.now() - _userRelaysCache.fetchedAt < USER_RELAY_TTL) {
return _userRelaysCache.relays;
}
try {
const token = typeof window !== "undefined" ? localStorage.getItem("bbe_token") : null;
if (!token) return [];
const res = await fetch("/api/user-relays", {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return [];
const data = await res.json();
if (Array.isArray(data)) {
_userRelaysCache = { relays: data, fetchedAt: Date.now() };
return data;
}
} catch {}
return [];
}
export function clearUserRelaysCache(): void {
_userRelaysCache = null;
}
export async function publishEvent(signedEvent: any): Promise<void> {
const { SimplePool } = await import("nostr-tools/pool");
let relayUrls: string[] = DEFAULT_RELAYS;
const siteRelays = await getSiteRelays();
const allRelays = new Set<string>(siteRelays);
// Merge user's stored write relays
try {
const stored = await getUserStoredRelays();
stored
.filter((r) => r.write)
.forEach((r) => allRelays.add(r.url));
} catch {}
// Merge NIP-65 write relays for the event author
if (signedEvent.pubkey) {
try {
const nip65 = await fetchNip65RelayList(signedEvent.pubkey);
nip65.write.forEach((url) => allRelays.add(url));
} catch {}
}
// Merge extension relays
try {
if (window.nostr?.getRelays) {
const ext = await window.nostr.getRelays();
const write = Object.entries(ext)
Object.entries(ext)
.filter(([, p]) => (p as any).write)
.map(([url]) => url);
if (write.length > 0) relayUrls = write;
.forEach(([url]) => allRelays.add(url));
}
} catch {}
const relayUrls = [...allRelays];
const pool = new SimplePool();
try {
await Promise.allSettled(pool.publish(relayUrls, signedEvent));
@@ -84,13 +203,21 @@ export async function publishEvent(signedEvent: any): Promise<void> {
export async function fetchNostrProfile(
pubkey: string,
relayUrls: string[] = DEFAULT_RELAYS
relayUrls?: string[]
): Promise<NostrProfile> {
const { SimplePool } = await import("nostr-tools/pool");
const allRelays = new Set<string>(relayUrls || await getSiteRelays());
try {
const nip65 = await fetchNip65RelayList(pubkey);
nip65.write.forEach((url) => allRelays.add(url));
} catch {}
const urls = [...allRelays];
const pool = new SimplePool();
try {
const event = await pool.get(relayUrls, {
const event = await pool.get(urls, {
kinds: [0],
authors: [pubkey],
});
@@ -107,6 +234,61 @@ export async function fetchNostrProfile(
};
} catch {
return {};
} finally {
pool.close(urls);
}
}
export async function fetchEventFromRelays(eventId: string): Promise<any | null> {
const { SimplePool } = await import("nostr-tools/pool");
const siteRelays = await getSiteRelays();
const pool = new SimplePool();
try {
const event = await pool.get(siteRelays, { ids: [eventId] });
return event || null;
} catch {
return null;
} finally {
pool.close(siteRelays);
}
}
export async function fetchLongformFromRelays(naddrStr: string): Promise<any | null> {
const { SimplePool } = await import("nostr-tools/pool");
const { nip19 } = await import("nostr-tools");
let decoded: { kind: number; pubkey: string; identifier: string; relays?: string[] };
try {
const result = nip19.decode(naddrStr);
if (result.type !== "naddr") return null;
decoded = result.data;
} catch {
return null;
}
const siteRelays = await getSiteRelays();
const naddrRelays = decoded.relays || [];
const allRelays = new Set<string>([...naddrRelays, ...siteRelays]);
try {
const nip65 = await fetchNip65RelayList(decoded.pubkey);
nip65.write.forEach((url) => allRelays.add(url));
} catch {}
const relayUrls = [...allRelays];
const filter = {
kinds: [decoded.kind],
authors: [decoded.pubkey],
"#d": [decoded.identifier],
};
const pool = new SimplePool();
try {
const event = await pool.get(relayUrls, filter);
return event || null;
} catch {
return null;
} finally {
pool.close(relayUrls);
}
@@ -128,8 +310,9 @@ export async function createBunkerSigner(
// NIP-46: Generate a nostrconnect:// URI for QR display
export async function generateNostrConnectSetup(
relayUrls: string[] = DEFAULT_RELAYS.slice(0, 2)
relayUrls?: string[]
): Promise<{ uri: string; clientSecretKey: Uint8Array }> {
if (!relayUrls) relayUrls = (await getSiteRelays()).slice(0, 2);
const { createNostrConnectURI } = await import("nostr-tools/nip46");
const clientSecretKey = generateSecretKey();
const clientPubkey = getPubKeyFromSecret(clientSecretKey);
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 1.6 MiB