Files
BelgianBitcoinEmbassy/frontend/app/dashboard/page.tsx
T
bbeandCursor 3bdd01dedf 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>
2026-06-23 05:54:34 +02:00

734 lines
26 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import Image from "next/image";
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";
import { formatDate } from "@/lib/utils";
import { Button } from "@/components/ui/Button";
interface Submission {
id: string;
eventId?: string;
naddr?: string;
title: string;
status: string;
reviewNote?: string;
createdAt: string;
}
const STATUS_CONFIG: Record<string, { label: string; icon: typeof Clock; className: string }> = {
PENDING: {
label: "Pending Review",
icon: Clock,
className: "text-primary bg-primary/10",
},
APPROVED: {
label: "Approved",
icon: CheckCircle,
className: "text-green-400 bg-green-400/10",
},
REJECTED: {
label: "Rejected",
icon: XCircle,
className: "text-error bg-error/10",
},
};
interface UserRelay {
id: string;
url: string;
read: boolean;
write: boolean;
createdAt: string;
}
type Tab = "submissions" | "profile" | "relays";
type UsernameStatus =
| { state: "idle" }
| { state: "checking" }
| { state: "available" }
| { state: "unavailable"; reason: string };
export default function DashboardPage() {
const { user, login } = useAuth();
const [activeTab, setActiveTab] = useState<Tab>("submissions");
// Submissions state
const [submissions, setSubmissions] = useState<Submission[]>([]);
const [loadingSubs, setLoadingSubs] = useState(true);
const [showForm, setShowForm] = useState(false);
const [title, setTitle] = useState("");
const [eventId, setEventId] = useState("");
const [naddr, setNaddr] = useState("");
const [submitting, setSubmitting] = useState(false);
const [formError, setFormError] = useState("");
const [formSuccess, setFormSuccess] = useState("");
// Profile state
const [username, setUsername] = useState("");
const [usernameStatus, setUsernameStatus] = useState<UsernameStatus>({ state: "idle" });
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState("");
const [saveSuccess, setSaveSuccess] = useState("");
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(() => {
setHostname(window.location.hostname);
}, []);
useEffect(() => {
if (user?.username) {
setUsername(user.username);
}
}, [user?.username]);
const loadSubmissions = useCallback(async () => {
try {
const data = await api.getMySubmissions();
setSubmissions(data);
} catch {
// Silently handle
} finally {
setLoadingSubs(false);
}
}, []);
const loadRelays = useCallback(async () => {
try {
const data = await api.getUserRelays();
setUserRelays(data);
} catch {
// Silently handle
} finally {
setLoadingRelays(false);
}
}, []);
useEffect(() => {
loadSubmissions();
loadRelays();
}, [loadSubmissions, loadRelays]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError("");
setFormSuccess("");
if (!title.trim()) {
setFormError("Title is required");
return;
}
if (!eventId.trim() && !naddr.trim()) {
setFormError("Either an Event ID or naddr is required");
return;
}
setSubmitting(true);
try {
await api.createSubmission({
title: title.trim(),
eventId: eventId.trim() || undefined,
naddr: naddr.trim() || undefined,
});
setFormSuccess("Submission sent for review!");
setTitle("");
setEventId("");
setNaddr("");
setShowForm(false);
await loadSubmissions();
} catch (err: any) {
setFormError(err.message || "Failed to submit");
} finally {
setSubmitting(false);
}
};
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("");
setSaveSuccess("");
if (debounceRef.current) clearTimeout(debounceRef.current);
const trimmed = value.trim().toLowerCase();
if (!trimmed || trimmed === (user?.username ?? "")) {
setUsernameStatus({ state: "idle" });
return;
}
setUsernameStatus({ state: "checking" });
debounceRef.current = setTimeout(async () => {
try {
const result = await api.checkUsername(trimmed);
if (result.available) {
setUsernameStatus({ state: "available" });
} else {
setUsernameStatus({ state: "unavailable", reason: result.reason || "Username is not available" });
}
} catch {
setUsernameStatus({ state: "unavailable", reason: "Could not check availability" });
}
}, 500);
};
const handleSaveProfile = async (e: React.FormEvent) => {
e.preventDefault();
setSaveError("");
setSaveSuccess("");
const trimmed = username.trim().toLowerCase();
if (!trimmed) {
setSaveError("Username is required");
return;
}
setSaving(true);
try {
const updated = await api.updateProfile({ username: trimmed });
setSaveSuccess(`Username saved! Your NIP-05 address is ${updated.username}@${hostname}`);
setUsernameStatus({ state: "idle" });
// Persist updated username into stored user
const stored = localStorage.getItem("bbe_user");
if (stored) {
try {
const parsed = JSON.parse(stored);
localStorage.setItem("bbe_user", JSON.stringify({ ...parsed, username: updated.username }));
} catch {
// ignore
}
}
} catch (err: any) {
setSaveError(err.message || "Failed to save username");
} finally {
setSaving(false);
}
};
const isSaveDisabled =
saving ||
usernameStatus.state === "checking" ||
usernameStatus.state === "unavailable" ||
!username.trim();
return (
<div>
<div className="flex items-center gap-5 mb-12">
{user?.picture ? (
<Image
src={user.picture}
alt={displayName}
width={56}
height={56}
className="rounded-full object-cover"
style={{ width: 56, height: 56 }}
unoptimized
/>
) : (
<div className="w-14 h-14 rounded-full bg-surface-container-high flex items-center justify-center text-on-surface font-bold text-xl">
{(displayName)[0]?.toUpperCase() || "?"}
</div>
)}
<div>
<h1 className="text-2xl font-bold text-on-surface">{displayName}</h1>
<p className="text-on-surface-variant text-sm">Your Dashboard</p>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 mb-8 border-b border-outline-variant">
<button
onClick={() => setActiveTab("submissions")}
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-colors ${
activeTab === "submissions"
? "border-primary text-primary"
: "border-transparent text-on-surface-variant hover:text-on-surface"
}`}
>
<FileText size={16} />
Submissions
</button>
<button
onClick={() => setActiveTab("profile")}
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-colors ${
activeTab === "profile"
? "border-primary text-primary"
: "border-transparent text-on-surface-variant hover:text-on-surface"
}`}
>
<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 */}
{activeTab === "submissions" && (
<>
<section>
<div className="flex items-center justify-between mb-8">
<h2 className="text-xl font-bold text-on-surface">Submit a Post</h2>
{!showForm && (
<Button
variant="primary"
size="sm"
onClick={() => {
setShowForm(true);
setFormSuccess("");
}}
>
<span className="flex items-center gap-2">
<Plus size={16} />
New Submission
</span>
</Button>
)}
</div>
{formSuccess && (
<div className="bg-green-400/10 text-green-400 rounded-lg px-4 py-3 text-sm mb-6">
{formSuccess}
</div>
)}
{showForm && (
<form
onSubmit={handleSubmit}
className="bg-surface-container-low rounded-xl p-6 mb-8 space-y-4"
>
<p className="text-on-surface-variant text-sm mb-2">
Submit a Nostr longform post for moderator review. Provide the
event ID or naddr of the article you&apos;d like published on the
blog.
</p>
<div>
<label className="block text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-2">
Title
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="My Bitcoin Article"
className="w-full bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-2">
Nostr Event ID
</label>
<input
type="text"
value={eventId}
onChange={(e) => setEventId(e.target.value)}
placeholder="note1... or hex event id"
className="w-full 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"
/>
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-2">
Or naddr
</label>
<input
type="text"
value={naddr}
onChange={(e) => setNaddr(e.target.value)}
placeholder="naddr1..."
className="w-full 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"
/>
</div>
{formError && (
<p className="text-error text-sm">{formError}</p>
)}
<div className="flex items-center gap-3 pt-2">
<Button
variant="primary"
size="md"
type="submit"
disabled={submitting}
>
<span className="flex items-center gap-2">
<Send size={16} />
{submitting ? "Submitting..." : "Submit for Review"}
</span>
</Button>
<Button
variant="secondary"
size="md"
type="button"
onClick={() => {
setShowForm(false);
setFormError("");
}}
>
Cancel
</Button>
</div>
</form>
)}
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-6">My Submissions</h2>
{loadingSubs ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="animate-pulse bg-surface-container-low rounded-xl p-6">
<div className="h-5 w-2/3 bg-surface-container-high rounded mb-3" />
<div className="h-4 w-1/3 bg-surface-container-high rounded" />
</div>
))}
</div>
) : submissions.length === 0 ? (
<div className="bg-surface-container-low rounded-xl p-8 text-center">
<FileText size={32} className="text-on-surface-variant/30 mx-auto mb-3" />
<p className="text-on-surface-variant/60 text-sm">
No submissions yet. Submit a Nostr longform post for review.
</p>
</div>
) : (
<div className="space-y-4">
{submissions.map((sub) => {
const statusCfg = STATUS_CONFIG[sub.status] || STATUS_CONFIG.PENDING;
const StatusIcon = statusCfg.icon;
return (
<div
key={sub.id}
className="bg-surface-container-low rounded-xl p-6"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-on-surface truncate">
{sub.title}
</h3>
<p className="text-on-surface-variant/60 text-xs mt-1">
{formatDate(sub.createdAt)}
{sub.eventId && (
<span className="ml-3 font-mono">
{sub.eventId.slice(0, 16)}...
</span>
)}
{sub.naddr && (
<span className="ml-3 font-mono">
{sub.naddr.slice(0, 20)}...
</span>
)}
</p>
</div>
<span
className={`flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold whitespace-nowrap ${statusCfg.className}`}
>
<StatusIcon size={14} />
{statusCfg.label}
</span>
</div>
{sub.reviewNote && (
<p className="mt-3 text-sm text-on-surface-variant bg-surface-container-high rounded-lg px-4 py-2">
{sub.reviewNote}
</p>
)}
</div>
);
})}
</div>
)}
</section>
</>
)}
{/* Profile tab */}
{activeTab === "profile" && (
<section>
<h2 className="text-xl font-bold text-on-surface mb-2">NIP-05 Username</h2>
<p className="text-on-surface-variant text-sm mb-8">
Claim a NIP-05 verified Nostr address hosted on this site. Other Nostr
clients will display your identity as{" "}
<span className="font-mono text-on-surface">username@{hostname || "…"}</span>.
</p>
<form
onSubmit={handleSaveProfile}
className="bg-surface-container-low rounded-xl p-6 space-y-5 max-w-lg"
>
<div>
<label className="block text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-2">
Username
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none">
<AtSign size={16} className="text-on-surface-variant/50" />
</div>
<input
type="text"
value={username}
onChange={(e) => handleUsernameChange(e.target.value)}
placeholder="yourname"
maxLength={50}
className="w-full bg-surface-container-highest text-on-surface rounded-lg pl-10 pr-10 py-3 font-mono text-sm placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
{usernameStatus.state === "checking" && (
<Loader2 size={16} className="animate-spin text-on-surface-variant/50" />
)}
{usernameStatus.state === "available" && (
<CheckCircle size={16} className="text-green-400" />
)}
{usernameStatus.state === "unavailable" && (
<XCircle size={16} className="text-error" />
)}
</div>
</div>
{/* Status message */}
<div className="mt-2 min-h-[20px]">
{usernameStatus.state === "checking" && (
<p className="text-xs text-on-surface-variant/60">Checking availability</p>
)}
{usernameStatus.state === "available" && (
<p className="text-xs text-green-400">Available</p>
)}
{usernameStatus.state === "unavailable" && (
<p className="text-xs text-error">{usernameStatus.reason}</p>
)}
</div>
</div>
{/* NIP-05 preview */}
{username.trim() && (
<div className="bg-surface-container-highest rounded-lg px-4 py-3">
<p className="text-xs text-on-surface-variant mb-1 uppercase tracking-widest font-bold">NIP-05 Address</p>
<p className="font-mono text-sm text-on-surface break-all">
{username.trim().toLowerCase()}@{hostname || "…"}
</p>
</div>
)}
{saveError && (
<p className="text-error text-sm">{saveError}</p>
)}
{saveSuccess && (
<div className="bg-green-400/10 text-green-400 rounded-lg px-4 py-3 text-sm">
{saveSuccess}
</div>
)}
<Button
variant="primary"
size="md"
type="submit"
disabled={isSaveDisabled}
>
{saving ? "Saving…" : "Save Username"}
</Button>
</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>
);
}