diff --git a/src/common/Header.tsx b/src/common/Header.tsx index 99f97769..830de273 100644 --- a/src/common/Header.tsx +++ b/src/common/Header.tsx @@ -29,11 +29,15 @@ const NavItems = [ pathname.startsWith('/others') || pathname.startsWith('/community'), }, + { + label: '스크랩', + path: '/mytodo/scrap', + }, + //마이드림 { label: '나의 할일', path: '/mytodo/list', - match: (pathname: string) => pathname.startsWith('/mytodo'), }, ]; diff --git a/src/hook/community/query/useCommunityGetTodo.ts b/src/hook/community/query/useCommunityGetTodo.ts new file mode 100644 index 00000000..d2982a9c --- /dev/null +++ b/src/hook/community/query/useCommunityGetTodo.ts @@ -0,0 +1,79 @@ +import api from '@hook/api'; +import { useInfiniteQuery } from '@tanstack/react-query'; + +export interface CommunityItem { + id: number; + name: string; + level: string; + imageUrl?: string; + dDay?: string; + description: string; + saveCount: number; + isSaved: boolean; +} + +export interface CommunityGetTodoResponse { + content: CommunityItem[]; + number: number; + size: number; + first: boolean; + last: boolean; + empty: boolean; + numberOfElements: number; + pageable: { + pageNumber: number; + }; + sort: { empty: boolean; sorted: boolean; unsorted: boolean }; +} + +type BaseParams = { + jobName: string; + level: string; + sort: string; + size: number; +}; + +const fetchCommunityPage = async ({ + jobName, + level, + sort, + page, + size, +}: BaseParams & { page: number }): Promise => { + const token = + typeof window !== 'undefined' ? localStorage.getItem('accessToken') : null; + + const res = await api.get('/v1/community/todos', { + params: { jobName, level, sort, page, size }, + ...(token ? { headers: { Authorization: `Bearer ${token}` } } : {}), + }); + const result = res.data?.data; + + return { + content: result.content, + number: result.number, + size: result.size, + first: result.first, + last: result.last, + empty: result.empty, + numberOfElements: result.numberOfElements, + pageable: { pageNumber: result.pageable?.pageNumber ?? page }, + sort: result.sort, + }; +}; + +export const useCommunityGetTodo = (params: BaseParams) => { + return useInfiniteQuery({ + queryKey: ['CommunityGetTodo', params], + initialPageParam: 0, + queryFn: ({ pageParam }) => + fetchCommunityPage({ ...params, page: pageParam as number }), + + getNextPageParam: (lastPage) => { + if (lastPage.last) return undefined; + return lastPage.number + 1; + }, + + retry: 0, + }); +}; diff --git a/src/hook/community/useCommunityAddTodoMutation.ts b/src/hook/community/useCommunityAddTodoMutation.ts new file mode 100644 index 00000000..5d676281 --- /dev/null +++ b/src/hook/community/useCommunityAddTodoMutation.ts @@ -0,0 +1,51 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import api from '@hook/api'; + +interface CommunityAddTodoRequest { + id: number; +} + +interface CommunityAddTodoResponse { + success: boolean; + data: { + id: number; + message: string; + }; +} + +export const useCommunityAddTodoMutation = () => { + const queryClient = useQueryClient(); + + return useMutation< + CommunityAddTodoResponse, + unknown, + CommunityAddTodoRequest + >({ + mutationFn: async ({ id }) => { + const token = localStorage.getItem('accessToken'); + if (!token) { + throw new Error('인증 토큰이 없습니다. 로그인 후 다시 시도해주세요.'); + } + + const { data } = await api.post( + `/v1/community/todos/${id}`, + { id }, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['CommunityGetTodo', 'mdTodo', 'community'], + }); + }, + onError: (error) => { + console.error('할일 추가 실패:', error); + }, + }); +}; diff --git a/src/hook/community/useDeleteCommunityTodos.ts b/src/hook/community/useDeleteCommunityTodos.ts new file mode 100644 index 00000000..7eb898ef --- /dev/null +++ b/src/hook/community/useDeleteCommunityTodos.ts @@ -0,0 +1,38 @@ +import api from '@hook/api'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +interface DeleteCommunityTodo { + id: number; +} + +export const useDeleteCommunityTodosMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id }: DeleteCommunityTodo) => { + const token = localStorage.getItem('accessToken'); + if (!token) { + throw new Error('인증 토큰이 없습니다. 로그인 후 다시 시도해주세요.'); + } + + const response = await api.delete(`/v1/community/todos/${id}`, { + headers: { Authorization: `Bearer ${token}` }, + data: { id }, + }); + return response.data; + }, + + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['CommunityGetTodo'], + }); + queryClient.refetchQueries({ + queryKey: ['CommunityGetTodo'], + }); + }, + + onError: (error) => { + console.error('내 할일 삭제 실패:', error); + }, + }); +}; diff --git a/src/hook/community/useInfinityScroll.ts b/src/hook/community/useInfinityScroll.ts new file mode 100644 index 00000000..b269df3c --- /dev/null +++ b/src/hook/community/useInfinityScroll.ts @@ -0,0 +1,60 @@ +import { useCallback, useEffect, useRef } from 'react'; + +type UseInfiniteScrollOptions = { + onIntersect: () => void; + enabled?: boolean; + root?: Element | null; + rootMargin?: string; + threshold?: number; + once?: boolean; +}; + +export function useInfiniteScroll({ + onIntersect, + enabled = true, + root = null, + rootMargin = '200px', + threshold = 0, + once = false, +}: UseInfiniteScrollOptions) { + const ref = useRef(null); + const calledOnceRef = useRef(false); + + const handleIntersect = useCallback( + (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => { + const entry = entries[0]; + if (!entry?.isIntersecting) return; + if (once && calledOnceRef.current) return; + + onIntersect(); + + if (once) { + calledOnceRef.current = true; + observer.unobserve(entry.target); + } + }, + [onIntersect, once] + ); + + useEffect(() => { + const el = ref.current; + if (!enabled || !el) return; + + if (typeof IntersectionObserver === 'undefined') return; + + const observer = new IntersectionObserver(handleIntersect, { + root, + rootMargin, + threshold, + }); + + observer.observe(el); + return () => observer.disconnect(); + }, [enabled, root, rootMargin, threshold, handleIntersect]); + + useEffect(() => { + if (!enabled) calledOnceRef.current = false; + }, [enabled]); + + return ref; +} diff --git a/src/pages/community/Community.tsx b/src/pages/community/Community.tsx index 03077684..23be5651 100644 --- a/src/pages/community/Community.tsx +++ b/src/pages/community/Community.tsx @@ -1,90 +1,16 @@ -// import DropDownIcon from '@assets/icons/drop_down.svg?react'; -import Arrow from '@assets/icons/arrow.svg?react'; -import Bookmark from '@assets/icons/bookmark.svg?react'; import CommunityRightSide from './components/CommunityRightSide'; -import CommunityDropdown from './components/CommunityDropdown'; -import { useGetHotPopularQuery } from '@hook/community/query/useGetHotPopularQuery'; -import jobNames, { findJobIdByName } from '@utils/data/community/jobs'; -import { useCommunityStore } from '@store/useCommunityStore'; -import { useNavigate } from 'react-router-dom'; +import CommunityLeftSide from './components/CommunityLeftSide'; const Community = () => { - const navigate = useNavigate(); - const { selectedJobName } = useCommunityStore(); - const { data: popularTodos = [] } = useGetHotPopularQuery(); return ( -
-
-
- { - console.log(value); - }} - /> - -
꿈꾸는 드리머
- -
{ - // 하드코딩 매핑된 jobId 사용 - const id = findJobIdByName(selectedJobName); - navigate(`/others/${id ?? 1}`); - }} - > - 전체보기 - -
-
- -
-
- {' '} - {selectedJobName} HOT 할 일 -
-
- {popularTodos.map((item, idx) => ( -
-
-
{idx + 1}
-
- 프로필이미지 - -
-
-
- {item.description} -
-
- {item.dDay} -
-
-
- {item.name} -
-
-
-
+
+
+ +
-
- - {item.saveCount} -
-
- ))} -
-
+
+
-
); }; diff --git a/src/pages/community/components/CommunityContents.tsx b/src/pages/community/components/CommunityContents.tsx index d5619529..37bf8822 100644 --- a/src/pages/community/components/CommunityContents.tsx +++ b/src/pages/community/components/CommunityContents.tsx @@ -1,53 +1,111 @@ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import Bookmark from '@assets/icons/bookmark.svg?react'; +import LoadingSpinner from '@common/LoadingSpinner'; +import { useCommunityAddTodoMutation } from '@hook/community/useCommunityAddTodoMutation'; +import { useDeleteCommunityTodosMutation } from '@hook/community/useDeleteCommunityTodos'; +import ToastModal from '@common/modal/ToastModal'; +import Info from '@assets/icons/info.svg?react'; -type Post = { +type CommunityItem = { id: number; - author: string; - tag: '씨앗' | '새싹' | '꿈나무' | string; - content: string; - time: string; - comments: number; + name: string; + level: string; + imageUrl?: string; + dDay?: string; + description: string; + saveCount: number; + isSaved: boolean; }; -const CommunityContents = () => { - const posts: Post[] = [ - { - id: 1, - author: '해바라기 엄마', - tag: '씨앗', - content: '할일 내용내용', - time: '2분 전', - comments: 11, - }, - { - id: 2, - author: '해바라기 엄마', - tag: '새싹', - content: - '안녕안녕안녕하세요 이거의 내용은 어떻게 길어지냐면요 좀만 더 힘을 내 이렇게 길어져요 날 따라해 봐요 이렇게', - time: '2분 전', - comments: 12, - }, - { - id: 3, - author: '해바라기 엄마', - tag: '씨앗', - content: '할일 내용내용', - time: '2분 전', - comments: 13, - }, - ]; +type Props = { + items: CommunityItem[] | undefined; + activeLevel?: '전체' | '씨앗' | '새싹' | '꿈나무'; + sort?: '최신순' | '인기순'; +}; + +const normalizeLevel = (s: string) => s.replace(' 단계', ''); +const CommunityContents = ({ + items, + activeLevel = '전체', + sort = '최신순', +}: Props) => { const [added, setAdded] = useState>({}); + const addTodoMutation = useCommunityAddTodoMutation(); + const deleteTodoMutation = useDeleteCommunityTodosMutation(); + const [showToast, setShowToast] = useState(false); + const [toastMessage, setToastMessage] = useState(''); + + const filtered = useMemo(() => { + if (!items) return []; + if (activeLevel === '전체') return items; + return items.filter((x) => normalizeLevel(x.level) === activeLevel); + }, [items, activeLevel]); + + const sorted = useMemo(() => { + const base = [...filtered]; + if (sort === '인기순') base.sort((a, b) => b.saveCount - a.saveCount); + return base; + }, [filtered, sort]); + + const toggleAdd = (id: number, isAdded: boolean) => { + if (isAdded) { + deleteTodoMutation.mutate( + { id }, + { + onSuccess: () => { + setAdded((prev) => ({ ...prev, [id]: false })); + setToastMessage('할일이 취소되었습니다.'); + setShowToast(true); + setTimeout(() => setShowToast(false), 2500); + }, + onError: () => { + alert('추가 취소에 실패했어요.'); + }, + } + ); + } else { + addTodoMutation.mutate( + { id }, + { + onSuccess: () => { + setShowToast(true); + setAdded((prev) => ({ ...prev, [id]: true })); + setToastMessage('할일이 추가되었습니다.'); + setTimeout(() => { + setShowToast(false); + }, 2500); + }, + onError: () => { + alert('내 할일 추가에 실패했어요.'); + }, + } + ); + } + }; + + if (!items) { + return ( +
+ +
+ ); + } - const toggleAdd = (id: number) => - setAdded((prev) => ({ ...prev, [id]: !prev[id] })); + if (sorted.length === 0) { + return ( +
+ 조건에 맞는 투두가 없습니다. +
+ ); + } return (
- {posts.map((post) => { - const isAdded = !!added[post.id]; + {sorted.map((post) => { + const isAdded = + typeof added[post.id] === 'boolean' ? added[post.id] : post.isSaved; + const tag = normalizeLevel(post.level); return (
{ >
-
- {post.author} + {post.imageUrl ? ( + {post.name} + ) : ( +
+ )} + {post.name} - {' '} - {post.tag} 단계 + {tag}
- {isAdded ? ( - - ) : ( - - )} +
- {post.content} - - - {post.time} + {post.description} + {post.dDay && ( + + {post.dDay} + + )}
- {post.comments} + {post.saveCount}
); })} + + {showToast && ( +
+ } + text={toastMessage} + width="w-[274px]" + /> +
+ )}
); }; diff --git a/src/pages/community/components/CommunityLeftSide.tsx b/src/pages/community/components/CommunityLeftSide.tsx new file mode 100644 index 00000000..b3c479e1 --- /dev/null +++ b/src/pages/community/components/CommunityLeftSide.tsx @@ -0,0 +1,88 @@ +import CommunityDropdown from './CommunityDropdown'; +import { useNavigate } from 'react-router-dom'; +import { useCommunityStore } from '@store/useCommunityStore'; +import { useGetHotPopularQuery } from '@hook/community/query/useGetHotPopularQuery'; +import Bookmark from '@assets/icons/bookmark.svg?react'; +import Arrow from '@assets/icons/arrow.svg?react'; +import jobNames, { findJobIdByName } from '@utils/data/community/jobs'; + +const CommunityLeftSide = () => { + const navigate = useNavigate(); + const { selectedJobName } = useCommunityStore(); + const { data: popularTodos = [] } = useGetHotPopularQuery(); + return ( +
+
+ { + console.log(value); + }} + className="cursor-pointer" + /> + +
꿈꾸는 드리머
+ +
{ + // 하드코딩 매핑된 jobId 사용 + const id = findJobIdByName(selectedJobName); + navigate(`/others/${id ?? 1}`); + }} + > + 전체보기 + +
+
+ +
+
+ {' '} + {selectedJobName} HOT 할 일 +
+
+ {popularTodos.map((item, idx) => ( +
+
+
{idx + 1}
+
+ 프로필이미지 + +
+
+
+ {item.description} +
+
+ {item.dDay} +
+
+
+ {item.name} +
+
+
+
+ +
+ + {item.saveCount} +
+
+ ))} +
+
+
+ ); +}; + +export default CommunityLeftSide; diff --git a/src/pages/community/components/CommunityRightSide.tsx b/src/pages/community/components/CommunityRightSide.tsx index 907ed237..f4b2eee4 100644 --- a/src/pages/community/components/CommunityRightSide.tsx +++ b/src/pages/community/components/CommunityRightSide.tsx @@ -1,37 +1,124 @@ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import DropDownIcon from '@assets/icons/drop_down.svg?react'; import CommunityContents from './CommunityContents'; +import { useCommunityStore } from '@store/useCommunityStore'; +import { useCommunityGetTodo } from '@hook/community/query/useCommunityGetTodo'; +import LoadingSpinner from '@common/LoadingSpinner'; +import { useInfiniteScroll } from '@hook/community/useInfinityScroll'; + +type Level = '전체' | '씨앗' | '새싹' | '꿈나무'; +type Sort = '최신순' | '인기순'; + +const levels: { value: Level; label: string; api: string }[] = [ + { value: '전체', label: '전체', api: '' }, + { value: '씨앗', label: '1단계: 씨앗', api: '씨앗 단계' }, + { value: '새싹', label: '2단계: 새싹', api: '새싹 단계' }, + { value: '꿈나무', label: '3단계: 꿈나무', api: '꿈나무 단계' }, +]; + +const sortOptions: Sort[] = ['최신순', '인기순']; +const toApiLevel = (v: Level) => levels.find((l) => l.value === v)?.api ?? ''; const CommunityRightSide = () => { - const [active, setActive] = useState('전체'); + const { selectedJobName } = useCommunityStore(); + + const [active, setActive] = useState('전체'); + const [isOpen, setIsOpen] = useState(false); + const [sort, setSort] = useState('최신순'); + + const size = 10; + + const { data, isLoading, isError, isFetching, fetchNextPage, hasNextPage } = + useCommunityGetTodo({ + jobName: selectedJobName, + level: toApiLevel(active), + sort, + size, + }); + + const items = useMemo( + () => data?.pages.flatMap((p) => p.content) ?? [], + [data] + ); - const filters = ['전체', '1단계: 씨앗', '2단계: 새싹', '3단계: 꿈나무']; + const Observer = useInfiniteScroll({ + onIntersect: () => fetchNextPage(), + enabled: !!hasNextPage && !isFetching && !isLoading && !isError, + rootMargin: '200px', + }); + + const handleSelect = (option: Sort) => { + setSort(option); + setIsOpen(false); + }; return (
- {filters.map((filter) => ( + {levels.map(({ value, label }) => ( ))}
-
- 최신순 - +
+ + + {isOpen && ( +
+ {sortOptions.map((option) => ( +
handleSelect(option)} + className={`w-full cursor-pointer px-5 py-6 text-left ${ + sort === option + ? 'text-purple-500 font-B01-SB' + : 'text-gray-400 font-B01-M' + }`} + > + {option} +
+ ))} +
+ )}
- + + {isLoading && isFetching && items.length > 0 && ( +
+ +
+ )} + {isError &&

에러가 발생했습니다

} + + {items.length > 0 && ( + + )} + +
+ + {!hasNextPage && items.length > 0 && ( +

마지막 할 일 입니다.

+ )}
); }; diff --git a/src/pages/myTodo/MyTodoPage.tsx b/src/pages/myTodo/MyTodoPage.tsx index ae043cb3..872adf5c 100644 --- a/src/pages/myTodo/MyTodoPage.tsx +++ b/src/pages/myTodo/MyTodoPage.tsx @@ -1,12 +1,7 @@ import { Outlet } from 'react-router-dom'; -import SidebarLayout from '@outlet/SidebarLayout.tsx'; const MyTodoPage = () => { - return ( - - - - ); + return ; }; export default MyTodoPage; diff --git a/src/pages/myTodo/components/scrap/ScrapEmptyState.tsx b/src/pages/myTodo/components/scrap/ScrapEmptyState.tsx index 3525c394..fc15f90e 100644 --- a/src/pages/myTodo/components/scrap/ScrapEmptyState.tsx +++ b/src/pages/myTodo/components/scrap/ScrapEmptyState.tsx @@ -7,7 +7,7 @@ interface Props { } const ScrapEmptyState = ({ type, onNavigate }: Props) => ( -
+
스크랩 비어 있음

스크랩한 {type === 'job' ? '채용' : '학원'} 정보가 아직 없어요!