tercul-frontend/client/src/pages/blog/BlogDetail.tsx
google-labs-jules[bot] 1dcd8f076c
feat: Fix TypeScript errors and improve type safety (#6)
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>
2025-11-27 18:48:47 +01:00

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>
);
}