first commit

Made-with: Cursor
This commit is contained in:
Michilis
2026-04-01 02:46:53 +00:00
commit 76210db03d
126 changed files with 20208 additions and 0 deletions

View File

@@ -0,0 +1,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>
);
}