-
Notifications
You must be signed in to change notification settings - Fork 1
[✨FEATURE] 커뮤니티 연결 #328
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[✨FEATURE] 커뮤니티 연결 #328
Changes from all commits
c6a4c53
cca7d76
c59cdba
a5bb523
5adb27c
b5e3a4a
abf1952
e14c43a
6e1325c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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<CommunityGetTodoResponse> => { | ||||||||||||||||||||||||||||||||
| 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, | ||||||||||||||||||||||||||||||||
|
Comment on lines
+50
to
+55
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion 응답 안전성 가드 추가
- const result = res.data?.data;
+ const result = res.data?.data;
+ if (!result || !Array.isArray(result.content)) {
+ throw new Error('커뮤니티 투두 응답 형식이 올바르지 않습니다.');
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||
| 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, | ||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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'], | ||||||||||||||
| }); | ||||||||||||||
|
Comment on lines
+43
to
+45
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 쿼리 무효화 키 불일치: 리스트 갱신이 안 될 수 있습니다 조회 훅의 키는 - queryClient.invalidateQueries({
- queryKey: ['CommunityGetTodo', 'mdTodo', 'community'],
- });
+ queryClient.invalidateQueries({
+ queryKey: ['CommunityGetTodo'],
+ });📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
| }, | ||||||||||||||
| onError: (error) => { | ||||||||||||||
| console.error('할일 추가 실패:', error); | ||||||||||||||
| }, | ||||||||||||||
| }); | ||||||||||||||
| }; | ||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }, | ||
| }); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<T extends HTMLElement>({ | ||
| onIntersect, | ||
| enabled = true, | ||
| root = null, | ||
| rootMargin = '200px', | ||
| threshold = 0, | ||
| once = false, | ||
| }: UseInfiniteScrollOptions) { | ||
| const ref = useRef<T | null>(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]); | ||
|
Comment on lines
+20
to
+53
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Verification agent ❓ Verification inconclusiveDOM 노드 교체 시 옵저버가 새 요소를 관찰하지 않는 문제 현재 구현은 ref.current로 최초 요소만 observe합니다. 무한스크롤 sentinel이 리렌더로 교체되면 기존 옵저버가 새 노드를 관찰하지 않아 페이징이 멈출 수 있습니다. 콜백 ref로 노드 교체를 안전하게 처리하는 패턴을 권장합니다. 반환 타입이 callback ref로 바뀌므로 사용처에서 ref={returnedRef} 형태인지 확인 부탁드립니다. const ref = useRef<T | null>(null);
const calledOnceRef = useRef(false);
+ const observerRef = useRef<IntersectionObserver | null>(null);
+ const targetRef = useRef<T | null>(null);
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]);
+ const setRef = useCallback((node: T | null) => {
+ // 외부에서 필요하면 접근 가능하도록 유지
+ ref.current = node;
+ if (observerRef.current && targetRef.current) {
+ observerRef.current.unobserve(targetRef.current);
+ }
+ targetRef.current = node;
+ if (!enabled || !node) return;
+ if (typeof IntersectionObserver === 'undefined') return;
+ if (!observerRef.current) {
+ observerRef.current = new IntersectionObserver(handleIntersect, {
+ root,
+ rootMargin,
+ threshold,
+ });
+ }
+ observerRef.current.observe(node);
+ }, [enabled, root, rootMargin, threshold, handleIntersect]);
+
+ useEffect(() => {
+ // enabled false로 전환 시 정리
+ if (!enabled && observerRef.current) {
+ observerRef.current.disconnect();
+ observerRef.current = null;
+ targetRef.current = null;
+ }
+ }, [enabled]);
+
+ useEffect(() => {
+ // 언마운트 정리
+ return () => {
+ if (observerRef.current) observerRef.current.disconnect();
+ };
+ }, []);
useEffect(() => {
if (!enabled) calledOnceRef.current = false;
}, [enabled]);
- return ref;
+ return setRef;필수: 콜백 ref로 sentinel 교체 처리 🤖 Prompt for AI Agents |
||
|
|
||
| useEffect(() => { | ||
| if (!enabled) calledOnceRef.current = false; | ||
| }, [enabled]); | ||
|
|
||
| return ref; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
빈 문자열 파라미터 전송 방지 (필터 무효화 이슈 예방)
jobName/level이 빈 문자열일 때도 그대로 쿼리에 붙어 서버가 “빈 문자열”로 필터링할 수 있습니다. 미설정인 경우 파라미터 자체를 제거하세요.📝 Committable suggestion
🤖 Prompt for AI Agents