diff --git a/src/App.tsx b/src/App.tsx index 043419b2..81d82b3f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -40,6 +40,9 @@ import AdminReports from "./pages/AdminReports"; import AdminSystemLog from "./pages/AdminSystemLog"; import AdminUsers from "./pages/AdminUsers"; import AdminSettings from "./pages/AdminSettings"; +import BlogIndex from "./pages/BlogIndex"; +import BlogPost from "./pages/BlogPost"; +import AdminBlog from "./pages/AdminBlog"; const queryClient = new QueryClient(); @@ -64,6 +67,8 @@ function AppContent() { } /> } /> } /> + } /> + } /> } /> } /> } /> @@ -86,6 +91,7 @@ function AppContent() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/pages/AdminBlog.tsx b/src/pages/AdminBlog.tsx new file mode 100644 index 00000000..4963e787 --- /dev/null +++ b/src/pages/AdminBlog.tsx @@ -0,0 +1,350 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import { useAuth } from '@/hooks/useAuth'; +import { useUserRole } from '@/hooks/useUserRole'; +import { AdminLayout } from '@/components/layout/AdminLayout'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Card } from '@/components/ui/card'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Badge } from '@/components/ui/badge'; +import { UppyPhotoUpload } from '@/components/upload/UppyPhotoUpload'; +import { generateSlugFromName } from '@/lib/slugUtils'; +import { Edit, Trash2, Eye, Plus } from 'lucide-react'; +import { toast } from 'sonner'; +import { formatDistanceToNow } from 'date-fns'; + +interface BlogPost { + id: string; + slug: string; + title: string; + content: string; + featured_image_id?: string; + featured_image_url?: string; + status: 'draft' | 'published'; + published_at?: string; + view_count: number; + created_at: string; +} + +export default function AdminBlog() { + const { user } = useAuth(); + const { isModerator } = useUserRole(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const [activeTab, setActiveTab] = useState('posts'); + const [editingPost, setEditingPost] = useState(null); + + const [title, setTitle] = useState(''); + const [slug, setSlug] = useState(''); + const [content, setContent] = useState(''); + const [featuredImageId, setFeaturedImageId] = useState(''); + const [featuredImageUrl, setFeaturedImageUrl] = useState(''); + + if (!isModerator()) { + navigate('/'); + return null; + } + + const { data: posts, isLoading } = useQuery({ + queryKey: ['admin-blog-posts'], + queryFn: async () => { + const { data, error } = await supabase + .from('blog_posts') + .select('*') + .order('created_at', { ascending: false }); + if (error) throw error; + return data as BlogPost[]; + }, + }); + + const saveMutation = useMutation({ + mutationFn: async ({ isDraft }: { isDraft: boolean }) => { + const postData = { + title, + slug, + content, + featured_image_id: featuredImageId || null, + featured_image_url: featuredImageUrl || null, + author_id: user!.id, + status: isDraft ? 'draft' : 'published', + published_at: isDraft ? null : new Date().toISOString(), + }; + + if (editingPost) { + const { error } = await supabase + .from('blog_posts') + .update(postData) + .eq('id', editingPost.id); + if (error) throw error; + } else { + const { error } = await supabase + .from('blog_posts') + .insert(postData); + if (error) throw error; + } + }, + onSuccess: (_, { isDraft }) => { + queryClient.invalidateQueries({ queryKey: ['admin-blog-posts'] }); + toast.success(isDraft ? 'Draft saved' : 'Post published'); + resetForm(); + setActiveTab('posts'); + }, + onError: (error) => { + toast.error('Failed to save post: ' + error.message); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async (id: string) => { + const { error } = await supabase + .from('blog_posts') + .delete() + .eq('id', id); + if (error) throw error; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-blog-posts'] }); + toast.success('Post deleted'); + }, + onError: (error) => { + toast.error('Failed to delete post: ' + error.message); + }, + }); + + const resetForm = () => { + setTitle(''); + setSlug(''); + setContent(''); + setFeaturedImageId(''); + setFeaturedImageUrl(''); + setEditingPost(null); + }; + + const loadPostForEditing = (post: BlogPost) => { + setEditingPost(post); + setTitle(post.title); + setSlug(post.slug); + setContent(post.content); + setFeaturedImageId(post.featured_image_id || ''); + setFeaturedImageUrl(post.featured_image_url || ''); + setActiveTab('editor'); + }; + + const handleTitleChange = (newTitle: string) => { + setTitle(newTitle); + if (!editingPost) { + setSlug(generateSlugFromName(newTitle)); + } + }; + + const handleImageUpload = (imageId: string, imageUrl: string) => { + setFeaturedImageId(imageId); + setFeaturedImageUrl(imageUrl); + }; + + return ( + +
+
+

Blog Management

+

Create and manage blog posts

+
+ + + + Posts + + {editingPost ? 'Edit Post' : 'New Post'} + + + + +
+

+ {posts?.length || 0} total posts +

+ +
+ + + + + + Title + Status + Published + Views + Actions + + + + {isLoading ? ( + + + Loading... + + + ) : posts?.length === 0 ? ( + + + No posts yet. Create your first post! + + + ) : ( + posts?.map((post) => ( + + + {post.title} + + + + {post.status} + + + + {post.published_at + ? formatDistanceToNow(new Date(post.published_at), { addSuffix: true }) + : '-' + } + + {post.view_count} + + + + + + + )) + )} + +
+
+
+ + + +
+ + handleTitleChange(e.target.value)} + placeholder="Enter post title..." + /> +
+ +
+ + setSlug(e.target.value)} + placeholder="post-url-slug" + /> +

+ URL: /blog/{slug || 'post-url-slug'} +

+
+ +
+ + { + if (results.length > 0) { + const result = results[0]; + handleImageUpload(result.cloudflareImageId, result.cloudflareImageUrl); + toast.success('Image uploaded'); + } + }} + maxFiles={1} + /> + {featuredImageUrl && ( + Preview + )} +
+ +
+ +