first commit

Made-with: Cursor
This commit is contained in:
Michilis
2026-04-01 02:46:53 +00:00
commit 76210db03d
126 changed files with 20208 additions and 0 deletions

View 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 />
</>
);
}

View 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&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>
)}
</div>
);
}