| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234 |
- '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>
- );
- }
|