mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 21:31:14 -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 { NavLink } from 'react-router-dom';
|
||||||
import { useUserRole } from '@/hooks/useUserRole';
|
import { useUserRole } from '@/hooks/useUserRole';
|
||||||
import { useSidebar } from '@/hooks/useSidebar';
|
import { useSidebar } from '@/hooks/useSidebar';
|
||||||
@@ -47,6 +47,11 @@ export function AdminSidebar() {
|
|||||||
url: '/admin/users',
|
url: '/admin/users',
|
||||||
icon: Users,
|
icon: Users,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Blog',
|
||||||
|
url: '/admin/blog',
|
||||||
|
icon: BookOpen,
|
||||||
|
},
|
||||||
...(isSuperuser ? [{
|
...(isSuperuser ? [{
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
url: '/admin/settings',
|
url: '/admin/settings',
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ export function Footer() {
|
|||||||
>
|
>
|
||||||
Submission Guidelines
|
Submission Guidelines
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/blog"
|
||||||
|
className="hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Blog
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -74,6 +74,51 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
Relationships: []
|
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: {
|
companies: {
|
||||||
Row: {
|
Row: {
|
||||||
average_rating: number | null
|
average_rating: number | null
|
||||||
@@ -2899,6 +2944,10 @@ export type Database = {
|
|||||||
Args: { ip_text: string }
|
Args: { ip_text: string }
|
||||||
Returns: string
|
Returns: string
|
||||||
}
|
}
|
||||||
|
increment_blog_view_count: {
|
||||||
|
Args: { post_slug: string }
|
||||||
|
Returns: undefined
|
||||||
|
}
|
||||||
is_moderator: {
|
is_moderator: {
|
||||||
Args: { _user_id: string }
|
Args: { _user_id: string }
|
||||||
Returns: boolean
|
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