Add blog page components

This commit is contained in:
gpt-engineer-app[bot]
2025-10-10 22:57:58 +00:00
parent 5f3ff2c9e9
commit 6127f902e9
4 changed files with 633 additions and 0 deletions

View File

@@ -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() {
<Route path="/parks/:parkSlug/rides/:rideSlug" element={<RideDetail />} />
<Route path="/rides" element={<Rides />} />
<Route path="/search" element={<Search />} />
<Route path="/blog" element={<BlogIndex />} />
<Route path="/blog/:slug" element={<BlogPost />} />
<Route path="/manufacturers" element={<Manufacturers />} />
<Route path="/manufacturers/:slug" element={<ManufacturerDetail />} />
<Route path="/manufacturers/:manufacturerSlug/rides" element={<ManufacturerRides />} />
@@ -86,6 +91,7 @@ function AppContent() {
<Route path="/admin/reports" element={<AdminReports />} />
<Route path="/admin/system-log" element={<AdminSystemLog />} />
<Route path="/admin/users" element={<AdminUsers />} />
<Route path="/admin/blog" element={<AdminBlog />} />
<Route path="/admin/settings" element={<AdminSettings />} />
<Route path="/terms" element={<Terms />} />
<Route path="/privacy" element={<Privacy />} />

350
src/pages/AdminBlog.tsx Normal file
View File

@@ -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<BlogPost | null>(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 (
<AdminLayout>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Blog Management</h1>
<p className="text-muted-foreground">Create and manage blog posts</p>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="posts">Posts</TabsTrigger>
<TabsTrigger value="editor">
{editingPost ? 'Edit Post' : 'New Post'}
</TabsTrigger>
</TabsList>
<TabsContent value="posts" className="space-y-4">
<div className="flex justify-between items-center">
<p className="text-sm text-muted-foreground">
{posts?.length || 0} total posts
</p>
<Button onClick={() => {
resetForm();
setActiveTab('editor');
}}>
<Plus className="w-4 h-4 mr-2" />
New Post
</Button>
</div>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Status</TableHead>
<TableHead>Published</TableHead>
<TableHead>Views</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={5} className="text-center">
Loading...
</TableCell>
</TableRow>
) : posts?.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center">
No posts yet. Create your first post!
</TableCell>
</TableRow>
) : (
posts?.map((post) => (
<TableRow key={post.id}>
<TableCell className="font-medium">
{post.title}
</TableCell>
<TableCell>
<Badge variant={post.status === 'published' ? 'default' : 'secondary'}>
{post.status}
</Badge>
</TableCell>
<TableCell>
{post.published_at
? formatDistanceToNow(new Date(post.published_at), { addSuffix: true })
: '-'
}
</TableCell>
<TableCell>{post.view_count}</TableCell>
<TableCell className="text-right space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => window.open(`/blog/${post.slug}`, '_blank')}
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => loadPostForEditing(post)}
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Delete this post?')) {
deleteMutation.mutate(post.id);
}
}}
>
<Trash2 className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</Card>
</TabsContent>
<TabsContent value="editor" className="space-y-6">
<Card className="p-6 space-y-6">
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
value={title}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder="Enter post title..."
/>
</div>
<div className="space-y-2">
<Label htmlFor="slug">Slug *</Label>
<Input
id="slug"
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="post-url-slug"
/>
<p className="text-xs text-muted-foreground">
URL: /blog/{slug || 'post-url-slug'}
</p>
</div>
<div className="space-y-2">
<Label>Featured Image</Label>
<UppyPhotoUpload
onUploadComplete={(results) => {
if (results.length > 0) {
const result = results[0];
handleImageUpload(result.cloudflareImageId, result.cloudflareImageUrl);
toast.success('Image uploaded');
}
}}
maxFiles={1}
/>
{featuredImageUrl && (
<img
src={featuredImageUrl}
alt="Preview"
className="w-full max-w-md rounded-lg"
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="content">Content (Markdown) *</Label>
<Textarea
id="content"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Write your post in markdown..."
rows={20}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Supports markdown formatting: **bold**, *italic*, # Headings, [links](url), etc.
</p>
</div>
<div className="flex gap-3">
<Button
onClick={() => saveMutation.mutate({ isDraft: true })}
disabled={!title || !slug || !content || saveMutation.isPending}
variant="outline"
>
Save Draft
</Button>
<Button
onClick={() => saveMutation.mutate({ isDraft: false })}
disabled={!title || !slug || !content || saveMutation.isPending}
>
Publish
</Button>
<Button
variant="ghost"
onClick={() => {
resetForm();
setActiveTab('posts');
}}
>
Cancel
</Button>
</div>
</Card>
</TabsContent>
</Tabs>
</div>
</AdminLayout>
);
}

137
src/pages/BlogIndex.tsx Normal file
View File

@@ -0,0 +1,137 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { BlogPostCard } from '@/components/blog/BlogPostCard';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Search, ChevronLeft, ChevronRight } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { Header } from '@/components/layout/Header';
import { Footer } from '@/components/layout/Footer';
const POSTS_PER_PAGE = 9;
export default function BlogIndex() {
const [page, setPage] = useState(1);
const [searchQuery, setSearchQuery] = useState('');
const { data, isLoading } = useQuery({
queryKey: ['blog-posts', page, searchQuery],
queryFn: async () => {
let query = supabase
.from('blog_posts')
.select(`
*,
author:profiles(
username,
display_name,
avatar_url
)
`, { count: 'exact' })
.eq('status', 'published')
.order('published_at', { ascending: false })
.range((page - 1) * POSTS_PER_PAGE, page * POSTS_PER_PAGE - 1);
if (searchQuery) {
query = query.or(`title.ilike.%${searchQuery}%,content.ilike.%${searchQuery}%`);
}
const { data, count, error } = await query;
if (error) throw error;
return { posts: data, totalCount: count || 0 };
},
});
const totalPages = Math.ceil((data?.totalCount || 0) / POSTS_PER_PAGE);
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-12 max-w-7xl">
<div className="text-center mb-12">
<h1 className="text-5xl font-bold mb-4">ThrillWiki Blog</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
Latest news, updates, and stories from the world of theme parks and roller coasters
</p>
</div>
<div className="max-w-md mx-auto mb-12">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search blog posts..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setPage(1);
}}
className="pl-10"
/>
</div>
</div>
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-[400px] rounded-lg" />
))}
</div>
) : data?.posts.length === 0 ? (
<div className="text-center py-12">
<p className="text-muted-foreground">No blog posts found.</p>
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{data?.posts.map((post) => (
<BlogPostCard
key={post.id}
slug={post.slug}
title={post.title}
content={post.content}
featuredImageId={post.featured_image_id}
author={{
username: post.author.username,
displayName: post.author.display_name,
avatarUrl: post.author.avatar_url,
}}
publishedAt={post.published_at!}
viewCount={post.view_count}
/>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-12">
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
>
<ChevronLeft className="w-4 h-4" />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {page} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
>
Next
<ChevronRight className="w-4 h-4" />
</Button>
</div>
)}
</>
)}
</div>
<Footer />
</div>
);
}

140
src/pages/BlogPost.tsx Normal file
View File

@@ -0,0 +1,140 @@
import { useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { MarkdownRenderer } from '@/components/blog/MarkdownRenderer';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { ArrowLeft, Calendar, Eye } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils';
import { Skeleton } from '@/components/ui/skeleton';
import { Header } from '@/components/layout/Header';
import { Footer } from '@/components/layout/Footer';
export default function BlogPost() {
const { slug } = useParams<{ slug: string }>();
const { data: post, isLoading } = useQuery({
queryKey: ['blog-post', slug],
queryFn: async () => {
const { data, error } = await supabase
.from('blog_posts')
.select(`
*,
author:profiles(
username,
display_name,
avatar_url,
avatar_image_id
)
`)
.eq('slug', slug)
.eq('status', 'published')
.single();
if (error) throw error;
return data;
},
enabled: !!slug,
});
useEffect(() => {
if (slug) {
supabase.rpc('increment_blog_view_count', { post_slug: slug });
}
}, [slug]);
if (isLoading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-12 max-w-4xl">
<Skeleton className="h-12 w-32 mb-8" />
<Skeleton className="h-16 w-full mb-4" />
<Skeleton className="h-8 w-2/3 mb-8" />
<Skeleton className="h-[400px] w-full mb-8" />
<Skeleton className="h-96 w-full" />
</div>
<Footer />
</div>
);
}
if (!post) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-12 max-w-4xl text-center">
<h1 className="text-2xl font-bold mb-4">Post Not Found</h1>
<Link to="/blog">
<Button variant="outline">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Blog
</Button>
</Link>
</div>
<Footer />
</div>
);
}
return (
<div className="min-h-screen bg-background">
<Header />
<article className="container mx-auto px-4 py-12 max-w-4xl">
<Link to="/blog" className="inline-block mb-8">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Blog
</Button>
</Link>
<h1 className="text-5xl font-bold mb-6 leading-tight">
{post.title}
</h1>
<div className="flex items-center justify-between mb-8 pb-6 border-b">
<div className="flex items-center gap-3">
<Avatar className="w-12 h-12">
<AvatarImage src={post.author.avatar_url} />
<AvatarFallback>
{post.author.display_name?.[0] || post.author.username[0]}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">
{post.author.display_name || post.author.username}
</p>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{formatDistanceToNow(new Date(post.published_at!), { addSuffix: true })}
</div>
<div className="flex items-center gap-1">
<Eye className="w-3 h-3" />
{post.view_count} views
</div>
</div>
</div>
</div>
</div>
{post.featured_image_id && (
<div className="mb-12 rounded-lg overflow-hidden shadow-2xl">
<img
src={getCloudflareImageUrl(post.featured_image_id, 'public')}
alt={post.title}
className="w-full"
/>
</div>
)}
<MarkdownRenderer content={post.content} />
</article>
<Footer />
</div>
);
}