first commit
Made-with: Cursor
This commit is contained in:
19
frontend/app/.well-known/nostr.json/route.ts
Normal file
19
frontend/app/.well-known/nostr.json/route.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
337
frontend/app/admin/blog/page.tsx
Normal file
337
frontend/app/admin/blog/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
184
frontend/app/admin/categories/page.tsx
Normal file
184
frontend/app/admin/categories/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
844
frontend/app/admin/events/page.tsx
Normal file
844
frontend/app/admin/events/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
354
frontend/app/admin/faq/page.tsx
Normal file
354
frontend/app/admin/faq/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
325
frontend/app/admin/gallery/page.tsx
Normal file
325
frontend/app/admin/gallery/page.tsx
Normal 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} · {formatFileSize(editingItem.size)} · {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>
|
||||
);
|
||||
}
|
||||
33
frontend/app/admin/layout.tsx
Normal file
33
frontend/app/admin/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
256
frontend/app/admin/moderation/page.tsx
Normal file
256
frontend/app/admin/moderation/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
frontend/app/admin/nostr/page.tsx
Normal file
147
frontend/app/admin/nostr/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
173
frontend/app/admin/overview/page.tsx
Normal file
173
frontend/app/admin/overview/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
frontend/app/admin/page.tsx
Normal file
74
frontend/app/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
225
frontend/app/admin/relays/page.tsx
Normal file
225
frontend/app/admin/relays/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
frontend/app/admin/settings/page.tsx
Normal file
105
frontend/app/admin/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
226
frontend/app/admin/submissions/page.tsx
Normal file
226
frontend/app/admin/submissions/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
162
frontend/app/admin/users/page.tsx
Normal file
162
frontend/app/admin/users/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
400
frontend/app/blog/[slug]/BlogPostClient.tsx
Normal file
400
frontend/app/blog/[slug]/BlogPostClient.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
84
frontend/app/blog/[slug]/page.tsx
Normal file
84
frontend/app/blog/[slug]/page.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
17
frontend/app/blog/layout.tsx
Normal file
17
frontend/app/blog/layout.tsx
Normal 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
283
frontend/app/blog/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
30
frontend/app/calendar/route.ts
Normal file
30
frontend/app/calendar/route.ts
Normal 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"',
|
||||
},
|
||||
});
|
||||
}
|
||||
17
frontend/app/community/layout.tsx
Normal file
17
frontend/app/community/layout.tsx
Normal 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;
|
||||
}
|
||||
33
frontend/app/community/page.tsx
Normal file
33
frontend/app/community/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
86
frontend/app/contact/page.tsx
Normal file
86
frontend/app/contact/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
47
frontend/app/dashboard/layout.tsx
Normal file
47
frontend/app/dashboard/layout.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Navbar } from "@/components/public/Navbar";
|
||||
import { Footer } from "@/components/public/Footer";
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
if (user.role === "ADMIN" || user.role === "MODERATOR") {
|
||||
router.push("/admin/overview");
|
||||
}
|
||||
}, [user, loading, router]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="text-on-surface/50">Loading...</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user || user.role === "ADMIN" || user.role === "MODERATOR") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main className="min-h-screen max-w-5xl mx-auto px-8 py-12">{children}</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
521
frontend/app/dashboard/page.tsx
Normal file
521
frontend/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,521 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import Image from "next/image";
|
||||
import { Send, FileText, Clock, CheckCircle, XCircle, Plus, User, Loader2, AtSign } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { api } from "@/lib/api";
|
||||
import { shortenPubkey } from "@/lib/nostr";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
interface Submission {
|
||||
id: string;
|
||||
eventId?: string;
|
||||
naddr?: string;
|
||||
title: string;
|
||||
status: string;
|
||||
reviewNote?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; icon: typeof Clock; className: string }> = {
|
||||
PENDING: {
|
||||
label: "Pending Review",
|
||||
icon: Clock,
|
||||
className: "text-primary bg-primary/10",
|
||||
},
|
||||
APPROVED: {
|
||||
label: "Approved",
|
||||
icon: CheckCircle,
|
||||
className: "text-green-400 bg-green-400/10",
|
||||
},
|
||||
REJECTED: {
|
||||
label: "Rejected",
|
||||
icon: XCircle,
|
||||
className: "text-error bg-error/10",
|
||||
},
|
||||
};
|
||||
|
||||
type Tab = "submissions" | "profile";
|
||||
|
||||
type UsernameStatus =
|
||||
| { state: "idle" }
|
||||
| { state: "checking" }
|
||||
| { state: "available" }
|
||||
| { state: "unavailable"; reason: string };
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user, login } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState<Tab>("submissions");
|
||||
|
||||
// Submissions state
|
||||
const [submissions, setSubmissions] = useState<Submission[]>([]);
|
||||
const [loadingSubs, setLoadingSubs] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [eventId, setEventId] = useState("");
|
||||
const [naddr, setNaddr] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [formError, setFormError] = useState("");
|
||||
const [formSuccess, setFormSuccess] = useState("");
|
||||
|
||||
// Profile state
|
||||
const [username, setUsername] = useState("");
|
||||
const [usernameStatus, setUsernameStatus] = useState<UsernameStatus>({ state: "idle" });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState("");
|
||||
const [saveSuccess, setSaveSuccess] = useState("");
|
||||
const [hostname, setHostname] = useState("");
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const displayName = user?.name || user?.displayName || shortenPubkey(user?.pubkey || "");
|
||||
|
||||
useEffect(() => {
|
||||
setHostname(window.location.hostname);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.username) {
|
||||
setUsername(user.username);
|
||||
}
|
||||
}, [user?.username]);
|
||||
|
||||
const loadSubmissions = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.getMySubmissions();
|
||||
setSubmissions(data);
|
||||
} catch {
|
||||
// Silently handle
|
||||
} finally {
|
||||
setLoadingSubs(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadSubmissions();
|
||||
}, [loadSubmissions]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError("");
|
||||
setFormSuccess("");
|
||||
|
||||
if (!title.trim()) {
|
||||
setFormError("Title is required");
|
||||
return;
|
||||
}
|
||||
if (!eventId.trim() && !naddr.trim()) {
|
||||
setFormError("Either an Event ID or naddr is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await api.createSubmission({
|
||||
title: title.trim(),
|
||||
eventId: eventId.trim() || undefined,
|
||||
naddr: naddr.trim() || undefined,
|
||||
});
|
||||
setFormSuccess("Submission sent for review!");
|
||||
setTitle("");
|
||||
setEventId("");
|
||||
setNaddr("");
|
||||
setShowForm(false);
|
||||
await loadSubmissions();
|
||||
} catch (err: any) {
|
||||
setFormError(err.message || "Failed to submit");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUsernameChange = (value: string) => {
|
||||
setUsername(value);
|
||||
setSaveError("");
|
||||
setSaveSuccess("");
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
||||
const trimmed = value.trim().toLowerCase();
|
||||
|
||||
if (!trimmed || trimmed === (user?.username ?? "")) {
|
||||
setUsernameStatus({ state: "idle" });
|
||||
return;
|
||||
}
|
||||
|
||||
setUsernameStatus({ state: "checking" });
|
||||
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const result = await api.checkUsername(trimmed);
|
||||
if (result.available) {
|
||||
setUsernameStatus({ state: "available" });
|
||||
} else {
|
||||
setUsernameStatus({ state: "unavailable", reason: result.reason || "Username is not available" });
|
||||
}
|
||||
} catch {
|
||||
setUsernameStatus({ state: "unavailable", reason: "Could not check availability" });
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleSaveProfile = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaveError("");
|
||||
setSaveSuccess("");
|
||||
|
||||
const trimmed = username.trim().toLowerCase();
|
||||
if (!trimmed) {
|
||||
setSaveError("Username is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await api.updateProfile({ username: trimmed });
|
||||
setSaveSuccess(`Username saved! Your NIP-05 address is ${updated.username}@${hostname}`);
|
||||
setUsernameStatus({ state: "idle" });
|
||||
// Persist updated username into stored user
|
||||
const stored = localStorage.getItem("bbe_user");
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
localStorage.setItem("bbe_user", JSON.stringify({ ...parsed, username: updated.username }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
setSaveError(err.message || "Failed to save username");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isSaveDisabled =
|
||||
saving ||
|
||||
usernameStatus.state === "checking" ||
|
||||
usernameStatus.state === "unavailable" ||
|
||||
!username.trim();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-5 mb-12">
|
||||
{user?.picture ? (
|
||||
<Image
|
||||
src={user.picture}
|
||||
alt={displayName}
|
||||
width={56}
|
||||
height={56}
|
||||
className="rounded-full object-cover"
|
||||
style={{ width: 56, height: 56 }}
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="w-14 h-14 rounded-full bg-surface-container-high flex items-center justify-center text-on-surface font-bold text-xl">
|
||||
{(displayName)[0]?.toUpperCase() || "?"}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-on-surface">{displayName}</h1>
|
||||
<p className="text-on-surface-variant text-sm">Your Dashboard</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 mb-8 border-b border-outline-variant">
|
||||
<button
|
||||
onClick={() => setActiveTab("submissions")}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-colors ${
|
||||
activeTab === "submissions"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-on-surface-variant hover:text-on-surface"
|
||||
}`}
|
||||
>
|
||||
<FileText size={16} />
|
||||
Submissions
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("profile")}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-colors ${
|
||||
activeTab === "profile"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-on-surface-variant hover:text-on-surface"
|
||||
}`}
|
||||
>
|
||||
<User size={16} />
|
||||
Profile
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Submissions tab */}
|
||||
{activeTab === "submissions" && (
|
||||
<>
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h2 className="text-xl font-bold text-on-surface">Submit a Post</h2>
|
||||
{!showForm && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowForm(true);
|
||||
setFormSuccess("");
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Plus size={16} />
|
||||
New Submission
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{formSuccess && (
|
||||
<div className="bg-green-400/10 text-green-400 rounded-lg px-4 py-3 text-sm mb-6">
|
||||
{formSuccess}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-surface-container-low rounded-xl p-6 mb-8 space-y-4"
|
||||
>
|
||||
<p className="text-on-surface-variant text-sm mb-2">
|
||||
Submit a Nostr longform post for moderator review. Provide the
|
||||
event ID or naddr of the article you'd like published on the
|
||||
blog.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-2">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="My Bitcoin Article"
|
||||
className="w-full bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-1 focus:ring-primary/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-2">
|
||||
Nostr Event ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={eventId}
|
||||
onChange={(e) => setEventId(e.target.value)}
|
||||
placeholder="note1... or hex event id"
|
||||
className="w-full bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 font-mono text-sm placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-1 focus:ring-primary/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-2">
|
||||
Or naddr
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={naddr}
|
||||
onChange={(e) => setNaddr(e.target.value)}
|
||||
placeholder="naddr1..."
|
||||
className="w-full bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 font-mono text-sm placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-1 focus:ring-primary/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formError && (
|
||||
<p className="text-error text-sm">{formError}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Send size={16} />
|
||||
{submitting ? "Submitting..." : "Submit for Review"}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowForm(false);
|
||||
setFormError("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-on-surface mb-6">My Submissions</h2>
|
||||
|
||||
{loadingSubs ? (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="animate-pulse bg-surface-container-low rounded-xl p-6">
|
||||
<div className="h-5 w-2/3 bg-surface-container-high rounded mb-3" />
|
||||
<div className="h-4 w-1/3 bg-surface-container-high rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : submissions.length === 0 ? (
|
||||
<div className="bg-surface-container-low rounded-xl p-8 text-center">
|
||||
<FileText size={32} className="text-on-surface-variant/30 mx-auto mb-3" />
|
||||
<p className="text-on-surface-variant/60 text-sm">
|
||||
No submissions yet. Submit a Nostr longform post for review.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{submissions.map((sub) => {
|
||||
const statusCfg = STATUS_CONFIG[sub.status] || STATUS_CONFIG.PENDING;
|
||||
const StatusIcon = statusCfg.icon;
|
||||
return (
|
||||
<div
|
||||
key={sub.id}
|
||||
className="bg-surface-container-low rounded-xl p-6"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-on-surface truncate">
|
||||
{sub.title}
|
||||
</h3>
|
||||
<p className="text-on-surface-variant/60 text-xs mt-1">
|
||||
{formatDate(sub.createdAt)}
|
||||
{sub.eventId && (
|
||||
<span className="ml-3 font-mono">
|
||||
{sub.eventId.slice(0, 16)}...
|
||||
</span>
|
||||
)}
|
||||
{sub.naddr && (
|
||||
<span className="ml-3 font-mono">
|
||||
{sub.naddr.slice(0, 20)}...
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold whitespace-nowrap ${statusCfg.className}`}
|
||||
>
|
||||
<StatusIcon size={14} />
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
</div>
|
||||
{sub.reviewNote && (
|
||||
<p className="mt-3 text-sm text-on-surface-variant bg-surface-container-high rounded-lg px-4 py-2">
|
||||
{sub.reviewNote}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Profile tab */}
|
||||
{activeTab === "profile" && (
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-on-surface mb-2">NIP-05 Username</h2>
|
||||
<p className="text-on-surface-variant text-sm mb-8">
|
||||
Claim a NIP-05 verified Nostr address hosted on this site. Other Nostr
|
||||
clients will display your identity as{" "}
|
||||
<span className="font-mono text-on-surface">username@{hostname || "…"}</span>.
|
||||
</p>
|
||||
|
||||
<form
|
||||
onSubmit={handleSaveProfile}
|
||||
className="bg-surface-container-low rounded-xl p-6 space-y-5 max-w-lg"
|
||||
>
|
||||
<div>
|
||||
<label className="block text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-2">
|
||||
Username
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none">
|
||||
<AtSign size={16} className="text-on-surface-variant/50" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => handleUsernameChange(e.target.value)}
|
||||
placeholder="yourname"
|
||||
maxLength={50}
|
||||
className="w-full bg-surface-container-highest text-on-surface rounded-lg pl-10 pr-10 py-3 font-mono text-sm placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-1 focus:ring-primary/40"
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
{usernameStatus.state === "checking" && (
|
||||
<Loader2 size={16} className="animate-spin text-on-surface-variant/50" />
|
||||
)}
|
||||
{usernameStatus.state === "available" && (
|
||||
<CheckCircle size={16} className="text-green-400" />
|
||||
)}
|
||||
{usernameStatus.state === "unavailable" && (
|
||||
<XCircle size={16} className="text-error" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status message */}
|
||||
<div className="mt-2 min-h-[20px]">
|
||||
{usernameStatus.state === "checking" && (
|
||||
<p className="text-xs text-on-surface-variant/60">Checking availability…</p>
|
||||
)}
|
||||
{usernameStatus.state === "available" && (
|
||||
<p className="text-xs text-green-400">Available</p>
|
||||
)}
|
||||
{usernameStatus.state === "unavailable" && (
|
||||
<p className="text-xs text-error">{usernameStatus.reason}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NIP-05 preview */}
|
||||
{username.trim() && (
|
||||
<div className="bg-surface-container-highest rounded-lg px-4 py-3">
|
||||
<p className="text-xs text-on-surface-variant mb-1 uppercase tracking-widest font-bold">NIP-05 Address</p>
|
||||
<p className="font-mono text-sm text-on-surface break-all">
|
||||
{username.trim().toLowerCase()}@{hostname || "…"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveError && (
|
||||
<p className="text-error text-sm">{saveError}</p>
|
||||
)}
|
||||
{saveSuccess && (
|
||||
<div className="bg-green-400/10 text-green-400 rounded-lg px-4 py-3 text-sm">
|
||||
{saveSuccess}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
type="submit"
|
||||
disabled={isSaveDisabled}
|
||||
>
|
||||
{saving ? "Saving…" : "Save Username"}
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
162
frontend/app/events/[id]/EventDetailClient.tsx
Normal file
162
frontend/app/events/[id]/EventDetailClient.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
86
frontend/app/events/[id]/page.tsx
Normal file
86
frontend/app/events/[id]/page.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
17
frontend/app/events/layout.tsx
Normal file
17
frontend/app/events/layout.tsx
Normal 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;
|
||||
}
|
||||
190
frontend/app/events/page.tsx
Normal file
190
frontend/app/events/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
17
frontend/app/faq/layout.tsx
Normal file
17
frontend/app/faq/layout.tsx
Normal 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
103
frontend/app/faq/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
134
frontend/app/gallery/[slug]/page.tsx
Normal file
134
frontend/app/gallery/[slug]/page.tsx
Normal 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
34
frontend/app/globals.css
Normal 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
92
frontend/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
frontend/app/login/layout.tsx
Normal file
11
frontend/app/login/layout.tsx
Normal 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
292
frontend/app/login/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
178
frontend/app/media/[id]/route.ts
Normal file
178
frontend/app/media/[id]/route.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
29
frontend/app/not-found.tsx
Normal file
29
frontend/app/not-found.tsx
Normal 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're looking for doesn'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
152
frontend/app/og/route.tsx
Normal 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
72
frontend/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
frontend/app/privacy/page.tsx
Normal file
78
frontend/app/privacy/page.tsx
Normal 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
17
frontend/app/robots.ts
Normal 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
56
frontend/app/sitemap.ts
Normal 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];
|
||||
}
|
||||
77
frontend/app/terms/page.tsx
Normal file
77
frontend/app/terms/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
121
frontend/components/admin/AdminSidebar.tsx
Normal file
121
frontend/components/admin/AdminSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
155
frontend/components/admin/MediaPickerModal.tsx
Normal file
155
frontend/components/admin/MediaPickerModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
frontend/components/providers/AuthProvider.tsx
Normal file
8
frontend/components/providers/AuthProvider.tsx
Normal 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>;
|
||||
}
|
||||
8
frontend/components/providers/ClientProviders.tsx
Normal file
8
frontend/components/providers/ClientProviders.tsx
Normal 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>;
|
||||
}
|
||||
23
frontend/components/public/AboutSection.tsx
Normal file
23
frontend/components/public/AboutSection.tsx
Normal 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">
|
||||
“Fix the money, fix the world.”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
81
frontend/components/public/BlogPreviewSection.tsx
Normal file
81
frontend/components/public/BlogPreviewSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
169
frontend/components/public/CommunityLinksSection.tsx
Normal file
169
frontend/components/public/CommunityLinksSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
frontend/components/public/FAQSection.tsx
Normal file
77
frontend/components/public/FAQSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
frontend/components/public/FinalCTASection.tsx
Normal file
44
frontend/components/public/FinalCTASection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
frontend/components/public/Footer.tsx
Normal file
37
frontend/components/public/Footer.tsx
Normal 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">
|
||||
© Belgian Bitcoin Embassy. No counterparty risk.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
71
frontend/components/public/HeroSection.tsx
Normal file
71
frontend/components/public/HeroSection.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
199
frontend/components/public/JsonLd.tsx
Normal file
199
frontend/components/public/JsonLd.tsx
Normal 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}`,
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
49
frontend/components/public/KnowledgeCards.tsx
Normal file
49
frontend/components/public/KnowledgeCards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
frontend/components/public/MeetupsSection.tsx
Normal file
137
frontend/components/public/MeetupsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
300
frontend/components/public/Navbar.tsx
Normal file
300
frontend/components/public/Navbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
frontend/components/ui/Button.tsx
Normal file
41
frontend/components/ui/Button.tsx
Normal 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 };
|
||||
27
frontend/components/ui/Card.tsx
Normal file
27
frontend/components/ui/Card.tsx
Normal 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
151
frontend/hooks/useAuth.ts
Normal 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
181
frontend/lib/api.ts
Normal 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
159
frontend/lib/nostr.ts
Normal 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
22
frontend/lib/utils.ts
Normal 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
5
frontend/next-env.d.ts
vendored
Normal 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
16
frontend/next.config.js
Normal 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
3815
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
frontend/package.json
Normal file
33
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
BIN
frontend/public/apple-touch-icon.png
Normal file
BIN
frontend/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 775 B |
4
frontend/public/favicon.svg
Normal file
4
frontend/public/favicon.svg
Normal 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">₿</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 292 B |
BIN
frontend/public/og-default.png
Normal file
BIN
frontend/public/og-default.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
76
frontend/tailwind.config.ts
Normal file
76
frontend/tailwind.config.ts
Normal 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
21
frontend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user