mirror of
https://github.com/SamyRai/tercul-frontend.git
synced 2025-12-27 00:11:35 +00:00
This commit addresses 275 TypeScript compilation errors and improves type safety, code quality, and maintainability across the frontend codebase. The following issues have been resolved: - Standardized `translationId` to `number` - Fixed missing properties on annotation types - Resolved `tags` type mismatch - Corrected `country` type mismatch - Addressed date vs. string mismatches - Fixed variable hoisting issues - Improved server-side type safety - Added missing null/undefined checks - Fixed arithmetic operations on non-numbers - Resolved `RefObject` type issues Note: I was unable to verify the frontend changes due to local setup issues with the development server. The server would not start, and I was unable to run the Playwright tests. Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
413 lines
12 KiB
TypeScript
413 lines
12 KiB
TypeScript
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
import { format } from "date-fns";
|
|
import {
|
|
Bookmark,
|
|
BookmarkPlus,
|
|
BookOpen,
|
|
Calendar,
|
|
ChevronLeft,
|
|
Clock,
|
|
CornerDownLeft,
|
|
Edit,
|
|
MessageSquare,
|
|
Share2,
|
|
ThumbsUp,
|
|
} from "lucide-react";
|
|
import { useState } from "react";
|
|
import { Link, useParams } from "wouter";
|
|
import { PageLayout } from "@/components/layout/PageLayout";
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
|
import type { BlogPostWithDetails } from "@/lib/types";
|
|
|
|
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: string | 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>
|
|
);
|
|
}
|