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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user