445 lines
17 KiB
TypeScript
445 lines
17 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 { getApiErrorMessage, 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 [notFound, setNotFound] = useState(false);
|
|
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() {
|
|
try {
|
|
const postData = await API.fetchPostById(idNum);
|
|
setPost(postData);
|
|
} catch (error: any) {
|
|
if (error.status === 404) {
|
|
setNotFound(true);
|
|
} else {
|
|
console.error("Failed to fetch post:", error);
|
|
// Optionally, you could navigate to a generic error page
|
|
// or show a toast notification.
|
|
alert(getApiErrorMessage(error));
|
|
}
|
|
}
|
|
}
|
|
fetchData();
|
|
}, [id]);
|
|
|
|
useEffect(() => {
|
|
async function fetchComments() {
|
|
const comments = await API.fetchCommentsByPostId(idNum, currentCommentPage);
|
|
setPaginatedComments(comments);
|
|
}
|
|
fetchComments();
|
|
}, [id, currentCommentPage]);
|
|
|
|
const { user } = useAuth();
|
|
|
|
if (notFound) {
|
|
return <RootLayout>
|
|
<NotFound />
|
|
</RootLayout>;
|
|
}
|
|
|
|
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) {
|
|
try {
|
|
const res = await API.updatePost(post.id, title, description, enteredTags, user);
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
setError(getApiErrorMessage(data));
|
|
return;
|
|
}
|
|
setPost(data);
|
|
setIsEditPostModalOpen(false);
|
|
} catch (err: any) {
|
|
setError(getApiErrorMessage(err));
|
|
}
|
|
}
|
|
};
|
|
|
|
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;
|
|
try {
|
|
const res = await API.deletePost(post.id, user)
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
alert(`Failed to delete post: ${getApiErrorMessage(err)}`);
|
|
return;
|
|
}
|
|
await navigate('/')
|
|
} catch (err: any) {
|
|
alert(`Failed to delete post: ${getApiErrorMessage(err)}`);
|
|
}
|
|
};
|
|
|
|
|
|
const onAddComment = async (postId: number, commentText: string) => {
|
|
if (!user) return;
|
|
try {
|
|
const res = await API.postComment(postId, commentText, user);
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
alert(`Failed to add comment: ${getApiErrorMessage(err)}`);
|
|
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;
|
|
}
|
|
});
|
|
}
|
|
} catch (err: any) {
|
|
alert(`Failed to add comment: ${getApiErrorMessage(err)}`);
|
|
}
|
|
};
|
|
|
|
const handleOpenEditCommentModal = (comment: Comment) => {
|
|
setCommentToEdit(comment);
|
|
setIsEditCommentModalOpen(true);
|
|
};
|
|
|
|
const onDeleteComment = async (postId: number, commentId: number) => {
|
|
if (!user || !paginatedComments) return;
|
|
try {
|
|
const res = await API.deleteComment(postId, commentId, user);
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
alert(`Failed to delete comment: ${getApiErrorMessage(err)}`);
|
|
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 };
|
|
});
|
|
} catch (err: any) {
|
|
alert(`Failed to delete comment: ${getApiErrorMessage(err)}`);
|
|
}
|
|
};
|
|
|
|
|
|
|
|
//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) {
|
|
try {
|
|
const res = await API.updateComment(post.id, commentToEdit.id, newText, user);
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
alert(`Failed to update comment: ${getApiErrorMessage(err)}`);
|
|
return;
|
|
}
|
|
setPaginatedComments(prev => prev ? { ...prev, items: prev.items.map(c => c.id === commentToEdit.id ? {...c, text: newText} : c) } : null);
|
|
} catch (err: any) {
|
|
alert(`Failed to update comment: ${getApiErrorMessage(err)}`);
|
|
}
|
|
}
|
|
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> • {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>
|
|
);
|
|
}
|