mirror of
https://github.com/SamyRai/tercul-frontend.git
synced 2025-12-27 02:31:34 +00:00
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:
parent
73c88cc6aa
commit
64fd506bdc
@ -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 (
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
364
client/src/pages/blog/BlogCreate.tsx
Normal file
364
client/src/pages/blog/BlogCreate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
392
client/src/pages/blog/BlogDetail.tsx
Normal file
392
client/src/pages/blog/BlogDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
212
client/src/pages/blog/BlogList.tsx
Normal file
212
client/src/pages/blog/BlogList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
client/src/pages/blog/index.ts
Normal file
3
client/src/pages/blog/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { default as BlogList } from './BlogList';
|
||||
export { default as BlogDetail } from './BlogDetail';
|
||||
export { default as BlogCreate } from './BlogCreate';
|
||||
Loading…
Reference in New Issue
Block a user