338 lines
12 KiB
TypeScript
338 lines
12 KiB
TypeScript
"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>
|
|
);
|
|
}
|