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