Introduce ApiKey model, CRUD endpoints, and admin UI so agents can authenticate with permission-scoped keys. Normalize pubkeys to hex on login, dedupe legacy npub/hex user rows, and ignore .cursor in git. Co-authored-by: Cursor <cursoragent@cursor.com>
731 lines
26 KiB
TypeScript
731 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, fetchEventFromRelays, fetchLongformFromRelays } 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 [noteInput, setNoteInput] = 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 extractTitle = (event: any): string => {
|
|
const titleTag = event?.tags?.find((t: string[]) => t[0] === "title");
|
|
return titleTag?.[1]?.trim() || "Untitled";
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setFormError("");
|
|
setFormSuccess("");
|
|
|
|
const input = noteInput.trim();
|
|
if (!input) {
|
|
setFormError("A Note ID or naddr is required");
|
|
return;
|
|
}
|
|
|
|
setSubmitting(true);
|
|
try {
|
|
let payload: { title: string; eventId?: string; naddr?: string };
|
|
|
|
if (input.startsWith("naddr1")) {
|
|
const event = await fetchLongformFromRelays(input);
|
|
if (!event) {
|
|
throw new Error("Could not find that article on the relays");
|
|
}
|
|
payload = { title: extractTitle(event), naddr: input };
|
|
} else {
|
|
let hexId = input;
|
|
if (input.startsWith("note1")) {
|
|
try {
|
|
const { nip19 } = await import("nostr-tools");
|
|
const decoded = nip19.decode(input);
|
|
if (decoded.type !== "note") throw new Error();
|
|
hexId = decoded.data as string;
|
|
} catch {
|
|
throw new Error("Invalid note id");
|
|
}
|
|
} else if (!/^[0-9a-f]{64}$/i.test(input)) {
|
|
throw new Error("Enter a valid note id, naddr, or hex event id");
|
|
}
|
|
const event = await fetchEventFromRelays(hexId);
|
|
if (!event) {
|
|
throw new Error("Could not find that note on the relays");
|
|
}
|
|
payload = { title: extractTitle(event), eventId: hexId };
|
|
}
|
|
|
|
await api.createSubmission(payload);
|
|
setFormSuccess(`Submission "${payload.title}" sent for review!`);
|
|
setNoteInput("");
|
|
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. Paste the
|
|
note ID or naddr of the article you'd like published on the
|
|
blog. The title is pulled automatically from the note.
|
|
</p>
|
|
|
|
<div>
|
|
<label className="block text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-2">
|
|
Note ID or naddr
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={noteInput}
|
|
onChange={(e) => setNoteInput(e.target.value)}
|
|
placeholder="naddr1... or 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>
|
|
|
|
{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 ? "Fetching & 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>
|
|
);
|
|
}
|