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,19 @@
import { NextRequest, NextResponse } from 'next/server';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
export async function GET(req: NextRequest) {
const name = req.nextUrl.searchParams.get('name');
const upstream = new URL(`${API_URL}/nip05`);
if (name) upstream.searchParams.set('name', name);
const res = await fetch(upstream.toString(), { cache: 'no-store' });
const data = await res.json();
return NextResponse.json(data, {
headers: {
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-store',
},
});
}

View File

@@ -0,0 +1,337 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { cn } from "@/lib/utils";
import {
Pencil,
Trash2,
X,
Download,
Star,
EyeOff,
} from "lucide-react";
export default function BlogPage() {
const [posts, setPosts] = useState<any[]>([]);
const [categories, setCategories] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [importOpen, setImportOpen] = useState(false);
const [importInput, setImportInput] = useState("");
const [importPreview, setImportPreview] = useState<any>(null);
const [importing, setImporting] = useState(false);
const [fetching, setFetching] = useState(false);
const [editingPost, setEditingPost] = useState<any>(null);
const [editForm, setEditForm] = useState({
title: "",
slug: "",
excerpt: "",
categories: [] as string[],
featured: false,
visible: true,
});
const loadData = async () => {
try {
const [p, c] = await Promise.all([
api.getPosts({ all: true }),
api.getCategories(),
]);
setPosts(p.posts || []);
setCategories(c);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const handleFetchPreview = async () => {
if (!importInput.trim()) return;
setFetching(true);
setError("");
try {
const isNaddr = importInput.startsWith("naddr");
const data = await api.fetchNostrEvent(
isNaddr ? { naddr: importInput } : { eventId: importInput }
);
setImportPreview(data);
} catch (err: any) {
setError(err.message);
setImportPreview(null);
} finally {
setFetching(false);
}
};
const handleImport = async () => {
if (!importInput.trim()) return;
setImporting(true);
setError("");
try {
const isNaddr = importInput.startsWith("naddr");
await api.importPost(
isNaddr ? { naddr: importInput } : { eventId: importInput }
);
setImportInput("");
setImportPreview(null);
setImportOpen(false);
await loadData();
} catch (err: any) {
setError(err.message);
} finally {
setImporting(false);
}
};
const openEdit = (post: any) => {
setEditingPost(post);
setEditForm({
title: post.title || "",
slug: post.slug || "",
excerpt: post.excerpt || "",
categories: post.categories?.map((c: any) => c.id || c) || [],
featured: post.featured || false,
visible: post.visible !== false,
});
};
const handleSaveEdit = async () => {
if (!editingPost) return;
setError("");
try {
await api.updatePost(editingPost.id, editForm);
setEditingPost(null);
await loadData();
} catch (err: any) {
setError(err.message);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Delete this post?")) return;
try {
await api.deletePost(id);
await loadData();
} catch (err: any) {
setError(err.message);
}
};
const toggleCategory = (catId: string) => {
setEditForm((prev) => ({
...prev,
categories: prev.categories.includes(catId)
? prev.categories.filter((c) => c !== catId)
: [...prev.categories, catId],
}));
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading posts...</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-on-surface">Blog Management</h1>
<button
onClick={() => setImportOpen(!importOpen)}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity"
>
<Download size={16} />
Import Post
</button>
</div>
{error && <p className="text-error text-sm">{error}</p>}
{importOpen && (
<div className="bg-surface-container-low rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-on-surface">Import from Nostr</h2>
<button onClick={() => setImportOpen(false)} className="text-on-surface/50 hover:text-on-surface">
<X size={20} />
</button>
</div>
<div className="flex gap-3">
<input
placeholder="Nostr event ID or naddr..."
value={importInput}
onChange={(e) => setImportInput(e.target.value)}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40 flex-1"
/>
<button
onClick={handleFetchPreview}
disabled={fetching || !importInput.trim()}
className="px-4 py-2 rounded-lg bg-surface-container-highest text-on-surface font-semibold text-sm hover:bg-surface-container-high transition-colors disabled:opacity-50 whitespace-nowrap"
>
{fetching ? "Fetching..." : "Fetch Preview"}
</button>
</div>
{importPreview && (
<div className="mt-4 bg-surface-container rounded-lg p-4">
<p className="text-on-surface font-semibold">{importPreview.title || "Untitled"}</p>
<p className="text-on-surface/60 text-sm mt-1 line-clamp-3">
{importPreview.content?.slice(0, 300)}...
</p>
<button
onClick={handleImport}
disabled={importing}
className="mt-3 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50"
>
{importing ? "Importing..." : "Import"}
</button>
</div>
)}
</div>
)}
{editingPost && (
<div className="bg-surface-container-low rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-on-surface">Edit Post Metadata</h2>
<button onClick={() => setEditingPost(null)} className="text-on-surface/50 hover:text-on-surface">
<X size={20} />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<input
placeholder="Title"
value={editForm.title}
onChange={(e) => setEditForm({ ...editForm, title: e.target.value })}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
<input
placeholder="Slug"
value={editForm.slug}
onChange={(e) => setEditForm({ ...editForm, slug: e.target.value })}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
<textarea
placeholder="Excerpt"
value={editForm.excerpt}
onChange={(e) => setEditForm({ ...editForm, excerpt: e.target.value })}
rows={2}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40 md:col-span-2"
/>
</div>
<div className="mt-4">
<p className="text-on-surface/60 text-sm mb-2">Categories</p>
<div className="flex flex-wrap gap-2">
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => toggleCategory(cat.id)}
className={cn(
"rounded-full px-3 py-1 text-xs font-bold transition-colors",
editForm.categories.includes(cat.id)
? "bg-primary/20 text-primary"
: "bg-surface-container-highest text-on-surface/60 hover:text-on-surface"
)}
>
{cat.name}
</button>
))}
</div>
</div>
<div className="flex items-center gap-6 mt-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={editForm.featured}
onChange={(e) => setEditForm({ ...editForm, featured: e.target.checked })}
className="accent-primary"
/>
<span className="text-on-surface text-sm">Featured</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={editForm.visible}
onChange={(e) => setEditForm({ ...editForm, visible: e.target.checked })}
className="accent-primary"
/>
<span className="text-on-surface text-sm">Visible</span>
</label>
</div>
<div className="flex gap-3 mt-4">
<button
onClick={handleSaveEdit}
className="px-6 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity"
>
Save
</button>
<button
onClick={() => setEditingPost(null)}
className="px-6 py-2 rounded-lg bg-surface-container-highest text-on-surface font-semibold text-sm hover:bg-surface-container-high transition-colors"
>
Cancel
</button>
</div>
</div>
)}
<div className="space-y-3">
{posts.length === 0 ? (
<p className="text-on-surface/50 text-sm">No posts found.</p>
) : (
posts.map((post) => (
<div
key={post.id}
className="bg-surface-container-low rounded-xl p-6 flex items-center justify-between"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-1">
<h3 className="text-on-surface font-semibold truncate">{post.title}</h3>
{post.featured && (
<Star size={14} className="text-primary flex-shrink-0" />
)}
{post.visible === false && (
<EyeOff size={14} className="text-on-surface/40 flex-shrink-0" />
)}
</div>
<p className="text-on-surface/50 text-sm truncate">/{post.slug}</p>
{post.categories?.length > 0 && (
<div className="flex gap-2 mt-2">
{post.categories.map((cat: any) => (
<span
key={cat.id || cat}
className="rounded-full px-2 py-0.5 text-xs bg-surface-container-highest text-on-surface/60"
>
{cat.name || cat}
</span>
))}
</div>
)}
</div>
<div className="flex items-center gap-2 ml-4">
<button
onClick={() => openEdit(post)}
className="p-2 rounded-lg hover:bg-surface-container-high text-on-surface/60 hover:text-on-surface transition-colors"
>
<Pencil size={16} />
</button>
<button
onClick={() => handleDelete(post.id)}
className="p-2 rounded-lg hover:bg-error-container/30 text-on-surface/60 hover:text-error transition-colors"
>
<Trash2 size={16} />
</button>
</div>
</div>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,184 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { slugify } from "@/lib/utils";
import { Plus, Pencil, Trash2, X } from "lucide-react";
interface CategoryForm {
name: string;
slug: string;
}
export default function CategoriesPage() {
const [categories, setCategories] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<CategoryForm>({ name: "", slug: "" });
const [saving, setSaving] = useState(false);
const loadCategories = async () => {
try {
const data = await api.getCategories();
setCategories(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadCategories();
}, []);
const openCreate = () => {
setForm({ name: "", slug: "" });
setEditingId(null);
setShowForm(true);
};
const openEdit = (cat: any) => {
setForm({ name: cat.name, slug: cat.slug });
setEditingId(cat.id);
setShowForm(true);
};
const handleNameChange = (name: string) => {
setForm({ name, slug: editingId ? form.slug : slugify(name) });
};
const handleSave = async () => {
if (!form.name.trim() || !form.slug.trim()) return;
setSaving(true);
setError("");
try {
if (editingId) {
await api.updateCategory(editingId, form);
} else {
await api.createCategory(form);
}
setShowForm(false);
setEditingId(null);
await loadCategories();
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Delete this category?")) return;
try {
await api.deleteCategory(id);
await loadCategories();
} catch (err: any) {
setError(err.message);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading categories...</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-on-surface">Categories</h1>
<button
onClick={openCreate}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity"
>
<Plus size={16} />
Add Category
</button>
</div>
{error && <p className="text-error text-sm">{error}</p>}
{showForm && (
<div className="bg-surface-container-low rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-on-surface">
{editingId ? "Edit Category" : "New Category"}
</h2>
<button onClick={() => setShowForm(false)} className="text-on-surface/50 hover:text-on-surface">
<X size={20} />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<input
placeholder="Category name"
value={form.name}
onChange={(e) => handleNameChange(e.target.value)}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
<input
placeholder="slug"
value={form.slug}
onChange={(e) => setForm({ ...form, slug: e.target.value })}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
</div>
<div className="flex gap-3 mt-4">
<button
onClick={handleSave}
disabled={saving || !form.name.trim()}
className="px-6 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50"
>
{saving ? "Saving..." : "Save"}
</button>
<button
onClick={() => setShowForm(false)}
className="px-6 py-2 rounded-lg bg-surface-container-highest text-on-surface font-semibold text-sm hover:bg-surface-container-high transition-colors"
>
Cancel
</button>
</div>
</div>
)}
<div className="space-y-3">
{categories.length === 0 ? (
<p className="text-on-surface/50 text-sm">No categories found.</p>
) : (
categories.map((cat, i) => (
<div
key={cat.id}
className="bg-surface-container-low rounded-xl p-6 flex items-center justify-between"
>
<div>
<h3 className="text-on-surface font-semibold">{cat.name}</h3>
<p className="text-on-surface/50 text-sm">/{cat.slug}</p>
{cat.sortOrder !== undefined && (
<p className="text-on-surface/40 text-xs mt-1">Order: {cat.sortOrder}</p>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => openEdit(cat)}
className="p-2 rounded-lg hover:bg-surface-container-high text-on-surface/60 hover:text-on-surface transition-colors"
>
<Pencil size={16} />
</button>
<button
onClick={() => handleDelete(cat.id)}
className="p-2 rounded-lg hover:bg-error-container/30 text-on-surface/60 hover:text-error transition-colors"
>
<Trash2 size={16} />
</button>
</div>
</div>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,844 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { formatDate } from "@/lib/utils";
import { cn } from "@/lib/utils";
import {
Plus,
Pencil,
Trash2,
X,
Image as ImageIcon,
Copy,
MoreHorizontal,
Star,
Eye,
EyeOff,
CheckSquare,
Square,
ChevronUp,
ChevronDown,
Link as LinkIcon,
Check,
} from "lucide-react";
import { MediaPickerModal } from "@/components/admin/MediaPickerModal";
interface Meetup {
id: string;
title: string;
description: string;
date: string;
time: string;
location: string;
link?: string;
imageId?: string;
status: string;
featured: boolean;
visibility: string;
createdAt: string;
updatedAt: string;
}
interface MeetupForm {
title: string;
description: string;
date: string;
time: string;
location: string;
link: string;
imageId: string;
status: string;
featured: boolean;
visibility: string;
}
const emptyForm: MeetupForm = {
title: "",
description: "",
date: "",
time: "",
location: "",
link: "",
imageId: "",
status: "DRAFT",
featured: false,
visibility: "PUBLIC",
};
// Statuses that can be manually set by an admin
const EDITABLE_STATUS_OPTIONS = ["DRAFT", "PUBLISHED", "CANCELLED"] as const;
type EditableStatus = (typeof EDITABLE_STATUS_OPTIONS)[number];
// Display statuses (includes computed Upcoming/Past from PUBLISHED + date)
type DisplayStatus = "DRAFT" | "UPCOMING" | "PAST" | "CANCELLED";
function getDisplayStatus(meetup: { status: string; date: string }): DisplayStatus {
if (meetup.status === "CANCELLED") return "CANCELLED";
if (meetup.status === "DRAFT") return "DRAFT";
// PUBLISHED (or legacy UPCOMING/PAST values) → derive from date
if (!meetup.date) return "DRAFT";
return new Date(meetup.date) > new Date() ? "UPCOMING" : "PAST";
}
const STATUS_LABELS: Record<string, string> = {
DRAFT: "Draft",
PUBLISHED: "Published",
UPCOMING: "Upcoming",
PAST: "Past",
CANCELLED: "Cancelled",
};
// Badge styles use the computed display status
const DISPLAY_STATUS_STYLES: Record<DisplayStatus, string> = {
DRAFT: "bg-surface-container-highest text-on-surface/60",
UPCOMING: "bg-green-900/40 text-green-400",
PAST: "bg-surface-container-highest text-on-surface/40",
CANCELLED: "bg-red-900/30 text-red-400",
};
function useOutsideClick(ref: React.RefObject<HTMLElement | null>, callback: () => void) {
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
callback();
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [ref, callback]);
}
function MoreMenu({
meetup,
onCopyUrl,
}: {
meetup: Meetup;
onCopyUrl: () => void;
}) {
const [open, setOpen] = useState(false);
const [copied, setCopied] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useOutsideClick(ref, () => setOpen(false));
const handleCopy = () => {
onCopyUrl();
setCopied(true);
setTimeout(() => {
setCopied(false);
setOpen(false);
}, 1500);
};
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((v) => !v)}
className="p-2 rounded-lg hover:bg-surface-container-high text-on-surface/60 hover:text-on-surface transition-colors"
title="More options"
>
<MoreHorizontal size={16} />
</button>
{open && (
<div className="absolute right-0 top-full mt-1 z-50 w-48 bg-surface-container-low border border-surface-container-highest rounded-xl shadow-lg overflow-hidden">
<button
onClick={handleCopy}
className="flex items-center gap-2 w-full px-4 py-3 text-sm text-on-surface/80 hover:bg-surface-container-high hover:text-on-surface transition-colors"
>
{copied ? <Check size={14} className="text-green-400" /> : <LinkIcon size={14} />}
{copied ? "Copied!" : "Copy Event URL"}
</button>
</div>
)}
</div>
);
}
function StatusDropdown({
meetup,
onChange,
}: {
meetup: { status: string; date: string };
onChange: (v: string) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useOutsideClick(ref, () => setOpen(false));
const displayStatus = getDisplayStatus(meetup);
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((v) => !v)}
className={cn(
"rounded-full px-3 py-1 text-xs font-bold cursor-pointer hover:opacity-80 transition-opacity",
DISPLAY_STATUS_STYLES[displayStatus]
)}
>
{STATUS_LABELS[displayStatus]}
</button>
{open && (
<div className="absolute left-0 top-full mt-1 z-50 w-36 bg-surface-container-low border border-surface-container-highest rounded-xl shadow-lg overflow-hidden">
{EDITABLE_STATUS_OPTIONS.map((s) => (
<button
key={s}
onClick={() => {
onChange(s);
setOpen(false);
}}
className={cn(
"flex items-center gap-2 w-full px-3 py-2 text-xs font-bold transition-colors hover:bg-surface-container-high",
meetup.status === s ? "text-on-surface" : "text-on-surface/60"
)}
>
<span className={cn("w-2 h-2 rounded-full", {
"bg-on-surface/40": s === "DRAFT",
"bg-green-400": s === "PUBLISHED",
"bg-red-400": s === "CANCELLED",
})} />
{STATUS_LABELS[s]}
</button>
))}
</div>
)}
</div>
);
}
export default function EventsPage() {
const [meetups, setMeetups] = useState<Meetup[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<MeetupForm>(emptyForm);
const [saving, setSaving] = useState(false);
const [showMediaPicker, setShowMediaPicker] = useState(false);
const formRef = useRef<HTMLDivElement>(null);
// Filters
const [filterStatus, setFilterStatus] = useState("ALL");
const [filterCity, setFilterCity] = useState("ALL");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
// Bulk selection
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkLoading, setBulkLoading] = useState(false);
const loadMeetups = async () => {
try {
const data = await api.getMeetups({ admin: true });
setMeetups(data as Meetup[]);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadMeetups();
}, []);
const openCreate = () => {
setForm(emptyForm);
setEditingId(null);
setShowForm(true);
setTimeout(() => formRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }), 50);
};
const openEdit = (meetup: Meetup) => {
setForm({
title: meetup.title,
description: meetup.description || "",
date: meetup.date?.split("T")[0] || meetup.date || "",
time: meetup.time || "",
location: meetup.location || "",
link: meetup.link || "",
imageId: meetup.imageId || "",
status: meetup.status || "DRAFT",
featured: meetup.featured || false,
visibility: meetup.visibility || "PUBLIC",
});
setEditingId(meetup.id);
setShowForm(true);
setTimeout(() => formRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }), 50);
};
const handleSave = async () => {
setSaving(true);
setError("");
try {
const payload = {
title: form.title,
description: form.description,
date: form.date,
time: form.time || "00:00",
location: form.location,
link: form.link,
imageId: form.imageId || null,
status: form.status,
featured: form.featured,
visibility: form.visibility,
};
if (editingId) {
const updated = await api.updateMeetup(editingId, payload);
setMeetups((prev) => prev.map((m) => (m.id === editingId ? updated : m)));
} else {
const created = await api.createMeetup(payload);
setMeetups((prev) => [...prev, created]);
}
setShowForm(false);
setEditingId(null);
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Delete this meetup?")) return;
setMeetups((prev) => prev.filter((m) => m.id !== id));
try {
await api.deleteMeetup(id);
} catch (err: any) {
setError(err.message);
await loadMeetups();
}
};
const handleDuplicate = async (id: string) => {
try {
const dup = await api.duplicateMeetup(id);
setMeetups((prev) => [dup, ...prev]);
openEdit(dup);
} catch (err: any) {
setError(err.message);
}
};
const handlePatch = async (id: string, patch: Partial<Meetup>) => {
setMeetups((prev) => prev.map((m) => (m.id === id ? { ...m, ...patch } : m)));
try {
await api.updateMeetup(id, patch);
} catch (err: any) {
setError(err.message);
await loadMeetups();
}
};
const handleCopyUrl = (meetup: Meetup) => {
const origin = typeof window !== "undefined" ? window.location.origin : "";
navigator.clipboard.writeText(`${origin}/events/${meetup.id}`);
};
const toggleSelect = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const selectAll = () => {
if (selected.size === filtered.length) {
setSelected(new Set());
} else {
setSelected(new Set(filtered.map((m) => m.id)));
}
};
const handleBulk = async (action: "delete" | "publish" | "duplicate") => {
if (selected.size === 0) return;
if (action === "delete" && !confirm(`Delete ${selected.size} meetup(s)?`)) return;
setBulkLoading(true);
try {
const ids = Array.from(selected);
if (action === "delete") {
await api.bulkMeetupAction("delete", ids);
setMeetups((prev) => prev.filter((m) => !ids.includes(m.id)));
} else if (action === "publish") {
await api.bulkMeetupAction("publish", ids);
setMeetups((prev) => prev.map((m) => (ids.includes(m.id) ? { ...m, status: "PUBLISHED" } : m)));
} else if (action === "duplicate") {
const result = await api.bulkMeetupAction("duplicate", ids);
setMeetups((prev) => [...(result as Meetup[]), ...prev]);
}
setSelected(new Set());
} catch (err: any) {
setError(err.message);
await loadMeetups();
} finally {
setBulkLoading(false);
}
};
// Derived: unique cities
const cities = Array.from(new Set(meetups.map((m) => m.location).filter(Boolean))).sort();
// Filter tabs use computed display status
const FILTER_STATUS_OPTIONS: Array<{ value: string; label: string }> = [
{ value: "ALL", label: "All" },
{ value: "UPCOMING", label: "Upcoming" },
{ value: "PAST", label: "Past" },
{ value: "DRAFT", label: "Draft" },
{ value: "CANCELLED", label: "Cancelled" },
];
// Filtered + sorted
const filtered = meetups
.filter((m) => {
if (filterStatus !== "ALL" && getDisplayStatus(m) !== filterStatus) return false;
if (filterCity !== "ALL" && m.location !== filterCity) return false;
return true;
})
.sort((a, b) => {
const da = a.date || "";
const db = b.date || "";
return sortDir === "asc" ? da.localeCompare(db) : db.localeCompare(da);
});
const allSelected = filtered.length > 0 && selected.size === filtered.length;
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading meetups...</div>
</div>
);
}
return (
<div className="space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-on-surface">Events</h1>
<button
onClick={openCreate}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity"
>
<Plus size={16} />
New Event
</button>
</div>
{error && (
<div className="flex items-center justify-between bg-error-container/20 text-error text-sm px-4 py-3 rounded-lg">
<span>{error}</span>
<button onClick={() => setError("")}>
<X size={14} />
</button>
</div>
)}
{/* Create / Edit Form */}
{showForm && (
<div ref={formRef} className="bg-surface-container-low rounded-xl p-6">
<div className="flex items-center justify-between mb-5">
<h2 className="text-lg font-semibold text-on-surface">
{editingId ? "Edit Event" : "New Event"}
</h2>
<button
onClick={() => setShowForm(false)}
className="text-on-surface/50 hover:text-on-surface"
>
<X size={20} />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<input
placeholder="Title (e.g. #54 Belgian Bitcoin Embassy Meetup)"
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40 md:col-span-2"
/>
<input
placeholder="Location"
value={form.location}
onChange={(e) => setForm({ ...form, location: e.target.value })}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
<input
type="date"
value={form.date}
onChange={(e) => setForm({ ...form, date: e.target.value })}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
<input
type="time"
value={form.time}
onChange={(e) => setForm({ ...form, time: e.target.value })}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
<div>
<label className="text-on-surface/60 text-xs mb-2 block">Status</label>
<select
value={form.status}
onChange={(e) => setForm({ ...form, status: e.target.value })}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40"
>
{EDITABLE_STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{STATUS_LABELS[s]}
</option>
))}
</select>
</div>
<div>
<label className="text-on-surface/60 text-xs mb-2 block">Visibility</label>
<select
value={form.visibility}
onChange={(e) => setForm({ ...form, visibility: e.target.value })}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40"
>
<option value="PUBLIC">Public</option>
<option value="HIDDEN">Hidden</option>
</select>
</div>
<div className="md:col-span-2">
<label className="text-on-surface/60 text-xs mb-2 block">
External registration link{" "}
<span className="text-on-surface/40">(optional)</span>
</label>
<input
placeholder="https://..."
value={form.link}
onChange={(e) => setForm({ ...form, link: e.target.value })}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
</div>
<textarea
placeholder="Description"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
rows={3}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40 md:col-span-2"
/>
<div className="md:col-span-2">
<label className="text-on-surface/60 text-xs mb-2 block">
Event image <span className="text-on-surface/40">(optional)</span>
</label>
<div className="flex items-center gap-3">
{form.imageId && (
<div className="relative w-20 h-20 rounded-lg overflow-hidden bg-surface-container-highest shrink-0">
<img
src={`/media/${form.imageId}?w=200`}
alt="Selected"
className="w-full h-full object-cover"
/>
</div>
)}
<button
type="button"
onClick={() => setShowMediaPicker(true)}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-surface-container-highest text-on-surface/70 hover:text-on-surface text-sm transition-colors"
>
<ImageIcon size={16} />
{form.imageId ? "Change Image" : "Select Image"}
</button>
{form.imageId && (
<button
type="button"
onClick={() => setForm({ ...form, imageId: "" })}
className="px-3 py-2 rounded-lg text-error/70 hover:text-error text-sm transition-colors"
>
Remove
</button>
)}
</div>
</div>
</div>
<div className="flex items-center gap-4 mt-4 pt-4 border-t border-surface-container-highest">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={form.featured}
onChange={(e) => setForm({ ...form, featured: e.target.checked })}
className="hidden"
/>
<span
className={cn(
"flex items-center gap-1.5 text-sm transition-colors",
form.featured ? "text-primary" : "text-on-surface/50 hover:text-on-surface"
)}
>
<Star size={15} className={form.featured ? "fill-primary" : ""} />
Featured
</span>
</label>
</div>
<div className="flex gap-3 mt-4">
<button
onClick={handleSave}
disabled={saving || !form.title || !form.date}
className="px-6 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50"
>
{saving ? "Saving..." : "Save"}
</button>
<button
onClick={() => setShowForm(false)}
className="px-6 py-2 rounded-lg bg-surface-container-highest text-on-surface font-semibold text-sm hover:bg-surface-container-high transition-colors"
>
Cancel
</button>
</div>
</div>
)}
{showMediaPicker && (
<MediaPickerModal
selectedId={form.imageId || null}
onSelect={(id) => {
setForm({ ...form, imageId: id });
setShowMediaPicker(false);
}}
onClose={() => setShowMediaPicker(false)}
/>
)}
{/* Filters */}
<div className="flex flex-wrap items-center gap-2">
{/* Status filter */}
<div className="flex items-center bg-surface-container-low rounded-lg overflow-hidden">
{FILTER_STATUS_OPTIONS.map(({ value, label }) => (
<button
key={value}
onClick={() => setFilterStatus(value)}
className={cn(
"px-3 py-1.5 text-xs font-semibold transition-colors",
filterStatus === value
? "bg-primary text-on-primary"
: "text-on-surface/60 hover:text-on-surface hover:bg-surface-container-high"
)}
>
{label}
</button>
))}
</div>
{/* City filter */}
{cities.length > 1 && (
<select
value={filterCity}
onChange={(e) => setFilterCity(e.target.value)}
className="bg-surface-container-low text-on-surface/70 text-xs rounded-lg px-3 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary/40"
>
<option value="ALL">All cities</option>
{cities.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
)}
{/* Sort */}
<button
onClick={() => setSortDir((d) => (d === "asc" ? "desc" : "asc"))}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-on-surface/60 hover:text-on-surface bg-surface-container-low rounded-lg transition-colors"
>
{sortDir === "asc" ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
Date
</button>
<span className="ml-auto text-xs text-on-surface/40">
{filtered.length} event{filtered.length !== 1 ? "s" : ""}
</span>
</div>
{/* Bulk action bar */}
{selected.size > 0 && (
<div className="flex items-center gap-3 bg-surface-container-low rounded-xl px-4 py-3 border border-primary/20">
<span className="text-sm text-on-surface/70 font-medium">
{selected.size} selected
</span>
<div className="flex items-center gap-2 ml-auto">
<button
onClick={() => handleBulk("duplicate")}
disabled={bulkLoading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-lg bg-surface-container-high text-on-surface/70 hover:text-on-surface transition-colors disabled:opacity-50"
>
<Copy size={12} />
Duplicate
</button>
<button
onClick={() => handleBulk("publish")}
disabled={bulkLoading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-lg bg-surface-container-high text-on-surface/70 hover:text-on-surface transition-colors disabled:opacity-50"
>
Publish
</button>
<button
onClick={() => handleBulk("delete")}
disabled={bulkLoading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-lg bg-error-container/20 text-error/70 hover:text-error transition-colors disabled:opacity-50"
>
<Trash2 size={12} />
Delete
</button>
<button
onClick={() => setSelected(new Set())}
className="ml-1 text-on-surface/40 hover:text-on-surface"
>
<X size={14} />
</button>
</div>
</div>
)}
{/* Event list */}
<div className="space-y-2">
{filtered.length === 0 ? (
<p className="text-on-surface/50 text-sm py-8 text-center">No events found.</p>
) : (
<>
{/* Select-all row */}
<div className="flex items-center gap-2 px-2 pb-1">
<button
onClick={selectAll}
className="text-on-surface/40 hover:text-on-surface transition-colors"
title={allSelected ? "Deselect all" : "Select all"}
>
{allSelected ? <CheckSquare size={15} /> : <Square size={15} />}
</button>
<span className="text-xs text-on-surface/40">
{allSelected ? "Deselect all" : "Select all"}
</span>
</div>
{filtered.map((meetup) => (
<div
key={meetup.id}
className={cn(
"bg-surface-container-low rounded-xl p-4 flex items-center gap-3 transition-colors",
selected.has(meetup.id) && "ring-1 ring-primary/30 bg-surface-container"
)}
>
{/* Checkbox */}
<button
onClick={() => toggleSelect(meetup.id)}
className="shrink-0 text-on-surface/40 hover:text-on-surface transition-colors"
>
{selected.has(meetup.id) ? (
<CheckSquare size={16} className="text-primary" />
) : (
<Square size={16} />
)}
</button>
{/* Image */}
{meetup.imageId ? (
<div className="w-14 h-14 rounded-lg overflow-hidden bg-surface-container-highest shrink-0">
<img
src={`/media/${meetup.imageId}?w=100`}
alt=""
className="w-full h-full object-cover"
/>
</div>
) : (
<div className="w-14 h-14 rounded-lg bg-surface-container-highest shrink-0 flex items-center justify-center">
<ImageIcon size={18} className="text-on-surface/20" />
</div>
)}
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 mb-1">
<h3 className="text-on-surface font-semibold truncate">{meetup.title}</h3>
{meetup.featured && (
<Star size={12} className="text-primary fill-primary shrink-0" />
)}
{meetup.visibility === "HIDDEN" && (
<EyeOff size={12} className="text-on-surface/40 shrink-0" />
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<StatusDropdown
meetup={meetup}
onChange={(v) => handlePatch(meetup.id, { status: v })}
/>
<span className="text-on-surface/50 text-xs">
{meetup.date ? formatDate(meetup.date) : "No date"}
{meetup.location && ` · ${meetup.location}`}
</span>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
{/* Featured toggle */}
<button
onClick={() => handlePatch(meetup.id, { featured: !meetup.featured })}
className={cn(
"p-2 rounded-lg transition-colors",
meetup.featured
? "text-primary hover:text-primary/70"
: "text-on-surface/30 hover:text-on-surface hover:bg-surface-container-high"
)}
title={meetup.featured ? "Unfeature" : "Feature"}
>
<Star size={15} className={meetup.featured ? "fill-primary" : ""} />
</button>
{/* Visibility toggle */}
<button
onClick={() =>
handlePatch(meetup.id, {
visibility: meetup.visibility === "PUBLIC" ? "HIDDEN" : "PUBLIC",
})
}
className={cn(
"p-2 rounded-lg transition-colors",
meetup.visibility === "HIDDEN"
? "text-on-surface/30 hover:text-on-surface hover:bg-surface-container-high"
: "text-on-surface/60 hover:text-on-surface hover:bg-surface-container-high"
)}
title={meetup.visibility === "PUBLIC" ? "Hide event" : "Make public"}
>
{meetup.visibility === "HIDDEN" ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
{/* Edit */}
<button
onClick={() => openEdit(meetup)}
className="p-2 rounded-lg hover:bg-surface-container-high text-on-surface/60 hover:text-on-surface transition-colors"
title="Edit"
>
<Pencil size={15} />
</button>
{/* Duplicate */}
<button
onClick={() => handleDuplicate(meetup.id)}
className="p-2 rounded-lg hover:bg-surface-container-high text-on-surface/60 hover:text-on-surface transition-colors"
title="Duplicate"
>
<Copy size={15} />
</button>
{/* Delete */}
<button
onClick={() => handleDelete(meetup.id)}
className="p-2 rounded-lg hover:bg-error-container/30 text-on-surface/60 hover:text-error transition-colors"
title="Delete"
>
<Trash2 size={15} />
</button>
{/* More menu */}
<MoreMenu meetup={meetup} onCopyUrl={() => handleCopyUrl(meetup)} />
</div>
</div>
))}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,354 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { Plus, Pencil, Trash2, X, ChevronUp, ChevronDown, Eye, EyeOff, GripVertical } from "lucide-react";
import { cn } from "@/lib/utils";
interface FaqItem {
id: string;
question: string;
answer: string;
order: number;
showOnHomepage: boolean;
}
interface FaqForm {
question: string;
answer: string;
showOnHomepage: boolean;
}
const emptyForm: FaqForm = {
question: "",
answer: "",
showOnHomepage: true,
};
export default function FaqAdminPage() {
const [faqs, setFaqs] = useState<FaqItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<FaqForm>(emptyForm);
const [saving, setSaving] = useState(false);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const loadFaqs = async () => {
try {
const data = await api.getAllFaqs();
setFaqs(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadFaqs();
}, []);
const openCreate = () => {
setForm(emptyForm);
setEditingId(null);
setShowForm(true);
};
const openEdit = (faq: FaqItem) => {
setForm({
question: faq.question,
answer: faq.answer,
showOnHomepage: faq.showOnHomepage,
});
setEditingId(faq.id);
setShowForm(true);
};
const handleSave = async () => {
if (!form.question.trim() || !form.answer.trim()) return;
setSaving(true);
setError("");
try {
if (editingId) {
await api.updateFaq(editingId, form);
} else {
await api.createFaq(form);
}
setShowForm(false);
setEditingId(null);
await loadFaqs();
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Delete this FAQ?")) return;
try {
await api.deleteFaq(id);
await loadFaqs();
} catch (err: any) {
setError(err.message);
}
};
const handleToggleHomepage = async (faq: FaqItem) => {
try {
await api.updateFaq(faq.id, { showOnHomepage: !faq.showOnHomepage });
setFaqs((prev) =>
prev.map((f) =>
f.id === faq.id ? { ...f, showOnHomepage: !faq.showOnHomepage } : f
)
);
} catch (err: any) {
setError(err.message);
}
};
const moveItem = async (index: number, direction: "up" | "down") => {
const newFaqs = [...faqs];
const targetIndex = direction === "up" ? index - 1 : index + 1;
if (targetIndex < 0 || targetIndex >= newFaqs.length) return;
[newFaqs[index], newFaqs[targetIndex]] = [newFaqs[targetIndex], newFaqs[index]];
const reordered = newFaqs.map((f, i) => ({ ...f, order: i }));
setFaqs(reordered);
try {
await api.reorderFaqs(reordered.map((f) => ({ id: f.id, order: f.order })));
} catch (err: any) {
setError(err.message);
await loadFaqs();
}
};
// Drag-and-drop handlers
const handleDragStart = (index: number) => {
setDragIndex(index);
};
const handleDragEnter = (index: number) => {
setDragOverIndex(index);
};
const handleDragEnd = async () => {
if (dragIndex === null || dragOverIndex === null || dragIndex === dragOverIndex) {
setDragIndex(null);
setDragOverIndex(null);
return;
}
const newFaqs = [...faqs];
const [moved] = newFaqs.splice(dragIndex, 1);
newFaqs.splice(dragOverIndex, 0, moved);
const reordered = newFaqs.map((f, i) => ({ ...f, order: i }));
setFaqs(reordered);
setDragIndex(null);
setDragOverIndex(null);
try {
await api.reorderFaqs(reordered.map((f) => ({ id: f.id, order: f.order })));
} catch (err: any) {
setError(err.message);
await loadFaqs();
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading FAQs...</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-on-surface">FAQ Management</h1>
<p className="text-on-surface/50 text-sm mt-1">
Drag to reorder · toggle visibility on homepage
</p>
</div>
<button
onClick={openCreate}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity"
>
<Plus size={16} />
Add FAQ
</button>
</div>
{error && <p className="text-error text-sm">{error}</p>}
{showForm && (
<div className="bg-surface-container-low rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-on-surface">
{editingId ? "Edit FAQ" : "Add FAQ"}
</h2>
<button
onClick={() => setShowForm(false)}
className="text-on-surface/50 hover:text-on-surface"
>
<X size={20} />
</button>
</div>
<div className="space-y-4">
<input
placeholder="Question"
value={form.question}
onChange={(e) => setForm({ ...form, question: e.target.value })}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
<textarea
placeholder="Answer"
value={form.answer}
onChange={(e) => setForm({ ...form, answer: e.target.value })}
rows={4}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40 resize-none"
/>
<label className="flex items-center gap-3 cursor-pointer select-none">
<div
onClick={() => setForm({ ...form, showOnHomepage: !form.showOnHomepage })}
className={cn(
"w-11 h-6 rounded-full relative transition-colors",
form.showOnHomepage ? "bg-primary" : "bg-surface-container-highest"
)}
>
<div
className={cn(
"absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform",
form.showOnHomepage ? "translate-x-6" : "translate-x-1"
)}
/>
</div>
<span className="text-on-surface/80 text-sm">Show on homepage</span>
</label>
</div>
<div className="flex gap-3 mt-4">
<button
onClick={handleSave}
disabled={saving || !form.question.trim() || !form.answer.trim()}
className="px-6 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50"
>
{saving ? "Saving..." : "Save"}
</button>
<button
onClick={() => setShowForm(false)}
className="px-6 py-2 rounded-lg bg-surface-container-highest text-on-surface font-semibold text-sm hover:bg-surface-container-high transition-colors"
>
Cancel
</button>
</div>
</div>
)}
<div className="space-y-2">
{faqs.length === 0 ? (
<div className="bg-surface-container-low rounded-xl p-12 text-center">
<p className="text-on-surface/40 text-sm">No FAQs yet. Add one to get started.</p>
</div>
) : (
faqs.map((faq, index) => (
<div
key={faq.id}
draggable
onDragStart={() => handleDragStart(index)}
onDragEnter={() => handleDragEnter(index)}
onDragEnd={handleDragEnd}
onDragOver={(e) => e.preventDefault()}
className={cn(
"bg-surface-container-low rounded-xl p-5 flex items-start gap-4 transition-all",
dragOverIndex === index && dragIndex !== index
? "ring-2 ring-primary/50 bg-surface-container"
: "",
dragIndex === index ? "opacity-50" : ""
)}
>
{/* Drag handle */}
<div className="mt-1 cursor-grab active:cursor-grabbing text-on-surface/30 hover:text-on-surface/60 shrink-0">
<GripVertical size={18} />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<p className="text-on-surface font-semibold truncate">{faq.question}</p>
{faq.showOnHomepage ? (
<span className="shrink-0 text-xs bg-green-900/30 text-green-400 rounded-full px-2 py-0.5 font-medium">
Homepage
</span>
) : (
<span className="shrink-0 text-xs bg-surface-container-highest text-on-surface/40 rounded-full px-2 py-0.5 font-medium">
Hidden
</span>
)}
</div>
<p className="text-on-surface/50 text-sm line-clamp-2">{faq.answer}</p>
</div>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => moveItem(index, "up")}
disabled={index === 0}
className="p-2 rounded-lg hover:bg-surface-container-high text-on-surface/40 hover:text-on-surface disabled:opacity-20 transition-colors"
title="Move up"
>
<ChevronUp size={16} />
</button>
<button
onClick={() => moveItem(index, "down")}
disabled={index === faqs.length - 1}
className="p-2 rounded-lg hover:bg-surface-container-high text-on-surface/40 hover:text-on-surface disabled:opacity-20 transition-colors"
title="Move down"
>
<ChevronDown size={16} />
</button>
<button
onClick={() => handleToggleHomepage(faq)}
className={cn(
"p-2 rounded-lg transition-colors",
faq.showOnHomepage
? "hover:bg-surface-container-high text-green-400 hover:text-on-surface"
: "hover:bg-surface-container-high text-on-surface/40 hover:text-on-surface"
)}
title={faq.showOnHomepage ? "Hide from homepage" : "Show on homepage"}
>
{faq.showOnHomepage ? <Eye size={16} /> : <EyeOff size={16} />}
</button>
<button
onClick={() => openEdit(faq)}
className="p-2 rounded-lg hover:bg-surface-container-high text-on-surface/40 hover:text-on-surface transition-colors"
title="Edit"
>
<Pencil size={16} />
</button>
<button
onClick={() => handleDelete(faq.id)}
className="p-2 rounded-lg hover:bg-error-container/30 text-on-surface/40 hover:text-error transition-colors"
title="Delete"
>
<Trash2 size={16} />
</button>
</div>
</div>
))
)}
</div>
{faqs.length > 0 && (
<p className="text-on-surface/30 text-xs text-center">
{faqs.filter((f) => f.showOnHomepage).length} of {faqs.length} shown on homepage
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,325 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { api } from "@/lib/api";
import { cn } from "@/lib/utils";
import { Upload, Trash2, Copy, Film, Image as ImageIcon, Check, Pencil, X } from "lucide-react";
interface MediaItem {
id: string;
slug: string;
type: "image" | "video";
mimeType: string;
size: number;
originalFilename: string;
uploadedBy: string;
createdAt: string;
url: string;
title?: string;
description?: string;
altText?: string;
}
interface EditForm {
title: string;
description: string;
altText: string;
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export default function GalleryPage() {
const [media, setMedia] = useState<MediaItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [uploading, setUploading] = useState(false);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [editingItem, setEditingItem] = useState<MediaItem | null>(null);
const [editForm, setEditForm] = useState<EditForm>({ title: "", description: "", altText: "" });
const [saving, setSaving] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const loadMedia = async () => {
try {
const data = await api.getMediaList();
setMedia(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadMedia();
}, []);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
setUploading(true);
setError("");
try {
for (const file of Array.from(files)) {
await api.uploadMedia(file);
}
await loadMedia();
} catch (err: any) {
setError(err.message);
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
const handleDelete = async (id: string) => {
if (!confirm("Delete this media item? This cannot be undone.")) return;
try {
await api.deleteMedia(id);
await loadMedia();
} catch (err: any) {
setError(err.message);
}
};
const handleCopyUrl = async (item: MediaItem) => {
const url = `${window.location.origin}/media/${item.id}`;
await navigator.clipboard.writeText(url);
setCopiedId(item.id);
setTimeout(() => setCopiedId(null), 2000);
};
const openEdit = (item: MediaItem) => {
setEditingItem(item);
setEditForm({
title: item.title || "",
description: item.description || "",
altText: item.altText || "",
});
};
const handleSaveEdit = async () => {
if (!editingItem) return;
setSaving(true);
setError("");
try {
await api.updateMedia(editingItem.id, {
title: editForm.title,
description: editForm.description,
altText: editForm.altText,
});
setEditingItem(null);
await loadMedia();
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading gallery...</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-on-surface">Media Gallery</h1>
<div>
<input
ref={fileInputRef}
type="file"
accept="image/*,video/*"
multiple
onChange={handleUpload}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50"
>
<Upload size={16} />
{uploading ? "Uploading..." : "Upload Media"}
</button>
</div>
</div>
{error && <p className="text-error text-sm">{error}</p>}
{media.length === 0 ? (
<div className="text-center py-20">
<ImageIcon size={48} className="mx-auto text-on-surface/20 mb-4" />
<p className="text-on-surface/50 text-sm">No media uploaded yet.</p>
<p className="text-on-surface/30 text-xs mt-1">
Upload images or videos to get started.
</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{media.map((item) => (
<div
key={item.id}
className="group bg-surface-container-low rounded-xl overflow-hidden"
>
<div className="relative aspect-square bg-surface-container-highest">
{item.type === "image" ? (
<img
src={`/media/${item.id}?w=300`}
alt={item.altText || item.title || item.originalFilename}
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Film size={40} className="text-on-surface/30" />
</div>
)}
<span
className={cn(
"absolute top-2 left-2 rounded-full px-2 py-0.5 text-[10px] font-bold uppercase",
item.type === "image"
? "bg-blue-900/60 text-blue-300"
: "bg-purple-900/60 text-purple-300"
)}
>
{item.type}
</span>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center gap-2 opacity-0 group-hover:opacity-100">
<button
onClick={() => handleCopyUrl(item)}
className="p-2 rounded-lg bg-white/20 hover:bg-white/30 text-white transition-colors"
title="Copy Full URL"
>
{copiedId === item.id ? <Check size={16} /> : <Copy size={16} />}
</button>
<button
onClick={() => openEdit(item)}
className="p-2 rounded-lg bg-white/20 hover:bg-white/30 text-white transition-colors"
title="Edit Media"
>
<Pencil size={16} />
</button>
<button
onClick={() => handleDelete(item.id)}
className="p-2 rounded-lg bg-red-500/30 hover:bg-red-500/50 text-white transition-colors"
title="Delete"
>
<Trash2 size={16} />
</button>
</div>
</div>
<div className="p-3">
<p className="text-on-surface text-xs font-medium truncate" title={item.title || item.originalFilename}>
{item.title || item.originalFilename}
</p>
<p className="text-on-surface/40 text-[10px] mt-0.5">
{formatFileSize(item.size)}
</p>
</div>
</div>
))}
</div>
)}
{editingItem && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/60" onClick={() => setEditingItem(null)} />
<div className="relative bg-surface-container-low rounded-2xl w-full max-w-lg overflow-hidden">
<div className="flex items-center justify-between p-5 border-b border-surface-container-highest">
<h2 className="text-lg font-semibold text-on-surface">Edit Media</h2>
<button
onClick={() => setEditingItem(null)}
className="text-on-surface/50 hover:text-on-surface transition-colors"
>
<X size={20} />
</button>
</div>
<div className="p-5 space-y-4">
<div className="flex items-center gap-4 p-3 bg-surface-container-highest rounded-lg">
{editingItem.type === "image" ? (
<img
src={`/media/${editingItem.id}?w=100`}
alt=""
className="w-14 h-14 rounded-lg object-cover shrink-0"
/>
) : (
<div className="w-14 h-14 rounded-lg bg-surface-container flex items-center justify-center shrink-0">
<Film size={20} className="text-on-surface/30" />
</div>
)}
<div className="min-w-0">
<p className="text-on-surface text-sm font-medium truncate">{editingItem.originalFilename}</p>
<p className="text-on-surface/40 text-xs">
{editingItem.type} &middot; {formatFileSize(editingItem.size)} &middot; {editingItem.mimeType}
</p>
</div>
</div>
<div>
<label className="text-on-surface/60 text-xs mb-1.5 block">Title</label>
<input
value={editForm.title}
onChange={(e) => setEditForm({ ...editForm, title: e.target.value })}
placeholder="SEO title for this media"
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40 text-sm"
/>
</div>
<div>
<label className="text-on-surface/60 text-xs mb-1.5 block">Description</label>
<textarea
value={editForm.description}
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
placeholder="SEO description for this media"
rows={3}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40 text-sm resize-none"
/>
</div>
<div>
<label className="text-on-surface/60 text-xs mb-1.5 block">Alt Text</label>
<input
value={editForm.altText}
onChange={(e) => setEditForm({ ...editForm, altText: e.target.value })}
placeholder="Accessible alt text for images"
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40 text-sm"
/>
</div>
</div>
<div className="flex justify-end gap-3 p-5 border-t border-surface-container-highest">
<button
onClick={() => setEditingItem(null)}
className="px-5 py-2 rounded-lg bg-surface-container-highest text-on-surface font-semibold text-sm hover:bg-surface-container-high transition-colors"
>
Cancel
</button>
<button
onClick={handleSaveEdit}
disabled={saving}
className="px-5 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50"
>
{saving ? "Saving..." : "Save"}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,33 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { AdminSidebar } from "@/components/admin/AdminSidebar";
export default function AdminLayout({ 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("/dashboard");
}
}, [user, loading, router]);
if (loading || !user || (user.role !== "ADMIN" && user.role !== "MODERATOR")) {
return null;
}
return (
<div className="flex">
<AdminSidebar />
<main className="flex-1 p-8 bg-surface min-h-screen">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,256 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { cn } from "@/lib/utils";
import { formatDate } from "@/lib/utils";
import { EyeOff, UserX, Undo2, Plus } from "lucide-react";
type Tab = "hidden" | "blocked";
export default function ModerationPage() {
const [tab, setTab] = useState<Tab>("hidden");
const [hiddenContent, setHiddenContent] = useState<any[]>([]);
const [blockedPubkeys, setBlockedPubkeys] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [hideEventId, setHideEventId] = useState("");
const [hideReason, setHideReason] = useState("");
const [blockPubkey, setBlockPubkey] = useState("");
const [blockReason, setBlockReason] = useState("");
const loadData = async () => {
try {
const [h, b] = await Promise.all([
api.getHiddenContent(),
api.getBlockedPubkeys(),
]);
setHiddenContent(h);
setBlockedPubkeys(b);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const handleHide = async () => {
if (!hideEventId.trim()) return;
setError("");
try {
await api.hideContent(hideEventId, hideReason || undefined);
setHideEventId("");
setHideReason("");
await loadData();
} catch (err: any) {
setError(err.message);
}
};
const handleUnhide = async (id: string) => {
try {
await api.unhideContent(id);
await loadData();
} catch (err: any) {
setError(err.message);
}
};
const handleBlock = async () => {
if (!blockPubkey.trim()) return;
setError("");
try {
await api.blockPubkey(blockPubkey, blockReason || undefined);
setBlockPubkey("");
setBlockReason("");
await loadData();
} catch (err: any) {
setError(err.message);
}
};
const handleUnblock = async (id: string) => {
try {
await api.unblockPubkey(id);
await loadData();
} catch (err: any) {
setError(err.message);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading moderation data...</div>
</div>
);
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-on-surface">Moderation</h1>
{error && <p className="text-error text-sm">{error}</p>}
<div className="flex gap-2">
<button
onClick={() => setTab("hidden")}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors",
tab === "hidden"
? "bg-surface-container-high text-primary"
: "bg-surface-container-low text-on-surface/60 hover:text-on-surface"
)}
>
<EyeOff size={16} />
Hidden Content
</button>
<button
onClick={() => setTab("blocked")}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors",
tab === "blocked"
? "bg-surface-container-high text-primary"
: "bg-surface-container-low text-on-surface/60 hover:text-on-surface"
)}
>
<UserX size={16} />
Blocked Pubkeys
</button>
</div>
{tab === "hidden" && (
<div className="space-y-4">
<div className="bg-surface-container-low rounded-xl p-6">
<h2 className="text-sm font-semibold text-on-surface/70 mb-3">Hide Content</h2>
<div className="flex gap-3">
<input
placeholder="Nostr event ID"
value={hideEventId}
onChange={(e) => setHideEventId(e.target.value)}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40 flex-1"
/>
<input
placeholder="Reason (optional)"
value={hideReason}
onChange={(e) => setHideReason(e.target.value)}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40 flex-1"
/>
<button
onClick={handleHide}
disabled={!hideEventId.trim()}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50 whitespace-nowrap"
>
<Plus size={16} />
Hide
</button>
</div>
</div>
<div className="space-y-3">
{hiddenContent.length === 0 ? (
<p className="text-on-surface/50 text-sm">No hidden content.</p>
) : (
hiddenContent.map((item) => (
<div
key={item.id}
className="bg-surface-container-low rounded-xl p-6 flex items-center justify-between"
>
<div>
<p className="text-on-surface font-mono text-sm">
{item.nostrEventId?.slice(0, 16)}...
</p>
{item.reason && (
<p className="text-on-surface/50 text-xs mt-1">{item.reason}</p>
)}
{item.createdAt && (
<p className="text-on-surface/40 text-xs mt-1">
{formatDate(item.createdAt)}
</p>
)}
</div>
<button
onClick={() => handleUnhide(item.id)}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface-container-highest text-on-surface/70 hover:text-on-surface text-sm transition-colors"
>
<Undo2 size={14} />
Unhide
</button>
</div>
))
)}
</div>
</div>
)}
{tab === "blocked" && (
<div className="space-y-4">
<div className="bg-surface-container-low rounded-xl p-6">
<h2 className="text-sm font-semibold text-on-surface/70 mb-3">Block Pubkey</h2>
<div className="flex gap-3">
<input
placeholder="Pubkey (hex)"
value={blockPubkey}
onChange={(e) => setBlockPubkey(e.target.value)}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40 flex-1"
/>
<input
placeholder="Reason (optional)"
value={blockReason}
onChange={(e) => setBlockReason(e.target.value)}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40 flex-1"
/>
<button
onClick={handleBlock}
disabled={!blockPubkey.trim()}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50 whitespace-nowrap"
>
<Plus size={16} />
Block
</button>
</div>
</div>
<div className="space-y-3">
{blockedPubkeys.length === 0 ? (
<p className="text-on-surface/50 text-sm">No blocked pubkeys.</p>
) : (
blockedPubkeys.map((item) => (
<div
key={item.id}
className="bg-surface-container-low rounded-xl p-6 flex items-center justify-between"
>
<div>
<p className="text-on-surface font-mono text-sm">
{item.pubkey?.slice(0, 16)}...{item.pubkey?.slice(-8)}
</p>
{item.reason && (
<p className="text-on-surface/50 text-xs mt-1">{item.reason}</p>
)}
{item.createdAt && (
<p className="text-on-surface/40 text-xs mt-1">
{formatDate(item.createdAt)}
</p>
)}
</div>
<button
onClick={() => handleUnblock(item.id)}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface-container-highest text-on-surface/70 hover:text-on-surface text-sm transition-colors"
>
<Undo2 size={14} />
Unblock
</button>
</div>
))
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,147 @@
"use client";
import { useState } from "react";
import { api } from "@/lib/api";
import { Search, RefreshCw, Bug } from "lucide-react";
export default function NostrToolsPage() {
const [fetchInput, setFetchInput] = useState("");
const [fetchResult, setFetchResult] = useState<any>(null);
const [fetching, setFetching] = useState(false);
const [cacheStatus, setCacheStatus] = useState("");
const [refreshing, setRefreshing] = useState(false);
const [debugInput, setDebugInput] = useState("");
const [debugResult, setDebugResult] = useState<any>(null);
const [debugging, setDebugging] = useState(false);
const [error, setError] = useState("");
const handleFetch = async () => {
if (!fetchInput.trim()) return;
setFetching(true);
setError("");
setFetchResult(null);
try {
const isNaddr = fetchInput.startsWith("naddr");
const data = await api.fetchNostrEvent(
isNaddr ? { naddr: fetchInput } : { eventId: fetchInput }
);
setFetchResult(data);
} catch (err: any) {
setError(err.message);
} finally {
setFetching(false);
}
};
const handleRefreshCache = async () => {
setRefreshing(true);
setCacheStatus("");
setError("");
try {
const result = await api.refreshCache();
setCacheStatus(result.message || "Cache refreshed successfully.");
} catch (err: any) {
setError(err.message);
} finally {
setRefreshing(false);
}
};
const handleDebug = async () => {
if (!debugInput.trim()) return;
setDebugging(true);
setError("");
setDebugResult(null);
try {
const data = await api.debugEvent(debugInput);
setDebugResult(data);
} catch (err: any) {
setError(err.message);
} finally {
setDebugging(false);
}
};
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-on-surface">Nostr Tools</h1>
{error && <p className="text-error text-sm">{error}</p>}
<div className="bg-surface-container-low rounded-xl p-6">
<h2 className="text-lg font-semibold text-on-surface mb-4 flex items-center gap-2">
<Search size={18} />
Manual Fetch
</h2>
<div className="flex gap-3">
<input
placeholder="Event ID or naddr..."
value={fetchInput}
onChange={(e) => setFetchInput(e.target.value)}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40 flex-1"
/>
<button
onClick={handleFetch}
disabled={fetching || !fetchInput.trim()}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50 whitespace-nowrap"
>
{fetching ? "Fetching..." : "Fetch"}
</button>
</div>
{fetchResult && (
<pre className="mt-4 bg-surface-container rounded-lg p-4 text-on-surface/80 text-xs overflow-x-auto max-h-96 overflow-y-auto font-mono">
{JSON.stringify(fetchResult, null, 2)}
</pre>
)}
</div>
<div className="bg-surface-container-low rounded-xl p-6">
<h2 className="text-lg font-semibold text-on-surface mb-4 flex items-center gap-2">
<RefreshCw size={18} />
Cache Management
</h2>
<button
onClick={handleRefreshCache}
disabled={refreshing}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50"
>
<RefreshCw size={16} className={refreshing ? "animate-spin" : ""} />
{refreshing ? "Refreshing..." : "Refresh Cache"}
</button>
{cacheStatus && (
<p className="mt-3 text-green-400 text-sm">{cacheStatus}</p>
)}
</div>
<div className="bg-surface-container-low rounded-xl p-6">
<h2 className="text-lg font-semibold text-on-surface mb-4 flex items-center gap-2">
<Bug size={18} />
Debug Event
</h2>
<div className="flex gap-3">
<input
placeholder="Event ID..."
value={debugInput}
onChange={(e) => setDebugInput(e.target.value)}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40 flex-1"
/>
<button
onClick={handleDebug}
disabled={debugging || !debugInput.trim()}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50 whitespace-nowrap"
>
{debugging ? "Debugging..." : "Debug"}
</button>
</div>
{debugResult && (
<pre className="mt-4 bg-surface-container rounded-lg p-4 text-on-surface/80 text-xs overflow-x-auto max-h-96 overflow-y-auto font-mono">
{JSON.stringify(debugResult, null, 2)}
</pre>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,173 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { api } from "@/lib/api";
import { formatDate } from "@/lib/utils";
import { Calendar, FileText, Tag, User, Plus, Download, FolderOpen } from "lucide-react";
import Link from "next/link";
export default function OverviewPage() {
const { user, loading: authLoading } = useAuth();
const router = useRouter();
const [meetups, setMeetups] = useState<any[]>([]);
const [posts, setPosts] = useState<any[]>([]);
const [categories, setCategories] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
if (!authLoading && !user) {
router.push("/admin");
}
}, [authLoading, user, router]);
useEffect(() => {
if (!user) return;
async function load() {
try {
const [m, p, c] = await Promise.all([
api.getMeetups(),
api.getPosts({ limit: 5, all: true }),
api.getCategories(),
]);
setMeetups(Array.isArray(m) ? m : []);
setPosts(p.posts || []);
setCategories(Array.isArray(c) ? c : []);
} catch (err: any) {
setError(err.message || "Failed to load dashboard data");
} finally {
setLoading(false);
}
}
load();
}, [user]);
if (authLoading || !user) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading...</div>
</div>
);
}
const shortPubkey = `${user.pubkey.slice(0, 8)}...${user.pubkey.slice(-8)}`;
const upcomingMeetup = meetups.find(
(m) => new Date(m.date) > new Date()
);
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading dashboard...</div>
</div>
);
}
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold text-on-surface">Welcome back</h1>
<p className="text-on-surface/60 font-mono text-sm mt-1">{shortPubkey}</p>
</div>
{error && (
<div className="bg-error-container/20 text-error rounded-xl p-4 text-sm">
{error}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard icon={Calendar} label="Total Meetups" value={meetups.length} />
<StatCard icon={FileText} label="Blog Posts" value={posts.length} />
<StatCard icon={Tag} label="Categories" value={categories.length} />
<StatCard icon={User} label="Your Role" value={user.role} />
</div>
{upcomingMeetup && (
<div className="bg-surface-container-low rounded-xl p-6">
<h2 className="text-lg font-semibold text-on-surface mb-3">Next Upcoming Meetup</h2>
<p className="text-primary font-semibold">{upcomingMeetup.title}</p>
<p className="text-on-surface/60 text-sm mt-1">
{formatDate(upcomingMeetup.date)} · {upcomingMeetup.location}
</p>
</div>
)}
<div className="bg-surface-container-low rounded-xl p-6">
<h2 className="text-lg font-semibold text-on-surface mb-4">Recent Posts</h2>
{posts.length === 0 ? (
<p className="text-on-surface/50 text-sm">No posts yet.</p>
) : (
<div className="space-y-3">
{posts.slice(0, 5).map((post: any) => (
<div
key={post.id}
className="flex items-center justify-between py-2"
>
<div>
<p className="text-on-surface text-sm font-medium">{post.title}</p>
<p className="text-on-surface/50 text-xs">{post.slug}</p>
</div>
{post.featured && (
<span className="rounded-full px-3 py-1 text-xs font-bold bg-primary/20 text-primary">
Featured
</span>
)}
</div>
))}
</div>
)}
</div>
<div className="bg-surface-container-low rounded-xl p-6">
<h2 className="text-lg font-semibold text-on-surface mb-4">Quick Actions</h2>
<div className="flex flex-wrap gap-3">
<Link
href="/admin/events"
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity"
>
<Plus size={16} />
Create Meetup
</Link>
<Link
href="/admin/blog"
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-surface-container-highest text-on-surface font-semibold text-sm hover:bg-surface-container-high transition-colors"
>
<Download size={16} />
Import Post
</Link>
<Link
href="/admin/categories"
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-surface-container-highest text-on-surface font-semibold text-sm hover:bg-surface-container-high transition-colors"
>
<FolderOpen size={16} />
Manage Categories
</Link>
</div>
</div>
</div>
);
}
function StatCard({
icon: Icon,
label,
value,
}: {
icon: any;
label: string;
value: string | number;
}) {
return (
<div className="bg-surface-container-low rounded-xl p-6">
<div className="flex items-center gap-3 mb-3">
<Icon size={20} className="text-primary" />
<span className="text-on-surface/60 text-sm">{label}</span>
</div>
<p className="text-2xl font-bold text-on-surface">{value}</p>
</div>
);
}

View File

@@ -0,0 +1,74 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { LogIn } from "lucide-react";
export default function AdminPage() {
const { user, loading, login } = useAuth();
const router = useRouter();
const [error, setError] = useState("");
const [loggingIn, setLoggingIn] = useState(false);
useEffect(() => {
if (loading) return;
if (!user) return;
if (user.role === "ADMIN" || user.role === "MODERATOR") {
router.push("/admin/overview");
} else {
router.push("/dashboard");
}
}, [user, loading, router]);
const handleLogin = async () => {
setError("");
setLoggingIn(true);
try {
await login();
} catch (err: any) {
setError(err.message || "Login failed");
} finally {
setLoggingIn(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading...</div>
</div>
);
}
if (user) return null;
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="bg-surface-container-low rounded-xl p-8 max-w-md w-full text-center">
<h1 className="text-2xl font-bold text-on-surface mb-2">Admin Dashboard</h1>
<p className="text-on-surface/60 mb-6">
Sign in with your Nostr identity to access the admin panel.
</p>
<button
onClick={handleLogin}
disabled={loggingIn}
className="w-full flex items-center justify-center gap-3 px-6 py-3 rounded-lg font-semibold transition-all bg-gradient-to-r from-primary to-primary-container text-on-primary hover:opacity-90 disabled:opacity-50"
>
<LogIn size={20} />
{loggingIn ? "Connecting..." : "Login with Nostr"}
</button>
{error && (
<p className="mt-4 text-error text-sm">{error}</p>
)}
<p className="mt-6 text-on-surface/40 text-xs leading-relaxed">
You need a Nostr browser extension (e.g. Alby, nos2x, or Flamingo) to sign in.
Your pubkey must be registered as an admin or moderator.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,225 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { cn } from "@/lib/utils";
import { Plus, Pencil, Trash2, X, Wifi, WifiOff, Zap } from "lucide-react";
export default function RelaysPage() {
const [relays, setRelays] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState({ url: "", priority: 0 });
const [saving, setSaving] = useState(false);
const [testResults, setTestResults] = useState<Record<string, boolean | null>>({});
const loadRelays = async () => {
try {
const data = await api.getRelays();
setRelays(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadRelays();
}, []);
const openCreate = () => {
setForm({ url: "", priority: 0 });
setEditingId(null);
setShowForm(true);
};
const openEdit = (relay: any) => {
setForm({ url: relay.url, priority: relay.priority || 0 });
setEditingId(relay.id);
setShowForm(true);
};
const handleSave = async () => {
if (!form.url.trim()) return;
setSaving(true);
setError("");
try {
if (editingId) {
await api.updateRelay(editingId, form);
} else {
await api.addRelay(form);
}
setShowForm(false);
setEditingId(null);
await loadRelays();
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Delete this relay?")) return;
try {
await api.deleteRelay(id);
await loadRelays();
} catch (err: any) {
setError(err.message);
}
};
const handleTest = async (id: string) => {
setTestResults((prev) => ({ ...prev, [id]: null }));
try {
const result = await api.testRelay(id);
setTestResults((prev) => ({ ...prev, [id]: result.success }));
} catch {
setTestResults((prev) => ({ ...prev, [id]: false }));
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading relays...</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-on-surface">Relay Configuration</h1>
<button
onClick={openCreate}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity"
>
<Plus size={16} />
Add Relay
</button>
</div>
{error && <p className="text-error text-sm">{error}</p>}
{showForm && (
<div className="bg-surface-container-low rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-on-surface">
{editingId ? "Edit Relay" : "Add Relay"}
</h2>
<button onClick={() => setShowForm(false)} className="text-on-surface/50 hover:text-on-surface">
<X size={20} />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<input
placeholder="wss://relay.example.com"
value={form.url}
onChange={(e) => setForm({ ...form, url: e.target.value })}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40 md:col-span-2"
/>
<input
type="number"
placeholder="Priority"
value={form.priority}
onChange={(e) => setForm({ ...form, priority: parseInt(e.target.value) || 0 })}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
</div>
<div className="flex gap-3 mt-4">
<button
onClick={handleSave}
disabled={saving || !form.url.trim()}
className="px-6 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50"
>
{saving ? "Saving..." : "Save"}
</button>
<button
onClick={() => setShowForm(false)}
className="px-6 py-2 rounded-lg bg-surface-container-highest text-on-surface font-semibold text-sm hover:bg-surface-container-high transition-colors"
>
Cancel
</button>
</div>
</div>
)}
<div className="space-y-3">
{relays.length === 0 ? (
<p className="text-on-surface/50 text-sm">No relays configured.</p>
) : (
relays.map((relay) => (
<div
key={relay.id}
className="bg-surface-container-low rounded-xl p-6 flex items-center justify-between"
>
<div className="flex items-center gap-4">
<div
className={cn(
"p-2 rounded-lg",
relay.active !== false
? "bg-green-900/30 text-green-400"
: "bg-surface-container-highest text-on-surface/40"
)}
>
{relay.active !== false ? <Wifi size={16} /> : <WifiOff size={16} />}
</div>
<div>
<p className="text-on-surface font-mono text-sm">{relay.url}</p>
<div className="flex items-center gap-3 mt-1">
<span className="text-on-surface/50 text-xs">
Priority: {relay.priority ?? 0}
</span>
{testResults[relay.id] !== undefined && (
<span
className={cn(
"rounded-full px-3 py-1 text-xs font-bold",
testResults[relay.id] === null
? "bg-surface-container-highest text-on-surface/50"
: testResults[relay.id]
? "bg-green-900/30 text-green-400"
: "bg-error-container/30 text-error"
)}
>
{testResults[relay.id] === null
? "Testing..."
: testResults[relay.id]
? "Connected"
: "Failed"}
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleTest(relay.id)}
className="p-2 rounded-lg hover:bg-surface-container-high text-on-surface/60 hover:text-primary transition-colors"
title="Test connection"
>
<Zap size={16} />
</button>
<button
onClick={() => openEdit(relay)}
className="p-2 rounded-lg hover:bg-surface-container-high text-on-surface/60 hover:text-on-surface transition-colors"
>
<Pencil size={16} />
</button>
<button
onClick={() => handleDelete(relay.id)}
className="p-2 rounded-lg hover:bg-error-container/30 text-on-surface/60 hover:text-error transition-colors"
>
<Trash2 size={16} />
</button>
</div>
</div>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,105 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { Save } from "lucide-react";
const settingFields = [
{ key: "site_title", label: "Site Title" },
{ key: "site_tagline", label: "Site Tagline" },
{ key: "telegram_link", label: "Telegram Link" },
{ key: "nostr_link", label: "Nostr Link" },
{ key: "x_link", label: "X Link" },
{ key: "youtube_link", label: "YouTube Link" },
{ key: "discord_link", label: "Discord Link" },
{ key: "linkedin_link", label: "LinkedIn Link" },
];
export default function SettingsPage() {
const [settings, setSettings] = useState<Record<string, string>>({});
const [original, setOriginal] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
useEffect(() => {
async function load() {
try {
const data = await api.getSettings();
setSettings(data);
setOriginal(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
}
load();
}, []);
const handleSave = async () => {
setSaving(true);
setError("");
setSuccess("");
try {
const changed = Object.entries(settings).filter(
([key, value]) => value !== (original[key] || "")
);
await Promise.all(
changed.map(([key, value]) => api.updateSetting(key, value))
);
setOriginal({ ...settings });
setSuccess("Settings saved successfully.");
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
};
const hasChanges = Object.entries(settings).some(
([key, value]) => value !== (original[key] || "")
);
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading settings...</div>
</div>
);
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-on-surface">Site Settings</h1>
{error && <p className="text-error text-sm">{error}</p>}
{success && <p className="text-green-400 text-sm">{success}</p>}
<div className="bg-surface-container-low rounded-xl p-6 space-y-4">
{settingFields.map((field) => (
<div key={field.key}>
<label className="block text-on-surface/70 text-sm mb-1">{field.label}</label>
<input
value={settings[field.key] || ""}
onChange={(e) =>
setSettings({ ...settings, [field.key]: e.target.value })
}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
</div>
))}
<button
onClick={handleSave}
disabled={saving || !hasChanges}
className="flex items-center gap-2 px-6 py-3 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50 mt-2"
>
<Save size={16} />
{saving ? "Saving..." : "Save Settings"}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,226 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { shortenPubkey } from "@/lib/nostr";
import { formatDate } from "@/lib/utils";
import {
Clock,
CheckCircle,
XCircle,
Inbox,
} from "lucide-react";
interface Submission {
id: string;
eventId?: string;
naddr?: string;
title: string;
authorPubkey: string;
status: string;
reviewedBy?: string;
reviewNote?: string;
createdAt: string;
}
type FilterStatus = "ALL" | "PENDING" | "APPROVED" | "REJECTED";
const TABS: { value: FilterStatus; label: string }[] = [
{ value: "ALL", label: "All" },
{ value: "PENDING", label: "Pending" },
{ value: "APPROVED", label: "Approved" },
{ value: "REJECTED", label: "Rejected" },
];
export default function AdminSubmissionsPage() {
const [submissions, setSubmissions] = useState<Submission[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [filter, setFilter] = useState<FilterStatus>("PENDING");
const [reviewingId, setReviewingId] = useState<string | null>(null);
const [reviewNote, setReviewNote] = useState("");
const [processing, setProcessing] = useState(false);
const loadSubmissions = async () => {
try {
const status = filter === "ALL" ? undefined : filter;
const data = await api.getSubmissions(status);
setSubmissions(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
setLoading(true);
loadSubmissions();
}, [filter]);
const handleReview = async (id: string, status: "APPROVED" | "REJECTED") => {
setProcessing(true);
setError("");
try {
await api.reviewSubmission(id, { status, reviewNote: reviewNote.trim() || undefined });
setReviewingId(null);
setReviewNote("");
await loadSubmissions();
} catch (err: any) {
setError(err.message);
} finally {
setProcessing(false);
}
};
const pendingCount = submissions.filter((s) => s.status === "PENDING").length;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-on-surface">User Submissions</h1>
{pendingCount > 0 && filter !== "PENDING" && (
<span className="text-xs font-bold bg-primary/10 text-primary px-3 py-1 rounded-full">
{pendingCount} pending
</span>
)}
</div>
{error && <p className="text-error text-sm">{error}</p>}
<div className="flex gap-2">
{TABS.map((tab) => (
<button
key={tab.value}
onClick={() => setFilter(tab.value)}
className={`px-4 py-2 rounded-lg text-sm font-semibold transition-colors ${
filter === tab.value
? "bg-primary/20 text-primary"
: "bg-surface-container-highest text-on-surface/60 hover:text-on-surface"
}`}
>
{tab.label}
</button>
))}
</div>
{loading ? (
<div className="space-y-3">
{[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">
<Inbox size={32} className="text-on-surface-variant/30 mx-auto mb-3" />
<p className="text-on-surface-variant/60 text-sm">
No {filter !== "ALL" ? filter.toLowerCase() : ""} submissions.
</p>
</div>
) : (
<div className="space-y-3">
{submissions.map((sub) => (
<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">{sub.title}</h3>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-on-surface-variant/60 mt-1">
<span>by {shortenPubkey(sub.authorPubkey)}</span>
<span>{formatDate(sub.createdAt)}</span>
{sub.eventId && (
<span className="font-mono">{sub.eventId.slice(0, 16)}...</span>
)}
{sub.naddr && (
<span className="font-mono">{sub.naddr.slice(0, 20)}...</span>
)}
</div>
</div>
<StatusBadge status={sub.status} />
</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>
)}
{sub.status === "PENDING" && (
<div className="mt-4">
{reviewingId === sub.id ? (
<div className="space-y-3">
<textarea
value={reviewNote}
onChange={(e) => setReviewNote(e.target.value)}
placeholder="Optional review note..."
rows={2}
className="w-full bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 text-sm placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-1 focus:ring-primary/40 resize-none"
/>
<div className="flex gap-2">
<button
onClick={() => handleReview(sub.id, "APPROVED")}
disabled={processing}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-green-500/20 text-green-400 text-sm font-semibold hover:bg-green-500/30 transition-colors disabled:opacity-50"
>
<CheckCircle size={14} />
Approve
</button>
<button
onClick={() => handleReview(sub.id, "REJECTED")}
disabled={processing}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-error/20 text-error text-sm font-semibold hover:bg-error/30 transition-colors disabled:opacity-50"
>
<XCircle size={14} />
Reject
</button>
<button
onClick={() => {
setReviewingId(null);
setReviewNote("");
}}
className="px-4 py-2 rounded-lg bg-surface-container-highest text-on-surface/60 text-sm font-semibold hover:text-on-surface transition-colors"
>
Cancel
</button>
</div>
</div>
) : (
<button
onClick={() => setReviewingId(sub.id)}
className="text-sm font-semibold text-primary hover:underline"
>
Review
</button>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const config: Record<string, { icon: typeof Clock; className: string; label: string }> = {
PENDING: { icon: Clock, className: "text-primary bg-primary/10", label: "Pending" },
APPROVED: { icon: CheckCircle, className: "text-green-400 bg-green-400/10", label: "Approved" },
REJECTED: { icon: XCircle, className: "text-error bg-error/10", label: "Rejected" },
};
const cfg = config[status] || config.PENDING;
const Icon = cfg.icon;
return (
<span className={`flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold whitespace-nowrap ${cfg.className}`}>
<Icon size={14} />
{cfg.label}
</span>
);
}

View File

@@ -0,0 +1,162 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { cn } from "@/lib/utils";
import { formatDate } from "@/lib/utils";
import { ShieldCheck, ShieldOff, UserPlus } from "lucide-react";
export default function UsersPage() {
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [promotePubkey, setPromotePubkey] = useState("");
const [promoting, setPromoting] = useState(false);
const loadUsers = async () => {
try {
const data = await api.getUsers();
setUsers(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadUsers();
}, []);
const handlePromote = async () => {
if (!promotePubkey.trim()) return;
setPromoting(true);
setError("");
try {
await api.promoteUser(promotePubkey);
setPromotePubkey("");
await loadUsers();
} catch (err: any) {
setError(err.message);
} finally {
setPromoting(false);
}
};
const handleDemote = async (pubkey: string) => {
if (!confirm("Demote this user to regular user?")) return;
setError("");
try {
await api.demoteUser(pubkey);
await loadUsers();
} catch (err: any) {
setError(err.message);
}
};
const handlePromoteUser = async (pubkey: string) => {
setError("");
try {
await api.promoteUser(pubkey);
await loadUsers();
} catch (err: any) {
setError(err.message);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading users...</div>
</div>
);
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-on-surface">User Management</h1>
{error && <p className="text-error text-sm">{error}</p>}
<div className="bg-surface-container-low rounded-xl p-6">
<h2 className="text-sm font-semibold text-on-surface/70 mb-3">Promote User</h2>
<div className="flex gap-3">
<input
placeholder="Pubkey (hex)"
value={promotePubkey}
onChange={(e) => setPromotePubkey(e.target.value)}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40 flex-1"
/>
<button
onClick={handlePromote}
disabled={promoting || !promotePubkey.trim()}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50 whitespace-nowrap"
>
<UserPlus size={16} />
{promoting ? "Promoting..." : "Promote"}
</button>
</div>
</div>
<div className="space-y-3">
{users.length === 0 ? (
<p className="text-on-surface/50 text-sm">No users found.</p>
) : (
users.map((user) => (
<div
key={user.pubkey || user.id}
className="bg-surface-container-low rounded-xl p-6 flex items-center justify-between"
>
<div>
<p className="text-on-surface font-mono text-sm">
{user.pubkey?.slice(0, 12)}...{user.pubkey?.slice(-8)}
</p>
<div className="flex items-center gap-3 mt-2">
<span
className={cn(
"rounded-full px-3 py-1 text-xs font-bold",
user.role === "ADMIN"
? "bg-primary-container/20 text-primary"
: user.role === "MODERATOR"
? "bg-secondary-container text-on-secondary-container"
: "bg-surface-container-highest text-on-surface/50"
)}
>
{user.role}
</span>
{user.createdAt && (
<span className="text-on-surface/40 text-xs">
Joined {formatDate(user.createdAt)}
</span>
)}
</div>
</div>
{user.role !== "ADMIN" && (
<div className="flex items-center gap-2">
{user.role !== "MODERATOR" && (
<button
onClick={() => handlePromoteUser(user.pubkey)}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface-container-highest text-on-surface/70 hover:text-primary text-sm transition-colors"
>
<ShieldCheck size={14} />
Promote
</button>
)}
{user.role === "MODERATOR" && (
<button
onClick={() => handleDemote(user.pubkey)}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface-container-highest text-on-surface/70 hover:text-error text-sm transition-colors"
>
<ShieldOff size={14} />
Demote
</button>
)}
</div>
)}
</div>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,400 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { ArrowLeft, Heart, Send } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { api } from "@/lib/api";
import { formatDate } from "@/lib/utils";
import { hasNostrExtension, getPublicKey, signEvent, publishEvent, shortenPubkey, fetchNostrProfile, type NostrProfile } from "@/lib/nostr";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import type { Components } from "react-markdown";
interface Post {
id: string;
slug: string;
title: string;
content: string;
excerpt?: string;
authorName?: string;
authorPubkey?: string;
publishedAt?: string;
createdAt?: string;
nostrEventId?: string;
categories?: { category: { id: string; name: string; slug: string } }[];
}
interface NostrReply {
id: string;
pubkey: string;
content: string;
created_at: number;
}
const markdownComponents: Components = {
h1: ({ children }) => (
<h1 className="text-3xl font-bold text-on-surface mb-4 mt-10">{children}</h1>
),
h2: ({ children }) => (
<h2 className="text-2xl font-bold text-on-surface mb-4 mt-8">{children}</h2>
),
h3: ({ children }) => (
<h3 className="text-xl font-bold text-on-surface mb-3 mt-6">{children}</h3>
),
h4: ({ children }) => (
<h4 className="text-lg font-semibold text-on-surface mb-2 mt-4">{children}</h4>
),
p: ({ children }) => (
<p className="text-on-surface-variant leading-relaxed mb-6">{children}</p>
),
a: ({ href, children }) => (
<a
href={href}
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
ul: ({ children }) => (
<ul className="list-disc ml-6 mb-6 space-y-2 text-on-surface-variant">{children}</ul>
),
ol: ({ children }) => (
<ol className="list-decimal ml-6 mb-6 space-y-2 text-on-surface-variant">{children}</ol>
),
li: ({ children }) => (
<li className="leading-relaxed">{children}</li>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-primary/30 pl-4 italic text-on-surface-variant mb-6">
{children}
</blockquote>
),
code: ({ className, children }) => {
const isBlock = className?.includes("language-");
if (isBlock) {
return (
<code className={`${className} block`}>
{children}
</code>
);
}
return (
<code className="bg-surface-container-high px-2 py-1 rounded text-sm text-primary">
{children}
</code>
);
},
pre: ({ children }) => (
<pre className="bg-surface-container-highest p-4 rounded-lg overflow-x-auto mb-6 text-sm">
{children}
</pre>
),
img: ({ src, alt }) => (
<img src={src} alt={alt || ""} className="rounded-lg max-w-full mb-6" />
),
hr: () => <hr className="border-surface-container-high my-8" />,
table: ({ children }) => (
<div className="overflow-x-auto mb-6">
<table className="w-full text-left text-on-surface-variant">{children}</table>
</div>
),
th: ({ children }) => (
<th className="px-4 py-2 font-semibold text-on-surface bg-surface-container-high">
{children}
</th>
),
td: ({ children }) => (
<td className="px-4 py-2">{children}</td>
),
};
function ArticleSkeleton() {
const widths = [85, 92, 78, 95, 88, 72, 90, 83];
return (
<div className="animate-pulse max-w-3xl mx-auto">
<div className="flex gap-2 mb-6">
<div className="h-5 w-20 bg-surface-container-high rounded-full" />
<div className="h-5 w-16 bg-surface-container-high rounded-full" />
</div>
<div className="h-12 w-3/4 bg-surface-container-high rounded mb-4" />
<div className="h-12 w-1/2 bg-surface-container-high rounded mb-8" />
<div className="h-5 w-48 bg-surface-container-high rounded mb-16" />
<div className="space-y-4">
{widths.map((w, i) => (
<div
key={i}
className="h-4 bg-surface-container-high rounded"
style={{ width: `${w}%` }}
/>
))}
</div>
</div>
);
}
export default function BlogPostClient({ slug }: { slug: string }) {
const [post, setPost] = useState<Post | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [liked, setLiked] = useState(false);
const [likeCount, setLikeCount] = useState(0);
const [comment, setComment] = useState("");
const [replies, setReplies] = useState<NostrReply[]>([]);
const [hasNostr, setHasNostr] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [authorProfile, setAuthorProfile] = useState<NostrProfile | null>(null);
useEffect(() => {
setHasNostr(hasNostrExtension());
}, []);
useEffect(() => {
if (!slug) return;
setLoading(true);
setError(null);
api
.getPost(slug)
.then((data) => {
setPost(data);
if (data?.authorPubkey) {
fetchNostrProfile(data.authorPubkey)
.then((profile) => setAuthorProfile(profile))
.catch(() => {});
}
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [slug]);
useEffect(() => {
if (!slug) return;
api.getPostReactions(slug)
.then((data) => setLikeCount(data.count))
.catch(() => {});
api.getPostReplies(slug)
.then((data) => setReplies(data.replies || []))
.catch(() => {});
}, [slug]);
const handleLike = useCallback(async () => {
if (liked || !post?.nostrEventId || !hasNostr) return;
try {
const pubkey = await getPublicKey();
const reactionEvent = {
kind: 7,
created_at: Math.floor(Date.now() / 1000),
tags: [["e", post.nostrEventId], ["p", post.authorPubkey || ""]],
content: "+",
pubkey,
};
const signedReaction = await signEvent(reactionEvent);
await publishEvent(signedReaction);
setLiked(true);
setLikeCount((c) => c + 1);
} catch {
// User rejected or extension unavailable
}
}, [liked, post, hasNostr]);
const handleComment = useCallback(async () => {
if (!comment.trim() || !post?.nostrEventId || !hasNostr) return;
setSubmitting(true);
try {
const pubkey = await getPublicKey();
const replyEvent = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [["e", post.nostrEventId, "", "reply"], ["p", post.authorPubkey || ""]],
content: comment.trim(),
pubkey,
};
const signed = await signEvent(replyEvent);
await publishEvent(signed);
setReplies((prev) => [
...prev,
{
id: signed.id || Date.now().toString(),
pubkey,
content: comment.trim(),
created_at: Math.floor(Date.now() / 1000),
},
]);
setComment("");
} catch {
// User rejected or extension unavailable
} finally {
setSubmitting(false);
}
}, [comment, post, hasNostr]);
const categories = post?.categories?.map((c) => c.category) || [];
return (
<>
<Navbar />
<div className="min-h-screen">
<div className="max-w-3xl mx-auto px-8 pt-12 pb-24">
<Link
href="/blog"
className="inline-flex items-center gap-2 text-on-surface-variant hover:text-primary transition-colors mb-12 text-sm font-medium"
>
<ArrowLeft size={16} />
Back to Blog
</Link>
{loading && <ArticleSkeleton />}
{error && (
<div className="bg-error-container/20 text-error rounded-xl p-6">
Failed to load post: {error}
</div>
)}
{!loading && !error && post && (
<>
<header className="mb-16">
{categories.length > 0 && (
<div className="flex flex-wrap gap-2 mb-6">
{categories.map((cat) => (
<span
key={cat.id}
className="px-3 py-1 text-xs font-bold uppercase tracking-widest text-primary bg-primary/10 rounded-full"
>
{cat.name}
</span>
))}
</div>
)}
<h1 className="text-4xl md:text-5xl font-black tracking-tight leading-tight mb-6">
{post.title}
</h1>
<div className="flex items-center gap-3 text-sm text-on-surface-variant/60">
{(authorProfile || post.authorName || post.authorPubkey) && (
<div className="flex items-center gap-2.5">
{authorProfile?.picture && (
<img
src={authorProfile.picture}
alt={authorProfile.name || post.authorName || "Author"}
className="w-8 h-8 rounded-full object-cover bg-zinc-800 shrink-0"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
)}
<span className="font-medium text-on-surface-variant">
{authorProfile?.name || post.authorName || shortenPubkey(post.authorPubkey!)}
</span>
</div>
)}
{(post.publishedAt || post.createdAt) && (
<>
{(authorProfile || post.authorName || post.authorPubkey) && (
<span className="text-on-surface-variant/30">·</span>
)}
<span>
{formatDate(post.publishedAt || post.createdAt!)}
</span>
</>
)}
</div>
</header>
<article className="mb-16">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownComponents}
>
{post.content}
</ReactMarkdown>
</article>
<section className="bg-surface-container-low rounded-xl p-8 mb-16">
<div className="flex items-center gap-6 mb-8">
<button
onClick={handleLike}
disabled={!hasNostr}
title={hasNostr ? "Like this post" : "Install a Nostr extension to interact"}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
liked
? "bg-primary/20 text-primary"
: hasNostr
? "bg-surface-container-high text-on-surface hover:bg-surface-bright"
: "bg-surface-container-high text-on-surface/40 cursor-not-allowed"
}`}
>
<Heart size={18} fill={liked ? "currentColor" : "none"} />
<span className="font-semibold">{likeCount}</span>
</button>
{!hasNostr && (
<span className="text-on-surface-variant/50 text-xs">
Install a Nostr extension to like and comment
</span>
)}
</div>
<h3 className="text-lg font-bold mb-6">
Comments {replies.length > 0 && `(${replies.length})`}
</h3>
{hasNostr && (
<div className="flex gap-3 mb-8">
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Share your thoughts..."
rows={3}
className="flex-1 bg-surface-container-highest text-on-surface rounded-lg p-4 resize-none placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
<button
onClick={handleComment}
disabled={!comment.trim() || submitting}
className="self-end px-4 py-3 bg-primary text-on-primary rounded-lg font-semibold hover:scale-105 transition-transform disabled:opacity-30 disabled:cursor-not-allowed"
>
<Send size={18} />
</button>
</div>
)}
{replies.length > 0 ? (
<div className="space-y-6">
{replies.map((r) => (
<div
key={r.id}
className="bg-surface-container-high rounded-lg p-4"
>
<div className="flex items-center gap-2.5 mb-2">
<span className="font-semibold text-xs font-mono text-on-surface-variant/70">
{shortenPubkey(r.pubkey)}
</span>
<span className="text-on-surface-variant/30">·</span>
<span className="text-xs text-on-surface-variant/50">
{formatDate(new Date(r.created_at * 1000))}
</span>
</div>
<p className="text-on-surface-variant text-sm leading-relaxed">
{r.content}
</p>
</div>
))}
</div>
) : (
<p className="text-on-surface-variant/50 text-sm">
No comments yet. Be the first to share your thoughts.
</p>
)}
</section>
</>
)}
</div>
</div>
<Footer />
</>
);
}

View File

@@ -0,0 +1,84 @@
import type { Metadata } from "next";
import BlogPostClient from "./BlogPostClient";
import { BlogPostingJsonLd, BreadcrumbJsonLd } from "@/components/public/JsonLd";
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000/api";
async function fetchPost(slug: string) {
try {
const res = await fetch(`${apiUrl}/posts/${slug}`, {
next: { revalidate: 300 },
});
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await fetchPost(slug);
if (!post) {
return { title: "Post Not Found" };
}
const description =
post.excerpt ||
`Read "${post.title}" on the Belgian Bitcoin Embassy blog.`;
const author = post.authorName || "Belgian Bitcoin Embassy";
const ogImageUrl = `/og?title=${encodeURIComponent(post.title)}&type=blog`;
return {
title: post.title,
description,
openGraph: {
type: "article",
title: post.title,
description,
publishedTime: post.publishedAt || post.createdAt,
authors: [author],
images: [{ url: ogImageUrl, width: 1200, height: 630, alt: post.title }],
},
twitter: {
card: "summary_large_image",
title: post.title,
description,
images: [ogImageUrl],
},
alternates: { canonical: `/blog/${slug}` },
};
}
export default async function BlogDetailPage({ params }: Props) {
const { slug } = await params;
const post = await fetchPost(slug);
return (
<>
{post && (
<>
<BlogPostingJsonLd
title={post.title}
description={post.excerpt || `Read "${post.title}" on the Belgian Bitcoin Embassy blog.`}
slug={slug}
publishedAt={post.publishedAt || post.createdAt}
authorName={post.authorName}
/>
<BreadcrumbJsonLd
items={[
{ name: "Home", href: "/" },
{ name: "Blog", href: "/blog" },
{ name: post.title, href: `/blog/${slug}` },
]}
/>
</>
)}
<BlogPostClient slug={slug} />
</>
);
}

View File

@@ -0,0 +1,17 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Blog - Curated Bitcoin Content from Nostr",
description:
"Read curated Bitcoin articles from the Nostr network. Education, technical analysis, and community insights from the Belgian Bitcoin Embassy.",
openGraph: {
title: "Blog - Belgian Bitcoin Embassy",
description:
"Curated Bitcoin content from the Nostr network. Education, analysis, and insights.",
},
alternates: { canonical: "/blog" },
};
export default function BlogLayout({ children }: { children: React.ReactNode }) {
return children;
}

283
frontend/app/blog/page.tsx Normal file
View File

@@ -0,0 +1,283 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { ArrowRight, ArrowLeft, ChevronRight } from "lucide-react";
import { api } from "@/lib/api";
import { formatDate } from "@/lib/utils";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
interface Post {
id: string;
slug: string;
title: string;
excerpt?: string;
content?: string;
author?: string;
authorPubkey?: string;
publishedAt?: string;
createdAt?: string;
categories?: { id: string; name: string; slug: string }[];
featured?: boolean;
}
interface Category {
id: string;
name: string;
slug: string;
}
function PostCardSkeleton() {
return (
<div className="bg-surface-container-low rounded-xl overflow-hidden animate-pulse">
<div className="p-6 space-y-4">
<div className="flex gap-2">
<div className="h-5 w-16 bg-surface-container-high rounded-full" />
<div className="h-5 w-20 bg-surface-container-high rounded-full" />
</div>
<div className="h-7 w-3/4 bg-surface-container-high rounded" />
<div className="space-y-2">
<div className="h-4 w-full bg-surface-container-high rounded" />
<div className="h-4 w-2/3 bg-surface-container-high rounded" />
</div>
<div className="flex justify-between items-center pt-4">
<div className="h-4 w-32 bg-surface-container-high rounded" />
<div className="h-4 w-24 bg-surface-container-high rounded" />
</div>
</div>
</div>
);
}
function FeaturedPostSkeleton() {
return (
<div className="bg-surface-container-low rounded-xl overflow-hidden animate-pulse mb-12">
<div className="p-8 md:p-12 space-y-4">
<div className="h-5 w-24 bg-surface-container-high rounded-full" />
<div className="h-10 w-2/3 bg-surface-container-high rounded" />
<div className="space-y-2 max-w-2xl">
<div className="h-4 w-full bg-surface-container-high rounded" />
<div className="h-4 w-full bg-surface-container-high rounded" />
<div className="h-4 w-1/2 bg-surface-container-high rounded" />
</div>
<div className="h-4 w-48 bg-surface-container-high rounded" />
</div>
</div>
);
}
export default function BlogPage() {
const [posts, setPosts] = useState<Post[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [activeCategory, setActiveCategory] = useState<string>("all");
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const limit = 9;
useEffect(() => {
api.getCategories().then(setCategories).catch(() => {});
}, []);
useEffect(() => {
setLoading(true);
setError(null);
api
.getPosts({
category: activeCategory === "all" ? undefined : activeCategory,
page,
limit,
})
.then(({ posts: data, total: t }) => {
setPosts(data);
setTotal(t);
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [activeCategory, page]);
const totalPages = Math.ceil(total / limit);
const featured = posts.find((p) => p.featured);
const regularPosts = featured ? posts.filter((p) => p.id !== featured.id) : posts;
return (
<>
<Navbar />
<div className="min-h-screen">
<header className="pt-24 pb-16 px-8">
<div className="max-w-7xl mx-auto">
<p className="uppercase tracking-[0.2em] text-primary mb-4 font-semibold text-sm">
From the Nostr Network
</p>
<h1 className="text-5xl md:text-7xl font-black tracking-tighter mb-4">
Blog
</h1>
<p className="text-xl text-on-surface-variant max-w-xl leading-relaxed">
Curated Bitcoin content from the Nostr network
</p>
</div>
</header>
<div className="max-w-7xl mx-auto px-8 mb-12">
<div className="flex flex-wrap gap-3">
<button
onClick={() => { setActiveCategory("all"); setPage(1); }}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeCategory === "all"
? "bg-primary text-on-primary"
: "bg-surface-container-high text-on-surface hover:bg-surface-bright"
}`}
>
All
</button>
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => { setActiveCategory(cat.slug); setPage(1); }}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeCategory === cat.slug
? "bg-primary text-on-primary"
: "bg-surface-container-high text-on-surface hover:bg-surface-bright"
}`}
>
{cat.name}
</button>
))}
</div>
</div>
<div className="max-w-7xl mx-auto px-8 pb-24">
{error && (
<div className="bg-error-container/20 text-error rounded-xl p-6 mb-8">
Failed to load posts: {error}
</div>
)}
{loading ? (
<>
<FeaturedPostSkeleton />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{Array.from({ length: 6 }).map((_, i) => (
<PostCardSkeleton key={i} />
))}
</div>
</>
) : posts.length === 0 ? (
<div className="text-center py-24">
<p className="text-2xl font-bold text-on-surface-variant mb-2">
No posts yet
</p>
<p className="text-on-surface-variant/60">
Check back soon for curated Bitcoin content.
</p>
</div>
) : (
<>
{featured && page === 1 && (
<Link
href={`/blog/${featured.slug}`}
className="block bg-surface-container-low rounded-xl overflow-hidden mb-12 group hover:bg-surface-container-high transition-colors"
>
<div className="p-8 md:p-12">
<span className="inline-block px-3 py-1 text-xs font-bold uppercase tracking-widest text-primary bg-primary/10 rounded-full mb-6">
Featured
</span>
<h2 className="text-3xl md:text-4xl font-black tracking-tight mb-4 group-hover:text-primary transition-colors">
{featured.title}
</h2>
{featured.excerpt && (
<p className="text-on-surface-variant text-lg leading-relaxed max-w-2xl mb-6">
{featured.excerpt}
</p>
)}
<div className="flex items-center gap-4 text-sm text-on-surface-variant/60">
{featured.author && <span>{featured.author}</span>}
{featured.publishedAt && (
<span>{formatDate(featured.publishedAt)}</span>
)}
</div>
</div>
</Link>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{regularPosts.map((post) => (
<Link
key={post.id}
href={`/blog/${post.slug}`}
className="group flex flex-col bg-zinc-900 border border-zinc-800 rounded-xl p-6 hover:border-zinc-700 hover:-translate-y-0.5 hover:shadow-xl transition-all duration-200"
>
{post.categories && post.categories.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{post.categories.map((cat) => (
<span
key={cat.id}
className="text-primary text-[10px] uppercase tracking-widest font-bold"
>
{cat.name}
</span>
))}
</div>
)}
<h3 className="font-bold text-base mb-3 leading-snug group-hover:text-primary transition-colors">
{post.title}
</h3>
{post.excerpt && (
<p className="text-on-surface-variant text-sm leading-relaxed mb-5 flex-1 line-clamp-3">
{post.excerpt}
</p>
)}
<div className="flex items-center justify-between mt-auto pt-4 border-t border-zinc-800/60">
<div className="flex items-center gap-2 text-xs text-on-surface-variant/50">
{post.author && <span>{post.author}</span>}
{post.author && (post.publishedAt || post.createdAt) && <span>·</span>}
{(post.publishedAt || post.createdAt) && (
<span>
{formatDate(post.publishedAt || post.createdAt!)}
</span>
)}
</div>
<span className="text-primary text-xs font-semibold flex items-center gap-1.5 group-hover:gap-2.5 transition-all">
Read <ArrowRight size={12} />
</span>
</div>
</Link>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-4 mt-16">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-surface-container-high text-on-surface font-medium transition-colors hover:bg-surface-bright disabled:opacity-30 disabled:cursor-not-allowed"
>
<ArrowLeft size={16} /> Previous
</button>
<span className="text-sm text-on-surface-variant">
Page {page} of {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-surface-container-high text-on-surface font-medium transition-colors hover:bg-surface-bright disabled:opacity-30 disabled:cursor-not-allowed"
>
Next <ChevronRight size={16} />
</button>
</div>
)}
</>
)}
</div>
</div>
<Footer />
</>
);
}

View File

@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
export async function GET() {
let upstream: Response;
try {
upstream = await fetch(`${API_URL}/calendar/ics`, {
headers: { Accept: 'text/calendar' },
cache: 'no-store',
});
} catch {
return new NextResponse('Calendar service unavailable', { status: 502 });
}
if (!upstream.ok) {
return new NextResponse('Failed to fetch calendar', { status: upstream.status });
}
const body = await upstream.text();
return new NextResponse(body, {
status: 200,
headers: {
'Content-Type': 'text/calendar; charset=utf-8',
'Cache-Control': 'public, max-age=300',
'Content-Disposition': 'inline; filename="bbe-events.ics"',
},
});
}

View File

@@ -0,0 +1,17 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Community - Connect with Belgian Bitcoiners",
description:
"Join the Belgian Bitcoin Embassy community on Telegram, Nostr, X, YouTube, Discord, and LinkedIn. Connect with Bitcoiners across Belgium.",
openGraph: {
title: "Community - Belgian Bitcoin Embassy",
description:
"Connect with Belgian Bitcoiners across every platform.",
},
alternates: { canonical: "/community" },
};
export default function CommunityLayout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -0,0 +1,33 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import { CommunityLinksSection } from "@/components/public/CommunityLinksSection";
export default function CommunityPage() {
const [settings, setSettings] = useState<Record<string, string>>({});
useEffect(() => {
api.getPublicSettings()
.then((data) => setSettings(data))
.catch(() => {});
}, []);
return (
<>
<Navbar />
<div className="min-h-screen">
<div className="max-w-3xl mx-auto px-8 pt-16 pb-4">
<h1 className="text-4xl font-black mb-4">Community</h1>
<p className="text-on-surface-variant text-lg">
Connect with Belgian Bitcoiners across every platform.
</p>
</div>
<CommunityLinksSection settings={settings} />
</div>
<Footer />
</>
);
}

View File

@@ -0,0 +1,86 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import { Send, Zap, ExternalLink } from "lucide-react";
export const metadata: Metadata = {
title: "Contact Us",
description:
"Get in touch with the Belgian Bitcoin Embassy community through Telegram, Nostr, or X. Join our monthly Bitcoin meetups in Belgium.",
openGraph: {
title: "Contact the Belgian Bitcoin Embassy",
description:
"Reach the Belgian Bitcoin community through our decentralized channels.",
},
alternates: { canonical: "/contact" },
};
export default function ContactPage() {
return (
<>
<Navbar />
<div className="min-h-screen">
<div className="max-w-3xl mx-auto px-8 pt-16 pb-24">
<h1 className="text-4xl font-black mb-4">Contact</h1>
<p className="text-on-surface-variant text-lg mb-12">
The best way to reach us is through our community channels. We are a
decentralized community there is no central office or email inbox.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<a
href="https://t.me/belgianbitcoinembassy"
target="_blank"
rel="noopener noreferrer"
className="bg-surface-container-low p-8 rounded-xl hover:bg-surface-container transition-colors group"
>
<Send size={28} className="text-primary mb-4" />
<h2 className="text-xl font-bold mb-2">Telegram</h2>
<p className="text-on-surface-variant text-sm">
Join our Telegram group for quick questions and community chat.
</p>
</a>
<a
href="#"
className="bg-surface-container-low p-8 rounded-xl hover:bg-surface-container transition-colors group"
>
<Zap size={28} className="text-primary mb-4" />
<h2 className="text-xl font-bold mb-2">Nostr</h2>
<p className="text-on-surface-variant text-sm">
Follow us on Nostr for censorship-resistant communication.
</p>
</a>
<a
href="#"
className="bg-surface-container-low p-8 rounded-xl hover:bg-surface-container transition-colors group"
>
<ExternalLink size={28} className="text-primary mb-4" />
<h2 className="text-xl font-bold mb-2">X (Twitter)</h2>
<p className="text-on-surface-variant text-sm">
Follow us on X for announcements and updates.
</p>
</a>
<div className="bg-surface-container-low p-8 rounded-xl">
<h2 className="text-xl font-bold mb-2">Meetups</h2>
<p className="text-on-surface-variant text-sm mb-4">
The best way to connect is in person. Come to our monthly meetup
in Brussels.
</p>
<Link
href="/#meetup"
className="text-primary font-bold text-sm hover:underline"
>
See next meetup
</Link>
</div>
</div>
</div>
</div>
<Footer />
</>
);
}

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

View File

@@ -0,0 +1,162 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { ArrowLeft, MapPin, Clock, Calendar, ExternalLink } from "lucide-react";
import { api } from "@/lib/api";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
function formatFullDate(dateStr: string) {
const d = new Date(dateStr);
return d.toLocaleString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
}
function DateBadge({ dateStr }: { dateStr: string }) {
const d = new Date(dateStr);
const month = d.toLocaleString("en-US", { month: "short" }).toUpperCase();
const day = String(d.getDate());
return (
<div className="bg-zinc-800 rounded-xl px-4 py-3 text-center shrink-0 min-w-[60px]">
<span className="block text-[11px] font-bold uppercase text-primary tracking-wider leading-none mb-1">
{month}
</span>
<span className="block text-3xl font-black leading-none">{day}</span>
</div>
);
}
function EventSkeleton() {
return (
<div className="animate-pulse max-w-3xl mx-auto">
<div className="h-64 bg-zinc-800 rounded-2xl mb-10" />
<div className="h-8 w-3/4 bg-zinc-800 rounded mb-4" />
<div className="h-5 w-1/2 bg-zinc-800 rounded mb-8" />
<div className="space-y-3">
{[90, 80, 95, 70].map((w, i) => (
<div key={i} className="h-4 bg-zinc-800 rounded" style={{ width: `${w}%` }} />
))}
</div>
</div>
);
}
export default function EventDetailClient({ id }: { id: string }) {
const [meetup, setMeetup] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!id) return;
setLoading(true);
api
.getMeetup(id)
.then(setMeetup)
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [id]);
const isPast = meetup ? new Date(meetup.date) < new Date() : false;
return (
<>
<Navbar />
<div className="min-h-screen">
<div className="max-w-3xl mx-auto px-8 pt-12 pb-24">
<Link
href="/events"
className="inline-flex items-center gap-2 text-on-surface-variant hover:text-primary transition-colors mb-12 text-sm font-medium"
>
<ArrowLeft size={16} />
All Events
</Link>
{loading && <EventSkeleton />}
{error && (
<div className="bg-red-900/20 text-red-400 rounded-xl p-6 text-sm">
Failed to load event: {error}
</div>
)}
{!loading && !error && meetup && (
<>
{meetup.imageId && (
<div className="rounded-2xl overflow-hidden mb-10 aspect-video bg-zinc-800">
<img
src={`/media/${meetup.imageId}`}
alt={meetup.title}
className="w-full h-full object-cover"
/>
</div>
)}
<div className="flex items-start gap-5 mb-8">
<DateBadge dateStr={meetup.date} />
<div className="min-w-0">
{isPast && (
<span className="inline-block text-[10px] font-bold uppercase tracking-widest text-on-surface-variant/50 bg-zinc-800 px-2.5 py-1 rounded-full mb-3">
Past Event
</span>
)}
{!isPast && (
<span className="inline-block text-[10px] font-bold uppercase tracking-widest text-primary bg-primary/10 px-2.5 py-1 rounded-full mb-3">
Upcoming
</span>
)}
<h1 className="text-3xl md:text-4xl font-black tracking-tight leading-tight">
{meetup.title}
</h1>
</div>
</div>
<div className="flex flex-wrap gap-4 mb-10 text-sm text-on-surface-variant">
<div className="flex items-center gap-2">
<Calendar size={15} className="text-primary/70 shrink-0" />
{formatFullDate(meetup.date)}
</div>
{meetup.time && (
<div className="flex items-center gap-2">
<Clock size={15} className="text-primary/70 shrink-0" />
{meetup.time}
</div>
)}
{meetup.location && (
<div className="flex items-center gap-2">
<MapPin size={15} className="text-primary/70 shrink-0" />
{meetup.location}
</div>
)}
</div>
{meetup.description && (
<div className="prose prose-invert max-w-none mb-12">
<p className="text-on-surface-variant leading-relaxed text-base whitespace-pre-wrap">
{meetup.description}
</p>
</div>
)}
{meetup.link && (
<a
href={meetup.link}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 bg-primary text-on-primary px-8 py-4 rounded-xl font-bold text-sm hover:opacity-90 transition-opacity"
>
Register for this event <ExternalLink size={16} />
</a>
)}
</>
)}
</div>
</div>
<Footer />
</>
);
}

View File

@@ -0,0 +1,86 @@
import type { Metadata } from "next";
import EventDetailClient from "./EventDetailClient";
import { EventJsonLd, BreadcrumbJsonLd } from "@/components/public/JsonLd";
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000/api";
async function fetchEvent(id: string) {
try {
const res = await fetch(`${apiUrl}/meetups/${id}`, {
next: { revalidate: 300 },
});
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
interface Props {
params: Promise<{ id: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const event = await fetchEvent(id);
if (!event) {
return { title: "Event Not Found" };
}
const description =
event.description?.slice(0, 160) ||
`Bitcoin meetup: ${event.title}${event.location ? ` in ${event.location}` : ""}. Organized by the Belgian Bitcoin Embassy.`;
const ogImage = event.imageId
? `/media/${event.imageId}`
: `/og?title=${encodeURIComponent(event.title)}&type=event`;
return {
title: event.title,
description,
openGraph: {
type: "article",
title: event.title,
description,
images: [{ url: ogImage, width: 1200, height: 630, alt: event.title }],
},
twitter: {
card: "summary_large_image",
title: event.title,
description,
images: [ogImage],
},
alternates: { canonical: `/events/${id}` },
};
}
export default async function EventDetailPage({ params }: Props) {
const { id } = await params;
const event = await fetchEvent(id);
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
return (
<>
{event && (
<>
<EventJsonLd
name={event.title}
description={event.description}
startDate={event.date}
location={event.location}
url={`${siteUrl}/events/${id}`}
imageUrl={event.imageId ? `${siteUrl}/media/${event.imageId}` : undefined}
/>
<BreadcrumbJsonLd
items={[
{ name: "Home", href: "/" },
{ name: "Events", href: "/events" },
{ name: event.title, href: `/events/${id}` },
]}
/>
</>
)}
<EventDetailClient id={id} />
</>
);
}

View File

@@ -0,0 +1,17 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Events - Bitcoin Meetups in Belgium",
description:
"Browse upcoming and past Bitcoin meetups in Belgium organized by the Belgian Bitcoin Embassy. Monthly gatherings for education and community.",
openGraph: {
title: "Events - Belgian Bitcoin Embassy",
description:
"Upcoming and past Bitcoin meetups in Belgium. Join the community.",
},
alternates: { canonical: "/events" },
};
export default function EventsLayout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -0,0 +1,190 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { MapPin, Clock, ArrowRight } from "lucide-react";
import { api } from "@/lib/api";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
function formatMeetupDate(dateStr: string) {
const d = new Date(dateStr);
return {
month: d.toLocaleString("en-US", { month: "short" }).toUpperCase(),
day: String(d.getDate()),
full: d.toLocaleString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
}),
};
}
function MeetupCard({ meetup, muted = false }: { meetup: any; muted?: boolean }) {
const { month, day, full } = formatMeetupDate(meetup.date);
return (
<Link
href={`/events/${meetup.id}`}
className={`group flex flex-col bg-zinc-900 border rounded-xl p-6 hover:-translate-y-0.5 hover:shadow-xl transition-all duration-200 ${
muted
? "border-zinc-800/60 opacity-70 hover:opacity-100 hover:border-zinc-700"
: "border-zinc-800 hover:border-zinc-700"
}`}
>
<div className="flex items-start gap-4 mb-4">
<div className={`rounded-lg px-3 py-2 text-center shrink-0 min-w-[52px] ${muted ? "bg-zinc-800/60" : "bg-zinc-800"}`}>
<span className={`block text-[10px] font-bold uppercase tracking-wider leading-none mb-0.5 ${muted ? "text-on-surface-variant/50" : "text-primary"}`}>
{month}
</span>
<span className="block text-2xl font-black leading-none">{day}</span>
</div>
<div className="min-w-0">
<h3 className="font-bold text-base leading-snug group-hover:text-primary transition-colors">
{meetup.title}
</h3>
<p className="text-on-surface-variant/60 text-xs mt-1">{full}</p>
</div>
</div>
{meetup.description && (
<p className="text-on-surface-variant text-sm leading-relaxed mb-4 flex-1 line-clamp-2">
{meetup.description}
</p>
)}
<div className="flex flex-col gap-1.5 mt-auto pt-4 border-t border-zinc-800/60">
{meetup.location && (
<p className="flex items-center gap-1.5 text-xs text-on-surface-variant/60">
<MapPin size={12} className={`shrink-0 ${muted ? "text-on-surface-variant/40" : "text-primary/60"}`} />
{meetup.location}
</p>
)}
{meetup.time && (
<p className="flex items-center gap-1.5 text-xs text-on-surface-variant/60">
<Clock size={12} className={`shrink-0 ${muted ? "text-on-surface-variant/40" : "text-primary/60"}`} />
{meetup.time}
</p>
)}
</div>
<span className={`flex items-center gap-1.5 text-xs font-semibold mt-4 group-hover:gap-2.5 transition-all ${muted ? "text-on-surface-variant/50" : "text-primary"}`}>
View Details <ArrowRight size={12} />
</span>
</Link>
);
}
function CardSkeleton() {
return (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6 animate-pulse">
<div className="flex items-start gap-4 mb-4">
<div className="bg-zinc-800 rounded-lg w-[52px] h-[58px] shrink-0" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-zinc-800 rounded w-3/4" />
<div className="h-3 bg-zinc-800 rounded w-1/2" />
</div>
</div>
<div className="space-y-2 mb-4">
<div className="h-3 bg-zinc-800 rounded w-full" />
<div className="h-3 bg-zinc-800 rounded w-5/6" />
</div>
</div>
);
}
export default function EventsPage() {
const [meetups, setMeetups] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api
.getMeetups()
.then((data: any) => {
const list = Array.isArray(data) ? data : [];
setMeetups(list);
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, []);
const now = new Date();
const upcoming = meetups.filter((m) => new Date(m.date) >= now);
const past = meetups.filter((m) => new Date(m.date) < now).reverse();
return (
<>
<Navbar />
<div className="min-h-screen">
<header className="pt-24 pb-12 px-8">
<div className="max-w-6xl mx-auto">
<p className="uppercase tracking-[0.2em] text-primary mb-2 font-semibold text-xs">
Belgian Bitcoin Embassy
</p>
<h1 className="text-4xl md:text-6xl font-black tracking-tighter mb-4">
All Events
</h1>
<p className="text-on-surface-variant max-w-md leading-relaxed">
Past and upcoming Bitcoin meetups in Belgium.
</p>
</div>
</header>
<div className="max-w-6xl mx-auto px-8 pb-24 space-y-20">
{error && (
<div className="bg-red-900/20 text-red-400 rounded-xl p-6 text-sm">
Failed to load events: {error}
</div>
)}
<div>
<h2 className="text-xl font-black mb-8 flex items-center gap-3">
Upcoming
{!loading && upcoming.length > 0 && (
<span className="text-xs font-bold bg-primary/10 text-primary px-2.5 py-1 rounded-full">
{upcoming.length}
</span>
)}
</h2>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{[0, 1, 2].map((i) => <CardSkeleton key={i} />)}
</div>
) : upcoming.length === 0 ? (
<div className="border border-zinc-800/60 rounded-xl px-8 py-12 text-center">
<p className="text-on-surface-variant text-sm">
No upcoming events scheduled. Check back soon.
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{upcoming.map((m) => <MeetupCard key={m.id} meetup={m} />)}
</div>
)}
</div>
{(loading || past.length > 0) && (
<div>
<h2 className="text-xl font-black mb-8 text-on-surface-variant/60">
Past Events
</h2>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{[0, 1, 2].map((i) => <CardSkeleton key={i} />)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{past.map((m) => <MeetupCard key={m.id} meetup={m} muted />)}
</div>
)}
</div>
)}
</div>
</div>
<Footer />
</>
);
}

View File

@@ -0,0 +1,17 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "FAQ - Frequently Asked Questions",
description:
"Everything you need to know about the Belgian Bitcoin Embassy. Common questions about Bitcoin meetups, community, education, and how to get involved.",
openGraph: {
title: "FAQ - Belgian Bitcoin Embassy",
description:
"Answers to common questions about the Belgian Bitcoin Embassy.",
},
alternates: { canonical: "/faq" },
};
export default function FaqLayout({ children }: { children: React.ReactNode }) {
return children;
}

103
frontend/app/faq/page.tsx Normal file
View File

@@ -0,0 +1,103 @@
"use client";
import { useEffect, useState } from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { api } from "@/lib/api";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import { FaqPageJsonLd } from "@/components/public/JsonLd";
interface FaqItem {
id: string;
question: string;
answer: string;
order: number;
showOnHomepage: boolean;
}
export default function FaqPage() {
const [items, setItems] = useState<FaqItem[]>([]);
const [openIndex, setOpenIndex] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.getFaqsAll()
.then((data) => {
if (Array.isArray(data)) setItems(data);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
return (
<>
{items.length > 0 && (
<FaqPageJsonLd items={items.map((i) => ({ question: i.question, answer: i.answer }))} />
)}
<Navbar />
<div className="min-h-screen">
<div className="max-w-3xl mx-auto px-8 pt-16 pb-24">
<h1 className="text-4xl font-black mb-4">Frequently Asked Questions</h1>
<p className="text-on-surface-variant text-lg mb-12">
Everything you need to know about the Belgian Bitcoin Embassy.
</p>
{loading && (
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="bg-surface-container-low rounded-xl h-[72px] animate-pulse" />
))}
</div>
)}
{!loading && items.length === 0 && (
<p className="text-on-surface-variant">No FAQs available yet.</p>
)}
{!loading && items.length > 0 && (
<div className="space-y-4">
{items.map((item, i) => {
const isOpen = openIndex === i;
return (
<div
key={item.id}
className="bg-surface-container-low rounded-xl overflow-hidden"
>
<button
onClick={() => setOpenIndex(isOpen ? null : i)}
className="w-full flex items-center justify-between p-6 text-left"
>
<span className="text-lg font-bold pr-4">{item.question}</span>
<ChevronDown
size={20}
className={cn(
"shrink-0 text-primary transition-transform duration-200",
isOpen && "rotate-180"
)}
/>
</button>
<div
className={cn(
"grid transition-all duration-200",
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
)}
>
<div className="overflow-hidden">
<p className="px-6 pb-6 text-on-surface-variant leading-relaxed">
{item.answer}
</p>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
<Footer />
</>
);
}

View File

@@ -0,0 +1,134 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { ArrowLeft, Download, Film } from "lucide-react";
import { api } from "@/lib/api";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
interface MediaItem {
id: string;
slug: string;
type: "image" | "video";
mimeType: string;
size: number;
originalFilename: string;
createdAt: string;
url: string;
title?: string;
description?: string;
altText?: string;
}
function extractUlid(slugParam: string): string {
const parts = slugParam.split("-");
return parts[parts.length - 1];
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export default function GalleryDetailPage() {
const { slug } = useParams<{ slug: string }>();
const [media, setMedia] = useState<MediaItem | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!slug) return;
const id = extractUlid(slug);
api
.getMedia(id)
.then((data) => setMedia(data))
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [slug]);
return (
<>
<Navbar />
<div className="min-h-screen">
<div className="max-w-4xl mx-auto px-8 pt-12 pb-24">
<Link
href="/"
className="inline-flex items-center gap-2 text-on-surface-variant hover:text-primary transition-colors mb-12 text-sm font-medium"
>
<ArrowLeft size={16} />
Back
</Link>
{loading && (
<div className="flex items-center justify-center py-24">
<div className="text-on-surface/50">Loading...</div>
</div>
)}
{error && (
<div className="bg-error-container/20 text-error rounded-xl p-6">
Media not found or failed to load.
</div>
)}
{!loading && !error && media && (
<div>
<div className="rounded-2xl overflow-hidden bg-surface-container-lowest mb-8">
{media.type === "image" ? (
<img
src={`/media/${media.id}`}
alt={media.altText || media.title || media.originalFilename}
className="w-full h-auto max-h-[80vh] object-contain mx-auto"
/>
) : (
<video
controls
src={`/media/${media.id}`}
className="w-full max-h-[80vh]"
>
Your browser does not support the video tag.
</video>
)}
</div>
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-bold text-on-surface mb-2">
{media.title || media.originalFilename}
</h1>
{media.description && (
<p className="text-on-surface-variant/70 text-sm mb-3 max-w-2xl">
{media.description}
</p>
)}
<div className="flex items-center gap-3 text-sm text-on-surface-variant/60">
<span className="flex items-center gap-1.5">
{media.type === "video" ? <Film size={14} /> : null}
{media.type.charAt(0).toUpperCase() + media.type.slice(1)}
</span>
<span>{formatFileSize(media.size)}</span>
<span>{media.mimeType}</span>
</div>
</div>
<a
href={`/media/${media.id}`}
download={media.originalFilename}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-surface-container-high text-on-surface hover:bg-surface-container-highest transition-colors text-sm font-medium"
>
<Download size={16} />
Download
</a>
</div>
</div>
)}
</div>
</div>
<Footer />
</>
);
}

34
frontend/app/globals.css Normal file
View File

@@ -0,0 +1,34 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');
body {
background-color: #09090b;
color: #e5e2e1;
font-family: 'Inter', sans-serif;
-webkit-font-smoothing: antialiased;
}
.glass-effect {
background: rgba(57, 57, 57, 0.4);
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
}
.asymmetric-grid {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
}
@media (max-width: 768px) {
.asymmetric-grid {
grid-template-columns: 1fr;
}
}
::selection {
background-color: #f7931a;
color: #603500;
}

92
frontend/app/layout.tsx Normal file
View File

@@ -0,0 +1,92 @@
import type { Metadata, Viewport } from "next";
import { ClientProviders } from "@/components/providers/ClientProviders";
import { OrganizationJsonLd, WebSiteJsonLd } from "@/components/public/JsonLd";
import "./globals.css";
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
export const metadata: Metadata = {
metadataBase: new URL(siteUrl),
title: {
default: "Belgian Bitcoin Embassy | Bitcoin Meetups & Education in Belgium",
template: "%s | Belgian Bitcoin Embassy",
},
description:
"Belgium's sovereign Bitcoin community. Monthly meetups in Antwerp, Bitcoin education, and curated Nostr content. No hype, just signal.",
keywords: [
"Bitcoin",
"Belgium",
"Antwerp",
"Bitcoin meetup",
"Bitcoin education",
"Nostr",
"Belgian Bitcoin Embassy",
"Bitcoin community Belgium",
"Bitcoin events Antwerp",
],
authors: [{ name: "Belgian Bitcoin Embassy" }],
creator: "Belgian Bitcoin Embassy",
publisher: "Belgian Bitcoin Embassy",
openGraph: {
type: "website",
locale: "en_BE",
siteName: "Belgian Bitcoin Embassy",
title: "Belgian Bitcoin Embassy | Bitcoin Meetups & Education in Belgium",
description:
"Belgium's sovereign Bitcoin community. Monthly meetups, education, and curated Nostr content.",
images: [
{
url: "/og-default.png",
width: 1200,
height: 630,
alt: "Belgian Bitcoin Embassy - Bitcoin Meetups & Education in Belgium",
},
],
},
twitter: {
card: "summary_large_image",
title: "Belgian Bitcoin Embassy",
description:
"Belgium's sovereign Bitcoin community. Monthly meetups, education, and curated Nostr content.",
images: ["/og-default.png"],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
icons: {
icon: [
{ url: "/favicon.svg", type: "image/svg+xml" },
{ url: "/favicon.ico", sizes: "32x32" },
],
apple: "/apple-touch-icon.png",
},
alternates: {
canonical: "/",
},
};
export const viewport: Viewport = {
themeColor: "#F7931A",
width: "device-width",
initialScale: 1,
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" dir="ltr" className="dark">
<body>
<OrganizationJsonLd />
<WebSiteJsonLd />
<ClientProviders>{children}</ClientProviders>
</body>
</html>
);
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Sign In",
description: "Sign in to the Belgian Bitcoin Embassy with your Nostr identity.",
robots: { index: false, follow: false },
};
export default function LoginLayout({ children }: { children: React.ReactNode }) {
return children;
}

292
frontend/app/login/page.tsx Normal file
View File

@@ -0,0 +1,292 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { LogIn, Puzzle, Smartphone, RefreshCw, Link2 } from "lucide-react";
import { QRCodeSVG } from "qrcode.react";
import { useAuth } from "@/hooks/useAuth";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import {
generateNostrConnectSetup,
waitForNostrConnectSigner,
} from "@/lib/nostr";
type Tab = "extension" | "external";
export default function LoginPage() {
const { user, loading, login, loginWithBunker, loginWithConnectedSigner } =
useAuth();
const router = useRouter();
const [activeTab, setActiveTab] = useState<Tab>("extension");
const [error, setError] = useState("");
const [loggingIn, setLoggingIn] = useState(false);
// Extension tab
// (no extra state needed)
// External signer tab — QR section
const [qrUri, setQrUri] = useState<string | null>(null);
const [qrStatus, setQrStatus] = useState<
"generating" | "waiting" | "connecting"
>("generating");
const qrAbortRef = useRef<AbortController | null>(null);
const qrSecretRef = useRef<Uint8Array | null>(null);
// External signer tab — bunker URI section
const [bunkerInput, setBunkerInput] = useState("");
useEffect(() => {
if (!loading && user) redirectByRole(user.role);
}, [user, loading]);
function redirectByRole(role: string) {
if (role === "ADMIN" || role === "MODERATOR") {
router.push("/admin/overview");
} else {
router.push("/dashboard");
}
}
// Start (or restart) the nostrconnect QR flow
async function startQrFlow() {
qrAbortRef.current?.abort();
const controller = new AbortController();
qrAbortRef.current = controller;
setQrUri(null);
setQrStatus("generating");
setError("");
try {
const { uri, clientSecretKey } = await generateNostrConnectSetup();
if (controller.signal.aborted) return;
qrSecretRef.current = clientSecretKey;
setQrUri(uri);
setQrStatus("waiting");
const { signer } = await waitForNostrConnectSigner(
clientSecretKey,
uri,
controller.signal
);
if (controller.signal.aborted) return;
setQrStatus("connecting");
setLoggingIn(true);
const loggedInUser = await loginWithConnectedSigner(signer);
await signer.close().catch(() => {});
redirectByRole(loggedInUser.role);
} catch (err: any) {
if (controller.signal.aborted) return;
setError(err.message || "Connection failed");
setQrStatus("waiting");
setLoggingIn(false);
}
}
// Launch / restart QR when switching to external tab
useEffect(() => {
if (activeTab !== "external") {
qrAbortRef.current?.abort();
return;
}
startQrFlow();
return () => {
qrAbortRef.current?.abort();
};
}, [activeTab]);
const handleExtensionLogin = async () => {
setError("");
setLoggingIn(true);
try {
const loggedInUser = await login();
redirectByRole(loggedInUser.role);
} catch (err: any) {
setError(err.message || "Login failed");
} finally {
setLoggingIn(false);
}
};
const handleBunkerLogin = async () => {
if (!bunkerInput.trim()) return;
setError("");
setLoggingIn(true);
try {
const loggedInUser = await loginWithBunker(bunkerInput.trim());
redirectByRole(loggedInUser.role);
} catch (err: any) {
setError(err.message || "Connection failed");
} finally {
setLoggingIn(false);
}
};
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) return null;
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
{ id: "extension", label: "Extension", icon: <Puzzle size={15} /> },
{ id: "external", label: "External Signer", icon: <Smartphone size={15} /> },
];
return (
<>
<Navbar />
<div className="flex items-center justify-center min-h-[70vh] px-8">
<div className="bg-surface-container-low rounded-xl p-8 max-w-md w-full">
{/* Header */}
<div className="text-center mb-6">
<div className="w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4">
<LogIn size={26} className="text-primary" />
</div>
<h1 className="text-2xl font-bold text-on-surface mb-1">
Sign in to the Embassy
</h1>
<p className="text-on-surface/60 text-sm leading-relaxed">
Use your Nostr identity to access your dashboard.
</p>
</div>
{/* Tab bar */}
<div className="flex rounded-lg bg-surface-container p-1 mb-6 gap-1">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => {
setActiveTab(tab.id);
setError("");
}}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-md text-sm font-medium transition-all ${
activeTab === tab.id
? "bg-surface-container-high text-on-surface shadow-sm"
: "text-on-surface/50 hover:text-on-surface/80"
}`}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
{/* Extension tab */}
{activeTab === "extension" && (
<div className="space-y-4">
<button
onClick={handleExtensionLogin}
disabled={loggingIn}
className="w-full flex items-center justify-center gap-3 px-6 py-3.5 rounded-lg font-semibold transition-all bg-gradient-to-r from-primary to-primary-container text-on-primary hover:scale-105 active:opacity-80 disabled:opacity-50 disabled:hover:scale-100"
>
<LogIn size={20} />
{loggingIn ? "Connecting..." : "Login with Nostr"}
</button>
<p className="text-on-surface/40 text-xs text-center leading-relaxed">
Requires a Nostr browser extension such as Alby, nos2x, or
Flamingo. Your keys never leave your device.
</p>
</div>
)}
{/* External signer tab */}
{activeTab === "external" && (
<div className="space-y-5">
{/* QR section */}
<div className="rounded-lg bg-surface-container p-4 flex flex-col items-center gap-3">
{qrStatus === "generating" || !qrUri ? (
<div className="w-[200px] h-[200px] flex items-center justify-center">
<div className="w-8 h-8 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
</div>
) : (
<div className="p-2 bg-white rounded-lg">
<QRCodeSVG value={qrUri} size={192} />
</div>
)}
<div className="text-center">
{qrStatus === "generating" && (
<p className="text-on-surface/50 text-xs">
Generating QR code
</p>
)}
{qrStatus === "waiting" && (
<p className="text-on-surface/60 text-xs">
Scan with your signer app (e.g.{" "}
<span className="text-primary font-medium">Amber</span>)
</p>
)}
{qrStatus === "connecting" && (
<p className="text-on-surface/60 text-xs">
Signer connected signing in
</p>
)}
</div>
{qrStatus === "waiting" && qrUri && (
<button
onClick={() => startQrFlow()}
className="flex items-center gap-1.5 text-xs text-on-surface/40 hover:text-on-surface/70 transition-colors"
>
<RefreshCw size={12} />
Refresh QR
</button>
)}
</div>
{/* Divider */}
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-on-surface/10" />
<span className="text-on-surface/30 text-xs">or</span>
<div className="flex-1 h-px bg-on-surface/10" />
</div>
{/* Bunker URI input */}
<div className="space-y-2">
<label className="text-xs font-medium text-on-surface/60 flex items-center gap-1.5">
<Link2 size={12} />
Bunker URL
</label>
<input
type="text"
value={bunkerInput}
onChange={(e) => setBunkerInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleBunkerLogin()}
placeholder="bunker://..."
disabled={loggingIn}
className="w-full bg-surface-container rounded-lg px-3 py-2.5 text-sm text-on-surface placeholder:text-on-surface/30 border border-on-surface/10 focus:outline-none focus:border-primary/50 disabled:opacity-50"
/>
<button
onClick={handleBunkerLogin}
disabled={loggingIn || !bunkerInput.trim()}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-all bg-primary/10 text-primary hover:bg-primary/20 disabled:opacity-40 disabled:cursor-not-allowed"
>
{loggingIn ? "Connecting..." : "Connect"}
</button>
</div>
</div>
)}
{/* Shared error */}
{error && (
<p className="mt-4 text-error text-sm text-center">{error}</p>
)}
</div>
</div>
<Footer />
</>
);
}

View File

@@ -0,0 +1,178 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
import sharp from 'sharp';
const STORAGE_PATH = process.env.MEDIA_STORAGE_PATH
? path.resolve(process.env.MEDIA_STORAGE_PATH)
: path.resolve(process.cwd(), '../storage/media');
const CACHE_PATH = path.join(STORAGE_PATH, 'cache');
const CACHE_HEADERS = {
'Cache-Control': 'public, max-age=31536000, immutable',
};
interface MediaMeta {
mimeType: string;
type: 'image' | 'video';
size: number;
}
function readMeta(id: string): MediaMeta | null {
const metaPath = path.join(STORAGE_PATH, `${id}.json`);
try {
const raw = fs.readFileSync(metaPath, 'utf-8');
return JSON.parse(raw);
} catch {
return null;
}
}
function fileExists(filePath: string): boolean {
try {
fs.accessSync(filePath, fs.constants.R_OK);
return true;
} catch {
return false;
}
}
async function handleImageResize(
filePath: string,
width: number,
meta: MediaMeta,
id: string
): Promise<NextResponse> {
fs.mkdirSync(CACHE_PATH, { recursive: true });
const cacheKey = `${id}_w${width}`;
const cachedPath = path.join(CACHE_PATH, cacheKey);
if (fileExists(cachedPath)) {
const cached = fs.readFileSync(cachedPath);
return new NextResponse(new Uint8Array(cached), {
status: 200,
headers: {
'Content-Type': meta.mimeType,
'Content-Length': String(cached.length),
...CACHE_HEADERS,
},
});
}
const buffer = fs.readFileSync(filePath);
const resized = await sharp(buffer)
.resize({ width, withoutEnlargement: true })
.toBuffer();
fs.writeFileSync(cachedPath, resized);
return new NextResponse(new Uint8Array(resized), {
status: 200,
headers: {
'Content-Type': meta.mimeType,
'Content-Length': String(resized.length),
...CACHE_HEADERS,
},
});
}
function handleVideoStream(
filePath: string,
meta: MediaMeta,
rangeHeader: string | null
): NextResponse {
const stat = fs.statSync(filePath);
const fileSize = stat.size;
if (rangeHeader) {
const parts = rangeHeader.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunkSize = end - start + 1;
const stream = fs.createReadStream(filePath, { start, end });
const readable = new ReadableStream({
start(controller) {
stream.on('data', (chunk: string | Buffer) => controller.enqueue(chunk as Buffer));
stream.on('end', () => controller.close());
stream.on('error', (err) => controller.error(err));
},
});
return new NextResponse(readable as any, {
status: 206,
headers: {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': String(chunkSize),
'Content-Type': meta.mimeType,
...CACHE_HEADERS,
},
});
}
const stream = fs.createReadStream(filePath);
const readable = new ReadableStream({
start(controller) {
stream.on('data', (chunk: string | Buffer) => controller.enqueue(chunk as Buffer));
stream.on('end', () => controller.close());
stream.on('error', (err) => controller.error(err));
},
});
return new NextResponse(readable as any, {
status: 200,
headers: {
'Accept-Ranges': 'bytes',
'Content-Length': String(fileSize),
'Content-Type': meta.mimeType,
...CACHE_HEADERS,
},
});
}
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const { id } = params;
const filePath = path.join(STORAGE_PATH, id);
if (!fileExists(filePath)) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const meta = readMeta(id);
if (!meta) {
return NextResponse.json({ error: 'Metadata not found' }, { status: 404 });
}
const { searchParams } = new URL(request.url);
const widthParam = searchParams.get('w');
if (meta.type === 'image' && widthParam) {
const width = parseInt(widthParam, 10);
if (isNaN(width) || width < 1 || width > 4096) {
return NextResponse.json({ error: 'Invalid width' }, { status: 400 });
}
return handleImageResize(filePath, width, meta, id);
}
if (meta.type === 'video') {
const rangeHeader = request.headers.get('range');
return handleVideoStream(filePath, meta, rangeHeader);
}
// Full image, no resize
const buffer = fs.readFileSync(filePath);
return new NextResponse(new Uint8Array(buffer), {
status: 200,
headers: {
'Content-Type': meta.mimeType,
'Content-Length': String(buffer.length),
...CACHE_HEADERS,
},
});
}

View File

@@ -0,0 +1,29 @@
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "Page Not Found",
robots: { index: false, follow: false },
};
export default function NotFound() {
return (
<div className="min-h-screen flex flex-col items-center justify-center px-8">
<span className="text-8xl md:text-[12rem] font-black tracking-tighter text-transparent bg-clip-text bg-gradient-to-r from-primary to-primary-container leading-none">
404
</span>
<h1 className="text-2xl md:text-3xl font-bold mt-6 mb-3">
Page not found
</h1>
<p className="text-on-surface-variant mb-10 text-center max-w-md">
The page you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<Link
href="/"
className="bg-gradient-to-r from-primary to-primary-container text-on-primary px-8 py-3 rounded-lg font-bold hover:scale-105 transition-transform"
>
Back to Home
</Link>
</div>
);
}

152
frontend/app/og/route.tsx Normal file
View File

@@ -0,0 +1,152 @@
import { ImageResponse } from "next/og";
import { type NextRequest } from "next/server";
export const runtime = "edge";
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const title = searchParams.get("title") || "Belgian Bitcoin Embassy";
const type = searchParams.get("type") || "default";
const subtitle =
searchParams.get("subtitle") ||
(type === "blog"
? "Blog"
: type === "event"
? "Event"
: "Bitcoin Meetups & Education in Belgium");
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
background: "linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 100%)",
fontFamily: "system-ui, sans-serif",
padding: "60px 80px",
}}
>
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: "4px",
background: "#F7931A",
}}
/>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "24px",
maxWidth: "1000px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "16px",
marginBottom: "8px",
}}
>
<div
style={{
width: "48px",
height: "48px",
borderRadius: "12px",
background: "#F7931A",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "28px",
fontWeight: 700,
color: "#fff",
}}
>
B
</div>
<span
style={{
fontSize: "14px",
fontWeight: 600,
color: "#F7931A",
letterSpacing: "4px",
textTransform: "uppercase",
}}
>
{subtitle}
</span>
</div>
<h1
style={{
fontSize: title.length > 60 ? "40px" : title.length > 40 ? "48px" : "56px",
fontWeight: 800,
color: "#ffffff",
textAlign: "center",
lineHeight: 1.15,
margin: 0,
letterSpacing: "-1px",
}}
>
{title}
</h1>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
marginTop: "16px",
}}
>
<span
style={{
fontSize: "16px",
color: "#666",
fontWeight: 500,
}}
>
belgianbitcoinembassy.org
</span>
</div>
</div>
<div
style={{
position: "absolute",
bottom: "40px",
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<span
style={{
fontSize: "13px",
fontWeight: 500,
color: "#F7931A",
letterSpacing: "4px",
textTransform: "uppercase",
}}
>
No hype, just signal
</span>
</div>
</div>
),
{
width: 1200,
height: 630,
},
);
}

72
frontend/app/page.tsx Normal file
View File

@@ -0,0 +1,72 @@
"use client";
import { useEffect, useState } from "react";
import { Navbar } from "@/components/public/Navbar";
import { HeroSection } from "@/components/public/HeroSection";
import { KnowledgeCards } from "@/components/public/KnowledgeCards";
import { AboutSection } from "@/components/public/AboutSection";
import { CommunityLinksSection } from "@/components/public/CommunityLinksSection";
import { MeetupsSection } from "@/components/public/MeetupsSection";
import { FAQSection } from "@/components/public/FAQSection";
import { FinalCTASection } from "@/components/public/FinalCTASection";
import { Footer } from "@/components/public/Footer";
import { api } from "@/lib/api";
export default function HomePage() {
const [meetup, setMeetup] = useState<any>(null);
const [allMeetups, setAllMeetups] = useState<any[]>([]);
const [settings, setSettings] = useState<Record<string, string>>({});
useEffect(() => {
api.getMeetups()
.then((data: any) => {
const all = Array.isArray(data) ? data : data?.meetups ?? [];
const now = new Date();
// Keep only PUBLISHED events with a future date, sorted closest-first
const upcoming = all
.filter((m: any) => m.status === "PUBLISHED" && m.date && new Date(m.date) > now)
.sort((a: any, b: any) => new Date(a.date).getTime() - new Date(b.date).getTime());
setAllMeetups(upcoming);
if (upcoming.length > 0) setMeetup(upcoming[0]);
})
.catch(() => {});
api.getPublicSettings()
.then((data) => setSettings(data))
.catch(() => {});
}, []);
const meetupProps = meetup
? {
id: meetup.id,
month: new Date(meetup.date).toLocaleString("en-US", { month: "short" }),
day: String(new Date(meetup.date).getDate()),
title: meetup.title,
location: meetup.location,
time: meetup.time,
link: meetup.link || "#meetup",
}
: undefined;
return (
<main>
<Navbar />
<section id="meetup">
<HeroSection meetup={meetupProps} />
</section>
<section id="about">
<AboutSection />
</section>
<KnowledgeCards />
<CommunityLinksSection settings={settings} />
<section id="upcoming-meetups">
<MeetupsSection meetups={allMeetups} />
</section>
<section id="faq">
<FAQSection />
</section>
<FinalCTASection telegramLink={settings.telegram_link} />
<Footer />
</main>
);
}

View File

@@ -0,0 +1,78 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
export const metadata: Metadata = {
title: "Privacy Policy",
description:
"Privacy policy for the Belgian Bitcoin Embassy website. We collect minimal data, use no tracking cookies, and respect your sovereignty.",
openGraph: {
title: "Privacy Policy - Belgian Bitcoin Embassy",
description: "How we handle your data. Minimal collection, no tracking, full transparency.",
},
alternates: { canonical: "/privacy" },
};
export default function PrivacyPage() {
return (
<>
<Navbar />
<div className="min-h-screen">
<div className="max-w-3xl mx-auto px-8 pt-16 pb-24">
<h1 className="text-4xl font-black mb-8">Privacy Policy</h1>
<div className="space-y-8 text-on-surface-variant leading-relaxed">
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Overview</h2>
<p>
The Belgian Bitcoin Embassy values your privacy. This website is designed
to collect as little personal data as possible. We do not use tracking
cookies, analytics services, or advertising networks.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Data We Collect</h2>
<p>
If you log in using a Nostr extension, we store your public key to
identify your session. Public keys are, by nature, public information
on the Nostr network. We do not collect email addresses, names, or
any other personal identifiers.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Nostr Interactions</h2>
<p>
Likes and comments are published to the Nostr network via your own
extension. These are peer-to-peer actions and are not stored on our
servers beyond local caching for display purposes.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Local Storage</h2>
<p>
We use browser local storage to persist your authentication session.
You can clear this at any time by logging out or clearing your
browser data.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Contact</h2>
<p>
For privacy-related questions, reach out to us via our{" "}
<Link href="/#community" className="text-primary hover:underline">
community channels
</Link>.
</p>
</section>
</div>
</div>
</div>
<Footer />
</>
);
}

17
frontend/app/robots.ts Normal file
View File

@@ -0,0 +1,17 @@
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
const siteUrl =
process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/admin", "/admin/", "/dashboard", "/dashboard/", "/login"],
},
],
sitemap: `${siteUrl}/sitemap.xml`,
};
}

56
frontend/app/sitemap.ts Normal file
View File

@@ -0,0 +1,56 @@
import type { MetadataRoute } from "next";
const siteUrl =
process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000/api";
async function fetchJson<T>(path: string): Promise<T | null> {
try {
const res = await fetch(`${apiUrl}${path}`, { next: { revalidate: 3600 } });
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const staticRoutes: MetadataRoute.Sitemap = [
{ url: siteUrl, lastModified: new Date(), changeFrequency: "weekly", priority: 1.0 },
{ url: `${siteUrl}/blog`, lastModified: new Date(), changeFrequency: "daily", priority: 0.9 },
{ url: `${siteUrl}/events`, lastModified: new Date(), changeFrequency: "weekly", priority: 0.9 },
{ url: `${siteUrl}/community`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.7 },
{ url: `${siteUrl}/contact`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.5 },
{ url: `${siteUrl}/faq`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.6 },
{ url: `${siteUrl}/privacy`, lastModified: new Date(), changeFrequency: "yearly", priority: 0.3 },
{ url: `${siteUrl}/terms`, lastModified: new Date(), changeFrequency: "yearly", priority: 0.3 },
];
const blogRoutes: MetadataRoute.Sitemap = [];
const postsData = await fetchJson<{ posts: any[]; total: number }>("/posts?limit=500");
if (postsData?.posts) {
for (const post of postsData.posts) {
blogRoutes.push({
url: `${siteUrl}/blog/${post.slug}`,
lastModified: post.updatedAt || post.publishedAt || post.createdAt,
changeFrequency: "weekly",
priority: 0.8,
});
}
}
const eventRoutes: MetadataRoute.Sitemap = [];
const meetups = await fetchJson<any[]>("/meetups");
if (Array.isArray(meetups)) {
for (const meetup of meetups) {
eventRoutes.push({
url: `${siteUrl}/events/${meetup.id}`,
lastModified: meetup.updatedAt || meetup.createdAt,
changeFrequency: "weekly",
priority: 0.7,
});
}
}
return [...staticRoutes, ...blogRoutes, ...eventRoutes];
}

View File

@@ -0,0 +1,77 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
export const metadata: Metadata = {
title: "Terms of Use",
description:
"Terms of use for the Belgian Bitcoin Embassy website. Community-driven, non-commercial Bitcoin education platform in Belgium.",
openGraph: {
title: "Terms of Use - Belgian Bitcoin Embassy",
description: "Terms governing the use of the Belgian Bitcoin Embassy platform.",
},
alternates: { canonical: "/terms" },
};
export default function TermsPage() {
return (
<>
<Navbar />
<div className="min-h-screen">
<div className="max-w-3xl mx-auto px-8 pt-16 pb-24">
<h1 className="text-4xl font-black mb-8">Terms of Use</h1>
<div className="space-y-8 text-on-surface-variant leading-relaxed">
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">About This Site</h2>
<p>
The Belgian Bitcoin Embassy website is a community-driven, non-commercial
platform focused on Bitcoin education and meetups in Belgium. By using
this site, you agree to these terms.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Content</h2>
<p>
Blog content on this site is curated from the Nostr network. The
Belgian Bitcoin Embassy does not claim ownership of third-party
content and provides it for educational purposes only. Content
moderation is applied locally and does not affect the Nostr network.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">No Financial Advice</h2>
<p>
Nothing on this website constitutes financial advice. Bitcoin is a
volatile asset. Always do your own research and consult qualified
professionals before making financial decisions.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">User Conduct</h2>
<p>
Users interacting via Nostr (likes, comments) are expected to behave
respectfully. The moderation team reserves the right to locally hide
content or block pubkeys that violate community standards.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Liability</h2>
<p>
The Belgian Bitcoin Embassy is a community initiative, not a legal
entity. We provide this platform as-is with no warranties. Use at
your own discretion.
</p>
</section>
</div>
</div>
</div>
<Footer />
</>
);
}

View File

@@ -0,0 +1,121 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { cn } from "@/lib/utils";
import {
LayoutDashboard,
Calendar,
FileText,
Shield,
Tag,
Users,
Radio,
Settings,
Wrench,
LogOut,
ArrowLeft,
Inbox,
ImageIcon,
HelpCircle,
} from "lucide-react";
const navItems = [
{ href: "/admin/overview", label: "Overview", icon: LayoutDashboard, adminOnly: false },
{ href: "/admin/events", label: "Events", icon: Calendar, adminOnly: false },
{ href: "/admin/gallery", label: "Gallery", icon: ImageIcon, adminOnly: false },
{ href: "/admin/blog", label: "Blog", icon: FileText, adminOnly: false },
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle, adminOnly: false },
{ href: "/admin/submissions", label: "Submissions", icon: Inbox, adminOnly: false },
{ href: "/admin/moderation", label: "Moderation", icon: Shield, adminOnly: false },
{ href: "/admin/categories", label: "Categories", icon: Tag, adminOnly: false },
{ href: "/admin/users", label: "Users", icon: Users, adminOnly: true },
{ href: "/admin/relays", label: "Relays", icon: Radio, adminOnly: true },
{ href: "/admin/settings", label: "Settings", icon: Settings, adminOnly: true },
{ href: "/admin/nostr", label: "Nostr Tools", icon: Wrench, adminOnly: true },
];
export function AdminSidebar() {
const pathname = usePathname();
const { user, logout, isAdmin } = useAuth();
const shortPubkey = user?.pubkey
? `${user.pubkey.slice(0, 8)}...${user.pubkey.slice(-8)}`
: "";
return (
<aside className="w-64 bg-surface-container-lowest min-h-screen p-6 flex flex-col shrink-0">
<div className="mb-8">
<Link href="/" className="text-primary-container font-bold text-xl">
BBE Admin
</Link>
</div>
{!user ? (
<div className="flex-1 flex items-center justify-center">
<p className="text-on-surface/40 text-sm text-center">
Please log in to access the dashboard.
</p>
</div>
) : (
<>
<div className="mb-6">
<p className="text-on-surface/70 text-sm font-mono truncate">{shortPubkey}</p>
<span
className={cn(
"inline-block mt-1 rounded-full px-3 py-1 text-xs font-bold",
user.role === "ADMIN"
? "bg-primary-container/20 text-primary"
: "bg-secondary-container text-on-secondary-container"
)}
>
{user.role}
</span>
</div>
<nav className="flex-1 space-y-1">
{navItems
.filter((item) => !item.adminOnly || isAdmin)
.map((item) => {
const Icon = item.icon;
const active = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-lg transition-colors",
active
? "bg-surface-container-high text-primary"
: "text-on-surface/70 hover:text-on-surface hover:bg-surface-container"
)}
>
<Icon size={20} />
<span>{item.label}</span>
</Link>
);
})}
</nav>
<div className="mt-auto space-y-2 pt-6">
<button
onClick={logout}
className="flex items-center gap-3 px-4 py-3 rounded-lg transition-colors text-on-surface/70 hover:text-on-surface hover:bg-surface-container w-full"
>
<LogOut size={20} />
<span>Logout</span>
</button>
<Link
href="/"
className="flex items-center gap-3 px-4 py-3 rounded-lg transition-colors text-on-surface/70 hover:text-on-surface hover:bg-surface-container"
>
<ArrowLeft size={20} />
<span>Back to site</span>
</Link>
</div>
</>
)}
</aside>
);
}

View File

@@ -0,0 +1,155 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { api } from "@/lib/api";
import { cn } from "@/lib/utils";
import { X, Upload, Film, Check } from "lucide-react";
interface MediaItem {
id: string;
slug: string;
type: "image" | "video";
mimeType: string;
size: number;
originalFilename: string;
url: string;
}
interface MediaPickerModalProps {
onSelect: (mediaId: string) => void;
onClose: () => void;
selectedId?: string | null;
}
export function MediaPickerModal({ onSelect, onClose, selectedId }: MediaPickerModalProps) {
const [media, setMedia] = useState<MediaItem[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const loadMedia = async () => {
try {
const data = await api.getMediaList();
setMedia(data.filter((m: MediaItem) => m.type === "image"));
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadMedia();
}, []);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [onClose]);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
setError("");
try {
const result = await api.uploadMedia(file);
await loadMedia();
onSelect(result.id);
} catch (err: any) {
setError(err.message);
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div className="relative bg-surface-container-low rounded-2xl w-full max-w-3xl max-h-[80vh] flex flex-col overflow-hidden">
<div className="flex items-center justify-between p-5 border-b border-surface-container-highest">
<h2 className="text-lg font-semibold text-on-surface">Select Image</h2>
<div className="flex items-center gap-3">
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleUpload}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-xs hover:opacity-90 transition-opacity disabled:opacity-50"
>
<Upload size={14} />
{uploading ? "Uploading..." : "Upload New"}
</button>
<button
onClick={onClose}
className="text-on-surface/50 hover:text-on-surface transition-colors"
>
<X size={20} />
</button>
</div>
</div>
{error && <p className="text-error text-sm px-5 pt-3">{error}</p>}
<div className="flex-1 overflow-y-auto p-5">
{loading ? (
<div className="flex items-center justify-center py-12">
<p className="text-on-surface/50 text-sm">Loading media...</p>
</div>
) : media.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<p className="text-on-surface/50 text-sm">No images available.</p>
<p className="text-on-surface/30 text-xs mt-1">Upload an image to get started.</p>
</div>
) : (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-3">
{media.map((item) => (
<button
key={item.id}
onClick={() => onSelect(item.id)}
className={cn(
"relative aspect-square rounded-lg overflow-hidden border-2 transition-all hover:border-primary/60",
selectedId === item.id
? "border-primary ring-2 ring-primary/30"
: "border-transparent"
)}
>
{item.type === "image" ? (
<img
src={`/media/${item.id}?w=200`}
alt={item.originalFilename}
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<div className="w-full h-full bg-surface-container-highest flex items-center justify-center">
<Film size={24} className="text-on-surface/30" />
</div>
)}
{selectedId === item.id && (
<div className="absolute inset-0 bg-primary/20 flex items-center justify-center">
<div className="w-7 h-7 rounded-full bg-primary flex items-center justify-center">
<Check size={16} className="text-on-primary" />
</div>
</div>
)}
</button>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,8 @@
"use client";
import { ReactNode } from "react";
import { AuthContext, useAuthProvider } from "@/hooks/useAuth";
export function AuthProvider({ children }: { children: ReactNode }) {
const auth = useAuthProvider();
return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>;
}

View File

@@ -0,0 +1,8 @@
"use client";
import { ReactNode } from "react";
import { AuthProvider } from "./AuthProvider";
export function ClientProviders({ children }: { children: ReactNode }) {
return <AuthProvider>{children}</AuthProvider>;
}

View File

@@ -0,0 +1,23 @@
export function AboutSection() {
return (
<section id="about" className="py-32 px-8">
<div className="max-w-5xl mx-auto text-center">
<span className="uppercase tracking-[0.3em] text-primary mb-8 block text-sm font-semibold">
The Mission
</span>
<h2 className="text-4xl md:text-5xl font-black mb-10 leading-tight">
&ldquo;Fix the money, fix the world.&rdquo;
</h2>
<p className="text-2xl text-on-surface-variant font-light leading-relaxed mb-12">
We help people in Belgium understand and adopt Bitcoin through
education, meetups, and community. We are not a company, but a
sovereign network of individuals building a sounder future.
</p>
<div className="w-24 h-1 bg-primary mx-auto opacity-50" />
</div>
</section>
);
}

View File

@@ -0,0 +1,81 @@
import { ArrowRight } from "lucide-react";
import Link from "next/link";
interface BlogPost {
slug: string;
title: string;
excerpt: string;
categories: string[];
}
interface BlogPreviewSectionProps {
posts?: BlogPost[];
}
export function BlogPreviewSection({ posts }: BlogPreviewSectionProps) {
return (
<section className="py-24 px-8 border-t border-zinc-800/50">
<div className="max-w-6xl mx-auto">
<div className="flex justify-between items-end mb-12">
<div>
<p className="uppercase tracking-[0.2em] text-primary mb-2 font-semibold text-xs">
From the network
</p>
<h2 className="text-3xl font-black tracking-tight">Latest from the Blog</h2>
</div>
<Link
href="/blog"
className="hidden md:flex items-center gap-2 text-sm text-primary font-semibold hover:gap-3 transition-all"
>
View All <ArrowRight size={16} />
</Link>
</div>
{!posts || posts.length === 0 ? (
<p className="text-on-surface-variant text-center py-16 text-sm">
No posts yet. Check back soon for curated Bitcoin content.
</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
{posts.map((post) => (
<Link
key={post.slug}
href={`/blog/${post.slug}`}
className="group flex flex-col bg-zinc-900 border border-zinc-800 rounded-xl p-6 hover:border-zinc-700 hover:-translate-y-0.5 hover:shadow-xl transition-all duration-200"
>
{post.categories.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{post.categories.map((cat) => (
<span
key={cat}
className="text-primary text-[10px] uppercase tracking-widest font-bold"
>
{cat}
</span>
))}
</div>
)}
<h3 className="font-bold text-base mb-3 leading-snug group-hover:text-primary transition-colors">
{post.title}
</h3>
<p className="text-on-surface-variant text-sm leading-relaxed mb-5 flex-1 line-clamp-3">
{post.excerpt}
</p>
<span className="text-primary text-xs font-semibold flex items-center gap-1.5 group-hover:gap-2.5 transition-all mt-auto">
Read More <ArrowRight size={13} />
</span>
</Link>
))}
</div>
)}
<Link
href="/blog"
className="md:hidden flex items-center justify-center gap-2 text-primary font-semibold mt-8 text-sm"
>
View All <ArrowRight size={16} />
</Link>
</div>
</section>
);
}

View File

@@ -0,0 +1,169 @@
import { type SVGProps } from "react";
interface PlatformDef {
name: string;
description: string;
settingKey: string;
Icon: (props: SVGProps<SVGSVGElement>) => JSX.Element;
}
function IconTelegram(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" />
</svg>
);
}
function IconNostr(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
);
}
function IconX(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
);
}
function IconYouTube(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M22.54 6.42a2.78 2.78 0 00-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 00-1.94 2A29 29 0 001 11.75a29 29 0 00.46 5.33 2.78 2.78 0 001.94 2c1.72.46 8.6.46 8.6.46s6.88 0 8.6-.46a2.78 2.78 0 001.94-2 29 29 0 00.46-5.33 29 29 0 00-.46-5.33z" />
<path d="M9.75 15.02l5.75-3.27-5.75-3.27v6.54z" />
</svg>
);
}
function IconDiscord(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.028zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
);
}
function IconLinkedIn(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zM7.119 20.452H3.554V9h3.565v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
</svg>
);
}
function IconArrowOut(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M7 17l9.2-9.2M17 17V7H7" />
</svg>
);
}
const PLATFORMS: PlatformDef[] = [
{
name: "Telegram",
description: "Join the main Belgian chat group for daily discussion and local coordination.",
settingKey: "telegram_link",
Icon: IconTelegram,
},
{
name: "Nostr",
description: "Follow the BBE on the censorship-resistant social protocol for true signal.",
settingKey: "nostr_link",
Icon: IconNostr,
},
{
name: "X",
description: "Stay updated with our latest local announcements and event drops.",
settingKey: "x_link",
Icon: IconX,
},
{
name: "YouTube",
description: "Watch past talks, educational content, and high-quality BBE meetup recordings.",
settingKey: "youtube_link",
Icon: IconYouTube,
},
{
name: "Discord",
description: "Deep dive into technical discussions, node running, and project collaboration.",
settingKey: "discord_link",
Icon: IconDiscord,
},
{
name: "LinkedIn",
description: "Connect with the Belgian Bitcoin professional network and industry leaders.",
settingKey: "linkedin_link",
Icon: IconLinkedIn,
},
];
interface CommunityLinksSectionProps {
settings?: Record<string, string>;
}
export function CommunityLinksSection({ settings = {} }: CommunityLinksSectionProps) {
return (
<section
id="community"
className="relative py-16 sm:py-20 px-4 sm:px-8"
>
<div className="max-w-[1100px] mx-auto w-full">
<header className="text-center mb-8 sm:mb-10">
<h2 className="text-[1.75rem] sm:text-4xl font-extrabold tracking-tight text-white mb-2">
Join the <span className="text-[#F7931A]">community</span>
</h2>
<p className="text-zinc-400 text-sm sm:text-[0.95rem] max-w-[550px] mx-auto leading-relaxed">
Connect with local Belgian Bitcoiners, builders, and educators.
</p>
</header>
<div className="grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-4">
{PLATFORMS.map((platform) => {
const href = settings[platform.settingKey] || "#";
const isExternal = href.startsWith("http");
const Icon = platform.Icon;
return (
<a
key={platform.name}
href={href}
target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noopener noreferrer" : undefined}
className="group relative flex flex-col rounded-xl border border-zinc-800 bg-zinc-900 p-5 no-underline overflow-hidden transition-all duration-300 hover:-translate-y-[3px] hover:border-[rgba(247,147,26,0.4)] hover:shadow-[0_10px_25px_-10px_rgba(0,0,0,0.5)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#F7931A]"
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100 bg-[radial-gradient(circle_at_top_right,rgba(247,147,26,0.08),transparent_60%)]"
/>
<div className="relative z-[1] flex items-center justify-between mb-3">
<div className="flex min-w-0 items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-zinc-800 bg-black text-[#F7931A] transition-all duration-300 group-hover:scale-105 group-hover:-rotate-[5deg] group-hover:border-[#F7931A] group-hover:bg-[#F7931A] group-hover:text-black">
<Icon className="h-[18px] w-[18px] shrink-0" aria-hidden />
</span>
<h3 className="text-[1.05rem] font-bold text-white truncate transition-colors duration-300 group-hover:text-[#F7931A]">
{platform.name}
</h3>
</div>
<IconArrowOut
className="h-[18px] w-[18px] shrink-0 text-zinc-600 transition-all duration-300 group-hover:text-[#F7931A] group-hover:translate-x-[3px] group-hover:-translate-y-[3px]"
aria-hidden
/>
</div>
<p className="relative z-[1] text-[0.85rem] leading-relaxed text-zinc-400">
{platform.description}
</p>
</a>
);
})}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,77 @@
"use client";
import { useEffect, useState } from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { api } from "@/lib/api";
interface FaqItem {
id: string;
question: string;
answer: string;
order: number;
showOnHomepage: boolean;
}
export function FAQSection() {
const [items, setItems] = useState<FaqItem[]>([]);
const [openIndex, setOpenIndex] = useState<number | null>(null);
useEffect(() => {
api.getFaqs().catch(() => []).then((data) => {
if (Array.isArray(data)) setItems(data);
});
}, []);
if (items.length === 0) return null;
return (
<section id="faq" className="py-24 px-8">
<div className="max-w-3xl mx-auto">
<h2 className="text-4xl font-black mb-16 text-center">
Frequently Asked Questions
</h2>
<div className="space-y-4">
{items.map((item, i) => {
const isOpen = openIndex === i;
return (
<div
key={item.id}
className="bg-surface-container-low rounded-xl overflow-hidden"
>
<button
onClick={() => setOpenIndex(isOpen ? null : i)}
className="w-full flex items-center justify-between p-6 text-left"
>
<span className="text-lg font-bold pr-4">{item.question}</span>
<ChevronDown
size={20}
className={cn(
"shrink-0 text-primary transition-transform duration-200",
isOpen && "rotate-180"
)}
/>
</button>
<div
className={cn(
"grid transition-all duration-200",
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
)}
>
<div className="overflow-hidden">
<p className="px-6 pb-6 text-on-surface-variant leading-relaxed">
{item.answer}
</p>
</div>
</div>
</div>
);
})}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,44 @@
import { Send, Bitcoin } from "lucide-react";
import { Button } from "@/components/ui/Button";
interface FinalCTASectionProps {
telegramLink?: string;
}
export function FinalCTASection({ telegramLink }: FinalCTASectionProps) {
return (
<section className="py-32 px-8 bg-surface-container-low relative overflow-hidden">
<div className="max-w-4xl mx-auto text-center relative z-10">
<h2 className="text-5xl font-black mb-8">
Join us
</h2>
<p className="text-on-surface-variant text-xl mb-12">
The best time to learn was 10 years ago. The second best time is
today. Join the community.
</p>
<div className="flex flex-col md:flex-row items-center justify-center gap-6">
<a
href={telegramLink || "#community"}
target={telegramLink ? "_blank" : undefined}
rel={telegramLink ? "noopener noreferrer" : undefined}
>
<Button variant="telegram" size="lg" className="w-full md:w-auto flex items-center justify-center gap-2">
<Send size={18} /> Join Telegram
</Button>
</a>
<a href="/events">
<Button variant="primary" size="lg" className="w-full md:w-auto">
Attend Meetup
</Button>
</a>
</div>
</div>
<Bitcoin
size={400}
className="absolute -bottom-20 -right-20 opacity-5 text-on-surface"
/>
</section>
);
}

View File

@@ -0,0 +1,37 @@
import Link from "next/link";
const LINKS = [
{ label: "FAQ", href: "/faq" },
{ label: "Community", href: "/community" },
{ label: "Privacy", href: "/privacy" },
{ label: "Terms", href: "/terms" },
{ label: "Contact", href: "/contact" },
];
export function Footer() {
return (
<footer className="w-full py-12 bg-surface-container-lowest">
<div className="flex flex-col items-center justify-center space-y-6 w-full px-8 text-center">
<Link href="/" className="text-lg font-black text-primary-container">
Belgian Bitcoin Embassy
</Link>
<nav aria-label="Footer navigation" className="flex space-x-12">
{LINKS.map((link) => (
<Link
key={link.label}
href={link.href}
className="text-white opacity-50 hover:opacity-100 transition-opacity text-sm tracking-widest uppercase"
>
{link.label}
</Link>
))}
</nav>
<p className="text-white opacity-50 text-sm tracking-widest uppercase">
&copy; Belgian Bitcoin Embassy. No counterparty risk.
</p>
</div>
</footer>
);
}

View File

@@ -0,0 +1,71 @@
import { ArrowRight, MapPin } from "lucide-react";
import Link from "next/link";
interface MeetupData {
id?: string;
month?: string;
day?: string;
title?: string;
location?: string;
time?: string;
link?: string;
}
interface HeroSectionProps {
meetup?: MeetupData;
}
export function HeroSection({ meetup }: HeroSectionProps) {
const month = meetup?.month ?? "TBD";
const day = meetup?.day ?? "--";
const title = meetup?.title ?? "Next Gathering";
const location = meetup?.location ?? "Brussels, BE";
const time = meetup?.time ?? "19:00";
const eventHref = meetup?.id ? `/events/${meetup.id}` : "#meetup";
return (
<section className="pt-32 pb-24 px-8">
<div className="max-w-4xl mx-auto text-center">
<span className="inline-block uppercase tracking-[0.25em] text-primary mb-8 font-semibold text-xs border border-primary/20 px-4 py-1.5 rounded-full">
Antwerp, Belgium
</span>
<h1 className="text-5xl md:text-7xl font-black tracking-tighter leading-[0.95] mb-6">
Belgium&apos;s Monthly
<br />
<span className="text-primary">Bitcoin Meetups</span>
</h1>
<p className="text-lg text-on-surface-variant max-w-md mx-auto leading-relaxed mb-14">
A sovereign space for education, technical discussion, and community.
No hype, just signal.
</p>
<div className="inline-flex flex-col sm:flex-row items-stretch sm:items-center gap-4 bg-zinc-900 border border-zinc-800 rounded-2xl p-4 sm:p-5 w-full max-w-xl">
<div className="flex items-center gap-4 flex-1 min-w-0">
<div className="bg-zinc-800 rounded-xl px-3 py-2 text-center shrink-0 min-w-[52px]">
<span className="block text-[10px] font-bold uppercase text-primary tracking-wider leading-none mb-0.5">
{month}
</span>
<span className="block text-2xl font-black leading-none">{day}</span>
</div>
<div className="text-left min-w-0">
<p className="font-bold text-base truncate">{title}</p>
<p className="text-on-surface-variant text-sm flex items-center gap-1 mt-0.5">
<MapPin size={12} className="shrink-0" />
<span className="truncate">{location} · {time}</span>
</p>
</div>
</div>
<Link
href={eventHref}
className="flex items-center justify-center gap-2 bg-primary text-on-primary px-6 py-3 rounded-xl font-bold text-sm hover:opacity-90 transition-opacity shrink-0"
>
More info <ArrowRight size={16} />
</Link>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,199 @@
interface JsonLdProps {
data: Record<string, unknown>;
}
export function JsonLd({ data }: JsonLdProps) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
);
}
const siteUrl =
process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
export function OrganizationJsonLd() {
return (
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "Organization",
name: "Belgian Bitcoin Embassy",
url: siteUrl,
logo: `${siteUrl}/og-default.png`,
description:
"Belgium's sovereign Bitcoin community. Monthly meetups, education, and curated Nostr content.",
sameAs: ["https://t.me/belgianbitcoinembassy"],
address: {
"@type": "PostalAddress",
addressLocality: "Antwerp",
addressCountry: "BE",
},
}}
/>
);
}
export function WebSiteJsonLd() {
return (
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "WebSite",
name: "Belgian Bitcoin Embassy",
url: siteUrl,
description:
"Belgium's sovereign Bitcoin community. Monthly meetups, education, and curated Nostr content.",
publisher: {
"@type": "Organization",
name: "Belgian Bitcoin Embassy",
logo: { "@type": "ImageObject", url: `${siteUrl}/og-default.png` },
},
}}
/>
);
}
interface BlogPostingJsonLdProps {
title: string;
description: string;
slug: string;
publishedAt?: string;
authorName?: string;
}
export function BlogPostingJsonLd({
title,
description,
slug,
publishedAt,
authorName,
}: BlogPostingJsonLdProps) {
return (
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: title,
description,
url: `${siteUrl}/blog/${slug}`,
...(publishedAt ? { datePublished: publishedAt } : {}),
author: {
"@type": "Person",
name: authorName || "Belgian Bitcoin Embassy",
},
publisher: {
"@type": "Organization",
name: "Belgian Bitcoin Embassy",
logo: { "@type": "ImageObject", url: `${siteUrl}/og-default.png` },
},
image: `${siteUrl}/og?title=${encodeURIComponent(title)}&type=blog`,
mainEntityOfPage: {
"@type": "WebPage",
"@id": `${siteUrl}/blog/${slug}`,
},
}}
/>
);
}
interface EventJsonLdProps {
name: string;
description?: string;
startDate: string;
location?: string;
url: string;
imageUrl?: string;
}
export function EventJsonLd({
name,
description,
startDate,
location,
url,
imageUrl,
}: EventJsonLdProps) {
return (
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "Event",
name,
description: description || `Bitcoin meetup: ${name}`,
startDate,
eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode",
eventStatus: "https://schema.org/EventScheduled",
...(location
? {
location: {
"@type": "Place",
name: location,
address: {
"@type": "PostalAddress",
addressLocality: location,
addressCountry: "BE",
},
},
}
: {}),
organizer: {
"@type": "Organization",
name: "Belgian Bitcoin Embassy",
url: siteUrl,
},
image:
imageUrl || `${siteUrl}/og?title=${encodeURIComponent(name)}&type=event`,
url,
}}
/>
);
}
interface FaqJsonLdProps {
items: { question: string; answer: string }[];
}
export function FaqPageJsonLd({ items }: FaqJsonLdProps) {
if (items.length === 0) return null;
return (
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: items.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: {
"@type": "Answer",
text: item.answer,
},
})),
}}
/>
);
}
interface BreadcrumbItem {
name: string;
href: string;
}
export function BreadcrumbJsonLd({ items }: { items: BreadcrumbItem[] }) {
return (
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items.map((item, index) => ({
"@type": "ListItem",
position: index + 1,
name: item.name,
item: `${siteUrl}${item.href}`,
})),
}}
/>
);
}

View File

@@ -0,0 +1,49 @@
import { Landmark, Infinity, Key } from "lucide-react";
const CARDS = [
{
icon: Landmark,
title: "Money without banks",
description:
"Operate outside the legacy financial system with peer-to-peer digital sound money.",
},
{
icon: Infinity,
title: "Scarcity: 21 million",
description:
"A mathematical certainty of fixed supply. No inflation, no dilution, ever.",
},
{
icon: Key,
title: "Self-custody",
description:
"True ownership. Your keys, your bitcoin. No counterparty risk, absolute freedom.",
},
];
export function KnowledgeCards() {
return (
<section className="py-16 px-8 border-t border-zinc-800/50">
<div className="max-w-5xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{CARDS.map((card) => (
<div
key={card.title}
className="flex gap-4 p-6 rounded-xl bg-zinc-900/60 border border-zinc-800/60"
>
<div className="mt-0.5 shrink-0 w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center">
<card.icon size={16} className="text-primary" />
</div>
<div>
<h4 className="font-bold mb-1.5 text-sm">{card.title}</h4>
<p className="text-on-surface-variant text-sm leading-relaxed">
{card.description}
</p>
</div>
</div>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,137 @@
import { MapPin, Clock, ArrowRight, CalendarPlus } from "lucide-react";
import Link from "next/link";
interface MeetupData {
id?: string;
title: string;
date: string;
time?: string;
location?: string;
link?: string;
description?: string;
}
interface MeetupsSectionProps {
meetups: MeetupData[];
}
function formatMeetupDate(dateStr: string) {
const d = new Date(dateStr);
return {
month: d.toLocaleString("en-US", { month: "short" }).toUpperCase(),
day: String(d.getDate()),
full: d.toLocaleString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" }),
};
}
export function MeetupsSection({ meetups }: MeetupsSectionProps) {
return (
<section className="py-24 px-8 border-t border-zinc-800/50">
<div className="max-w-6xl mx-auto">
<div className="flex justify-between items-end mb-12">
<div>
<p className="uppercase tracking-[0.2em] text-primary mb-2 font-semibold text-xs">
Mark your calendar
</p>
<h2 className="text-3xl font-black tracking-tight">Upcoming Meetups</h2>
</div>
<div className="hidden md:flex items-center gap-4">
<a
href="/calendar.ics"
title="Subscribe to get all future meetups automatically"
className="flex items-center gap-1.5 text-xs text-on-surface-variant/60 hover:text-primary border border-zinc-700 hover:border-primary/50 rounded-lg px-3 py-1.5 transition-all"
>
<CalendarPlus size={14} />
Add to Calendar
</a>
<Link
href="/events"
className="flex items-center gap-2 text-sm text-primary font-semibold hover:gap-3 transition-all"
>
All events <ArrowRight size={16} />
</Link>
</div>
</div>
{meetups.length === 0 ? (
<div className="border border-zinc-800 rounded-xl px-8 py-12 text-center">
<p className="text-on-surface-variant text-sm">
No upcoming meetups scheduled. Check back soon.
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{meetups.map((meetup, i) => {
const { month, day, full } = formatMeetupDate(meetup.date);
const href = meetup.id ? `/events/${meetup.id}` : "#upcoming-meetups";
return (
<Link
key={meetup.id ?? i}
href={href}
className="group flex flex-col bg-zinc-900 border border-zinc-800 rounded-xl p-6 hover:border-zinc-700 hover:-translate-y-0.5 hover:shadow-xl transition-all duration-200"
>
<div className="flex items-start gap-4 mb-4">
<div className="bg-zinc-800 rounded-lg px-3 py-2 text-center shrink-0 min-w-[52px]">
<span className="block text-[10px] font-bold uppercase text-primary tracking-wider leading-none mb-0.5">
{month}
</span>
<span className="block text-2xl font-black leading-none">{day}</span>
</div>
<div className="min-w-0">
<h3 className="font-bold text-base leading-snug group-hover:text-primary transition-colors">
{meetup.title}
</h3>
<p className="text-on-surface-variant/60 text-xs mt-1">{full}</p>
</div>
</div>
{meetup.description && (
<p className="text-on-surface-variant text-sm leading-relaxed mb-4 flex-1 line-clamp-2">
{meetup.description}
</p>
)}
<div className="flex flex-col gap-1.5 mt-auto pt-4 border-t border-zinc-800/60">
{meetup.location && (
<p className="flex items-center gap-1.5 text-xs text-on-surface-variant/60">
<MapPin size={12} className="shrink-0 text-primary/60" />
{meetup.location}
</p>
)}
{meetup.time && (
<p className="flex items-center gap-1.5 text-xs text-on-surface-variant/60">
<Clock size={12} className="shrink-0 text-primary/60" />
{meetup.time}
</p>
)}
</div>
<span className="flex items-center gap-1.5 text-primary text-xs font-semibold mt-4 group-hover:gap-2.5 transition-all">
View Details <ArrowRight size={12} />
</span>
</Link>
);
})}
</div>
)}
<div className="md:hidden flex flex-col items-center gap-3 mt-8">
<Link
href="/events"
className="flex items-center gap-2 text-primary font-semibold text-sm"
>
All events <ArrowRight size={16} />
</Link>
<a
href="/calendar.ics"
className="flex items-center gap-1.5 text-xs text-on-surface-variant/60 hover:text-primary border border-zinc-700 hover:border-primary/50 rounded-lg px-3 py-1.5 transition-all"
>
<CalendarPlus size={14} />
Add to Calendar
</a>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,300 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { Menu, X, LogIn, User, LayoutDashboard, LogOut, Shield } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/Button";
import { useAuth } from "@/hooks/useAuth";
import { shortenPubkey } from "@/lib/nostr";
const SECTION_LINKS = [{ label: "About", anchor: "about" }];
const PAGE_LINKS = [
{ label: "Meetups", href: "/events" },
{ label: "Community", href: "/community" },
{ label: "FAQ", href: "/faq" },
];
function ProfileAvatar({
picture,
name,
size = 36,
}: {
picture?: string;
name?: string;
size?: number;
}) {
const [imgError, setImgError] = useState(false);
const initial = (name || "?")[0].toUpperCase();
if (picture && !imgError) {
return (
<Image
src={picture}
alt={name || "Profile"}
width={size}
height={size}
className="rounded-full object-cover"
style={{ width: size, height: size }}
onError={() => setImgError(true)}
unoptimized
/>
);
}
return (
<div
className="rounded-full bg-surface-container-high flex items-center justify-center text-on-surface font-bold text-sm"
style={{ width: size, height: size }}
>
{initial}
</div>
);
}
export function Navbar() {
const [open, setOpen] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const pathname = usePathname();
const router = useRouter();
const { user, loading, logout } = useAuth();
const isHome = pathname === "/";
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
function sectionHref(anchor: string) {
return isHome ? `#${anchor}` : `/#${anchor}`;
}
const displayName = user?.name || user?.displayName || shortenPubkey(user?.pubkey || "");
const isStaff = user?.role === "ADMIN" || user?.role === "MODERATOR";
function handleLogout() {
setDropdownOpen(false);
setOpen(false);
logout();
router.push("/");
}
return (
<nav className="sticky top-0 z-50 bg-surface/95 backdrop-blur-md">
<div className="flex justify-between items-center max-w-7xl mx-auto px-8 h-20">
<Link
href="/"
className="text-xl font-bold text-primary-container tracking-[-0.02em]"
>
Belgian Bitcoin Embassy
</Link>
<div className="hidden md:flex space-x-10 items-center">
{SECTION_LINKS.map((link) => (
<a
key={link.anchor}
href={sectionHref(link.anchor)}
className="font-medium tracking-tight transition-colors duration-200 text-white/70 hover:text-primary"
>
{link.label}
</a>
))}
{PAGE_LINKS.map((link) => (
<Link
key={link.href}
href={link.href}
className={cn(
"font-medium tracking-tight transition-colors duration-200",
pathname.startsWith(link.href)
? "text-primary font-bold"
: "text-white/70 hover:text-primary"
)}
>
{link.label}
</Link>
))}
<Link
href="/blog"
className={cn(
"font-medium tracking-tight transition-colors duration-200",
pathname.startsWith("/blog")
? "text-primary font-bold"
: "text-white/70 hover:text-primary"
)}
>
Blog
</Link>
</div>
<div className="hidden md:block">
{loading ? (
<div className="w-24 h-10" />
) : user ? (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
className="flex items-center gap-3 px-3 py-1.5 rounded-lg transition-colors hover:bg-surface-container-high"
>
<ProfileAvatar
picture={user.picture}
name={user.name || user.displayName}
size={32}
/>
<span className="text-sm font-medium text-on-surface max-w-[120px] truncate">
{displayName}
</span>
</button>
{dropdownOpen && (
<div className="absolute right-0 mt-2 w-52 bg-surface-container-high rounded-xl py-2 shadow-lg shadow-black/30">
<Link
href="/dashboard"
onClick={() => setDropdownOpen(false)}
className="flex items-center gap-3 px-4 py-2.5 text-sm text-on-surface hover:bg-surface-bright transition-colors"
>
<LayoutDashboard size={16} className="text-on-surface-variant" />
Dashboard
</Link>
{isStaff && (
<Link
href="/admin"
onClick={() => setDropdownOpen(false)}
className="flex items-center gap-3 px-4 py-2.5 text-sm text-on-surface hover:bg-surface-bright transition-colors"
>
<Shield size={16} className="text-on-surface-variant" />
Admin
</Link>
)}
<button
onClick={handleLogout}
className="flex items-center gap-3 px-4 py-2.5 text-sm text-on-surface hover:bg-surface-bright transition-colors w-full text-left"
>
<LogOut size={16} className="text-on-surface-variant" />
Logout
</button>
</div>
)}
</div>
) : (
<Link href="/login">
<Button variant="primary" size="md">
<span className="flex items-center gap-2">
<LogIn size={16} />
Login
</span>
</Button>
</Link>
)}
</div>
<button
className="md:hidden text-on-surface"
onClick={() => setOpen(!open)}
aria-label="Toggle menu"
>
{open ? <X size={24} /> : <Menu size={24} />}
</button>
</div>
{open && (
<div className="md:hidden bg-surface-container px-8 pb-6 space-y-4">
{SECTION_LINKS.map((link) => (
<a
key={link.anchor}
href={sectionHref(link.anchor)}
onClick={() => setOpen(false)}
className="block py-2 font-medium tracking-tight transition-colors text-white/70 hover:text-primary"
>
{link.label}
</a>
))}
{PAGE_LINKS.map((link) => (
<Link
key={link.href}
href={link.href}
onClick={() => setOpen(false)}
className={cn(
"block py-2 font-medium tracking-tight transition-colors",
pathname.startsWith(link.href)
? "text-primary font-bold"
: "text-white/70 hover:text-primary"
)}
>
{link.label}
</Link>
))}
<Link
href="/blog"
onClick={() => setOpen(false)}
className={cn(
"block py-2 font-medium tracking-tight transition-colors",
pathname.startsWith("/blog")
? "text-primary font-bold"
: "text-white/70 hover:text-primary"
)}
>
Blog
</Link>
{loading ? null : user ? (
<>
<div className="flex items-center gap-3 pt-4">
<ProfileAvatar
picture={user.picture}
name={user.name || user.displayName}
size={32}
/>
<span className="text-sm font-medium text-on-surface truncate">
{displayName}
</span>
</div>
<Link
href="/dashboard"
onClick={() => setOpen(false)}
className="flex items-center gap-3 py-2 text-sm font-medium text-white/70 hover:text-primary transition-colors"
>
<LayoutDashboard size={16} />
Dashboard
</Link>
{isStaff && (
<Link
href="/admin"
onClick={() => setOpen(false)}
className="flex items-center gap-3 py-2 text-sm font-medium text-white/70 hover:text-primary transition-colors"
>
<Shield size={16} />
Admin
</Link>
)}
<button
onClick={handleLogout}
className="flex items-center gap-3 py-2 text-sm font-medium text-white/70 hover:text-primary transition-colors w-full"
>
<LogOut size={16} />
Logout
</button>
</>
) : (
<Link href="/login" onClick={() => setOpen(false)}>
<Button variant="primary" size="md" className="w-full mt-4">
<span className="flex items-center justify-center gap-2">
<LogIn size={16} />
Login
</span>
</Button>
</Link>
)}
</div>
)}
</nav>
);
}

View File

@@ -0,0 +1,41 @@
import { type ButtonHTMLAttributes, forwardRef } from "react";
import { cn } from "@/lib/utils";
type ButtonVariant = "primary" | "secondary" | "tertiary" | "telegram";
type ButtonSize = "sm" | "md" | "lg";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
}
const variantStyles: Record<ButtonVariant, string> = {
primary:
"bg-gradient-to-r from-primary to-primary-container text-on-primary font-bold hover:scale-105 active:opacity-80 transition-all",
secondary:
"bg-surface-container-highest text-on-surface hover:bg-surface-bright transition-colors",
tertiary: "text-primary-fixed-dim hover:opacity-80 transition-opacity",
telegram:
"bg-[#24A1DE] text-white hover:opacity-90 transition-opacity",
};
const sizeStyles: Record<ButtonSize, string> = {
sm: "px-4 py-2 text-sm rounded-md",
md: "px-6 py-2.5 rounded-lg",
lg: "px-10 py-4 rounded-lg font-bold",
};
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = "primary", size = "md", className, children, ...rest }, ref) => (
<button
ref={ref}
className={cn(variantStyles[variant], sizeStyles[size], className)}
{...rest}
>
{children}
</button>
)
);
Button.displayName = "Button";
export { Button, type ButtonProps };

View File

@@ -0,0 +1,27 @@
import { type HTMLAttributes, forwardRef } from "react";
import { cn } from "@/lib/utils";
interface CardProps extends HTMLAttributes<HTMLDivElement> {
hover?: boolean;
variant?: "low" | "default";
}
const Card = forwardRef<HTMLDivElement, CardProps>(
({ hover, variant = "low", className, children, ...rest }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl p-6",
variant === "low" ? "bg-surface-container-low" : "bg-surface-container",
hover && "hover:bg-surface-container-high transition-colors",
className
)}
{...rest}
>
{children}
</div>
)
);
Card.displayName = "Card";
export { Card, type CardProps };

151
frontend/hooks/useAuth.ts Normal file
View File

@@ -0,0 +1,151 @@
"use client";
import { useState, useEffect, useCallback, createContext, useContext } from "react";
import { api } from "@/lib/api";
import {
hasNostrExtension,
getPublicKey,
signEvent,
createAuthEvent,
fetchNostrProfile,
createBunkerSigner,
type BunkerSignerInterface,
type NostrProfile,
} from "@/lib/nostr";
export interface User {
pubkey: string;
role: string;
username?: string;
name?: string;
picture?: string;
about?: string;
nip05?: string;
displayName?: string;
}
interface AuthContextType {
user: User | null;
loading: boolean;
login: () => Promise<User>;
loginWithBunker: (input: string) => Promise<User>;
loginWithConnectedSigner: (signer: BunkerSignerInterface) => Promise<User>;
logout: () => void;
isAdmin: boolean;
isModerator: boolean;
}
export const AuthContext = createContext<AuthContextType>({
user: null,
loading: true,
login: async () => ({ pubkey: "", role: "USER" }),
loginWithBunker: async () => ({ pubkey: "", role: "USER" }),
loginWithConnectedSigner: async () => ({ pubkey: "", role: "USER" }),
logout: () => {},
isAdmin: false,
isModerator: false,
});
export function useAuth() {
return useContext(AuthContext);
}
export function useAuthProvider(): AuthContextType {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const stored = localStorage.getItem("bbe_user");
const token = localStorage.getItem("bbe_token");
if (stored && token) {
try {
setUser(JSON.parse(stored));
} catch {
localStorage.removeItem("bbe_user");
localStorage.removeItem("bbe_token");
}
}
setLoading(false);
}, []);
const completeAuth = useCallback(
async (
getPubKey: () => Promise<string>,
sign: (event: any) => Promise<any>
): Promise<User> => {
const pubkey = await getPubKey();
const { challenge } = await api.getChallenge(pubkey);
const event = createAuthEvent(pubkey, challenge);
const signedEvent = await sign(event);
const { token, user: userData } = await api.verify(pubkey, signedEvent);
let profile: NostrProfile = {};
try {
profile = await fetchNostrProfile(pubkey);
} catch {
// Profile fetch is best-effort
}
const fullUser: User = {
...userData,
name: profile.name,
displayName: profile.displayName,
picture: profile.picture,
about: profile.about,
nip05: profile.nip05,
username: userData.username,
};
localStorage.setItem("bbe_token", token);
localStorage.setItem("bbe_user", JSON.stringify(fullUser));
setUser(fullUser);
return fullUser;
},
[]
);
const login = useCallback(async (): Promise<User> => {
if (!hasNostrExtension()) {
throw new Error("Please install a Nostr extension (e.g., Alby, nos2x)");
}
return completeAuth(getPublicKey, signEvent);
}, [completeAuth]);
const loginWithConnectedSigner = useCallback(
async (signer: BunkerSignerInterface): Promise<User> => {
return completeAuth(
() => signer.getPublicKey(),
(event) => signer.signEvent(event)
);
},
[completeAuth]
);
const loginWithBunker = useCallback(
async (input: string): Promise<User> => {
const { signer } = await createBunkerSigner(input);
try {
return await loginWithConnectedSigner(signer);
} finally {
await signer.close().catch(() => {});
}
},
[loginWithConnectedSigner]
);
const logout = useCallback(() => {
localStorage.removeItem("bbe_token");
localStorage.removeItem("bbe_user");
setUser(null);
}, []);
return {
user,
loading,
login,
loginWithBunker,
loginWithConnectedSigner,
logout,
isAdmin: user?.role === "ADMIN",
isModerator: user?.role === "MODERATOR" || user?.role === "ADMIN",
};
}

181
frontend/lib/api.ts Normal file
View File

@@ -0,0 +1,181 @@
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000/api";
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const token = typeof window !== "undefined" ? localStorage.getItem("bbe_token") : null;
const headers: HeadersInit = {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options?.headers,
};
const res = await fetch(`${API_URL}${path}`, { ...options, headers });
if (!res.ok) {
const error = await res.json().catch(() => ({ message: "Request failed" }));
throw new Error(error.message || `HTTP ${res.status}`);
}
return res.json();
}
export const api = {
// Auth
getChallenge: (pubkey: string) =>
request<{ challenge: string }>("/auth/challenge", {
method: "POST",
body: JSON.stringify({ pubkey }),
}),
verify: (pubkey: string, signedEvent: any) =>
request<{ token: string; user: { pubkey: string; role: string; username?: string } }>("/auth/verify", {
method: "POST",
body: JSON.stringify({ pubkey, signedEvent }),
}),
// Posts
getPosts: (params?: { category?: string; page?: number; limit?: number; all?: boolean }) => {
const searchParams = new URLSearchParams();
if (params?.category) searchParams.set("category", params.category);
if (params?.page) searchParams.set("page", String(params.page));
if (params?.limit) searchParams.set("limit", String(params.limit));
if (params?.all) searchParams.set("all", "true");
return request<{ posts: any[]; total: number }>(`/posts?${searchParams}`);
},
getPost: (slug: string) => request<any>(`/posts/${slug}`),
getPostReactions: (slug: string) =>
request<{ count: number; reactions: any[] }>(`/posts/${slug}/reactions`),
getPostReplies: (slug: string) =>
request<{ count: number; replies: any[] }>(`/posts/${slug}/replies`),
importPost: (data: { eventId?: string; naddr?: string }) =>
request<any>("/posts/import", { method: "POST", body: JSON.stringify(data) }),
updatePost: (id: string, data: any) =>
request<any>(`/posts/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
deletePost: (id: string) =>
request<void>(`/posts/${id}`, { method: "DELETE" }),
// Meetups
getMeetups: (params?: { status?: string; admin?: boolean }) => {
const searchParams = new URLSearchParams();
if (params?.status) searchParams.set("status", params.status);
if (params?.admin) searchParams.set("admin", "true");
const qs = searchParams.toString();
return request<any[]>(`/meetups${qs ? `?${qs}` : ""}`);
},
getMeetup: (id: string) => request<any>(`/meetups/${id}`),
createMeetup: (data: any) =>
request<any>("/meetups", { method: "POST", body: JSON.stringify(data) }),
updateMeetup: (id: string, data: any) =>
request<any>(`/meetups/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
deleteMeetup: (id: string) =>
request<void>(`/meetups/${id}`, { method: "DELETE" }),
duplicateMeetup: (id: string) =>
request<any>(`/meetups/${id}/duplicate`, { method: "POST" }),
bulkMeetupAction: (action: string, ids: string[]) =>
request<any>("/meetups/bulk", { method: "POST", body: JSON.stringify({ action, ids }) }),
// Moderation
getHiddenContent: () => request<any[]>("/moderation/hidden"),
hideContent: (nostrEventId: string, reason?: string) =>
request<any>("/moderation/hide", { method: "POST", body: JSON.stringify({ nostrEventId, reason }) }),
unhideContent: (id: string) =>
request<void>(`/moderation/unhide/${id}`, { method: "DELETE" }),
getBlockedPubkeys: () => request<any[]>("/moderation/blocked"),
blockPubkey: (pubkey: string, reason?: string) =>
request<any>("/moderation/block", { method: "POST", body: JSON.stringify({ pubkey, reason }) }),
unblockPubkey: (id: string) =>
request<void>(`/moderation/unblock/${id}`, { method: "DELETE" }),
// Users
getUsers: () => request<any[]>("/users"),
promoteUser: (pubkey: string) =>
request<any>("/users/promote", { method: "POST", body: JSON.stringify({ pubkey }) }),
demoteUser: (pubkey: string) =>
request<any>("/users/demote", { method: "POST", body: JSON.stringify({ pubkey }) }),
// Categories
getCategories: () => request<any[]>("/categories"),
createCategory: (data: { name: string; slug: string }) =>
request<any>("/categories", { method: "POST", body: JSON.stringify(data) }),
updateCategory: (id: string, data: any) =>
request<any>(`/categories/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
deleteCategory: (id: string) =>
request<void>(`/categories/${id}`, { method: "DELETE" }),
// Relays
getRelays: () => request<any[]>("/relays"),
addRelay: (data: { url: string; priority?: number }) =>
request<any>("/relays", { method: "POST", body: JSON.stringify(data) }),
updateRelay: (id: string, data: any) =>
request<any>(`/relays/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
deleteRelay: (id: string) =>
request<void>(`/relays/${id}`, { method: "DELETE" }),
testRelay: (id: string) =>
request<{ success: boolean }>(`/relays/${id}/test`, { method: "POST" }),
// Settings
getSettings: () => request<Record<string, string>>("/settings"),
getPublicSettings: () => request<Record<string, string>>("/settings/public"),
updateSetting: (key: string, value: string) =>
request<any>("/settings", { method: "PATCH", body: JSON.stringify({ key, value }) }),
// Nostr tools
fetchNostrEvent: (data: { eventId?: string; naddr?: string }) =>
request<any>("/nostr/fetch", { method: "POST", body: JSON.stringify(data) }),
refreshCache: () =>
request<any>("/nostr/cache/refresh", { method: "POST" }),
debugEvent: (eventId: string) =>
request<any>(`/nostr/debug/${eventId}`),
// Media
uploadMedia: async (file: File) => {
const token = typeof window !== "undefined" ? localStorage.getItem("bbe_token") : null;
const formData = new FormData();
formData.append("file", file);
const res = await fetch(`${API_URL}/media/upload`, {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
});
if (!res.ok) {
const error = await res.json().catch(() => ({ message: "Upload failed" }));
throw new Error(error.error || error.message || `HTTP ${res.status}`);
}
return res.json() as Promise<{ id: string; slug: string; url: string }>;
},
getMediaList: () => request<any[]>("/media"),
getMedia: (id: string) => request<any>(`/media/${id}`),
deleteMedia: (id: string) =>
request<void>(`/media/${id}`, { method: "DELETE" }),
updateMedia: (id: string, data: { title?: string; description?: string; altText?: string }) =>
request<any>(`/media/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
// FAQs
getFaqs: () => request<any[]>('/faqs'),
getFaqsAll: () => request<any[]>('/faqs?all=true'),
getAllFaqs: () => request<any[]>('/faqs/all'),
createFaq: (data: { question: string; answer: string; showOnHomepage?: boolean }) =>
request<any>('/faqs', { method: 'POST', body: JSON.stringify(data) }),
updateFaq: (id: string, data: { question?: string; answer?: string; showOnHomepage?: boolean }) =>
request<any>(`/faqs/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteFaq: (id: string) =>
request<void>(`/faqs/${id}`, { method: 'DELETE' }),
reorderFaqs: (items: { id: string; order: number }[]) =>
request<any>('/faqs/reorder', { method: 'POST', body: JSON.stringify({ items }) }),
// Profile (self)
updateProfile: (data: { username?: string }) =>
request<any>('/users/me', { method: 'PATCH', body: JSON.stringify(data) }),
checkUsername: (username: string) =>
request<{ available: boolean; reason?: string }>(
`/users/me/username-check?username=${encodeURIComponent(username)}`
),
// Submissions
createSubmission: (data: { eventId?: string; naddr?: string; title: string }) =>
request<any>("/submissions", { method: "POST", body: JSON.stringify(data) }),
getMySubmissions: () =>
request<any[]>("/submissions/mine"),
getSubmissions: (status?: string) => {
const params = status ? `?status=${status}` : "";
return request<any[]>(`/submissions${params}`);
},
reviewSubmission: (id: string, data: { status: string; reviewNote?: string }) =>
request<any>(`/submissions/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
};

159
frontend/lib/nostr.ts Normal file
View File

@@ -0,0 +1,159 @@
import { generateSecretKey, getPublicKey as getPubKeyFromSecret } from "nostr-tools/pure";
declare global {
interface Window {
nostr?: {
getPublicKey(): Promise<string>;
signEvent(event: any): Promise<any>;
getRelays?(): Promise<Record<string, { read: boolean; write: boolean }>>;
};
}
}
export type BunkerSignerInterface = {
getPublicKey(): Promise<string>;
signEvent(event: any): Promise<any>;
close(): Promise<void>;
};
export function hasNostrExtension(): boolean {
return typeof window !== "undefined" && !!window.nostr;
}
export async function getPublicKey(): Promise<string> {
if (!window.nostr) throw new Error("No Nostr extension found");
return window.nostr.getPublicKey();
}
export async function signEvent(event: any): Promise<any> {
if (!window.nostr) throw new Error("No Nostr extension found");
return window.nostr.signEvent(event);
}
export function createAuthEvent(pubkey: string, challenge: string) {
return {
kind: 22242,
created_at: Math.floor(Date.now() / 1000),
tags: [
["relay", ""],
["challenge", challenge],
],
content: "",
pubkey,
};
}
export function shortenPubkey(pubkey: string): string {
if (!pubkey) return "";
return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`;
}
export interface NostrProfile {
name?: string;
picture?: string;
about?: string;
nip05?: string;
displayName?: string;
}
const DEFAULT_RELAYS = [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.nostr.band",
];
export async function publishEvent(signedEvent: any): Promise<void> {
const { SimplePool } = await import("nostr-tools/pool");
let relayUrls: string[] = DEFAULT_RELAYS;
try {
if (window.nostr?.getRelays) {
const ext = await window.nostr.getRelays();
const write = Object.entries(ext)
.filter(([, p]) => (p as any).write)
.map(([url]) => url);
if (write.length > 0) relayUrls = write;
}
} catch {}
const pool = new SimplePool();
try {
await Promise.allSettled(pool.publish(relayUrls, signedEvent));
} finally {
pool.close(relayUrls);
}
}
export async function fetchNostrProfile(
pubkey: string,
relayUrls: string[] = DEFAULT_RELAYS
): Promise<NostrProfile> {
const { SimplePool } = await import("nostr-tools/pool");
const pool = new SimplePool();
try {
const event = await pool.get(relayUrls, {
kinds: [0],
authors: [pubkey],
});
if (!event?.content) return {};
const meta = JSON.parse(event.content);
return {
name: meta.name || meta.display_name,
displayName: meta.display_name,
picture: meta.picture,
about: meta.about,
nip05: meta.nip05,
};
} catch {
return {};
} finally {
pool.close(relayUrls);
}
}
// NIP-46: External signer (bunker:// URI)
export async function createBunkerSigner(
input: string
): Promise<{ signer: BunkerSignerInterface; pubkey: string }> {
const { BunkerSigner, parseBunkerInput } = await import("nostr-tools/nip46");
const bp = await parseBunkerInput(input);
if (!bp) throw new Error("Invalid bunker URI or NIP-05 identifier");
const clientSecretKey = generateSecretKey();
const signer = BunkerSigner.fromBunker(clientSecretKey, bp);
await signer.connect();
const pubkey = await signer.getPublicKey();
return { signer, pubkey };
}
// NIP-46: Generate a nostrconnect:// URI for QR display
export async function generateNostrConnectSetup(
relayUrls: string[] = DEFAULT_RELAYS.slice(0, 2)
): Promise<{ uri: string; clientSecretKey: Uint8Array }> {
const { createNostrConnectURI } = await import("nostr-tools/nip46");
const clientSecretKey = generateSecretKey();
const clientPubkey = getPubKeyFromSecret(clientSecretKey);
const secretBytes = crypto.getRandomValues(new Uint8Array(8));
const secret = Array.from(secretBytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
const uri = createNostrConnectURI({
clientPubkey,
relays: relayUrls,
secret,
name: "Belgian Bitcoin Embassy",
});
return { uri, clientSecretKey };
}
// NIP-46: Wait for a remote signer to connect via nostrconnect:// URI
export async function waitForNostrConnectSigner(
clientSecretKey: Uint8Array,
uri: string,
signal?: AbortSignal
): Promise<{ signer: BunkerSignerInterface; pubkey: string }> {
const { BunkerSigner } = await import("nostr-tools/nip46");
const signer = await BunkerSigner.fromURI(clientSecretKey, uri, {}, signal);
const pubkey = await signer.getPublicKey();
return { signer, pubkey };
}

22
frontend/lib/utils.ts Normal file
View File

@@ -0,0 +1,22 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date: string | Date) {
return new Date(date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}
export function slugify(text: string) {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_-]+/g, "-")
.replace(/^-+|-+$/g, "");
}

5
frontend/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

16
frontend/next.config.js Normal file
View File

@@ -0,0 +1,16 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
serverExternalPackages: ['sharp'],
images: {
remotePatterns: [
{ protocol: 'https', hostname: '**' },
],
},
async rewrites() {
return [
{ source: '/calendar.ics', destination: '/calendar' },
];
},
};
module.exports = nextConfig;

3815
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
frontend/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "bbe-frontend",
"version": "1.0.0",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"lucide-react": "^0.460.0",
"next": "^14.2.0",
"nostr-tools": "^2.10.0",
"qrcode.react": "^4.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-markdown": "^9.0.0",
"remark-gfm": "^4.0.0",
"sharp": "^0.34.5",
"tailwind-merge": "^2.5.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.6.0"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 775 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<rect width="32" height="32" rx="6" fill="#F7931A"/>
<text x="16" y="23" text-anchor="middle" font-family="Arial,Helvetica,sans-serif" font-weight="bold" font-size="22" fill="#fff">&#x20bf;</text>
</svg>

After

Width:  |  Height:  |  Size: 292 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,76 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: "class",
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./lib/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
"surface-dim": "#131313",
"primary-container": "#f7931a",
"on-secondary-container": "#b6b5b4",
"on-background": "#e5e2e1",
"on-error-container": "#ffdad6",
error: "#ffb4ab",
"surface-bright": "#393939",
"on-tertiary-fixed-variant": "#46464b",
"inverse-surface": "#e5e2e1",
"surface-container-high": "#2a2a2a",
"on-primary-fixed": "#2d1600",
"on-surface-variant": "#dbc2ae",
"secondary-container": "#474747",
"on-tertiary-container": "#3f3f44",
"on-secondary-fixed-variant": "#474747",
"tertiary-container": "#ababb0",
"inverse-primary": "#8c4f00",
"tertiary-fixed": "#e3e2e7",
tertiary: "#c7c6cb",
"primary-fixed": "#ffdcbf",
"on-primary-fixed-variant": "#6b3b00",
"surface-container-low": "#1c1b1b",
"secondary-fixed": "#e4e2e1",
"on-tertiary-fixed": "#1a1b1f",
"surface-container-highest": "#353534",
"on-primary": "#4b2800",
"on-primary-container": "#603500",
"error-container": "#93000a",
"surface-container-lowest": "#0e0e0e",
"outline-variant": "#554335",
outline: "#a38d7b",
"surface-variant": "#353534",
"surface-tint": "#ffb874",
"on-error": "#690005",
"secondary-fixed-dim": "#c8c6c6",
surface: "#131313",
"on-secondary-fixed": "#1b1c1c",
background: "#131313",
secondary: "#c8c6c6",
"inverse-on-surface": "#313030",
"primary-fixed-dim": "#ffb874",
"on-tertiary": "#2f3034",
"on-surface": "#e5e2e1",
"tertiary-fixed-dim": "#c6c6cb",
primary: "#ffb874",
"on-secondary": "#303030",
"surface-container": "#201f1f",
},
fontFamily: {
headline: ["Inter", "sans-serif"],
body: ["Inter", "sans-serif"],
label: ["Inter", "sans-serif"],
},
borderRadius: {
DEFAULT: "0.25rem",
lg: "0.5rem",
xl: "0.75rem",
full: "9999px",
},
},
},
plugins: [],
};
export default config;

21
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}