Create comprehensive blog management component for admin panel
cgen-ec91e95ed662404da8a71a57c9a1b5cc
This commit is contained in:
parent
652a363ac8
commit
c087669079
1 changed files with 314 additions and 0 deletions
314
client/components/admin/AdminBlogManager.tsx
Normal file
314
client/components/admin/AdminBlogManager.tsx
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Loader2, Trash2, ExternalLink, RefreshCw } from "lucide-react";
|
||||
import { aethexToast } from "@/lib/aethex-toast";
|
||||
|
||||
interface BlogPost {
|
||||
id?: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt?: string | null;
|
||||
author?: string | null;
|
||||
date?: string | null;
|
||||
category?: string | null;
|
||||
image?: string | null;
|
||||
published_at?: string | null;
|
||||
body_html?: string | null;
|
||||
}
|
||||
|
||||
export default function AdminBlogManager() {
|
||||
const [blogPosts, setBlogPosts] = useState<BlogPost[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [filterCategory, setFilterCategory] = useState("");
|
||||
|
||||
const loadBlogPosts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/blog?limit=100");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) {
|
||||
setBlogPosts(data);
|
||||
aethexToast.success({
|
||||
title: "Blog posts loaded",
|
||||
description: `Loaded ${data.length} blog posts`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const errorText = await res.text();
|
||||
console.error("Failed to load blog posts:", errorText);
|
||||
aethexToast.error({
|
||||
title: "Failed to load blog posts",
|
||||
description: res.statusText || "Unknown error",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading blog posts:", error);
|
||||
aethexToast.error({
|
||||
title: "Error loading blog posts",
|
||||
description: String(error),
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadBlogPosts();
|
||||
}, [loadBlogPosts]);
|
||||
|
||||
const handleDeleteBlogPost = useCallback(
|
||||
async (slug: string) => {
|
||||
setDeleting(slug);
|
||||
try {
|
||||
const res = await fetch(`/api/blog/${slug}`, { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
setBlogPosts((posts) => posts.filter((p) => p.slug !== slug));
|
||||
aethexToast.success({
|
||||
title: "Blog post deleted",
|
||||
description: `Post "${slug}" has been removed`,
|
||||
});
|
||||
} else {
|
||||
aethexToast.error({
|
||||
title: "Failed to delete blog post",
|
||||
description: res.statusText || "Unknown error",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting blog post:", error);
|
||||
aethexToast.error({
|
||||
title: "Error deleting blog post",
|
||||
description: String(error),
|
||||
});
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const filteredPosts = blogPosts.filter((post) => {
|
||||
const matchesSearch =
|
||||
post.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
post.slug.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(post.author && post.author.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
const matchesCategory = !filterCategory || post.category === filterCategory;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
const categories = Array.from(new Set(blogPosts.map((p) => p.category).filter(Boolean)));
|
||||
|
||||
const formatDate = (dateStr?: string | null) => {
|
||||
if (!dateStr) return "—";
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="bg-card/60 border-border/40 backdrop-blur">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Blog Management</CardTitle>
|
||||
<CardDescription>
|
||||
{blogPosts.length} published {blogPosts.length === 1 ? "post" : "posts"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={loadBlogPosts}
|
||||
disabled={loading}
|
||||
className="gap-2"
|
||||
>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
{loading ? "Loading..." : "Refresh"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="search-blogs" className="text-xs mb-1 block">
|
||||
Search
|
||||
</Label>
|
||||
<Input
|
||||
id="search-blogs"
|
||||
placeholder="Search by title, slug, or author..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="filter-category" className="text-xs mb-1 block">
|
||||
Category
|
||||
</Label>
|
||||
<select
|
||||
id="filter-category"
|
||||
value={filterCategory}
|
||||
onChange={(e) => setFilterCategory(e.target.value)}
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
|
||||
>
|
||||
<option value="">All categories</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat} value={cat || ""}>
|
||||
{cat || "Uncategorized"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredPosts.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">
|
||||
{blogPosts.length === 0 ? "No blog posts found" : "No matching blog posts"}
|
||||
</p>
|
||||
{blogPosts.length === 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="mt-4"
|
||||
onClick={() => window.open("/blog", "_blank")}
|
||||
>
|
||||
View public blog
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Author</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead className="w-20 text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredPosts.map((post) => (
|
||||
<TableRow key={post.slug} className="hover:bg-muted/50">
|
||||
<TableCell className="font-medium">
|
||||
<div className="max-w-xs">
|
||||
<p className="truncate">{post.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{post.slug}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{post.author || "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{post.category ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{post.category}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{formatDate(post.published_at || post.date)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex gap-1 justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => window.open(`/blog/${post.slug}`, "_blank")}
|
||||
title="View published post"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("openDeleteDialog", { detail: post.slug }),
|
||||
);
|
||||
}}
|
||||
className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-9 w-9 hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
|
||||
disabled={deleting === post.slug}
|
||||
title="Delete post"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
{deleting === post.slug && (
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete blog post?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{post.title}"? This action cannot be
|
||||
undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<AlertDialogCancel
|
||||
onClick={() => setDeleting(null)}
|
||||
>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
handleDeleteBlogPost(post.slug);
|
||||
}}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
)}
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue