first commit
Made-with: Cursor
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user