mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 19:51:12 -05:00
Approve blog post migration
This commit is contained in:
88
src/components/blog/BlogPostCard.tsx
Normal file
88
src/components/blog/BlogPostCard.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Eye, Calendar } from 'lucide-react';
|
||||
import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
interface BlogPostCardProps {
|
||||
slug: string;
|
||||
title: string;
|
||||
content: string;
|
||||
featuredImageId?: string;
|
||||
author: {
|
||||
username: string;
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
};
|
||||
publishedAt: string;
|
||||
viewCount: number;
|
||||
}
|
||||
|
||||
export function BlogPostCard({
|
||||
slug,
|
||||
title,
|
||||
content,
|
||||
featuredImageId,
|
||||
author,
|
||||
publishedAt,
|
||||
viewCount,
|
||||
}: BlogPostCardProps) {
|
||||
const excerpt = content.substring(0, 150) + (content.length > 150 ? '...' : '');
|
||||
|
||||
return (
|
||||
<Link to={`/blog/${slug}`}>
|
||||
<Card className="overflow-hidden hover:scale-[1.02] hover:shadow-xl transition-all duration-300 group">
|
||||
<div className="aspect-[16/9] overflow-hidden bg-gradient-to-br from-primary/20 to-secondary/20">
|
||||
{featuredImageId ? (
|
||||
<img
|
||||
src={getCloudflareImageUrl(featuredImageId, 'public')}
|
||||
alt={title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<span className="text-6xl opacity-20">📝</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-3">
|
||||
<h3 className="text-xl font-bold line-clamp-2 group-hover:text-primary transition-colors">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-muted-foreground line-clamp-3">
|
||||
{excerpt}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between pt-3 border-t">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="w-6 h-6">
|
||||
<AvatarImage src={author.avatarUrl} />
|
||||
<AvatarFallback>
|
||||
{author.displayName?.[0] || author.username[0]}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{author.displayName || author.username}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{formatDistanceToNow(new Date(publishedAt), { addSuffix: true })}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Eye className="w-3 h-3" />
|
||||
{viewCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
31
src/components/blog/MarkdownRenderer.tsx
Normal file
31
src/components/blog/MarkdownRenderer.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MarkdownRenderer({ content, className }: MarkdownRendererProps) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
className={cn(
|
||||
'prose dark:prose-invert max-w-none',
|
||||
'prose-headings:font-bold prose-headings:tracking-tight',
|
||||
'prose-h1:text-4xl prose-h2:text-3xl prose-h3:text-2xl',
|
||||
'prose-p:text-base prose-p:leading-relaxed',
|
||||
'prose-a:text-primary prose-a:no-underline hover:prose-a:underline',
|
||||
'prose-strong:text-foreground prose-strong:font-semibold',
|
||||
'prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-sm',
|
||||
'prose-pre:bg-muted prose-pre:border prose-pre:border-border',
|
||||
'prose-blockquote:border-l-4 prose-blockquote:border-primary prose-blockquote:italic',
|
||||
'prose-img:rounded-lg prose-img:shadow-lg',
|
||||
'prose-hr:border-border',
|
||||
'prose-ul:list-disc prose-ol:list-decimal',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LayoutDashboard, FileText, Flag, Users, Settings, ArrowLeft, ScrollText } from 'lucide-react';
|
||||
import { LayoutDashboard, FileText, Flag, Users, Settings, ArrowLeft, ScrollText, BookOpen } from 'lucide-react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useUserRole } from '@/hooks/useUserRole';
|
||||
import { useSidebar } from '@/hooks/useSidebar';
|
||||
@@ -47,6 +47,11 @@ export function AdminSidebar() {
|
||||
url: '/admin/users',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
title: 'Blog',
|
||||
url: '/admin/blog',
|
||||
icon: BookOpen,
|
||||
},
|
||||
...(isSuperuser ? [{
|
||||
title: 'Settings',
|
||||
url: '/admin/settings',
|
||||
|
||||
@@ -27,6 +27,12 @@ export function Footer() {
|
||||
>
|
||||
Submission Guidelines
|
||||
</Link>
|
||||
<Link
|
||||
to="/blog"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Blog
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -74,6 +74,51 @@ export type Database = {
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
blog_posts: {
|
||||
Row: {
|
||||
author_id: string
|
||||
content: string
|
||||
created_at: string | null
|
||||
featured_image_id: string | null
|
||||
featured_image_url: string | null
|
||||
id: string
|
||||
published_at: string | null
|
||||
slug: string
|
||||
status: string
|
||||
title: string
|
||||
updated_at: string | null
|
||||
view_count: number | null
|
||||
}
|
||||
Insert: {
|
||||
author_id: string
|
||||
content: string
|
||||
created_at?: string | null
|
||||
featured_image_id?: string | null
|
||||
featured_image_url?: string | null
|
||||
id?: string
|
||||
published_at?: string | null
|
||||
slug: string
|
||||
status?: string
|
||||
title: string
|
||||
updated_at?: string | null
|
||||
view_count?: number | null
|
||||
}
|
||||
Update: {
|
||||
author_id?: string
|
||||
content?: string
|
||||
created_at?: string | null
|
||||
featured_image_id?: string | null
|
||||
featured_image_url?: string | null
|
||||
id?: string
|
||||
published_at?: string | null
|
||||
slug?: string
|
||||
status?: string
|
||||
title?: string
|
||||
updated_at?: string | null
|
||||
view_count?: number | null
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
companies: {
|
||||
Row: {
|
||||
average_rating: number | null
|
||||
@@ -2899,6 +2944,10 @@ export type Database = {
|
||||
Args: { ip_text: string }
|
||||
Returns: string
|
||||
}
|
||||
increment_blog_view_count: {
|
||||
Args: { post_slug: string }
|
||||
Returns: undefined
|
||||
}
|
||||
is_moderator: {
|
||||
Args: { _user_id: string }
|
||||
Returns: boolean
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
-- Create blog_posts table
|
||||
create table public.blog_posts (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
slug text unique not null,
|
||||
title text not null,
|
||||
content text not null,
|
||||
featured_image_id text,
|
||||
featured_image_url text,
|
||||
author_id uuid references auth.users(id) not null,
|
||||
status text not null default 'draft' check (status in ('draft', 'published')),
|
||||
published_at timestamptz,
|
||||
view_count integer default 0,
|
||||
created_at timestamptz default now(),
|
||||
updated_at timestamptz default now()
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
create index idx_blog_posts_slug on blog_posts(slug);
|
||||
create index idx_blog_posts_status on blog_posts(status);
|
||||
create index idx_blog_posts_published_at on blog_posts(published_at desc nulls last);
|
||||
create index idx_blog_posts_author on blog_posts(author_id);
|
||||
|
||||
-- Enable RLS
|
||||
alter table blog_posts enable row level security;
|
||||
|
||||
-- RLS Policies
|
||||
create policy "Public can read published posts"
|
||||
on blog_posts for select
|
||||
using (status = 'published');
|
||||
|
||||
create policy "Admins can do everything"
|
||||
on blog_posts for all
|
||||
using (is_moderator(auth.uid()));
|
||||
|
||||
-- Auto-update updated_at timestamp
|
||||
create trigger update_blog_posts_updated_at
|
||||
before update on blog_posts
|
||||
for each row
|
||||
execute function update_updated_at_column();
|
||||
|
||||
-- Function to increment view count
|
||||
create or replace function increment_blog_view_count(post_slug text)
|
||||
returns void
|
||||
language plpgsql
|
||||
security definer
|
||||
as $$
|
||||
begin
|
||||
update blog_posts
|
||||
set view_count = view_count + 1
|
||||
where slug = post_slug;
|
||||
end;
|
||||
$$;
|
||||
Reference in New Issue
Block a user