mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 17:31:12 -05:00
Add blog page components
This commit is contained in:
@@ -40,6 +40,9 @@ import AdminReports from "./pages/AdminReports";
|
|||||||
import AdminSystemLog from "./pages/AdminSystemLog";
|
import AdminSystemLog from "./pages/AdminSystemLog";
|
||||||
import AdminUsers from "./pages/AdminUsers";
|
import AdminUsers from "./pages/AdminUsers";
|
||||||
import AdminSettings from "./pages/AdminSettings";
|
import AdminSettings from "./pages/AdminSettings";
|
||||||
|
import BlogIndex from "./pages/BlogIndex";
|
||||||
|
import BlogPost from "./pages/BlogPost";
|
||||||
|
import AdminBlog from "./pages/AdminBlog";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
@@ -64,6 +67,8 @@ function AppContent() {
|
|||||||
<Route path="/parks/:parkSlug/rides/:rideSlug" element={<RideDetail />} />
|
<Route path="/parks/:parkSlug/rides/:rideSlug" element={<RideDetail />} />
|
||||||
<Route path="/rides" element={<Rides />} />
|
<Route path="/rides" element={<Rides />} />
|
||||||
<Route path="/search" element={<Search />} />
|
<Route path="/search" element={<Search />} />
|
||||||
|
<Route path="/blog" element={<BlogIndex />} />
|
||||||
|
<Route path="/blog/:slug" element={<BlogPost />} />
|
||||||
<Route path="/manufacturers" element={<Manufacturers />} />
|
<Route path="/manufacturers" element={<Manufacturers />} />
|
||||||
<Route path="/manufacturers/:slug" element={<ManufacturerDetail />} />
|
<Route path="/manufacturers/:slug" element={<ManufacturerDetail />} />
|
||||||
<Route path="/manufacturers/:manufacturerSlug/rides" element={<ManufacturerRides />} />
|
<Route path="/manufacturers/:manufacturerSlug/rides" element={<ManufacturerRides />} />
|
||||||
@@ -86,6 +91,7 @@ function AppContent() {
|
|||||||
<Route path="/admin/reports" element={<AdminReports />} />
|
<Route path="/admin/reports" element={<AdminReports />} />
|
||||||
<Route path="/admin/system-log" element={<AdminSystemLog />} />
|
<Route path="/admin/system-log" element={<AdminSystemLog />} />
|
||||||
<Route path="/admin/users" element={<AdminUsers />} />
|
<Route path="/admin/users" element={<AdminUsers />} />
|
||||||
|
<Route path="/admin/blog" element={<AdminBlog />} />
|
||||||
<Route path="/admin/settings" element={<AdminSettings />} />
|
<Route path="/admin/settings" element={<AdminSettings />} />
|
||||||
<Route path="/terms" element={<Terms />} />
|
<Route path="/terms" element={<Terms />} />
|
||||||
<Route path="/privacy" element={<Privacy />} />
|
<Route path="/privacy" element={<Privacy />} />
|
||||||
|
|||||||
350
src/pages/AdminBlog.tsx
Normal file
350
src/pages/AdminBlog.tsx
Normal 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
137
src/pages/BlogIndex.tsx
Normal 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
140
src/pages/BlogPost.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user