522 lines
18 KiB
TypeScript
522 lines
18 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 } 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",
|
|
},
|
|
};
|
|
|
|
type Tab = "submissions" | "profile";
|
|
|
|
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);
|
|
|
|
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);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadSubmissions();
|
|
}, [loadSubmissions]);
|
|
|
|
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 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>
|
|
</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'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>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|