Add blog feature for sharing articles and engaging with literary content

Implements BlogList, BlogDetail, and BlogCreate components with corresponding types and API integrations.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: cbacfb18-842a-4116-a907-18c0105ad8ec
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/39b5c689-6e8a-4d5a-9792-69cc81a56534/ef0e7cfe-4f63-478f-9d08-4bb22d0da15e.jpg
This commit is contained in:
mukimovd 2025-05-01 03:34:53 +00:00
parent 73c88cc6aa
commit 64fd506bdc
6 changed files with 1004 additions and 0 deletions

View File

@ -14,6 +14,7 @@ import Collections from "@/pages/collections/Collections";
import CreateCollection from "@/pages/collections/CreateCollection";
import Profile from "@/pages/user/Profile";
import Submit from "@/pages/Submit";
import { BlogList, BlogDetail, BlogCreate } from "@/pages/blog";
function Router() {
return (

View File

@ -96,3 +96,35 @@ export interface Annotation {
likes: number;
liked: boolean;
}
export interface BlogPostWithDetails {
id: number;
title: string;
slug: string;
content: string;
authorId: number;
excerpt?: string;
publishedAt: Date | null;
createdAt: Date;
author?: Omit<User, 'password'>;
tags?: Tag[];
commentCount?: number;
likeCount?: number;
}
export interface BlogPostListItem {
id: number;
title: string;
slug: string;
excerpt?: string;
publishedAt: Date | null;
createdAt: Date;
author?: {
id: number;
displayName: string | null;
avatar: string | null;
};
tags?: Tag[];
commentCount: number;
likeCount: number;
}

View File

@ -0,0 +1,364 @@
import { PageLayout } from "@/components/layout/PageLayout";
import { Button } from "@/components/ui/button";
import { Link, useLocation } from "wouter";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
import { ChevronLeft, Plus, X, Loader2 } from "lucide-react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
import { insertBlogPostSchema } from "@shared/schema";
import { Tag } from "@/lib/types";
const blogPostSchema = insertBlogPostSchema.extend({
tags: z.array(z.number()).optional(),
});
type FormValues = z.infer<typeof blogPostSchema>;
// Simple rich text editor toolbar
function EditorToolbar() {
return (
<div className="flex flex-wrap gap-1 mb-2 border-b border-sage/20 dark:border-sage/10 pb-2">
<Button type="button" variant="ghost" size="sm" className="px-2 font-bold">
B
</Button>
<Button type="button" variant="ghost" size="sm" className="px-2 italic">
I
</Button>
<Button type="button" variant="ghost" size="sm" className="px-2 underline">
U
</Button>
<Button type="button" variant="ghost" size="sm" className="px-2">
H1
</Button>
<Button type="button" variant="ghost" size="sm" className="px-2">
H2
</Button>
<Button type="button" variant="ghost" size="sm" className="px-3">
List
</Button>
<Button type="button" variant="ghost" size="sm" className="px-3">
1. List
</Button>
<Button type="button" variant="ghost" size="sm" className="px-2">
Link
</Button>
<Button type="button" variant="ghost" size="sm" className="px-2">
Quote
</Button>
<Button type="button" variant="ghost" size="sm" className="px-2">
Code
</Button>
</div>
);
}
export default function BlogCreate() {
const [, navigate] = useLocation();
const { toast } = useToast();
const [selectedTags, setSelectedTags] = useState<Tag[]>([]);
const [selectedTagId, setSelectedTagId] = useState<string>("");
const [publishNow, setPublishNow] = useState(true);
// Fetch tags for selection
const { data: tags } = useQuery<Tag[]>({
queryKey: ['/api/tags'],
});
// Form definition
const form = useForm<FormValues>({
resolver: zodResolver(blogPostSchema),
defaultValues: {
title: "",
content: "",
excerpt: "",
authorId: 1, // Mock user ID for demo
tags: [],
},
});
// Create blog post mutation
const createMutation = useMutation({
mutationFn: async (values: FormValues) => {
// If publishNow is true, set publishedAt to current date
const postData = publishNow
? { ...values, publishedAt: new Date().toISOString() }
: values;
return await apiRequest('POST', '/api/blog', postData);
},
onSuccess: () => {
toast({
title: "Success",
description: publishNow
? "Your article has been published"
: "Your article has been saved as a draft",
});
// Invalidate queries to refresh the data
queryClient.invalidateQueries({ queryKey: ['/api/blog'] });
// Navigate to blog list
navigate("/blog");
},
onError: (error) => {
toast({
title: "Error",
description: "Failed to create blog post",
variant: "destructive",
});
},
});
// Handle tag selection
const handleAddTag = () => {
if (!selectedTagId) return;
const tagId = parseInt(selectedTagId, 10);
const tag = tags?.find(t => t.id === tagId);
if (tag && !selectedTags.some(t => t.id === tag.id)) {
setSelectedTags([...selectedTags, tag]);
// Update form values
const currentTags = form.getValues("tags") || [];
form.setValue("tags", [...currentTags, tag.id]);
}
setSelectedTagId("");
};
// Handle tag removal
const handleRemoveTag = (tagId: number) => {
setSelectedTags(selectedTags.filter(tag => tag.id !== tagId));
// Update form values
const currentTags = form.getValues("tags") || [];
form.setValue("tags", currentTags.filter(id => id !== tagId));
};
// Handle form submission
const onSubmit = (values: FormValues) => {
createMutation.mutate(values);
};
// Preview content (would be processed markdown/html in a real app)
const previewContent = form.watch("content");
return (
<PageLayout>
<div className="max-w-5xl mx-auto px-4 py-8">
<div className="mb-6">
<Link href="/blog">
<Button variant="ghost" className="px-0 text-navy/70 dark:text-cream/70 hover:text-navy dark:hover:text-cream hover:bg-transparent">
<ChevronLeft className="h-4 w-4 mr-1" />
Back to Blog
</Button>
</Link>
<h1 className="text-3xl font-serif font-bold text-navy dark:text-cream mt-4">
Write an Article
</h1>
<p className="text-navy/70 dark:text-cream/70 mt-1">
Share your knowledge, insights, and perspectives on literature and translations
</p>
</div>
<div className="bg-cream dark:bg-dark-surface rounded-lg border border-sage/20 dark:border-sage/10 p-6">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input
placeholder="Enter a descriptive title for your article"
className="text-lg font-medium"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="excerpt"
render={({ field }) => (
<FormItem>
<FormLabel>Excerpt (Summary)</FormLabel>
<FormControl>
<Textarea
placeholder="Provide a short summary of your article (will appear in previews)"
className="resize-y h-20"
{...field}
/>
</FormControl>
<FormDescription>
A brief summary helps readers decide if they want to read the full article.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div>
<FormLabel>Tags</FormLabel>
<div className="flex flex-wrap gap-2 mb-2">
{selectedTags.map(tag => (
<Badge
key={tag.id}
variant="secondary"
className="px-2 py-1 flex items-center gap-1"
>
{tag.name}
<Button
type="button"
variant="ghost"
size="sm"
className="h-4 w-4 p-0 text-navy/60 dark:text-cream/60 hover:text-navy hover:dark:text-cream hover:bg-transparent"
onClick={() => handleRemoveTag(tag.id)}
>
<X className="h-3 w-3" />
<span className="sr-only">Remove {tag.name}</span>
</Button>
</Badge>
))}
{selectedTags.length === 0 && (
<p className="text-sm text-navy/60 dark:text-cream/60">
No tags selected
</p>
)}
</div>
<div className="flex gap-2">
<Select
value={selectedTagId}
onValueChange={setSelectedTagId}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select a tag" />
</SelectTrigger>
<SelectContent>
{tags?.map(tag => (
<SelectItem
key={tag.id}
value={tag.id.toString()}
disabled={selectedTags.some(t => t.id === tag.id)}
>
{tag.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
onClick={handleAddTag}
disabled={!selectedTagId}
className="flex items-center gap-1"
>
<Plus className="h-4 w-4" />
<span>Add Tag</span>
</Button>
</div>
</div>
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>Content</FormLabel>
<Tabs defaultValue="write">
<TabsList className="mb-2">
<TabsTrigger value="write">Write</TabsTrigger>
<TabsTrigger value="preview">Preview</TabsTrigger>
</TabsList>
<TabsContent value="write" className="mt-0">
<EditorToolbar />
<FormControl>
<Textarea
placeholder="Write your article content here..."
className="resize-y min-h-96 font-serif leading-relaxed"
{...field}
/>
</FormControl>
</TabsContent>
<TabsContent value="preview" className="mt-0">
<div className="border border-sage/20 dark:border-sage/10 rounded-md p-4 min-h-96 prose prose-russet dark:prose-invert max-w-none">
{previewContent ? (
<div dangerouslySetInnerHTML={{ __html: previewContent.replace(/\n/g, '<br/>') }} />
) : (
<p className="text-navy/60 dark:text-cream/60 italic">
Nothing to preview yet. Start writing in the "Write" tab.
</p>
)}
</div>
</TabsContent>
</Tabs>
<FormDescription>
You can use markdown formatting. The article will be attributed to your account.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex items-center space-x-2">
<Switch
id="publish-now"
checked={publishNow}
onCheckedChange={setPublishNow}
/>
<Label htmlFor="publish-now">
{publishNow ? "Publish immediately" : "Save as draft"}
</Label>
</div>
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => navigate("/blog")}
>
Cancel
</Button>
<Button
type="submit"
className="bg-russet hover:bg-russet/90"
disabled={createMutation.isPending}
>
{createMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{publishNow ? "Publish Article" : "Save Draft"}
</Button>
</div>
</form>
</Form>
</div>
</div>
</PageLayout>
);
}

View File

@ -0,0 +1,392 @@
import { PageLayout } from "@/components/layout/PageLayout";
import { useParams, Link } from "wouter";
import { useQuery, useMutation } from "@tanstack/react-query";
import { BlogPostWithDetails } from "@/lib/types";
import { formatDistanceToNow, format } from "date-fns";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import { useState } from "react";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import {
Calendar,
Clock,
MessageSquare,
Share2,
ThumbsUp,
BookOpen,
ChevronLeft,
CornerDownLeft,
Edit,
Bookmark,
BookmarkPlus
} from "lucide-react";
export default function BlogDetail() {
const { slug } = useParams();
const { toast } = useToast();
const [comment, setComment] = useState("");
const [isLiked, setIsLiked] = useState(false);
const [isBookmarked, setIsBookmarked] = useState(false);
// Fetch blog post
const {
data: post,
isLoading,
error
} = useQuery<BlogPostWithDetails>({
queryKey: [`/api/blog/${slug}`],
});
// Comment mutation
const commentMutation = useMutation({
mutationFn: async (commentText: string) => {
// In a real app, this would be an API call to add a comment
return await apiRequest('POST', '/api/comments', {
userId: 1, // Using a mock user for demo
content: commentText,
entityType: 'blogPost',
entityId: post?.id
});
},
onSuccess: () => {
toast({
description: "Comment added successfully",
});
setComment("");
// Invalidate queries to refresh the data
queryClient.invalidateQueries({ queryKey: [`/api/blog/${slug}`] });
},
onError: (error) => {
toast({
title: "Error",
description: "Failed to add comment",
variant: "destructive",
});
}
});
// Like mutation
const likeMutation = useMutation({
mutationFn: async () => {
// In a real app, this would be an API call to like/unlike
return await apiRequest('POST', '/api/likes', {
userId: 1,
entityType: 'blogPost',
entityId: post?.id
});
},
onSuccess: () => {
setIsLiked(!isLiked);
toast({
description: isLiked ? "Removed like" : "Added like",
});
// Invalidate queries to refresh the data
queryClient.invalidateQueries({ queryKey: [`/api/blog/${slug}`] });
},
onError: (error) => {
toast({
title: "Error",
description: "Failed to update like",
variant: "destructive",
});
}
});
// Bookmark mutation
const bookmarkMutation = useMutation({
mutationFn: async () => {
// In a real app, this would be an API call to bookmark/unbookmark
return await apiRequest('POST', '/api/bookmarks', {
userId: 1,
entityType: 'blogPost',
entityId: post?.id
});
},
onSuccess: () => {
setIsBookmarked(!isBookmarked);
toast({
description: isBookmarked ? "Removed from bookmarks" : "Added to bookmarks",
});
},
onError: (error) => {
toast({
title: "Error",
description: "Failed to update bookmark",
variant: "destructive",
});
}
});
// Share functionality
const handleShare = async () => {
try {
if (navigator.share) {
await navigator.share({
title: post?.title || "Tercul Blog Post",
text: post?.excerpt || "Check out this article on Tercul",
url: window.location.href
});
} else {
// Fallback for browsers that don't support the Web Share API
navigator.clipboard.writeText(window.location.href);
toast({
description: "Link copied to clipboard",
});
}
} catch (error) {
console.error('Error sharing:', error);
}
};
// Handle comment submission
const handleCommentSubmit = () => {
if (!comment.trim()) return;
commentMutation.mutate(comment);
};
// Format date for display
const formatDate = (date: Date | null) => {
if (!date) return "";
return format(new Date(date), "MMMM d, yyyy");
};
if (isLoading) {
return (
<PageLayout>
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="mb-6">
<Skeleton className="h-8 w-3/4 mb-2" />
<Skeleton className="h-4 w-1/3 mb-4" />
<div className="flex items-center gap-2">
<Skeleton className="h-10 w-10 rounded-full" />
<Skeleton className="h-4 w-32" />
</div>
</div>
<Skeleton className="h-64 w-full mb-8" />
<div className="space-y-4">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
</div>
</PageLayout>
);
}
if (error || !post) {
return (
<PageLayout>
<div className="max-w-4xl mx-auto px-4 py-16 text-center">
<h1 className="text-2xl font-bold mb-4">Blog post not found</h1>
<p className="mb-6">The article you're looking for could not be found.</p>
<Link href="/blog">
<Button className="bg-russet hover:bg-russet/90">Back to Blog</Button>
</Link>
</div>
</PageLayout>
);
}
return (
<PageLayout>
<article className="max-w-4xl mx-auto px-4 py-8">
<div className="mb-4">
<Link href="/blog">
<Button variant="ghost" className="px-0 text-navy/70 dark:text-cream/70 hover:text-navy dark:hover:text-cream hover:bg-transparent">
<ChevronLeft className="h-4 w-4 mr-1" />
Back to Blog
</Button>
</Link>
</div>
<header className="mb-8">
<h1 className="text-3xl md:text-4xl font-serif font-bold text-navy dark:text-cream mb-3">
{post.title}
</h1>
<div className="flex items-center gap-3 mb-4 flex-wrap">
<div className="flex items-center gap-2 text-sm text-navy/60 dark:text-cream/60">
<Calendar className="h-4 w-4" />
<span>{formatDate(post.publishedAt || post.createdAt)}</span>
</div>
<Separator orientation="vertical" className="h-4" />
<div className="flex items-center gap-2 text-sm text-navy/60 dark:text-cream/60">
<Clock className="h-4 w-4" />
<span>{Math.ceil(post.content.length / 1000)} min read</span>
</div>
{post.tags && post.tags.length > 0 && (
<>
<Separator orientation="vertical" className="h-4" />
<div className="flex flex-wrap gap-1">
{post.tags.map((tag) => (
<Badge key={tag.id} variant="outline" className="text-xs">
{tag.name}
</Badge>
))}
</div>
</>
)}
</div>
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<AvatarImage src={post.author?.avatar || ""} alt={post.author?.displayName || "Author"} />
<AvatarFallback>
{post.author?.displayName?.charAt(0) || "A"}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium text-navy dark:text-cream">
{post.author?.displayName || "Anonymous"}
</p>
<p className="text-xs text-navy/60 dark:text-cream/60">
{post.author?.role || "Contributor"}
</p>
</div>
</div>
</header>
<div className="blog-content prose prose-russet dark:prose-invert max-w-none mb-8">
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</div>
<footer className="border-t border-sage/20 dark:border-sage/10 pt-6">
<div className="flex flex-wrap justify-between items-center mb-8">
<div className="flex gap-4">
<Button
variant="outline"
size="sm"
className={`flex items-center gap-1 ${isLiked ? 'text-russet' : ''}`}
onClick={() => likeMutation.mutate()}
disabled={likeMutation.isPending}
>
<ThumbsUp className={`h-4 w-4 ${isLiked ? 'fill-russet' : ''}`} />
<span>{post.likeCount || 0} Likes</span>
</Button>
<Button
variant="outline"
size="sm"
className="flex items-center gap-1"
onClick={handleShare}
>
<Share2 className="h-4 w-4" />
<span>Share</span>
</Button>
<Button
variant="outline"
size="sm"
className={`flex items-center gap-1 ${isBookmarked ? 'text-russet' : ''}`}
onClick={() => bookmarkMutation.mutate()}
disabled={bookmarkMutation.isPending}
>
{isBookmarked ? (
<Bookmark className="h-4 w-4 fill-russet" />
) : (
<BookmarkPlus className="h-4 w-4" />
)}
<span>{isBookmarked ? 'Saved' : 'Save'}</span>
</Button>
</div>
{/* Only show edit button for author or admins */}
{post.author?.id === 1 && (
<Link href={`/blog/${slug}/edit`}>
<Button
variant="outline"
size="sm"
className="flex items-center gap-1"
>
<Edit className="h-4 w-4" />
<span>Edit Article</span>
</Button>
</Link>
)}
</div>
<div className="mb-8">
<h3 className="text-xl font-serif font-medium mb-4 flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-russet" />
<span>Comments ({post.commentCount || 0})</span>
</h3>
<div className="mb-6">
<Textarea
placeholder="Share your thoughts on this article..."
value={comment}
onChange={(e) => setComment(e.target.value)}
className="min-h-24 resize-y mb-2"
/>
<div className="flex justify-end">
<Button
className="bg-russet hover:bg-russet/90"
onClick={handleCommentSubmit}
disabled={!comment.trim() || commentMutation.isPending}
>
<CornerDownLeft className="h-4 w-4 mr-2" />
{commentMutation.isPending ? "Posting..." : "Post Comment"}
</Button>
</div>
</div>
{post.comments && post.comments.length > 0 ? (
<div className="space-y-6">
{/* Comment items would go here */}
<div className="p-4 border border-sage/20 dark:border-sage/10 rounded-lg">
<div className="flex items-start gap-3 mb-2">
<Avatar className="h-8 w-8">
<AvatarFallback>U</AvatarFallback>
</Avatar>
<div>
<p className="font-medium text-navy dark:text-cream">Reader</p>
<p className="text-xs text-navy/60 dark:text-cream/60">3 days ago</p>
</div>
</div>
<p className="text-navy/80 dark:text-cream/80">
This is a sample comment. In a real implementation, comments would be loaded from the API.
</p>
</div>
</div>
) : (
<div className="text-center py-8 border border-dashed border-navy/20 dark:border-cream/20 rounded-lg">
<MessageSquare className="h-12 w-12 mx-auto text-navy/30 dark:text-cream/30 mb-3" />
<h4 className="text-lg font-medium mb-2">No comments yet</h4>
<p className="text-navy/70 dark:text-cream/70 mb-4">
Be the first to share your thoughts on this article.
</p>
</div>
)}
</div>
{/* Related articles section */}
<div>
<h3 className="text-xl font-serif font-medium mb-4 flex items-center gap-2">
<BookOpen className="h-5 w-5 text-russet" />
<span>Related Articles</span>
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* This would be populated with related articles from the API */}
<div className="p-4 border border-sage/20 dark:border-sage/10 rounded-lg">
<p className="text-navy/60 dark:text-cream/60 text-sm mb-2">
Related articles will be displayed here based on similar tags or topics.
</p>
</div>
</div>
</div>
</footer>
</article>
</PageLayout>
);
}

View File

@ -0,0 +1,212 @@
import { PageLayout } from "@/components/layout/PageLayout";
import { useQuery } from "@tanstack/react-query";
import { BlogPostListItem } from "@/lib/types";
import { Link } from "wouter";
import { formatDistanceToNow } from "date-fns";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Input } from "@/components/ui/input";
import { useState } from "react";
import { Calendar, Clock, MessageSquare, Search, ThumbsUp, BookOpen, PenSquare } from "lucide-react";
export default function BlogList() {
const [searchQuery, setSearchQuery] = useState("");
const [activeTab, setActiveTab] = useState("all");
const { data: posts, isLoading, error } = useQuery<BlogPostListItem[]>({
queryKey: ['/api/blog'],
});
// Filter posts based on search query and active tab
const filteredPosts = posts?.filter(post => {
const matchesSearch = searchQuery === "" ||
post.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
post.excerpt?.toLowerCase().includes(searchQuery.toLowerCase()) ||
post.tags?.some(tag => tag.name.toLowerCase().includes(searchQuery.toLowerCase()));
if (activeTab === "all") return matchesSearch;
if (activeTab === "popular") return matchesSearch && post.likeCount > 5;
if (activeTab === "recent") {
// Consider posts from the last 30 days as recent
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
return matchesSearch && new Date(post.publishedAt || post.createdAt) >= thirtyDaysAgo;
}
return matchesSearch;
});
const formatDate = (date: Date | null) => {
if (!date) return "";
return formatDistanceToNow(new Date(date), { addSuffix: true });
};
return (
<PageLayout>
<section className="max-w-5xl mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-3xl font-serif font-bold text-navy dark:text-cream">Literary Blog</h1>
<p className="text-navy/70 dark:text-cream/70 mt-1">
Articles, essays, and discussions on literature, translations, and the literary world
</p>
</div>
<Link href="/blog/create">
<Button className="bg-russet text-white hover:bg-russet/90 flex items-center gap-2">
<PenSquare className="h-4 w-4" />
<span>Write Article</span>
</Button>
</Link>
</div>
<div className="mb-8 flex flex-col sm:flex-row justify-between gap-4">
<Tabs defaultValue="all" className="w-full sm:w-auto" onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="all">All Articles</TabsTrigger>
<TabsTrigger value="popular">Popular</TabsTrigger>
<TabsTrigger value="recent">Recent</TabsTrigger>
</TabsList>
</Tabs>
<div className="relative w-full sm:w-64">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-navy/50 dark:text-cream/50" />
<Input
type="search"
placeholder="Search articles..."
className="pl-9 w-full"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
{isLoading ? (
<div className="space-y-6">
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i} className="relative overflow-hidden">
<CardHeader className="pb-2">
<Skeleton className="h-4 w-20 mb-1" />
<Skeleton className="h-7 w-3/4 mb-1" />
<Skeleton className="h-5 w-1/3" />
</CardHeader>
<CardContent className="pb-2">
<Skeleton className="h-24 w-full" />
</CardContent>
<CardFooter>
<Skeleton className="h-4 w-32" />
</CardFooter>
</Card>
))}
</div>
) : error ? (
<div className="text-center py-12">
<h3 className="text-xl font-medium mb-2">Failed to load blog posts</h3>
<p className="text-navy/70 dark:text-cream/70 mb-4">
There was an error retrieving the blog articles. Please try again later.
</p>
<Button variant="outline" onClick={() => window.location.reload()}>
Refresh
</Button>
</div>
) : filteredPosts?.length === 0 ? (
<div className="text-center py-12 border border-dashed border-navy/20 dark:border-cream/20 rounded-lg">
<BookOpen className="h-16 w-16 mx-auto text-navy/30 dark:text-cream/30 mb-4" />
<h3 className="text-xl font-medium mb-2">No articles found</h3>
<p className="text-navy/70 dark:text-cream/70 mb-4">
{searchQuery
? "No articles match your search query. Try different keywords."
: "There are no blog posts yet. Be the first to contribute!"}
</p>
<Link href="/blog/create">
<Button className="bg-russet text-white hover:bg-russet/90">
Write an Article
</Button>
</Link>
</div>
) : (
<div className="space-y-6">
{filteredPosts?.map((post) => (
<Link key={post.id} href={`/blog/${post.slug}`}>
<Card className="hover:shadow-md transition-shadow cursor-pointer relative overflow-hidden group">
{post.likeCount > 10 && (
<div className="absolute top-0 right-0 bg-russet text-white text-xs py-1 px-2 rounded-bl-md">
Popular
</div>
)}
<CardHeader className="pb-2">
<div className="flex justify-between">
<div className="flex items-center gap-2 text-sm text-navy/60 dark:text-cream/60">
<Calendar className="h-3.5 w-3.5" />
<span>{formatDate(post.publishedAt || post.createdAt)}</span>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-1 text-sm text-navy/60 dark:text-cream/60">
<MessageSquare className="h-3.5 w-3.5" />
<span>{post.commentCount}</span>
</div>
<div className="flex items-center gap-1 text-sm text-navy/60 dark:text-cream/60">
<ThumbsUp className="h-3.5 w-3.5" />
<span>{post.likeCount}</span>
</div>
</div>
</div>
<CardTitle className="text-xl font-serif group-hover:text-russet transition-colors">
{post.title}
</CardTitle>
{post.tags && post.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{post.tags.map((tag) => (
<Badge key={tag.id} variant="outline" className="text-xs">
{tag.name}
</Badge>
))}
</div>
)}
</CardHeader>
<CardContent className="pb-2">
<CardDescription className="text-navy/70 dark:text-cream/70 line-clamp-3">
{post.excerpt || ""}
</CardDescription>
</CardContent>
<CardFooter>
<div className="flex items-center gap-2">
<Avatar className="h-6 w-6">
<AvatarImage src={post.author?.avatar || ""} alt={post.author?.displayName || "Author"} />
<AvatarFallback className="text-xs">
{post.author?.displayName?.charAt(0) || "A"}
</AvatarFallback>
</Avatar>
<span className="text-sm text-navy/80 dark:text-cream/80">
{post.author?.displayName || "Anonymous"}
</span>
</div>
</CardFooter>
</Card>
</Link>
))}
{posts && posts.length > 0 && (
<div className="flex justify-center pt-4">
<Button variant="outline" disabled>
<Clock className="mr-2 h-4 w-4" />
Load more articles
</Button>
</div>
)}
</div>
)}
</section>
</PageLayout>
);
}

View File

@ -0,0 +1,3 @@
export { default as BlogList } from './BlogList';
export { default as BlogDetail } from './BlogDetail';
export { default as BlogCreate } from './BlogCreate';