diff --git a/backend/prisma/migrations/20260405073517_add_board_tables/migration.sql b/backend/prisma/migrations/20260405073517_add_board_tables/migration.sql
new file mode 100644
index 0000000..f19d688
--- /dev/null
+++ b/backend/prisma/migrations/20260405073517_add_board_tables/migration.sql
@@ -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";
diff --git a/backend/prisma/migrations/20260405073752_add_user_relay/migration.sql b/backend/prisma/migrations/20260405073752_add_user_relay/migration.sql
new file mode 100644
index 0000000..ef04a1f
--- /dev/null
+++ b/backend/prisma/migrations/20260405073752_add_user_relay/migration.sql
@@ -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");
diff --git a/backend/prisma/migrations/20260405081139_add_post_naddr/migration.sql b/backend/prisma/migrations/20260405081139_add_post_naddr/migration.sql
new file mode 100644
index 0000000..48b9f5c
--- /dev/null
+++ b/backend/prisma/migrations/20260405081139_add_post_naddr/migration.sql
@@ -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.
diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma
index d18b631..59e653a 100644
--- a/backend/prisma/schema.prisma
+++ b/backend/prisma/schema.prisma
@@ -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
diff --git a/backend/src/api/posts.ts b/backend/src/api/posts.ts
index 8e738d8..b25d725 100644
--- a/backend/src/api/posts.ts
+++ b/backend/src/api/posts.ts
@@ -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' });
diff --git a/backend/src/api/relays.ts b/backend/src/api/relays.ts
index ad89488..cb2586d 100644
--- a/backend/src/api/relays.ts
+++ b/backend/src/api/relays.ts
@@ -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,
diff --git a/backend/src/api/userRelays.ts b/backend/src/api/userRelays.ts
new file mode 100644
index 0000000..7d47673
--- /dev/null
+++ b/backend/src/api/userRelays.ts
@@ -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();
+ 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;
diff --git a/backend/src/index.ts b/backend/src/index.ts
index 8f9a921..aaa9890 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -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' });
diff --git a/backend/src/services/nostr.ts b/backend/src/services/nostr.ts
index 3907cef..7aede6e 100644
--- a/backend/src/services/nostr.ts
+++ b/backend/src/services/nostr.ts
@@ -11,7 +11,62 @@ async function getRelayUrls(): Promise {
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) {
diff --git a/frontend/app/admin/blog/page.tsx b/frontend/app/admin/blog/page.tsx
index e29710a..c05bba0 100644
--- a/frontend/app/admin/blog/page.tsx
+++ b/frontend/app/admin/blog/page.tsx
@@ -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() {
{importPreview.content?.slice(0, 300)}...
+ {(() => {
+ const previewTags = (importPreview.tags || [])
+ .filter((t: string[]) => t[0] === "t" && t[1])
+ .map((t: string[]) => t[1]);
+ return previewTags.length > 0 ? (
+
+ {previewTags.map((tag: string) => (
+
+ {tag}
+
+ ))}
+
+ ) : null;
+ })()}
/{post.slug}
{post.categories?.length > 0 && (
- {post.categories.map((cat: any) => (
+ {post.categories.map((pc: any) => (
- {cat.name || cat}
+ {pc.category?.name || pc.categoryId}
))}
diff --git a/frontend/app/blog/[slug]/BlogPostClient.tsx b/frontend/app/blog/[slug]/BlogPostClient.tsx
index 1a6515a..1bcf32e 100644
--- a/frontend/app/blog/[slug]/BlogPostClient.tsx
+++ b/frontend/app/blog/[slug]/BlogPostClient.tsx
@@ -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(null);
+ const [liveContent, setLiveContent] = useState(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 }) {
-
- {post.content}
-
+ {loadingContent ? (
+
+ {[85, 92, 78, 95, 88, 72, 90, 83].map((w, i) => (
+
+ ))}
+
+ ) : (
+
+ {post.content || liveContent || ""}
+
+ )}
diff --git a/frontend/app/blog/page.tsx b/frontend/app/blog/page.tsx
index c159f58..2827568 100644
--- a/frontend/app/blog/page.tsx
+++ b/frontend/app/blog/page.tsx
@@ -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 && (
- {post.categories.map((cat) => (
+ {post.categories.map((pc) => (
- {cat.name}
+ {pc.category.name}
))}
diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx
index e7bd8c9..8b8453a 100644
--- a/frontend/app/dashboard/page.tsx
+++ b/frontend/app/dashboard/page.tsx
@@ -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 | null>(null);
+ // Relay state
+ const [userRelays, setUserRelays] = useState([]);
+ 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() {
Profile
+ 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"
+ }`}
+ >
+
+ Relays
+
{/* Submissions tab */}
@@ -516,6 +613,121 @@ export default function DashboardPage() {
)}
+
+ {/* Relays tab */}
+ {activeTab === "relays" && (
+
+ Your Relays
+
+ 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).
+
+
+ {relaySuccess && (
+
+ {relaySuccess}
+
+ )}
+ {relayError && (
+
+ {relayError}
+
+ )}
+
+
+
+
+
+ {importingNip65 ? (
+
+ ) : (
+
+ )}
+ {importingNip65 ? "Importing…" : "Import from NIP-65"}
+
+
+
+
+ {loadingRelays ? (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ ) : userRelays.length === 0 ? (
+
+
+
+ No relays configured. Add a relay manually or import from your NIP-65 profile.
+
+
+ ) : (
+
+ {userRelays.map((relay) => (
+
+
+
+ {relay.url}
+
+
+ {relay.read && (
+
+
+ Read
+
+ )}
+ {relay.write && (
+
+
+ Write
+
+ )}
+
+
+
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"
+ >
+
+
+
+ ))}
+
+ )}
+
+ )}
);
}
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
index f93a1f2..60fcf37 100644
--- a/frontend/app/layout.tsx
+++ b/frontend/app/layout.tsx
@@ -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: {
diff --git a/frontend/components/public/JsonLd.tsx b/frontend/components/public/JsonLd.tsx
index cceb275..2505079 100644
--- a/frontend/components/public/JsonLd.tsx
+++ b/frontend/components/public/JsonLd.tsx
@@ -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",
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts
index e58b680..1ddbff8 100644
--- a/frontend/lib/api.ts
+++ b/frontend/lib/api.ts
@@ -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("/posts/import", { method: "POST", body: JSON.stringify(data) }),
updatePost: (id: string, data: any) =>
request(`/posts/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
@@ -116,6 +124,7 @@ export const api = {
request(`/organizers/${id}`, { method: "DELETE" }),
// Relays
+ getPublicRelays: () => request<{ relays: string[] }>("/relays/public"),
getRelays: () => request("/relays"),
addRelay: (data: { url: string; priority?: number }) =>
request("/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("/user-relays"),
+ addUserRelay: (data: { url: string; read?: boolean; write?: boolean }) =>
+ request("/user-relays", { method: "POST", body: JSON.stringify(data) }),
+ removeUserRelay: (id: string) =>
+ request(`/user-relays/${id}`, { method: "DELETE" }),
+ importNip65Relays: () =>
+ request<{ imported: number; total?: number; message?: string }>("/user-relays/import-nip65", { method: "POST" }),
+
// Settings
getSettings: () => request>("/settings"),
getPublicSettings: () => request>("/settings/public"),
diff --git a/frontend/lib/nostr.ts b/frontend/lib/nostr.ts
index 2387d3c..3fe1ee0 100644
--- a/frontend/lib/nostr.ts
+++ b/frontend/lib/nostr.ts
@@ -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 {
+ 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();
+const NIP65_TTL = 10 * 60 * 1000; // 10 minutes
+
+export async function fetchNip65RelayList(pubkey: string): Promise {
+ 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 {
const { SimplePool } = await import("nostr-tools/pool");
- let relayUrls: string[] = DEFAULT_RELAYS;
+ const siteRelays = await getSiteRelays();
+ const allRelays = new Set(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 {
export async function fetchNostrProfile(
pubkey: string,
- relayUrls: string[] = DEFAULT_RELAYS
+ relayUrls?: string[]
): Promise {
const { SimplePool } = await import("nostr-tools/pool");
+ const allRelays = new Set(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 {
+ 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 {
+ 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([...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);
diff --git a/frontend/public/og-default-old.png b/frontend/public/og-default-old.png
new file mode 100644
index 0000000..0f2d48d
Binary files /dev/null and b/frontend/public/og-default-old.png differ
diff --git a/frontend/public/og-default.png b/frontend/public/og-default.png
index 0f2d48d..df3cb66 100644
Binary files a/frontend/public/og-default.png and b/frontend/public/og-default.png differ