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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
bbe
2026-06-23 05:54:34 +02:00
co-authored by Cursor
parent 78271ea110
commit 3bdd01dedf
19 changed files with 896 additions and 132 deletions
+56 -15
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { cn } from "@/lib/utils";
import { fetchEventFromRelays, fetchLongformFromRelays } from "@/lib/nostr";
import {
Pencil,
Trash2,
@@ -56,13 +57,24 @@ export default function BlogPage() {
setFetching(true);
setError("");
try {
const isNaddr = importInput.startsWith("naddr");
const data = await api.fetchNostrEvent(
isNaddr ? { naddr: importInput } : { eventId: importInput }
);
setImportPreview(data);
const isNaddr = importInput.trim().startsWith("naddr");
const event = isNaddr
? await fetchLongformFromRelays(importInput.trim())
: await fetchEventFromRelays(importInput.trim());
if (!event) {
setError("Event not found on relays");
setImportPreview(null);
return;
}
const titleTag = event.tags?.find((t: string[]) => t[0] === "title");
setImportPreview({
...event,
title: titleTag?.[1] || "Untitled",
});
} catch (err: any) {
setError(err.message);
setError(err.message || "Failed to fetch event");
setImportPreview(null);
} finally {
setFetching(false);
@@ -70,14 +82,29 @@ export default function BlogPage() {
};
const handleImport = async () => {
if (!importInput.trim()) return;
if (!importPreview) return;
setImporting(true);
setError("");
try {
const isNaddr = importInput.startsWith("naddr");
await api.importPost(
isNaddr ? { naddr: importInput } : { eventId: importInput }
);
const isNaddr = importInput.trim().startsWith("naddr");
const excerpt = (importPreview.content || "")
.slice(0, 200)
.replace(/[#*_\n]/g, "")
.trim();
const tags: string[] = (importPreview.tags || [])
.filter((t: string[]) => t[0] === "t" && t[1])
.map((t: string[]) => t[1].toLowerCase());
await api.importPost({
nostrEventId: importPreview.id,
naddr: isNaddr ? importInput.trim() : undefined,
title: importPreview.title || "Untitled",
excerpt: excerpt || undefined,
authorPubkey: importPreview.pubkey,
publishedAt: importPreview.created_at,
tags: tags.length > 0 ? tags : undefined,
});
setImportInput("");
setImportPreview(null);
setImportOpen(false);
@@ -95,7 +122,7 @@ export default function BlogPage() {
title: post.title || "",
slug: post.slug || "",
excerpt: post.excerpt || "",
categories: post.categories?.map((c: any) => c.id || c) || [],
categories: post.categories?.map((c: any) => c.categoryId || c.category?.id || c) || [],
featured: post.featured || false,
visible: post.visible !== false,
});
@@ -184,6 +211,20 @@ export default function BlogPage() {
<p className="text-on-surface/60 text-sm mt-1 line-clamp-3">
{importPreview.content?.slice(0, 300)}...
</p>
{(() => {
const previewTags = (importPreview.tags || [])
.filter((t: string[]) => t[0] === "t" && t[1])
.map((t: string[]) => t[1]);
return previewTags.length > 0 ? (
<div className="flex flex-wrap gap-1.5 mt-2">
{previewTags.map((tag: string) => (
<span key={tag} className="rounded-full px-2 py-0.5 text-xs bg-primary/10 text-primary font-medium">
{tag}
</span>
))}
</div>
) : null;
})()}
<button
onClick={handleImport}
disabled={importing}
@@ -303,12 +344,12 @@ export default function BlogPage() {
<p className="text-on-surface/50 text-sm truncate">/{post.slug}</p>
{post.categories?.length > 0 && (
<div className="flex gap-2 mt-2">
{post.categories.map((cat: any) => (
{post.categories.map((pc: any) => (
<span
key={cat.id || cat}
key={pc.categoryId || pc.category?.id}
className="rounded-full px-2 py-0.5 text-xs bg-surface-container-highest text-on-surface/60"
>
{cat.name || cat}
{pc.category?.name || pc.categoryId}
</span>
))}
</div>