view.tsx 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. 'use client';
  2. import './style.scss';
  3. import { usePathname, useSearchParams } from 'next/navigation';
  4. import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
  5. import { BoardSort, PostSearchType } from '@/constants/forum';
  6. import { faMagnifyingGlass } from '@fortawesome/free-solid-svg-icons';
  7. import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
  8. import { fetchApi } from '@/lib/utils/client';
  9. import useErrorAlert from '@/hooks/useErrorAlert';
  10. import Loading from '@/app/component/Loading';
  11. import Pagination from '@/app/component/Pagination';
  12. import { BoardPostsResponse } from '@/types/response/forum/board';
  13. import Post from '@/types/forum/post';
  14. import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
  15. import LatestListLayout from './_component/LatestListLayout';
  16. type ViewProps = {
  17. _query: {
  18. page: number;
  19. perPage: number;
  20. sort?: BoardSort;
  21. search: PostSearchType;
  22. keyword?: string;
  23. },
  24. _postList: BoardPostsResponse
  25. };
  26. export default function View({ _query, _postList }: ViewProps)
  27. {
  28. const pathname = usePathname();
  29. const searchParams = useSearchParams();
  30. const { setError } = useErrorAlert();
  31. const [loading, setLoading] = useState<boolean>(false);
  32. const [total, setTotal] = useState<number>(_postList.total);
  33. const [list, setList] = useState<Post[]>(_postList.list);
  34. const [page, setPage] = useState<number>(_query.page);
  35. const [perPage, setPerPage] = useState<number>(_query.perPage);
  36. const [sort, setSort] = useState<BoardSort|undefined>(_query.sort);
  37. const [search, setSearch] = useState<PostSearchType>(_query.search);
  38. const [keyword, setKeyword] = useState<string|undefined>(_query.keyword);
  39. const [params, setParams] = useState<Record<string, string>>({});
  40. const [searchDialogOpen, setSearchDialogOpen] = useState(false);
  41. const isMounted = useRef(false);
  42. const searchRef = useRef(search);
  43. const keywordRef = useRef(keyword);
  44. searchRef.current = search;
  45. keywordRef.current = keyword;
  46. const startIndex = useMemo(() => total - ((page - 1) * perPage), [total, page, perPage]);
  47. // 상태 => URL 동기화
  48. useEffect(() => {
  49. const alreadyParams = new URLSearchParams(searchParams.toString());
  50. Object.entries(params).forEach(([k, v]) => {
  51. if (v) {
  52. alreadyParams.set(k, v);
  53. } else {
  54. alreadyParams.delete(k);
  55. }
  56. });
  57. const queryString = `?${alreadyParams.toString()}`;
  58. if (window.location.search !== queryString) {
  59. window.history.replaceState(null, '', `${pathname}${queryString}`);
  60. }
  61. }, [page, perPage, sort, search, keyword, params, pathname, searchParams]);
  62. const handleFetchPosts = useCallback(async () => {
  63. try {
  64. setLoading(true);
  65. const queryParams = new URLSearchParams();
  66. queryParams.set('page', String(page));
  67. queryParams.set('perPage', String(perPage));
  68. if (sort !== undefined && sort !== null) {
  69. queryParams.set('sort', String(sort));
  70. }
  71. if (searchRef.current !== undefined && searchRef.current !== null) {
  72. queryParams.set('search', String(searchRef.current));
  73. }
  74. if (keywordRef.current) {
  75. queryParams.set('keyword', keywordRef.current);
  76. }
  77. const res = await fetchApi<BoardPostsResponse>(`/api/forum/posts?${queryParams.toString()}`);
  78. if (!res.data) {
  79. setError('게시글을 불러올 수 없습니다.');
  80. } else {
  81. setTotal(res.data.total);
  82. setList(res.data.list);
  83. }
  84. } catch (err) {
  85. if (err instanceof Error) {
  86. setError(err.message || '알 수 없는 오류가 발생했습니다.');
  87. }
  88. } finally {
  89. setLoading(false);
  90. }
  91. }, [page, perPage, sort]);
  92. const handlePageChange = useCallback((page: number) => {
  93. setPage(page);
  94. setParams((prev) => ({ ...prev, page: String(page) }));
  95. }, []);
  96. const handleChange = useCallback((e: React.ChangeEvent<HTMLSelectElement|HTMLInputElement>) => {
  97. const { name, value } = e.target;
  98. switch (name) {
  99. case 'sort':
  100. setSort(Number(value) as BoardSort);
  101. break;
  102. case 'perPage':
  103. setPerPage(Number(value));
  104. break;
  105. case 'search':
  106. setSearch(Number(value) as PostSearchType);
  107. break;
  108. case 'keyword':
  109. setKeyword(value);
  110. break;
  111. }
  112. if (['perPage', 'search', 'keyword'].includes(name)) {
  113. handlePageChange(1);
  114. }
  115. setParams((prev) => ({ ...prev, [name]: value }));
  116. }, [handlePageChange]);
  117. const handleSearch = useCallback((e: React.FormEvent) => {
  118. e.preventDefault();
  119. handleFetchPosts();
  120. }, [handleFetchPosts]);
  121. const handleSearchDialog = useCallback((e: React.FormEvent) => {
  122. e.preventDefault();
  123. handleFetchPosts();
  124. setSearchDialogOpen(false);
  125. }, [handleFetchPosts]);
  126. useEffect(() => {
  127. if (!isMounted.current) {
  128. isMounted.current = true;
  129. return;
  130. }
  131. handleFetchPosts();
  132. }, [page, perPage, sort, handleFetchPosts]);
  133. return (
  134. <div id='latest'>
  135. {loading && <Loading />}
  136. <div className='list-header'>
  137. <section>
  138. <h1>토론</h1>
  139. </section>
  140. {/* 정렬 */}
  141. <section aria-label='게시글 정렬'>
  142. <select name='sort' value={sort ?? ''} title='게시글 정렬' onChange={handleChange}>
  143. <option value={BoardSort.CreatedAt}>최신순</option>
  144. <option value={BoardSort.Views}>조회순</option>
  145. <option value={BoardSort.Comments}>댓글순</option>
  146. <option value={BoardSort.Likes}>공감순</option>
  147. </select>
  148. </section>
  149. {/* 출력 수 */}
  150. <section aria-label='게시글 출력 수'>
  151. <select name='perPage' value={perPage} title='출력 수' onChange={handleChange}>
  152. <option value='10'>10개씩</option>
  153. <option value='20'>20개씩</option>
  154. <option value='30'>30개씩</option>
  155. <option value='50'>50개씩</option>
  156. <option value='100'>100개씩</option>
  157. </select>
  158. </section>
  159. </div>
  160. {/* 게시글 목록 */}
  161. <LatestListLayout list={list} startIndex={startIndex} />
  162. {/* 검색 */}
  163. <div className='list-footer'>
  164. {/* 모바일: 검색 아이콘 → Dialog */}
  165. <Dialog open={searchDialogOpen} onOpenChange={setSearchDialogOpen}>
  166. <DialogTrigger asChild>
  167. <button type='button' className='btn btn-default search-toggle' title='검색'>
  168. <FontAwesomeIcon icon={faMagnifyingGlass}/>
  169. </button>
  170. </DialogTrigger>
  171. <DialogContent className='w-[90%] sm:max-w-md'>
  172. <DialogHeader>
  173. <DialogTitle>게시글 검색</DialogTitle>
  174. </DialogHeader>
  175. <form onSubmit={handleSearchDialog} autoComplete='off' className='flex flex-col gap-3'>
  176. <select name='search' value={search ?? ''} title='검색 구분' onChange={handleChange} className='h-9 rounded-md border px-3'>
  177. <option value={PostSearchType.Subject}>제목</option>
  178. <option value={PostSearchType.Content}>내용</option>
  179. <option value={PostSearchType.Author}>작성자</option>
  180. <option value={PostSearchType.Comment}>댓글</option>
  181. </select>
  182. <input type='text' name='keyword' value={keyword} placeholder='검색어를 입력해주세요.' onChange={handleChange} className='h-9 rounded-md border px-3' />
  183. <button type='submit' className='btn btn-default w-full'>검색</button>
  184. </form>
  185. </DialogContent>
  186. </Dialog>
  187. {/* 데스크톱: 인라인 검색 폼 */}
  188. <section aria-label='게시글 검색'>
  189. <form onSubmit={handleSearch} autoComplete='off'>
  190. <select name='search' value={search ?? ''} title='검색 구분' onChange={handleChange}>
  191. <option value={PostSearchType.Subject}>제목</option>
  192. <option value={PostSearchType.Content}>내용</option>
  193. <option value={PostSearchType.Author}>작성자</option>
  194. <option value={PostSearchType.Comment}>댓글</option>
  195. </select>
  196. <input type='text' name='keyword' value={keyword} placeholder='검색어를 입력해주세요.' onChange={handleChange} />
  197. <button type='submit' className='btn btn-default'>검색</button>
  198. </form>
  199. </section>
  200. </div>
  201. <Pagination total={total} page={page} perPage={perPage} onChange={handlePageChange} />
  202. </div>
  203. );
  204. }