Almost finish implementing client side

This commit is contained in:
2025-11-06 20:22:16 +02:00
parent a847779582
commit a891a686fd
42 changed files with 6450 additions and 57 deletions

392
Client/app/routes/post.tsx Normal file
View File

@@ -0,0 +1,392 @@
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 || !paginatedComments) return;
const res = await API.postComment(postId, commentText, user);
if (!res.ok) {
alert("Failed to add comment");
return;
}
const newComment: Comment = await res.json();
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],
total: 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);
if (newItems.length === 0 && currentCommentPage > 1) {
setCurrentCommentPage(currentCommentPage - 1);
}
return { ...prev, items: newItems, total: prev.totalCount - 1 };
});
};
//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>
);
}

155
Client/app/routes/posts.tsx Normal file
View File

@@ -0,0 +1,155 @@
import type { Route } from "../+types/root";
import { useState, useEffect, type FormEventHandler } from "react";
import RootLayout from "~/layout/RootLayout";
import { getTagColor } from "../utils/tags";
import type { Post } from "../types/post";
import Icon, { ICONS } from "../components/Icon";
import { API } from "../api/api";
import { useNavigate } from "react-router";
import type { PaginatedResponse } from "~/types/PaginatedResponse";
import Loading from "~/components/Loading";
interface PostCardProps {
post: Post;
onClick: (id: number) => void;
}
export function meta({}: Route.MetaArgs) {
return [
{ title: "ImgBoard - Posts" },
];
}
export default function Posts() {
const PostCard = ({ post, onClick }: PostCardProps) => (
<div className="bg-white rounded-lg shadow-md overflow-hidden cursor-pointer transform hover:-translate-y-1 transition-transform duration-300" onClick={() => onClick(post.id)}>
<img src={post.fileUrl} alt={post.title} className="w-full h-48 object-cover" />
<div className="p-4">
<h3 className="font-bold text-lg truncate">{post.title}</h3>
<div className="flex flex-wrap gap-1 mt-2">
{post.tags.slice(0, 3).map(tag => (
<span key={tag.name} className={`text-xs font-semibold px-2 py-1 rounded-full ${getTagColor(tag)}`}>
{tag.name}
</span>
))}
{post.tags.length > 3 && <span className="text-gray-500 text-xs font-semibold py-1">...</span>}
</div>
</div>
</div>
);
const SearchBar = ({ currentQuery, onSearch }: { currentQuery: string, onSearch: (query: string) => void }) => {
const [query, setQuery] = useState(currentQuery);
const handleSearch: FormEventHandler = (e) => {
e.preventDefault();
onSearch(query);
};
return (
<div className="p-4 md:px-6 md:pt-6">
<form onSubmit={handleSearch} className="max-w-xl mx-auto">
<div className="relative">
<input type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search posts by tags (e.g., +high-res -theotown)" className="w-full px-5 py-3 text-gray-700 bg-white border-2 border-gray-200 rounded-full focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"/>
<button type="submit" className="absolute top-0 right-0 mt-3 mr-4 text-gray-400 hover:text-gray-600">
<Icon path={ICONS.search}/>
</button>
</div>
</form>
</div>
);
};
const GridView = ({ posts, onPostSelect }: { posts: Post[]; onPostSelect: (id: number) => void }) => (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6 p-4 md:p-6">
{posts.map(post => <PostCard key={post.id} post={post} onClick={onPostSelect} />)}
</div>
);
const Pagination = ({
postsPerPage,
totalPosts,
paginate,
currentPage,
}: {
postsPerPage: number;
totalPosts: number;
paginate: (pageNumber: number) => void;
currentPage: number;
}) => {
const pageNumbers = [];
for (let i = 1; i <= Math.ceil(totalPosts / postsPerPage); i++) { pageNumbers.push(i); }
if(pageNumbers.length <= 1) return null;
return (
<nav className="mt-8 flex justify-center">
<ul className="flex items-center space-x-2">
{pageNumbers.map(number => (
<li key={number}>
<button onClick={() => paginate(number)} className={`px-4 py-2 rounded-md transition-colors ${ currentPage === number ? 'bg-blue-500 text-white' : 'bg-white text-gray-700 hover:bg-gray-100'}`}>
{number}
</button>
</li>
))}
</ul>
</nav>
);
};
const [paginatedData, setPaginatedData] = useState<PaginatedResponse<Post> | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [searchQuery, setSearchQuery] = useState('');
const navigate = useNavigate();
useEffect(() => {
setCurrentPage(1);
}, [searchQuery])
useEffect(() => {
const fetchData = async () => {
const data = await API.fetchPosts(currentPage, searchQuery);
setPaginatedData(data);
}
fetchData();
}, [currentPage, searchQuery]);
const handleSearch = (query: string) => { setSearchQuery(query); setCurrentPage(1); };
const handlePostSelect = (id: number) => { navigate(`/posts/${id}`) };
const currentPosts = paginatedData?.items ?? [];
const paginate = (pageNumber: number) => setCurrentPage(pageNumber);
if (!paginatedData) {
return <RootLayout>
<Loading />
</RootLayout>;
}
return (
<RootLayout>
{paginatedData.items.length > 0 ?
<>
<SearchBar currentQuery={searchQuery} onSearch={handleSearch} />
<GridView posts={currentPosts} onPostSelect={handlePostSelect} />
<Pagination postsPerPage={paginatedData.pageSize} totalPosts={paginatedData.totalCount} paginate={paginate} currentPage={currentPage} />
</>
:
<div className="p-4 md:p-6 max-w-4xl mx-auto">
<div className="bg-white rounded-xl shadow-lg p-6 text-center">
<h2 className="text-2xl font-bold mb-4 text-gray-800">No Posts Found</h2>
<p className="text-gray-600 mb-6">Try adjusting your search to find what you're looking for.</p>
<button onClick={() => { setSearchQuery(''); setCurrentPage(1); }} className="bg-blue-500 text-white font-semibold px-4 py-2 rounded-md hover:bg-blue-600 transition-colors">
Clear Search
</button>
</div>
</div>
}
</RootLayout>
);
}