|
|
@@ -0,0 +1,234 @@
|
|
|
+'use client';
|
|
|
+
|
|
|
+import './style.scss';
|
|
|
+import { usePathname, useSearchParams } from 'next/navigation';
|
|
|
+import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
|
+import { BoardSort, PostSearchType } from '@/constants/forum';
|
|
|
+import { faMagnifyingGlass } from '@fortawesome/free-solid-svg-icons';
|
|
|
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
|
+import { fetchApi } from '@/lib/utils/client';
|
|
|
+import useErrorAlert from '@/hooks/useErrorAlert';
|
|
|
+import Loading from '@/app/component/Loading';
|
|
|
+import Pagination from '@/app/component/Pagination';
|
|
|
+import { BoardPostsResponse } from '@/types/response/forum/board';
|
|
|
+import Post from '@/types/forum/post';
|
|
|
+import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
|
+import LatestListLayout from './_component/LatestListLayout';
|
|
|
+
|
|
|
+type ViewProps = {
|
|
|
+ _query: {
|
|
|
+ page: number;
|
|
|
+ perPage: number;
|
|
|
+ sort?: BoardSort;
|
|
|
+ search: PostSearchType;
|
|
|
+ keyword?: string;
|
|
|
+ },
|
|
|
+ _postList: BoardPostsResponse
|
|
|
+};
|
|
|
+
|
|
|
+export default function View({ _query, _postList }: ViewProps)
|
|
|
+{
|
|
|
+ const pathname = usePathname();
|
|
|
+ const searchParams = useSearchParams();
|
|
|
+ const { setError } = useErrorAlert();
|
|
|
+ const [loading, setLoading] = useState<boolean>(false);
|
|
|
+ const [total, setTotal] = useState<number>(_postList.total);
|
|
|
+ const [list, setList] = useState<Post[]>(_postList.list);
|
|
|
+ const [page, setPage] = useState<number>(_query.page);
|
|
|
+ const [perPage, setPerPage] = useState<number>(_query.perPage);
|
|
|
+ const [sort, setSort] = useState<BoardSort|undefined>(_query.sort);
|
|
|
+ const [search, setSearch] = useState<PostSearchType>(_query.search);
|
|
|
+ const [keyword, setKeyword] = useState<string|undefined>(_query.keyword);
|
|
|
+ const [params, setParams] = useState<Record<string, string>>({});
|
|
|
+ const [searchDialogOpen, setSearchDialogOpen] = useState(false);
|
|
|
+ const isMounted = useRef(false);
|
|
|
+ const searchRef = useRef(search);
|
|
|
+ const keywordRef = useRef(keyword);
|
|
|
+ searchRef.current = search;
|
|
|
+ keywordRef.current = keyword;
|
|
|
+ const startIndex = useMemo(() => total - ((page - 1) * perPage), [total, page, perPage]);
|
|
|
+
|
|
|
+ // 상태 => URL 동기화
|
|
|
+ useEffect(() => {
|
|
|
+ const alreadyParams = new URLSearchParams(searchParams.toString());
|
|
|
+
|
|
|
+ Object.entries(params).forEach(([k, v]) => {
|
|
|
+ if (v) {
|
|
|
+ alreadyParams.set(k, v);
|
|
|
+ } else {
|
|
|
+ alreadyParams.delete(k);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ const queryString = `?${alreadyParams.toString()}`;
|
|
|
+ if (window.location.search !== queryString) {
|
|
|
+ window.history.replaceState(null, '', `${pathname}${queryString}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ }, [page, perPage, sort, search, keyword, params, pathname, searchParams]);
|
|
|
+
|
|
|
+ const handleFetchPosts = useCallback(async () => {
|
|
|
+ try {
|
|
|
+ setLoading(true);
|
|
|
+
|
|
|
+ const queryParams = new URLSearchParams();
|
|
|
+
|
|
|
+ queryParams.set('page', String(page));
|
|
|
+ queryParams.set('perPage', String(perPage));
|
|
|
+
|
|
|
+ if (sort !== undefined && sort !== null) {
|
|
|
+ queryParams.set('sort', String(sort));
|
|
|
+ }
|
|
|
+ if (searchRef.current !== undefined && searchRef.current !== null) {
|
|
|
+ queryParams.set('search', String(searchRef.current));
|
|
|
+ }
|
|
|
+ if (keywordRef.current) {
|
|
|
+ queryParams.set('keyword', keywordRef.current);
|
|
|
+ }
|
|
|
+
|
|
|
+ const res = await fetchApi<BoardPostsResponse>(`/api/forum/posts?${queryParams.toString()}`);
|
|
|
+
|
|
|
+ if (!res.data) {
|
|
|
+ setError('게시글을 불러올 수 없습니다.');
|
|
|
+ } else {
|
|
|
+ setTotal(res.data.total);
|
|
|
+ setList(res.data.list);
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ if (err instanceof Error) {
|
|
|
+ setError(err.message || '알 수 없는 오류가 발생했습니다.');
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ setLoading(false);
|
|
|
+ }
|
|
|
+ }, [page, perPage, sort]);
|
|
|
+
|
|
|
+ const handlePageChange = useCallback((page: number) => {
|
|
|
+ setPage(page);
|
|
|
+ setParams((prev) => ({ ...prev, page: String(page) }));
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const handleChange = useCallback((e: React.ChangeEvent<HTMLSelectElement|HTMLInputElement>) => {
|
|
|
+ const { name, value } = e.target;
|
|
|
+
|
|
|
+ switch (name) {
|
|
|
+ case 'sort':
|
|
|
+ setSort(Number(value) as BoardSort);
|
|
|
+ break;
|
|
|
+ case 'perPage':
|
|
|
+ setPerPage(Number(value));
|
|
|
+ break;
|
|
|
+ case 'search':
|
|
|
+ setSearch(Number(value) as PostSearchType);
|
|
|
+ break;
|
|
|
+ case 'keyword':
|
|
|
+ setKeyword(value);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (['perPage', 'search', 'keyword'].includes(name)) {
|
|
|
+ handlePageChange(1);
|
|
|
+ }
|
|
|
+
|
|
|
+ setParams((prev) => ({ ...prev, [name]: value }));
|
|
|
+ }, [handlePageChange]);
|
|
|
+
|
|
|
+ const handleSearch = useCallback((e: React.FormEvent) => {
|
|
|
+ e.preventDefault();
|
|
|
+ handleFetchPosts();
|
|
|
+ }, [handleFetchPosts]);
|
|
|
+
|
|
|
+ const handleSearchDialog = useCallback((e: React.FormEvent) => {
|
|
|
+ e.preventDefault();
|
|
|
+ handleFetchPosts();
|
|
|
+ setSearchDialogOpen(false);
|
|
|
+ }, [handleFetchPosts]);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (!isMounted.current) {
|
|
|
+ isMounted.current = true;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ handleFetchPosts();
|
|
|
+ }, [page, perPage, sort, handleFetchPosts]);
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div id='latest'>
|
|
|
+ {loading && <Loading />}
|
|
|
+
|
|
|
+ <div className='list-header'>
|
|
|
+ <section>
|
|
|
+ <h1>토론</h1>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ {/* 정렬 */}
|
|
|
+ <section aria-label='게시글 정렬'>
|
|
|
+ <select name='sort' value={sort ?? ''} title='게시글 정렬' onChange={handleChange}>
|
|
|
+ <option value={BoardSort.CreatedAt}>최신순</option>
|
|
|
+ <option value={BoardSort.Views}>조회순</option>
|
|
|
+ <option value={BoardSort.Comments}>댓글순</option>
|
|
|
+ <option value={BoardSort.Likes}>공감순</option>
|
|
|
+ </select>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ {/* 출력 수 */}
|
|
|
+ <section aria-label='게시글 출력 수'>
|
|
|
+ <select name='perPage' value={perPage} title='출력 수' onChange={handleChange}>
|
|
|
+ <option value='10'>10개씩</option>
|
|
|
+ <option value='20'>20개씩</option>
|
|
|
+ <option value='30'>30개씩</option>
|
|
|
+ <option value='50'>50개씩</option>
|
|
|
+ <option value='100'>100개씩</option>
|
|
|
+ </select>
|
|
|
+ </section>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 게시글 목록 */}
|
|
|
+ <LatestListLayout list={list} startIndex={startIndex} />
|
|
|
+
|
|
|
+ {/* 검색 */}
|
|
|
+ <div className='list-footer'>
|
|
|
+ {/* 모바일: 검색 아이콘 → Dialog */}
|
|
|
+ <Dialog open={searchDialogOpen} onOpenChange={setSearchDialogOpen}>
|
|
|
+ <DialogTrigger asChild>
|
|
|
+ <button type='button' className='btn btn-default search-toggle' title='검색'>
|
|
|
+ <FontAwesomeIcon icon={faMagnifyingGlass}/>
|
|
|
+ </button>
|
|
|
+ </DialogTrigger>
|
|
|
+ <DialogContent className='w-[90%] sm:max-w-md'>
|
|
|
+ <DialogHeader>
|
|
|
+ <DialogTitle>게시글 검색</DialogTitle>
|
|
|
+ </DialogHeader>
|
|
|
+ <form onSubmit={handleSearchDialog} autoComplete='off' className='flex flex-col gap-3'>
|
|
|
+ <select name='search' value={search ?? ''} title='검색 구분' onChange={handleChange} className='h-9 rounded-md border px-3'>
|
|
|
+ <option value={PostSearchType.Subject}>제목</option>
|
|
|
+ <option value={PostSearchType.Content}>내용</option>
|
|
|
+ <option value={PostSearchType.Author}>작성자</option>
|
|
|
+ <option value={PostSearchType.Comment}>댓글</option>
|
|
|
+ </select>
|
|
|
+ <input type='text' name='keyword' value={keyword} placeholder='검색어를 입력해주세요.' onChange={handleChange} className='h-9 rounded-md border px-3' />
|
|
|
+ <button type='submit' className='btn btn-default w-full'>검색</button>
|
|
|
+ </form>
|
|
|
+ </DialogContent>
|
|
|
+ </Dialog>
|
|
|
+
|
|
|
+ {/* 데스크톱: 인라인 검색 폼 */}
|
|
|
+ <section aria-label='게시글 검색'>
|
|
|
+ <form onSubmit={handleSearch} autoComplete='off'>
|
|
|
+ <select name='search' value={search ?? ''} title='검색 구분' onChange={handleChange}>
|
|
|
+ <option value={PostSearchType.Subject}>제목</option>
|
|
|
+ <option value={PostSearchType.Content}>내용</option>
|
|
|
+ <option value={PostSearchType.Author}>작성자</option>
|
|
|
+ <option value={PostSearchType.Comment}>댓글</option>
|
|
|
+ </select>
|
|
|
+ <input type='text' name='keyword' value={keyword} placeholder='검색어를 입력해주세요.' onChange={handleChange} />
|
|
|
+ <button type='submit' className='btn btn-default'>검색</button>
|
|
|
+ </form>
|
|
|
+ </section>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <Pagination total={total} page={page} perPage={perPage} onChange={handlePageChange} />
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|