diff --git a/src/components/blog/BlogPostCard.tsx b/src/components/blog/BlogPostCard.tsx new file mode 100644 index 00000000..90fd54bd --- /dev/null +++ b/src/components/blog/BlogPostCard.tsx @@ -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 ( + + +
+ {featuredImageId ? ( + {title} + ) : ( +
+ 📝 +
+ )} +
+ +
+

+ {title} +

+ +

+ {excerpt} +

+ +
+
+ + + + {author.displayName?.[0] || author.username[0]} + + + + {author.displayName || author.username} + +
+ +
+
+ + {formatDistanceToNow(new Date(publishedAt), { addSuffix: true })} +
+
+ + {viewCount} +
+
+
+
+
+ + ); +} diff --git a/src/components/blog/MarkdownRenderer.tsx b/src/components/blog/MarkdownRenderer.tsx new file mode 100644 index 00000000..85b92770 --- /dev/null +++ b/src/components/blog/MarkdownRenderer.tsx @@ -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 ( + + {content} + + ); +} diff --git a/src/components/layout/AdminSidebar.tsx b/src/components/layout/AdminSidebar.tsx index 2e079f10..3cd88bc1 100644 --- a/src/components/layout/AdminSidebar.tsx +++ b/src/components/layout/AdminSidebar.tsx @@ -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', diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index 37c6bf44..90b06515 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -27,6 +27,12 @@ export function Footer() { > Submission Guidelines + + Blog + diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 5d517af7..43d8629b 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -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 diff --git a/supabase/migrations/20251010224939_bbcfdb14-0001-4a44-af91-1e1fe4572bbc.sql b/supabase/migrations/20251010224939_bbcfdb14-0001-4a44-af91-1e1fe4572bbc.sql new file mode 100644 index 00000000..27471334 --- /dev/null +++ b/supabase/migrations/20251010224939_bbcfdb14-0001-4a44-af91-1e1fe4572bbc.sql @@ -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; +$$; \ No newline at end of file