Files
T120B165/Client/app/routes/post.tsx

404 lines
15 KiB
TypeScript

import RootLayout from "~/layout/RootLayout";
import type { Route } from "../+types/root";
import { useNavigate, useParams } from "react-router";
import { useEffect, useState, type FormEventHandler } from "react";
import { API } from "~/api/api";
import Icon, { ICONS } from "~/components/Icon";
import { getTagColor } from "../utils/tags";
import type { Post } from "~/types/post";
import Modal from "~/components/Modal";
import type { Comment } from "~/types/comment";
import Loading from "~/components/Loading";
import { useAuth } from "~/context/AuthContext";
import { NotFound } from "~/components/NotFound";
import type { PaginatedResponse } from "~/types/PaginatedResponse";
type ActionButtonProps = {
onClick?: React.MouseEventHandler<HTMLButtonElement>;
icon: string;
children: React.ReactNode;
className?: string;
};
export function meta({}: Route.MetaArgs) {
return [
{ title: "ImgBoard - Viewing Post" },
];
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
export default function Post() {
const { id } = useParams();
const navigate = useNavigate();
if (!id) {
return <NotFound/>
}
const idNum = parseInt(id, 10);
if (isNaN(idNum)) {
return <NotFound/>
}
const [post, setPost] = useState<Post|null>(null);
const [isEditPostModalOpen, setIsEditPostModalOpen] = useState(false);
const [paginatedComments, setPaginatedComments] = useState<PaginatedResponse<Comment> | null>(null);
const [currentCommentPage, setCurrentCommentPage] = useState(1);
const [commentToEdit, setCommentToEdit] = useState<Comment|null>(null);
const [isEditCommentModalOpen, setIsEditCommentModalOpen] = useState(false);
console.log("Post ID from params:", id);
useEffect(() => {
// Fetch post details using the id
console.log(`Fetching post with id: ${id}`);
async function fetchData() {
const post = await API.fetchPostById(idNum);
setPost(post);
}
fetchData();
}, [id]);
useEffect(() => {
async function fetchComments() {
const comments = await API.fetchCommentsByPostId(idNum, currentCommentPage);
setPaginatedComments(comments);
}
fetchComments();
}, [id, currentCommentPage]);
const { user } = useAuth();
if (!post) {
return <RootLayout>
<Loading />
</RootLayout>;
}
/**
* Button component for actions like Edit, Delete, View Raw, etc.
*/
const ActionButton: React.FC<ActionButtonProps> = ({ onClick, icon, children, className }) => (
<button onClick={onClick} className={`flex items-center space-x-1.5 font-semibold px-3 py-1.5 rounded-md hover:bg-opacity-80 transition-colors text-sm ${className}`}>
<Icon path={icon} className="w-4 h-4" />
<span>{children}</span>
</button>
);
const Pagination = ({
itemsPerPage,
totalItems,
paginate,
currentPage,
}: {
itemsPerPage: number;
totalItems: number;
paginate: (pageNumber: number) => void;
currentPage: number;
}) => {
const pageNumbers = [];
for (let i = 1; i <= Math.ceil(totalItems / itemsPerPage); i++) { pageNumbers.push(i); }
if(pageNumbers.length <= 1) return null;
return (
<nav className="mt-4 flex justify-center">
<ul className="flex items-center space-x-2">
{pageNumbers.map(number => (
<li key={number}>
<button onClick={() => paginate(number)} className={`px-3 py-1 rounded-md transition-colors text-sm ${ currentPage === number ? 'bg-blue-500 text-white' : 'bg-white text-gray-700 hover:bg-gray-100'}`}>
{number}
</button>
</li>
))}
</ul>
</nav>
);
};
const EditPostModal = () => {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [tags, setTags] = useState('');
const [error, setError] = useState('');
useEffect(() => {
if (post) {
setTitle(post.title);
setDescription(post.description);
setTags(post.tags.map(t => t.name).join(' '));
}
}, [post]);
const handleSubmit: FormEventHandler = async (e) => {
e.preventDefault();
setError('');
if (!user) return;
const enteredTags = tags.split(' ').filter(t => t);
if (title && description && tags) {
const res = await API.updatePost(post.id, title, description, enteredTags, user);
const data = await res.json();
if (!res.ok) {
setError(data.detail || "Failed to update post.");
return;
}
setPost(data);
setIsEditPostModalOpen(false);
}
};
if (!post) return null;
return (
<Modal isOpen={isEditPostModalOpen} onClose={() => setIsEditPostModalOpen(false)}>
<h2 className="text-2xl font-bold mb-4">Edit Post</h2>
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="edit-post-title">Title</label>
<input id="edit-post-title" type="text" value={title} onChange={(e) => setTitle(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700"/>
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="edit-post-description">Description</label>
<textarea id="edit-post-description" rows={3} value={description} onChange={(e) => setDescription(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700"/>
</div>
<div className="mb-6">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="edit-post-tags">Tags</label>
<input id="edit-post-tags" type="text" value={tags} onChange={(e) => setTags(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700"/>
</div>
{error && <p className="text-red-500 text-xs italic mb-4">{error}</p>}
<div className="flex items-center justify-end">
<button type="submit" className="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded">Save Changes</button>
</div>
</form>
</Modal>
);
};
const canManagePost = user && (user.isAdmin || user.id === post.author.userId);
/**
* Called when a button to edit a post is clicked.
*/
const onOpenEditPostModal = () => {
setIsEditPostModalOpen(true);
}
/**
* Called when a button to delete a post is clicked.
*/
const onPostDelete = async () => {
if (!user) return;
const res = await API.deletePost(post.id, user)
if (!res.ok) {
alert("Failed to delete post.");
return;
}
await navigate('/')
};
const onAddComment = async (postId: number, commentText: string) => {
if (!user) return;
const res = await API.postComment(postId, commentText, user);
if (!res.ok) {
alert("Failed to add comment");
return;
}
const newComment: Comment = await res.json();
// Refetch comments if this is the first one.
if (!paginatedComments || paginatedComments.totalCount === 0) {
const comments = await API.fetchCommentsByPostId(idNum, 1);
setPaginatedComments(comments);
setCurrentCommentPage(1);
return;
}
const totalPages = Math.ceil((paginatedComments.totalCount + 1) / paginatedComments.pageSize);
if (currentCommentPage !== totalPages) {
setCurrentCommentPage(totalPages);
} else {
setPaginatedComments(prev => {
if (!prev) return null;
// If current page is not full, just add the comment
if (prev.items.length < prev.pageSize) {
return {
...prev,
items: [...prev.items, newComment],
totalCount: prev.totalCount + 1
};
} else {
// This case is unlikely if totalPages is calculated correctly, but as a fallback, we refetch.
setCurrentCommentPage(totalPages);
return prev;
}
});
}
};
const handleOpenEditCommentModal = (comment: Comment) => {
setCommentToEdit(comment);
setIsEditCommentModalOpen(true);
};
const onDeleteComment = async (postId: number, commentId: number) => {
if (!user || !paginatedComments) return;
const res = await API.deleteComment(postId, commentId, user);
if (!res.ok) {
alert("Failed to delete comment.");
return;
}
setPaginatedComments(prev => {
if (!prev) return null;
const newItems = prev.items.filter(c => c.id !== commentId);
const newTotalCount = prev.totalCount - 1;
if (newItems.length === 0 && currentCommentPage > 1) {
setCurrentCommentPage(currentCommentPage - 1);
return { ...prev, items: newItems, totalCount: newTotalCount };
}
return { ...prev, items: newItems, totalCount: newTotalCount };
});
};
//const allTags = [...new Set(posts.flatMap(p => p.tags))];
const CommentForm = () => {
const [error, setError] = useState('');
const [commentText, setCommentText] = useState('');
const handleSubmit: FormEventHandler = async (e) => {
setError('');
e.preventDefault();
if (!commentText.trim()) return;
if (!user) return;
await onAddComment(post.id, commentText);
setCommentText('');
};
return (
<form onSubmit={handleSubmit} className="mt-6">
<textarea className="w-full border-2 border-gray-200 rounded-lg p-3 focus:outline-none focus:border-blue-500 transition-colors" rows={3} placeholder="Write a comment..." value={commentText} onChange={(e) => setCommentText(e.target.value)}></textarea>
{error && <p className="text-red-500 text-xs italic mb-4">{error}</p>}
<button type="submit" className="mt-2 bg-blue-500 text-white font-semibold px-4 py-2 rounded-lg hover:bg-blue-600 transition-colors disabled:bg-blue-300" disabled={!commentText.trim()}>Post Comment</button>
</form>
);
};
const EditCommentModal = () => {
const [newText, setNewText] = useState(commentToEdit?.text || '');
useEffect(() => {
if (commentToEdit) {
setNewText(commentToEdit.text);
}
}, [commentToEdit]);
const handleSubmit: FormEventHandler = async (e) => {
e.preventDefault();
if (newText && commentToEdit && newText !== commentToEdit.text && user) {
// TODO
const res = await API.updateComment(post.id, commentToEdit.id, newText, user);
if (!res.ok) {
alert("Failed to update comment.");
return;
}
setPaginatedComments(prev => prev ? { ...prev, items: prev.items.map(c => c.id === commentToEdit.id ? {...c, text: newText} : c) } : null);
}
setIsEditCommentModalOpen(false)
};
return (
<Modal isOpen={isEditCommentModalOpen} onClose={() => setIsEditCommentModalOpen(false)}>
<h2 className="text-2xl font-bold mb-4">Edit Comment</h2>
<form onSubmit={handleSubmit}>
<div className="mb-4">
<textarea value={newText} onChange={(e) => setNewText(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" rows={4}/>
</div>
<div className="flex items-center justify-end">
<button type="submit" className="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline">Save Changes</button>
</div>
</form>
</Modal>
);
};
return (
<RootLayout>
<div className="p-4 md:p-6 max-w-4xl mx-auto">
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
<img src={post.fileUrl} alt={post.title} className="w-full h-auto object-cover max-h-[75vh]" />
<div className="p-6">
<div className="flex flex-col sm:flex-row justify-between sm:items-start gap-4">
<h2 className="text-3xl font-bold text-gray-800 break-words">{post.title}</h2>
<div className="flex items-center space-x-2 flex-shrink-0">
<a href={post.fileUrl} target="_blank" rel="noopener noreferrer">
<ActionButton icon={ICONS.viewRaw} className="bg-gray-200 text-gray-800">
Raw
</ActionButton>
</a>
{canManagePost && (
<>
<ActionButton onClick={() => onOpenEditPostModal()} icon={ICONS.edit} className="bg-yellow-500 text-white">Edit</ActionButton>
<ActionButton onClick={() => onPostDelete()} icon={ICONS.delete} className="bg-red-500 text-white">Delete</ActionButton>
</>
)}
</div>
</div>
<div className="flex flex-wrap gap-2 my-4">
{post.tags.map(tag => (
<span key={tag.name} className={`text-sm font-medium px-2.5 py-0.5 rounded-full ${getTagColor(tag)}`}>{tag.name}</span>
))}
</div>
<p className="text-md text-gray-600 mb-4">Posted by <span className="font-semibold">{post.author.userName}</span> &bull; {formatDate(post.createdAt)}</p>
<p className="text-gray-700 bg-gray-50 p-4 rounded-md whitespace-pre-wrap">{post.description}</p>
<div className="mt-6">
<h3 className="text-xl font-semibold mb-4 text-gray-700 border-b pb-2">Comments ({paginatedComments?.totalCount ?? 0})</h3>
<div className="space-y-4">
{!paginatedComments ? <Loading/> : paginatedComments.items.length > 0 ? (
paginatedComments.items.map(comment => {
const canManageComment = user && (user.isAdmin || user.id === comment.author.userId);
return (
<div key={comment.id} className="bg-gray-50 p-4 rounded-lg">
<div className="flex justify-between items-center mb-1">
<p className="font-semibold text-gray-800">{comment.author.userName}</p>
{canManageComment && (
<div className="space-x-2">
<button onClick={() => handleOpenEditCommentModal(comment)} className="text-xs font-semibold text-yellow-600 hover:underline">Edit</button>
<button onClick={() => onDeleteComment(post.id, comment.id)} className="text-xs font-semibold text-red-600 hover:underline">Delete</button>
</div>
)}
</div>
<p className="text-gray-600 whitespace-pre-wrap">{comment.text}</p>
</div>
)
})
) : ( <p className="text-gray-500">No comments yet. Be the first to share your thoughts!</p> )}
</div>
{paginatedComments && <Pagination itemsPerPage={paginatedComments.pageSize} totalItems={paginatedComments.totalCount} paginate={setCurrentCommentPage} currentPage={currentCommentPage} />}
{user && <CommentForm/>}
</div>
</div>
</div>
</div>
<EditPostModal/>
<EditCommentModal/>
</RootLayout>
);
}