first commit
Made-with: Cursor
This commit is contained in:
47
frontend/app/dashboard/layout.tsx
Normal file
47
frontend/app/dashboard/layout.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Navbar } from "@/components/public/Navbar";
|
||||
import { Footer } from "@/components/public/Footer";
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
if (user.role === "ADMIN" || user.role === "MODERATOR") {
|
||||
router.push("/admin/overview");
|
||||
}
|
||||
}, [user, loading, router]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="text-on-surface/50">Loading...</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user || user.role === "ADMIN" || user.role === "MODERATOR") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main className="min-h-screen max-w-5xl mx-auto px-8 py-12">{children}</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
521
frontend/app/dashboard/page.tsx
Normal file
521
frontend/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,521 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user