160 lines
5.7 KiB
TypeScript
160 lines
5.7 KiB
TypeScript
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";
|
|
import { getApiErrorMessage } from "~/context/AuthContext";
|
|
|
|
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 () => {
|
|
try {
|
|
const data = await API.fetchPosts(currentPage, searchQuery);
|
|
setPaginatedData(data);
|
|
} catch (err: any) {
|
|
alert(`Failed to fetch posts: ${getApiErrorMessage(err)}`);
|
|
}
|
|
}
|
|
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>
|
|
);
|
|
}
|