Skip to content
6 changes: 5 additions & 1 deletion src/common/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
},
];

Expand Down
79 changes: 79 additions & 0 deletions src/hook/community/query/useCommunityGetTodo.ts
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}` } } : {}),
});
Comment on lines +46 to +49
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

빈 문자열 파라미터 전송 방지 (필터 무효화 이슈 예방)

jobName/level이 빈 문자열일 때도 그대로 쿼리에 붙어 서버가 “빈 문자열”로 필터링할 수 있습니다. 미설정인 경우 파라미터 자체를 제거하세요.

-  const res = await api.get('/v1/community/todos', {
-    params: { jobName, level, sort, page, size },
-    ...(token ? { headers: { Authorization: `Bearer ${token}` } } : {}),
-  });
+  const params: Record<string, string | number> = { sort, page, size };
+  if (jobName) params.jobName = jobName;
+  if (level) params.level = level;
+
+  const res = await api.get('/v1/community/todos', {
+    params,
+    ...(token ? { headers: { Authorization: `Bearer ${token}` } } : {}),
+  });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const res = await api.get('/v1/community/todos', {
params: { jobName, level, sort, page, size },
...(token ? { headers: { Authorization: `Bearer ${token}` } } : {}),
});
// Prevent sending empty-string filters, which could invalidate server-side filtering
const params: Record<string, string | number> = { sort, page, size };
if (jobName) params.jobName = jobName;
if (level) params.level = level;
const res = await api.get('/v1/community/todos', {
params,
...(token ? { headers: { Authorization: `Bearer ${token}` } } : {}),
});
🤖 Prompt for AI Agents
In src/hook/community/query/useCommunityGetTodo.ts around lines 46 to 49, the
request always includes jobName and level even when they are empty strings which
can cause the backend to treat them as explicit filters; build the params object
by only adding jobName and level when they are non-empty (e.g., check for !== ''
or truthiness) along with page, size and sort, so that empty-string values are
omitted from the query; keep the conditional Authorization header logic as-is.

const result = res.data?.data;

return {
content: result.content,
number: result.number,
size: result.size,
Comment on lines +50 to +55
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

응답 안전성 가드 추가

res.data?.data가 없거나 content가 배열이 아니면 이후 접근에서 런타임 오류가 납니다. 즉시 명확한 에러를 던져 React Query가 핸들하도록 하세요.

-  const result = res.data?.data;
+  const result = res.data?.data;
+  if (!result || !Array.isArray(result.content)) {
+    throw new Error('커뮤니티 투두 응답 형식이 올바르지 않습니다.');
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const result = res.data?.data;
return {
content: result.content,
number: result.number,
size: result.size,
const result = res.data?.data;
if (!result || !Array.isArray(result.content)) {
throw new Error('커뮤니티 투두 응답 형식이 올바르지 않습니다.');
}
return {
content: result.content,
number: result.number,
size: result.size,
🤖 Prompt for AI Agents
In src/hook/community/query/useCommunityGetTodo.ts around lines 50 to 55,
res.data?.data is used without validation which can cause runtime errors if it's
missing or if content is not an array; add an explicit guard: check that
res.data?.data exists and that Array.isArray(res.data.data.content) (or the
expected type) is true, and if not throw a clear Error (e.g. "Invalid response:
missing data.content") so React Query can handle the failure; then safely return
result.content, number, and size after the validation.

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,
});
};
51 changes: 51 additions & 0 deletions src/hook/community/useCommunityAddTodoMutation.ts
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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

쿼리 무효화 키 불일치: 리스트 갱신이 안 될 수 있습니다

조회 훅의 키는 ['CommunityGetTodo', params]인데, 무효화는 ['CommunityGetTodo', 'mdTodo', 'community']로 되어 있어 매치되지 않을 가능성이 큽니다. 프리픽스 기준으로 전체를 무효화하세요.

-      queryClient.invalidateQueries({
-        queryKey: ['CommunityGetTodo', 'mdTodo', 'community'],
-      });
+      queryClient.invalidateQueries({
+        queryKey: ['CommunityGetTodo'],
+      });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
queryClient.invalidateQueries({
queryKey: ['CommunityGetTodo', 'mdTodo', 'community'],
});
queryClient.invalidateQueries({
queryKey: ['CommunityGetTodo'],
});
🤖 Prompt for AI Agents
In src/hook/community/useCommunityAddTodoMutation.ts around lines 43 to 45, the
invalidateQueries call uses the wrong key ['CommunityGetTodo', 'mdTodo',
'community'] which won't match the reader hook's key ['CommunityGetTodo',
params]; replace the invalidate call to invalidate by the prefix used by the
query (e.g. queryClient.invalidateQueries(['CommunityGetTodo']) or otherwise
pass the same params object) so the list query is correctly invalidated and
refreshed.

},
onError: (error) => {
console.error('할일 추가 실패:', error);
},
});
};
38 changes: 38 additions & 0 deletions src/hook/community/useDeleteCommunityTodos.ts
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);
},
});
};
60 changes: 60 additions & 0 deletions src/hook/community/useInfinityScroll.ts
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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

❓ Verification inconclusive

DOM 노드 교체 시 옵저버가 새 요소를 관찰하지 않는 문제

현재 구현은 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 교체 처리
현재 useRef로 최초 요소만 observe하고 있어, sentinel이 리렌더로 교체될 때 새 요소를 관찰하지 않습니다. 이전 노드를 unobserve한 뒤 새 노드를 observe하도록 callback ref(setRef) 패턴으로 변경해야 합니다.

🤖 Prompt for AI Agents
In src/hook/community/useInfinityScroll.ts around lines 20 to 53, the hook
currently uses a static useRef for the sentinel so when that DOM node is
replaced by a re-render the new node is never observed; replace the ref with a
callback ref (setRef) that tracks the previous element, unobserves it if
present, assigns the new element, and observes the new element with the existing
IntersectionObserver logic (create or reuse observer), and ensure cleanup by
disconnecting or unobserving in the callback and in useEffect cleanup so the
hook correctly observes new sentinels on re-renders.


useEffect(() => {
if (!enabled) calledOnceRef.current = false;
}, [enabled]);

return ref;
}
88 changes: 7 additions & 81 deletions src/pages/community/Community.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex h-full w-full flex-row gap-[22px] bg-gray-50 px-[120px]">
<div className="mt-[95px] flex-col items-start">
<div className="flex w-[384px] flex-col items-start rounded-[20px] bg-white p-[30px]">
<CommunityDropdown
options={jobNames}
value={jobNames[0]}
onSelect={(value) => {
console.log(value);
}}
/>

<div className="text-black font-T02-B"> 꿈꾸는 드리머</div>

<div
className="mt-[30px] flex w-full flex-row items-center justify-end text-gray-500 font-B02-SB"
onClick={() => {
// 하드코딩 매핑된 jobId 사용
const id = findJobIdByName(selectedJobName);
navigate(`/others/${id ?? 1}`);
}}
>
전체보기
<Arrow />
</div>
</div>

<div className="mb-[102px] mt-[22px] flex w-[384px] flex-col items-start rounded-[20px] bg-white px-[30px] pb-[30px] pt-10">
<div className="text-black font-T04-B">
{' '}
{selectedJobName} HOT 할 일
</div>
<div className="mt-[37px] flex w-full flex-col items-start">
{popularTodos.map((item, idx) => (
<div
key={item.id}
className="flex w-full flex-row items-start justify-between py-4"
>
<div className="flex flex-row items-center gap-[15px]">
<div className="text-purple-500 font-T05-SB">{idx + 1}</div>
<div className="flex flex-row items-center gap-[15px]">
<img
src={item.imageUrl}
alt="프로필이미지"
className="h-[30px] w-[30px] rounded-full bg-gray-50"
/>

<div className="flex flex-col">
<div className="flex w-full flex-row items-start justify-between gap-[10px]">
<div className="flex-1 break-words text-black font-B01-SB">
{item.description}
</div>
<div className="mr-2 shrink-0 whitespace-nowrap text-gray-500 font-C01-R">
{item.dDay}
</div>
</div>
<div className="mt-1 text-gray-500 font-C01-R">
{item.name}
</div>
</div>
</div>
</div>
<div className="flex h-full w-full gap-[22px] bg-gray-50 px-[120px]">
<div className="sticky top-0 h-fit w-[384px] flex-shrink-0">
<CommunityLeftSide />
</div>

<div className="flex flex-row items-center gap-[6px] text-purple-500">
<Bookmark />
<span className="font-B03-SB">{item.saveCount}</span>
</div>
</div>
))}
</div>
</div>
<div className="flex-1">
<CommunityRightSide />
</div>
<CommunityRightSide />
</div>
);
};
Expand Down
Loading