Create Week08 Mission 1, 2, 3#65
Conversation
📝 WalkthroughWalkthrough3개의 거의 동일한 Vite + React 프로젝트(Mission1, 2, 3)를 추가. 각각 Axios API 클라이언트(auth 토큰 인터셉터), React Context 인증, React Query 데이터 조회, TypeScript, Tailwind를 포함. LP 게시판(목록/상세/댓글/검색)과 사용자 관리(로그인/가입/프로필) 기능 구현. Changes3개 Vite React 풀 스택 프로젝트 (Mission1, Mission2, Mission3)
예상 코드 리뷰 노력🎯 4 (Complex) | ⏱️ ~60 분 관련 가능성 있는 PR
제안 검토자
Poem
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
|
There was a problem hiding this comment.
Actionable comments posted: 18
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
🟡 Minor comments (15)
Week08/SJ01-max/Mission3/src/hooks/useSidebar.ts-21-25 (1)
21-25:⚠️ Potential issue | 🟡 Minor | ⚡ Quick win기존 body overflow 값을 보존해서 복원해 주세요.
현재 구현은 사이드바가 닫히거나 언마운트될 때
document.body.style.overflow를 빈 문자열로 고정 복원합니다. 다른 컴포넌트가 기존에 설정한 overflow 값을 덮어쓸 수 있습니다.수정 예시
useEffect(() => { - document.body.style.overflow = isOpen ? 'hidden' : '' + const prevOverflow = document.body.style.overflow + document.body.style.overflow = isOpen ? 'hidden' : prevOverflow return () => { - document.body.style.overflow = '' + document.body.style.overflow = prevOverflow } }, [isOpen])🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Week08/SJ01-max/Mission3/src/hooks/useSidebar.ts` around lines 21 - 25, In the useEffect inside the useSidebar hook, preserve and restore the existing document.body.style.overflow instead of always resetting to '' — capture the current value (e.g., const previousOverflow = document.body.style.overflow) before you set overflow based on isOpen, set overflow to 'hidden' when isOpen is true, and on cleanup (and when closing) restore document.body.style.overflow = previousOverflow so other components' overflow settings are not lost; modify the useEffect that references isOpen/document.body.style.overflow accordingly.Week08/SJ01-max/Mission3/src/pages/LoginPage.tsx-11-11 (1)
11-11:⚠️ Potential issue | 🟡 Minor | ⚡ Quick win타입 단언이 불완전합니다.
Location타입이 임포트 없이 사용되었습니다. 명시적으로{ pathname: string }형태의 타입을 정의하거나, 올바른 타입을 임포트해야 합니다.🔧 타입 수정 제안
- const from = (location.state as { from?: Location })?.from?.pathname ?? '/' + const from = (location.state as { from?: { pathname: string } })?.from?.pathname ?? '/'🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Week08/SJ01-max/Mission3/src/pages/LoginPage.tsx` at line 11, The cast for location.state is using an undefined Location type; update the type assertion in LoginPage by either importing the correct Location type (e.g., import { Location } from 'history') and using (location.state as { from?: Location }) or replace it with an explicit shape such as (location.state as { from?: { pathname: string } }) so that the expression const from = (location.state as { from?: ... })?.from?.pathname ?? '/' is properly typed; adjust imports or the inline type accordingly in LoginPage.tsx (refer to the const from declaration).Week08/SJ01-max/Mission3/src/pages/LpCreatePage.tsx-25-26 (1)
25-26:⚠️ Potential issue | 🟡 Minor | ⚡ Quick win에러 로깅을 추가하여 디버깅을 개선하세요.
catch 블록에서 에러 객체를 사용하지 않고 있습니다. 개발 중 문제 해결을 위해 에러를 콘솔에 로깅하는 것이 좋습니다.
🐛 에러 로깅 추가 제안
- } catch { + } catch (err) { + console.error('LP 생성 실패:', err) setError('LP 생성에 실패했습니다.') } finally {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Week08/SJ01-max/Mission3/src/pages/LpCreatePage.tsx` around lines 25 - 26, The catch block in LpCreatePage.tsx currently swallows errors; update the catch to accept the error (e.g., catch (error)) and log it for debugging, e.g., call console.error('LP 생성 실패:', error) (while keeping setError('LP 생성에 실패했습니다.')) so the error object is preserved and visible; locate the catch surrounding the async LP creation logic and the setError call to implement this change.Week08/SJ01-max/Mission1/src/pages/MyPage.tsx-32-35 (1)
32-35:⚠️ Potential issue | 🟡 Minor | ⚡ Quick win소개(bio) 필드를 빈 값으로 초기화할 수 없습니다.
bio.trim()이 참일 때만 payload에 포함되므로, 사용자가 기존 소개를 삭제하고 빈 값으로 저장하려고 해도 API 요청에bio필드가 포함되지 않아 변경되지 않습니다.🐛 빈 문자열도 전송하도록 수정
const payload: { name?: string; bio?: string; avatar?: string | null } = {} if (name.trim()) payload.name = name.trim() - if (bio.trim()) payload.bio = bio.trim() + payload.bio = bio.trim() payload.avatar = avatar또는 사용자가 명시적으로 삭제했는지 확인하려면:
const payload: { name?: string; bio?: string; avatar?: string | null } = {} if (name.trim()) payload.name = name.trim() - if (bio.trim()) payload.bio = bio.trim() + // bio는 항상 포함 (빈 문자열 또는 내용) + payload.bio = bio.trim() || '' payload.avatar = avatar🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Week08/SJ01-max/Mission1/src/pages/MyPage.tsx` around lines 32 - 35, The payload construction currently only includes bio when bio.trim() is truthy so deleting an existing bio doesn't send an empty string; change the payload logic in the save handler (the block that defines payload, name, bio, avatar) to always set payload.bio = bio.trim() (or otherwise explicitly include bio when the user cleared the field) instead of conditionally adding it, so the API receives an empty string to clear the bio; keep the existing conditional behaviour for name if desired and still set payload.avatar = avatar as before.Week08/SJ01-max/Mission3/src/pages/LpListPage.tsx-182-188 (1)
182-188:⚠️ Potential issue | 🟡 Minor | ⚡ Quick win테스트용 더미 텍스트는 본 화면에서 제거하는 편이 좋습니다.
Line 182-188의 placeholder 콘텐츠는 실제 목록 UX를 방해하고 스크롤 트리거 동작을 오해하게 만들 수 있습니다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Week08/SJ01-max/Mission3/src/pages/LpListPage.tsx` around lines 182 - 188, Remove the temporary placeholder scroll content in the LpListPage component: delete the JSX block that renders the "스크롤 테스트용 텍스트입니다." paragraph and the Array.from({ length: 30 }).map(...) loop (the dummy <div className="mt-8 space-y-4 text-gray-600 text-sm"> block) so the real list UX and scroll-trigger behavior are not obscured; if you need to keep it for local testing, wrap it behind a DEV flag or conditional (e.g., process.env.NODE_ENV === 'development' or a isDev prop) instead of rendering it unconditionally.Week08/SJ01-max/Mission2/src/pages/LpCreatePage.tsx-13-23 (1)
13-23:⚠️ Potential issue | 🟡 Minor | ⚡ Quick win공백-only 입력이 그대로 등록될 수 있습니다.
required만으로는" "같은 값이 통과됩니다. Line 13 이후 제출 전에trim()검증을 추가해 빈 제목/내용 등록을 막아주세요.수정 예시
const handleSubmit = async (e: FormEvent) => { e.preventDefault() setError('') + const normalizedTitle = title.trim() + const normalizedContent = content.trim() + if (!normalizedTitle || !normalizedContent) { + setError('제목과 내용을 입력해주세요.') + return + } setLoading(true) try { const { data } = await apiClient.post('/lps', { - title, - content, + title: normalizedTitle, + content: normalizedContent, thumbnail: thumbnail || null, published: true, })Also applies to: 41-64
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Week08/SJ01-max/Mission2/src/pages/LpCreatePage.tsx` around lines 13 - 23, The form allows inputs that are only whitespace to be submitted; update the submit validation in the handleSubmit async function to trim title and content and reject empty strings before calling apiClient.post: obtain trimmedTitle = title.trim() and trimmedContent = content.trim(), if either is empty setError('제목/내용을 입력하세요') (or similar), setLoading(false) and return early; apply the same trim-and-empty check to the other submit handler referenced around lines 41-64 so neither handler sends whitespace-only title/content to the server.Week08/SJ01-max/Mission3/src/pages/MyPage.tsx-60-65 (1)
60-65:⚠️ Potential issue | 🟡 Minor | ⚡ Quick win아바타 미리보기 Object URL 정리가 필요합니다.
Line 64에서 생성한 Object URL을 해제하지 않아 파일을 여러 번 바꾸면 메모리가 누적될 수 있습니다.
수정 예시
+import { useEffect, useRef, useState } from 'react' @@ const [avatarPreview, setAvatarPreview] = useState<string | null>(user?.avatar ?? null) + const previewUrlRef = useRef<string | null>(null) @@ const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => { const f = e.target.files?.[0] if (!f) return setAvatarFile(f) - setAvatarPreview(URL.createObjectURL(f)) + if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current) + const nextUrl = URL.createObjectURL(f) + previewUrlRef.current = nextUrl + setAvatarPreview(nextUrl) } + + useEffect(() => { + return () => { + if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current) + } + }, [])🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Week08/SJ01-max/Mission3/src/pages/MyPage.tsx` around lines 60 - 65, handleAvatarChange currently creates an object URL with URL.createObjectURL(f) and assigns it to avatarPreview without revoking prior URLs; update handleAvatarChange to revoke the previous preview URL (call URL.revokeObjectURL(prevPreview) if prevPreview exists) before creating a new one, and ensure you add a cleanup (useEffect return) that revokes the final avatarPreview when the component unmounts so no object URLs leak; reference handleAvatarChange, setAvatarPreview, avatarPreview, and URL.createObjectURL/URL.revokeObjectURL when making the changes.Week08/SJ01-max/Mission2/src/components/LpCreateModal.tsx-1-1 (1)
1-1:⚠️ Potential issue | 🟡 Minor | ⚡ Quick win파일 미리보기 Object URL 정리가 없어 메모리 누수가 발생할 수 있습니다.
Line 66의
URL.createObjectURL결과를 해제하지 않아, 파일을 여러 번 변경하면 브라우저 메모리가 누적됩니다.🧹 제안 수정안
-import { useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' @@ const queryClient = useQueryClient() const fileInputRef = useRef<HTMLInputElement>(null) + const objectUrlRef = useRef<string | null>(null) @@ const [tags, setTags] = useState<string[]>(editLp?.tags.map((t) => t.name) ?? []) + + useEffect(() => { + return () => { + if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current) + } + }, []) @@ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const f = e.target.files?.[0] if (!f) return + if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current) + const objectUrl = URL.createObjectURL(f) + objectUrlRef.current = objectUrl setFile(f) - setPreview(URL.createObjectURL(f)) + setPreview(objectUrl) }Also applies to: 34-37, 62-67
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Week08/SJ01-max/Mission2/src/components/LpCreateModal.tsx` at line 1, The file preview created by URL.createObjectURL is never revoked, causing memory leaks; update the file-selection and cleanup logic (e.g., in the handler that calls URL.createObjectURL — likely handleFileChange or wherever setPreview is used — and the component-level cleanup) to revoke previous object URLs using URL.revokeObjectURL before replacing them and also revoke any remaining object URL in a useEffect cleanup on unmount; use a ref (e.g., prevObjectUrlRef) to track the current object URL so you can call URL.revokeObjectURL(prevObjectUrlRef.current) before setting a new preview and clear the ref after revocation.Week08/SJ01-max/Mission3/src/components/LpCreateModal.tsx-62-67 (1)
62-67:⚠️ Potential issue | 🟡 Minor | ⚡ Quick win프리뷰 blob URL 해제 누락으로 메모리 누수 위험이 있습니다.
Line 66에서 생성한 object URL을 정리하지 않아 이미지 교체가 반복되면 누수가 누적됩니다.
useEffect를 추가하여 preview 상태가 변경될 때마다 이전 blob URL을URL.revokeObjectURL()로 해제하거나,handleFileChange에서 새로운 URL 생성 전 이전 URL을 정리하는 것이 필요합니다.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Week08/SJ01-max/Mission3/src/components/LpCreateModal.tsx` around lines 62 - 67, The preview blob URL created in handleFileChange is not revoked, causing memory leaks; modify the component to revoke the previous URL whenever preview changes and on unmount. Specifically, either: (1) in handleFileChange, call URL.revokeObjectURL(preview) before creating a new URL and then setPreview(URL.createObjectURL(f)), or (2) add a useEffect that watches preview and on cleanup calls URL.revokeObjectURL(oldPreview) and also returns a cleanup to revoke the current preview on unmount; reference the handleFileChange function and the preview state/setPreview when implementing this fix.Week08/SJ01-max/Mission1/src/pages/LpDetailPage.tsx-124-129 (1)
124-129:⚠️ Potential issue | 🟡 Minor | ⚡ Quick win썸네일 프리뷰 blob URL을 정리해야 합니다.
Line 128에서 생성하는 blob URL을 해제하지 않아 썸네일을 반복 변경할 때마다 메모리 누수가 발생합니다. 새로운 파일을 선택하기 전에 기존 blob URL을
URL.revokeObjectURL()로 정리하고, 편집 취소 시에도 마찬가지로 정리해야 합니다.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Week08/SJ01-max/Mission1/src/pages/LpDetailPage.tsx` around lines 124 - 129, The preview blob URL created in handleFileChange (which calls setEditPreview(URL.createObjectURL(f))) must be revoked to avoid memory leaks: before creating a new blob URL, revoke the previous one stored in editPreview via URL.revokeObjectURL(editPreview); likewise revoke the current editPreview when clearing the edit (e.g., in your cancel/clear handler that calls setEditFile(null) or setEditPreview(null)) and also add a useEffect cleanup that revokes editPreview on component unmount; update the handlers and add the cleanup to ensure every created blob URL is revoked.Week08/SJ01-max/Mission2/src/pages/MyPage.tsx-60-65 (1)
60-65:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winBlob URL 해제가 없어 프리뷰 교체 시 메모리 누수가 발생합니다.
Line 64에서
URL.createObjectURL(f)로 생성한 object URL을 해제하지 않으면, 사용자가 파일을 여러 번 변경할 때마다 blob URL이 메모리에 누적되고, 컴포넌트 언마운트 시에도 정리되지 않습니다.수정 예시
-import { useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' ... export default function MyPage() { const { user, updateUser } = useAuth() const [isEditing, setIsEditing] = useState(false) const [name, setName] = useState(user?.name ?? '') const [bio, setBio] = useState(user?.bio ?? '') const [avatarFile, setAvatarFile] = useState<File | null>(null) const [avatarPreview, setAvatarPreview] = useState<string | null>(user?.avatar ?? null) const fileInputRef = useRef<HTMLInputElement>(null) + const objectUrlRef = useRef<string | null>(null) ... const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => { const f = e.target.files?.[0] if (!f) return setAvatarFile(f) - setAvatarPreview(URL.createObjectURL(f)) + if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current) + const nextUrl = URL.createObjectURL(f) + objectUrlRef.current = nextUrl + setAvatarPreview(nextUrl) } + + useEffect(() => { + return () => { + if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current) + } + }, [])🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Week08/SJ01-max/Mission2/src/pages/MyPage.tsx` around lines 60 - 65, The avatar preview uses URL.createObjectURL in handleAvatarChange (setting state via setAvatarPreview) but never revokes previous blob URLs; update handleAvatarChange to revoke the prior preview URL (if any) via URL.revokeObjectURL before creating a new one, and add a cleanup (e.g., in a useEffect cleanup or componentWillUnmount equivalent) to revoke the current avatarPreview when the component unmounts to avoid memory leaks.Week08/SJ01-max/Mission1/src/components/LpCreateModal.tsx-62-67 (1)
62-67:⚠️ Potential issue | 🟡 Minor | ⚡ Quick win
URL.createObjectURL로 생성된 객체 URL 메모리 누수.LpDetailPage와 동일한 문제입니다. 이전 프리뷰 URL을 해제하지 않으면 메모리 누수가 발생합니다.
🔧 수정 방법
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const f = e.target.files?.[0] if (!f) return setFile(f) + if (preview) URL.revokeObjectURL(preview) setPreview(URL.createObjectURL(f)) }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Week08/SJ01-max/Mission1/src/components/LpCreateModal.tsx` around lines 62 - 67, handleFileChange creates object URLs with URL.createObjectURL but never revokes previous ones, causing a memory leak; update handleFileChange to store the previous preview value and call URL.revokeObjectURL(prev) before calling setPreview(URL.createObjectURL(f)), and add a cleanup useEffect that revokes the current preview on unmount (referencing handleFileChange and the preview state/setPreview) so any remaining object URL is released when the component unmounts.Week08/SJ01-max/Mission3/src/pages/LpDetailPage.tsx-124-129 (1)
124-129:⚠️ Potential issue | 🟡 Minor | ⚡ Quick win
URL.createObjectURL로 생성된 객체 URL을 해제하지 않아 메모리 누수가 발생합니다.파일 선택 시마다 새 객체 URL이 생성되지만 이전 URL이 해제되지 않습니다. 컴포넌트 언마운트 또는 새 파일 선택 시
URL.revokeObjectURL을 호출해야 합니다.🔧 메모리 누수 수정
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const f = e.target.files?.[0] if (!f) return setEditFile(f) + if (editPreview) URL.revokeObjectURL(editPreview) setEditPreview(URL.createObjectURL(f)) }또한 컴포넌트 언마운트 시 정리를 위해
useEffect를 추가하세요:useEffect(() => { return () => { if (editPreview) URL.revokeObjectURL(editPreview) } }, [editPreview])🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Week08/SJ01-max/Mission3/src/pages/LpDetailPage.tsx` around lines 124 - 129, The handleFileChange handler creates object URLs with URL.createObjectURL but never revokes them, causing leaks; update handleFileChange to revoke any existing preview URL before calling setEditPreview (i.e., if editPreview exists call URL.revokeObjectURL(editPreview) then setEditPreview(newUrl)), and add a useEffect cleanup that revokes editPreview on unmount (useEffect(() => () => { if (editPreview) URL.revokeObjectURL(editPreview) }, [editPreview])); reference handleFileChange, setEditFile, setEditPreview, and the new useEffect cleanup to implement this.Week08/SJ01-max/Mission1/src/components/LpCreateModal.tsx-133-134 (1)
133-134:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winEnter 키로 태그 추가 시 기본 폼 동작을 방지해야 합니다.
Enter 키를 눌렀을 때 폼이 의도치 않게 제출될 수 있습니다.
preventDefault()를 호출하세요.🔧 수정
-onKeyDown={(e) => e.key === 'Enter' && handleAddTag()} +onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddTag() } }}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Week08/SJ01-max/Mission1/src/components/LpCreateModal.tsx` around lines 133 - 134, The input's onKeyDown handler should prevent the default form-submit behavior when Enter is pressed: in the onKeyDown callback for the tag input (where it currently does e.key === 'Enter' && handleAddTag()), first call e.preventDefault() when e.key === 'Enter' and then invoke handleAddTag(); update the handler in LpCreateModal.tsx to use the event parameter to call preventDefault() before calling handleAddTag.Week08/SJ01-max/Mission3/src/components/SearchModal.tsx-63-72 (1)
63-72:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winIntersectionObserver가
isFetchingNextPage를 확인하지 않아 중복 요청이 발생할 수 있습니다.스크롤 시 이미 다음 페이지를 가져오는 중인데도
fetchNextPage가 다시 호출될 수 있습니다. 쓰로틀링이 적용된 Mission2와 달리 이 컴포넌트는 보호 장치가 없습니다.🔧 중복 요청 방지
useEffect(() => { const el = bottomRef.current if (!el) return const observer = new IntersectionObserver( - (entries) => { if (entries[0].isIntersecting && hasNextPage) fetchNextPage() }, + (entries) => { + if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage() + }, { threshold: 0.1 }, ) observer.observe(el) return () => observer.disconnect() -}, [hasNextPage, fetchNextPage]) +}, [hasNextPage, fetchNextPage, isFetchingNextPage])🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Week08/SJ01-max/Mission3/src/components/SearchModal.tsx` around lines 63 - 72, The IntersectionObserver callback should guard against duplicate loads by checking isFetchingNextPage before calling fetchNextPage: update the observer callback to only call fetchNextPage when entries[0].isIntersecting && hasNextPage && !isFetchingNextPage, and add isFetchingNextPage to the useEffect dependency array so the closure sees current state; locate this change around bottomRef, hasNextPage, fetchNextPage and isFetchingNextPage usage inside the useEffect/IntersectionObserver block and adjust dependencies accordingly.
🧹 Nitpick comments (11)
Week08/SJ01-max/Mission3/src/pages/SignupPage.tsx (1)
16-30: ⚡ Quick win일관성을 위해 useMutation 사용을 권장합니다.
LoginPage는useMutation을 사용하지만 이 컴포넌트는 수동으로 상태를 관리합니다. 동일한 패턴을 적용하면 코드베이스 전반의 일관성이 향상됩니다.♻️ useMutation 패턴으로 리팩토링 제안
+ import { useMutation } from '`@tanstack/react-query`' import { useState, type FormEvent } from 'react' import { Link, useNavigate } from 'react-router-dom' import { useAuth } from '../contexts/AuthContext' import { apiSignup, apiSignin } from '../api/client' export default function SignupPage() { const { login } = useAuth() const navigate = useNavigate() const [name, setName] = useState('') const [email, setEmail] = useState('') const [password, setPassword] = useState('') - const [error, setError] = useState('') - const [loading, setLoading] = useState(false) + const { mutate, isPending, error } = useMutation({ + mutationFn: async () => { + await apiSignup({ name, email, password }) + return await apiSignin({ email, password }) + }, + onSuccess: async (tokens) => { + await login(tokens) + navigate('/', { replace: true }) + }, + }) const handleSubmit = async (e: FormEvent) => { e.preventDefault() - setError('') - setLoading(true) - try { - await apiSignup({ name, email, password }) - const tokens = await apiSignin({ email, password }) - await login(tokens) - navigate('/', { replace: true }) - } catch { - setError('회원가입에 실패했습니다. 이미 사용 중인 이메일일 수 있습니다.') - } finally { - setLoading(false) - } + mutate() }그리고 JSX에서:
- {error && <p className="text-center text-sm text-red-400">{error}</p>} + {error && <p className="text-center text-sm text-red-400">회원가입에 실패했습니다. 이미 사용 중인 이메일일 수 있습니다.</p>} <button type="submit" - disabled={loading} + disabled={isPending} className="rounded-lg bg-pink-500 py-3 text-sm font-semibold text-white hover:bg-pink-400 disabled:opacity-50" > - {loading ? '처리 중...' : '회원가입'} + {isPending ? '처리 중...' : '회원가입'} </button>🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Week08/SJ01-max/Mission3/src/pages/SignupPage.tsx` around lines 16 - 30, Replace the manual handleSubmit state management with React Query's useMutation: create a signup mutation (calling apiSignup) and a signin mutation (calling apiSignin) or chain them inside the signup mutation's onSuccess; in signup's onSuccess call the signin mutation or call login(tokens) after receiving tokens, then call navigate('/', { replace: true }); remove manual setLoading and setError usage in handleSubmit and instead derive loading from mutation.isLoading and set error via mutation.onError (or by reading mutation.error) so the component uses useMutation for apiSignup/apiSignin and the existing functions login and navigate.Week08/SJ01-max/Mission1/src/pages/LpCreatePage.tsx (1)
13-30: ⚡ Quick win일관성을 위해 useMutation 사용을 권장합니다.
다른 페이지들(예: LoginPage)은
@tanstack/react-query의useMutation을 사용하지만, 이 컴포넌트는 수동으로loading과error상태를 관리합니다. 일관된 패턴을 유지하면 코드 유지보수성이 향상됩니다.♻️ useMutation 패턴으로 리팩토링 제안
+ import { useMutation } from '`@tanstack/react-query`' import { useState, type FormEvent } from 'react' import { useNavigate } from 'react-router-dom' import { apiClient } from '../api/client' export default function LpCreatePage() { const navigate = useNavigate() const [title, setTitle] = useState('') const [content, setContent] = useState('') const [thumbnail, setThumbnail] = useState('') - const [loading, setLoading] = useState(false) - const [error, setError] = useState('') + const { mutate, isPending, error } = useMutation({ + mutationFn: () => apiClient.post('/lps', { + title, + content, + thumbnail: thumbnail || null, + published: true, + }), + onSuccess: ({ data }) => { + navigate(`/lp/${data.data.id}`, { replace: true }) + }, + }) const handleSubmit = async (e: FormEvent) => { e.preventDefault() - setError('') - setLoading(true) - try { - const { data } = await apiClient.post('/lps', { - title, - content, - thumbnail: thumbnail || null, - published: true, - }) - navigate(`/lp/${data.data.id}`, { replace: true }) - } catch { - setError('LP 생성에 실패했습니다.') - } finally { - setLoading(false) - } + mutate() }그리고 JSX에서:
- {error && <p className="text-center text-sm text-red-400">{error}</p>} + {error && <p className="text-center text-sm text-red-400">LP 생성에 실패했습니다.</p>} <button type="submit" - disabled={loading} + disabled={isPending} className="rounded-lg bg-pink-500 py-3 text-sm font-semibold text-white hover:bg-pink-400 disabled:opacity-50" > - {loading ? '생성 중...' : 'LP 만들기'} + {isPending ? '생성 중...' : 'LP 만들기'} </button>🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Week08/SJ01-max/Mission1/src/pages/LpCreatePage.tsx` around lines 13 - 30, Replace the manual loading/error state in LpCreatePage's handleSubmit with a react-query useMutation: create a mutation (using useMutation from `@tanstack/react-query`) that calls apiClient.post('/lps', { title, content, thumbnail: thumbnail || null, published: true }); move navigation into the mutation's onSuccess handler (call navigate(`/lp/${data.data.id}`, { replace: true })), set the error message in onError (call setError('LP 생성에 실패했습니다.') or use mutation.error), and use mutation.isLoading and mutation.error in the JSX instead of setLoading/setError; remove the try/catch/finally and the local loading state management in handleSubmit so handleSubmit simply calls mutation.mutate with the payload.Week08/SJ01-max/Mission2/src/contexts/AuthContext.tsx (1)
46-54: ⚡ Quick win로그인 실패 시 원본 에러 정보가 손실됩니다.
apiGetMe()가 실패했을 때 원본 에러를 무시하고 새로운 일반 에러 메시지를 던지고 있습니다. 이는 디버깅을 어렵게 만들며, 실제 실패 원인(네트워크 오류, 401 인증 실패 등)을 파악할 수 없게 합니다.♻️ 원본 에러를 포함하도록 개선
const login = useCallback(async (tokens: AuthTokens) => { localStorage.setItem(AUTH_TOKENS_KEY, JSON.stringify(tokens)) try { setUser(await apiGetMe()); setStatus('authenticated') - } catch { + } catch (error) { localStorage.removeItem(AUTH_TOKENS_KEY); setStatus('unauthenticated') - throw new Error('사용자 정보를 가져오는 데 실패했습니다.') + throw new Error('사용자 정보를 가져오는 데 실패했습니다.', { cause: error }) } }, [])🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Week08/SJ01-max/Mission2/src/contexts/AuthContext.tsx` around lines 46 - 54, The login function currently swallows the original failure from apiGetMe(); change the catch block to capture the error (catch (err)), still remove AUTH_TOKENS_KEY and setStatus('unauthenticated'), and then rethrow the original error or throw a new Error that includes the original error message/stack (e.g., include err.message or use error cause) so callers and logs can see the underlying failure; update the login function, referencing login, apiGetMe, AUTH_TOKENS_KEY, setUser, and setStatus accordingly.Week08/SJ01-max/Mission1/src/pages/MyPage.tsx (2)
26-37: ⚡ Quick win아바타 업로드 실패 시 사용자 피드백이 없습니다.
30번 라인에서 아바타 업로드가 실패해도 에러가 조용히 무시됩니다. 사용자는 새 이미지를 선택했는데 실제로는 업로드되지 않았다는 사실을 알 수 없습니다.
♻️ 업로드 실패 알림 추가
const { mutate, isPending, error } = useMutation({ mutationFn: async () => { let avatar = user?.avatar ?? null + let avatarUploadFailed = false if (avatarFile) { - try { avatar = await apiUploadFile(avatarFile) } catch { /* 업로드 실패 시 유지 */ } + try { + avatar = await apiUploadFile(avatarFile) + } catch { + avatarUploadFailed = true + } } const payload: { name?: string; bio?: string; avatar?: string | null } = {} if (name.trim()) payload.name = name.trim() if (bio.trim()) payload.bio = bio.trim() payload.avatar = avatar - return apiUpdateMe(payload) + const result = await apiUpdateMe(payload) + if (avatarUploadFailed) { + alert('프로필 정보는 저장되었으나 이미지 업로드에 실패했습니다.') + } + return result },🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Week08/SJ01-max/Mission1/src/pages/MyPage.tsx` around lines 26 - 37, The avatar upload error is currently swallowed inside the useMutation's mutationFn (apiUploadFile called with avatarFile), so users get no feedback when upload fails; update the catch to surface the failure by either setting a component-level error/notification state (e.g., setUploadError or a toast) or rethrowing the caught error so useMutation's error becomes populated (mutate / error can then show a message), and ensure the payload.avatar remains correct only when upload succeeded; reference mutationFn, apiUploadFile, avatarFile, payload, apiUpdateMe, mutate and error when making the change.
145-154: 💤 Low value저장 버튼이 중복되어 있습니다.
이름 입력 필드 내부의 체크 아이콘 버튼(145-154번 라인)과 하단의 "저장" 버튼(180-190번 라인) 모두
mutate()를 호출합니다. 두 버튼이 같은 동작을 수행하여 사용자에게 혼란을 줄 수 있습니다.체크 아이콘 버튼을 제거하고 하단의 저장/취소 버튼만 남기는 것을 고려해보세요.
Also applies to: 180-190
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Week08/SJ01-max/Mission1/src/pages/MyPage.tsx` around lines 145 - 154, The inline SVG "check" button that calls mutate() (the button with onClick={() => mutate()} containing the <svg> polyline) is duplicating the lower "저장" save button behavior; remove that inline check-icon button from the name input area so only the bottom Save/Cancel controls invoke mutate(), or if you prefer keep it, change its handler to only perform local validation and not call mutate(); update any related disabled/isPending props or aria/title attributes accordingly to ensure consistent UX.Week08/SJ01-max/Mission2/src/hooks/useLocalStorage.ts (1)
19-31: ⚡ Quick win함수 타입 검사는
typeof를 사용하는 것이 더 안전합니다.21번 라인의
value instanceof Function은 일부 엣지 케이스(다른 실행 컨텍스트의 함수 등)에서 예상대로 동작하지 않을 수 있습니다.typeof value === 'function'을 사용하는 것이 더 안정적입니다.♻️ typeof로 변경
const setValue: SetValue<T> = (value) => { try { - const next = value instanceof Function ? value(state) : value + const next = typeof value === 'function' ? value(state) : value setState(next as T) if (next == null) {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Week08/SJ01-max/Mission2/src/hooks/useLocalStorage.ts` around lines 19 - 31, The conditional in the setValue function uses "value instanceof Function", which can fail across different execution contexts; update the check inside setValue (in the useLocalStorage hook) to use "typeof value === 'function'" when deciding whether to call the updater with state, then proceed to setState and localStorage as before; keep the rest of setValue's try/catch and null-check logic unchanged.Week08/SJ01-max/Mission3/src/contexts/AuthContext.tsx (1)
46-54: ⚡ Quick win로그인 실패 시 원본 에러 정보가 손실됩니다.
apiGetMe()가 실패했을 때 원본 에러를 무시하고 새로운 일반 에러 메시지를 던지고 있습니다. 이는 디버깅을 어렵게 만들며, 실제 실패 원인(네트워크 오류, 401 인증 실패 등)을 파악할 수 없게 합니다.♻️ 원본 에러를 포함하도록 개선
const login = useCallback(async (tokens: AuthTokens) => { localStorage.setItem(AUTH_TOKENS_KEY, JSON.stringify(tokens)) try { setUser(await apiGetMe()); setStatus('authenticated') - } catch { + } catch (error) { localStorage.removeItem(AUTH_TOKENS_KEY); setStatus('unauthenticated') - throw new Error('사용자 정보를 가져오는 데 실패했습니다.') + throw new Error('사용자 정보를 가져오는 데 실패했습니다.', { cause: error }) } }, [])🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Week08/SJ01-max/Mission3/src/contexts/AuthContext.tsx` around lines 46 - 54, The catch block in the login callback discards the original error from apiGetMe; update the catch to capture the error (e.g., catch (err) or (error)), include the original error details when removing tokens and setting status, and rethrow a wrapped error or augment the thrown Error with the original message or cause so debugging info is preserved; locate the login function (useCallback async (tokens: AuthTokens) { ... }) and change the catch accordingly while keeping the existing localStorage removal, setStatus('unauthenticated'), and thrown error behavior.Week08/SJ01-max/Mission3/src/pages/LpDetailPage.tsx (2)
78-93: ⚡ Quick win업로드 실패 시 사용자에게 피드백이 없습니다.
apiUploadFile실패 시 조용히 무시되어 사용자는 썸네일이 변경되지 않은 이유를 알 수 없습니다. 최소한 실패 알림을 제공하는 것이 좋습니다.♻️ 에러 처리 개선 예시
const updateMutation = useMutation({ mutationFn: async () => { let thumbnail = lp?.thumbnail ?? null if (editFile) { - try { thumbnail = await apiUploadFile(editFile) } catch { /* 유지 */ } + try { + thumbnail = await apiUploadFile(editFile) + } catch (err) { + console.error('썸네일 업로드 실패:', err) + // 선택적: toast 알림 등으로 사용자에게 알림 + } } return apiUpdateLp(id, { title: editTitle.trim() || lp?.title, thumbnail }) },🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Week08/SJ01-max/Mission3/src/pages/LpDetailPage.tsx` around lines 78 - 93, The mutation quietly swallows errors from apiUploadFile so users get no feedback; update the try/catch inside the mutationFn of updateMutation to catch the error from apiUploadFile(editFile), surface a user-visible notification (e.g., call your toast/alert function or set an error state) and handle control flow (either rethrow to abort apiUpdateLp or continue with the existing thumbnail) depending on desired behavior; reference the updateMutation mutationFn, the editFile variable, apiUploadFile, and apiUpdateLp when making the change so the upload error is logged/notified and the UI reflects the failure.
47-59: 💤 Low value스크롤 초기화 로직이 과도하게 복잡합니다.
requestAnimationFrame,setTimeout(0),setTimeout(100)의 조합은 불필요하게 복잡합니다. 대부분의 경우 단일 호출로 충분합니다.♻️ 간소화된 스크롤 초기화
useLayoutEffect(() => { - const resetScroll = () => { - window.scrollTo(0, 0) - document.documentElement.scrollTop = 0 - document.body.scrollTop = 0 - } - resetScroll() - requestAnimationFrame(() => { - resetScroll() - setTimeout(resetScroll, 0) - setTimeout(resetScroll, 100) - }) + window.scrollTo({ top: 0, behavior: 'instant' }) }, [lpid])🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Week08/SJ01-max/Mission3/src/pages/LpDetailPage.tsx` around lines 47 - 59, The scroll-reset logic inside the useLayoutEffect (function resetScroll referenced in the effect) is overly complex (uses requestAnimationFrame and two setTimeouts); simplify by removing requestAnimationFrame and both setTimeout calls and call a single, reliable scroll reset (e.g., window.scrollTo(0, 0) and optionally set document.documentElement.scrollTop/document.body.scrollTop) once when lpid changes — keep the effect dependency on lpid and remove the extra timing wrappers to make the code deterministic and readable.Week08/SJ01-max/Mission3/src/components/SearchModal.tsx (1)
80-82: 💤 Low value불필요한 래퍼 함수입니다.
handleSearch는 단순히setQuery를 호출하는 것 외에 아무것도 하지 않습니다. 직접setQuery를 사용하세요.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Week08/SJ01-max/Mission3/src/components/SearchModal.tsx` around lines 80 - 82, The handleSearch wrapper in SearchModal.tsx is unnecessary because it only calls setQuery; remove the handleSearch function and replace any references/callbacks that pass handleSearch with setQuery directly (e.g., event handlers, child prop bindings) so components call setQuery(...) instead of handleSearch(...), and delete the now-unused handleSearch declaration to avoid dead code and linter warnings.Week08/SJ01-max/Mission2/src/api/client.ts (1)
19-22: 💤 Low value하드코딩된 baseURL을 환경 변수로 변경하는 것을 권장합니다.
프로덕션 배포 시
http://localhost:8000/v1은 동작하지 않습니다. Vite의 환경 변수를 활용하세요.♻️ 환경 변수 사용 예시
export const apiClient = axios.create({ - baseURL: 'http://localhost:8000/v1', + baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/v1', withCredentials: true, })🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Week08/SJ01-max/Mission2/src/api/client.ts` around lines 19 - 22, 현재 apiClient axios.create에서 baseURL이 하드코딩('http://localhost:8000/v1')되어 있어 프로덕션에서 동작하지 않습니다; apiClient 생성 코드에서 baseURL을 Vite 환경변수(예: import.meta.env.VITE_API_BASE 또는 VITE_API_BASE + '/v1')를 사용하도록 교체하고, 환경변수가 없을 경우를 대비한 안전한 기본값(fallback)을 설정하세요 so that axios.create({ baseURL: apiBase, withCredentials: true }) uses the env var; update any related usage if needed.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: b9d08d3f-19ac-455f-b2cb-4a4e42125d3c
⛔ Files ignored due to path filters (6)
Week08/SJ01-max/Mission1/package-lock.jsonis excluded by!**/package-lock.jsonWeek08/SJ01-max/Mission1/pnpm-lock.yamlis excluded by!**/pnpm-lock.yamlWeek08/SJ01-max/Mission2/package-lock.jsonis excluded by!**/package-lock.jsonWeek08/SJ01-max/Mission2/pnpm-lock.yamlis excluded by!**/pnpm-lock.yamlWeek08/SJ01-max/Mission3/package-lock.jsonis excluded by!**/package-lock.jsonWeek08/SJ01-max/Mission3/pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (88)
Week08/SJ01-max/Mission1/.gitignoreWeek08/SJ01-max/Mission1/index.htmlWeek08/SJ01-max/Mission1/package.jsonWeek08/SJ01-max/Mission1/src/App.tsxWeek08/SJ01-max/Mission1/src/api/client.tsWeek08/SJ01-max/Mission1/src/components/CommentSection.tsxWeek08/SJ01-max/Mission1/src/components/Layout.tsxWeek08/SJ01-max/Mission1/src/components/LoginModal.tsxWeek08/SJ01-max/Mission1/src/components/LpCreateModal.tsxWeek08/SJ01-max/Mission1/src/components/SearchModal.tsxWeek08/SJ01-max/Mission1/src/components/lp/SkeletonDetail.tsxWeek08/SJ01-max/Mission1/src/contexts/AuthContext.tsxWeek08/SJ01-max/Mission1/src/hooks/useDebounce.tsWeek08/SJ01-max/Mission1/src/hooks/useLocalStorage.tsWeek08/SJ01-max/Mission1/src/index.cssWeek08/SJ01-max/Mission1/src/main.tsxWeek08/SJ01-max/Mission1/src/pages/LoginPage.tsxWeek08/SJ01-max/Mission1/src/pages/LpCreatePage.tsxWeek08/SJ01-max/Mission1/src/pages/LpDetailPage.tsxWeek08/SJ01-max/Mission1/src/pages/LpListPage.tsxWeek08/SJ01-max/Mission1/src/pages/MyPage.tsxWeek08/SJ01-max/Mission1/src/pages/SignupPage.tsxWeek08/SJ01-max/Mission1/src/types/lp.tsWeek08/SJ01-max/Mission1/src/vite-env.d.tsWeek08/SJ01-max/Mission1/tsconfig.app.jsonWeek08/SJ01-max/Mission1/tsconfig.jsonWeek08/SJ01-max/Mission1/tsconfig.node.jsonWeek08/SJ01-max/Mission1/vite.config.tsWeek08/SJ01-max/Mission2/.gitignoreWeek08/SJ01-max/Mission2/index.htmlWeek08/SJ01-max/Mission2/package.jsonWeek08/SJ01-max/Mission2/src/App.tsxWeek08/SJ01-max/Mission2/src/api/client.tsWeek08/SJ01-max/Mission2/src/components/CommentSection.tsxWeek08/SJ01-max/Mission2/src/components/Layout.tsxWeek08/SJ01-max/Mission2/src/components/LoginModal.tsxWeek08/SJ01-max/Mission2/src/components/LpCreateModal.tsxWeek08/SJ01-max/Mission2/src/components/SearchModal.tsxWeek08/SJ01-max/Mission2/src/components/lp/SkeletonDetail.tsxWeek08/SJ01-max/Mission2/src/contexts/AuthContext.tsxWeek08/SJ01-max/Mission2/src/hooks/useDebounce.tsWeek08/SJ01-max/Mission2/src/hooks/useLocalStorage.tsWeek08/SJ01-max/Mission2/src/hooks/useThrottle.tsWeek08/SJ01-max/Mission2/src/index.cssWeek08/SJ01-max/Mission2/src/main.tsxWeek08/SJ01-max/Mission2/src/pages/LoginPage.tsxWeek08/SJ01-max/Mission2/src/pages/LpCreatePage.tsxWeek08/SJ01-max/Mission2/src/pages/LpDetailPage.tsxWeek08/SJ01-max/Mission2/src/pages/LpListPage.tsxWeek08/SJ01-max/Mission2/src/pages/MyPage.tsxWeek08/SJ01-max/Mission2/src/pages/SignupPage.tsxWeek08/SJ01-max/Mission2/src/types/lp.tsWeek08/SJ01-max/Mission2/src/vite-env.d.tsWeek08/SJ01-max/Mission2/tsconfig.app.jsonWeek08/SJ01-max/Mission2/tsconfig.jsonWeek08/SJ01-max/Mission2/tsconfig.node.jsonWeek08/SJ01-max/Mission2/vite.config.tsWeek08/SJ01-max/Mission3/.gitignoreWeek08/SJ01-max/Mission3/index.htmlWeek08/SJ01-max/Mission3/package.jsonWeek08/SJ01-max/Mission3/src/App.tsxWeek08/SJ01-max/Mission3/src/api/client.tsWeek08/SJ01-max/Mission3/src/components/CommentSection.tsxWeek08/SJ01-max/Mission3/src/components/Layout.tsxWeek08/SJ01-max/Mission3/src/components/LoginModal.tsxWeek08/SJ01-max/Mission3/src/components/LpCreateModal.tsxWeek08/SJ01-max/Mission3/src/components/SearchModal.tsxWeek08/SJ01-max/Mission3/src/components/Sidebar.tsxWeek08/SJ01-max/Mission3/src/components/lp/SkeletonDetail.tsxWeek08/SJ01-max/Mission3/src/contexts/AuthContext.tsxWeek08/SJ01-max/Mission3/src/hooks/useDebounce.tsWeek08/SJ01-max/Mission3/src/hooks/useLocalStorage.tsWeek08/SJ01-max/Mission3/src/hooks/useSidebar.tsWeek08/SJ01-max/Mission3/src/hooks/useThrottle.tsWeek08/SJ01-max/Mission3/src/index.cssWeek08/SJ01-max/Mission3/src/main.tsxWeek08/SJ01-max/Mission3/src/pages/LoginPage.tsxWeek08/SJ01-max/Mission3/src/pages/LpCreatePage.tsxWeek08/SJ01-max/Mission3/src/pages/LpDetailPage.tsxWeek08/SJ01-max/Mission3/src/pages/LpListPage.tsxWeek08/SJ01-max/Mission3/src/pages/MyPage.tsxWeek08/SJ01-max/Mission3/src/pages/SignupPage.tsxWeek08/SJ01-max/Mission3/src/types/lp.tsWeek08/SJ01-max/Mission3/src/vite-env.d.tsWeek08/SJ01-max/Mission3/tsconfig.app.jsonWeek08/SJ01-max/Mission3/tsconfig.jsonWeek08/SJ01-max/Mission3/tsconfig.node.jsonWeek08/SJ01-max/Mission3/vite.config.ts
| <div | ||
| key={lp.id} | ||
| onClick={() => { | ||
| if (debouncedQuery.trim()) { | ||
| saveRecent(debouncedQuery.trim()) | ||
| setRecentSearches(getRecent()) | ||
| } | ||
| navigate(`/lps/${lp.id}`) | ||
| onClose() | ||
| }} | ||
| className="group relative aspect-square cursor-pointer overflow-hidden rounded-sm bg-gray-900" |
There was a problem hiding this comment.
검색 결과 카드가 키보드로 접근되지 않습니다.
Line 200의 클릭 가능한 div는 키보드 포커스/엔터 실행이 불가해 접근성 흐름이 막힙니다.
수정 예시
- <div
+ <button
+ type="button"
key={lp.id}
onClick={() => {
if (debouncedQuery.trim()) {
saveRecent(debouncedQuery.trim())
setRecentSearches(getRecent())
}
navigate(`/lps/${lp.id}`)
onClose()
}}
className="group relative aspect-square cursor-pointer overflow-hidden rounded-sm bg-gray-900"
>
@@
- </div>
+ </button>📝 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.
| <div | |
| key={lp.id} | |
| onClick={() => { | |
| if (debouncedQuery.trim()) { | |
| saveRecent(debouncedQuery.trim()) | |
| setRecentSearches(getRecent()) | |
| } | |
| navigate(`/lps/${lp.id}`) | |
| onClose() | |
| }} | |
| className="group relative aspect-square cursor-pointer overflow-hidden rounded-sm bg-gray-900" | |
| <button | |
| type="button" | |
| key={lp.id} | |
| onClick={() => { | |
| if (debouncedQuery.trim()) { | |
| saveRecent(debouncedQuery.trim()) | |
| setRecentSearches(getRecent()) | |
| } | |
| navigate(`/lps/${lp.id}`) | |
| onClose() | |
| }} | |
| className="group relative aspect-square cursor-pointer overflow-hidden rounded-sm bg-gray-900" |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Week08/SJ01-max/Mission1/src/components/SearchModal.tsx` around lines 200 -
210, The clickable search-result card uses a plain <div> with an onClick but is
not keyboard-accessible; replace or augment that element in SearchModal (the
mapping that renders the lp card with key={lp.id} and onClick) by making it
keyboard focusable and actionable: add tabIndex={0}, role="button" (or use a
semantic <button>/<a>), and implement an onKeyDown handler that calls the same
logic as onClick when Enter or Space is pressed; also include an accessible name
(aria-label or visible text) so screen readers can announce the card.
| const setValue: SetValue<T> = (value) => { | ||
| try { | ||
| const next = value instanceof Function ? value(state) : value | ||
| setState(next as T) | ||
| if (next == null) { | ||
| localStorage.removeItem(key) | ||
| } else { | ||
| localStorage.setItem(key, JSON.stringify(next)) | ||
| } |
There was a problem hiding this comment.
setValue의 함수형 업데이트가 stale state를 참조합니다.
Line 21에서 value(state)를 사용하면 연속 호출 시 이전 렌더의 state를 읽어 값이 유실될 수 있습니다. setState(prev => ...) 내부에서 next를 계산하도록 바꿔 주세요.
수정 예시
const setValue: SetValue<T> = (value) => {
try {
- const next = value instanceof Function ? value(state) : value
- setState(next as T)
- if (next == null) {
- localStorage.removeItem(key)
- } else {
- localStorage.setItem(key, JSON.stringify(next))
- }
+ setState((prev) => {
+ const next = value instanceof Function ? value(prev) : value
+ if (next == null) {
+ localStorage.removeItem(key)
+ return defaultValue
+ }
+ localStorage.setItem(key, JSON.stringify(next))
+ return next as T
+ })
} catch (error) {
console.error(`useLocalStorage setValue error [${key}]:`, 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.
| const setValue: SetValue<T> = (value) => { | |
| try { | |
| const next = value instanceof Function ? value(state) : value | |
| setState(next as T) | |
| if (next == null) { | |
| localStorage.removeItem(key) | |
| } else { | |
| localStorage.setItem(key, JSON.stringify(next)) | |
| } | |
| const setValue: SetValue<T> = (value) => { | |
| try { | |
| setState((prev) => { | |
| const next = value instanceof Function ? value(prev) : value | |
| if (next == null) { | |
| localStorage.removeItem(key) | |
| return defaultValue | |
| } | |
| localStorage.setItem(key, JSON.stringify(next)) | |
| return next as T | |
| }) | |
| } catch (error) { | |
| console.error(`useLocalStorage setValue error [${key}]:`, error) | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Week08/SJ01-max/Mission1/src/hooks/useLocalStorage.ts` around lines 19 - 27,
The setValue function currently calls value(state) directly which can read a
stale state; change setValue to use the functional form of setState
(setState(prev => { const next = value instanceof Function ? value(prev) :
value; if (next == null) { localStorage.removeItem(key) } else {
localStorage.setItem(key, JSON.stringify(next)) } return next as T })) so the
next value is computed from the latest prev state and localStorage is updated
inside that updater; update references to value, setState, setValue and
localStorage accordingly.
| if (editFile) { | ||
| try { thumbnail = await apiUploadFile(editFile) } catch { /* 유지 */ } | ||
| } |
There was a problem hiding this comment.
썸네일 업로드 실패를 숨기면 수정 성공 상태가 신뢰되지 않습니다.
Line 82에서 업로드 실패를 무시하고 업데이트를 계속해, 사용자가 변경한 썸네일이 반영되지 않아도 수정 성공으로 종료될 수 있습니다.
수정 예시
if (editFile) {
- try { thumbnail = await apiUploadFile(editFile) } catch { /* 유지 */ }
+ thumbnail = await apiUploadFile(editFile)
}📝 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.
| if (editFile) { | |
| try { thumbnail = await apiUploadFile(editFile) } catch { /* 유지 */ } | |
| } | |
| if (editFile) { | |
| thumbnail = await apiUploadFile(editFile) | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Week08/SJ01-max/Mission1/src/pages/LpDetailPage.tsx` around lines 81 - 83,
The current try/catch around await apiUploadFile(editFile) in LpDetailPage.tsx
(using variables editFile and thumbnail) swallows upload errors; change it so
upload failures are surfaced and stop or flag the overall save: catch the error
from apiUploadFile(editFile), record or show the error (e.g., call
setError/setToast or setUploadFailed), and abort or return from the submit
handler instead of proceeding to update the resource; alternatively, mark the
update as partial and include the upload failure in the response state so users
know the thumbnail wasn't updated.
| <div | ||
| onClick={() => navigate(`/lps/${id}`)} | ||
| className="group relative aspect-square cursor-pointer overflow-hidden rounded-sm bg-gray-900" | ||
| > |
There was a problem hiding this comment.
카드가 키보드로 접근 불가능합니다.
Line 42~45는 클릭 가능한 div라서 키보드 사용자(Enter/Space)로 상세 페이지 이동이 불가능합니다.
♿ 제안 수정안
- <div
+ <button
+ type="button"
onClick={() => navigate(`/lps/${id}`)}
className="group relative aspect-square cursor-pointer overflow-hidden rounded-sm bg-gray-900"
+ aria-label={`${title} 상세 보기`}
>
@@
- </div>
+ </button>📝 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.
| <div | |
| onClick={() => navigate(`/lps/${id}`)} | |
| className="group relative aspect-square cursor-pointer overflow-hidden rounded-sm bg-gray-900" | |
| > | |
| <button | |
| type="button" | |
| onClick={() => navigate(`/lps/${id}`)} | |
| className="group relative aspect-square cursor-pointer overflow-hidden rounded-sm bg-gray-900" | |
| aria-label={`${title} 상세 보기`} | |
| > |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Week08/SJ01-max/Mission1/src/pages/LpListPage.tsx` around lines 42 - 45, The
clickable div in LpListPage.tsx (the element with onClick={() =>
navigate(`/lps/${id}`)} and className="group relative ...") is not keyboard
accessible; replace the non-semantic div with a focusable element (preferably an
anchor <a> with href={`/lps/${id}`} or a <button>) or add accessibility
handlers: give the div role="button", tabIndex={0}, and implement onKeyDown to
trigger navigate when Enter or Space is pressed, while keeping the existing
onClick; ensure ARIA/semantic change also preserves the existing className and
behavior.
| const observer = new IntersectionObserver( | ||
| (entries) => { if (entries[0].isIntersecting && hasNextPage) fetchNextPage() }, | ||
| { threshold: 0.1 }, | ||
| ) | ||
| observer.observe(el) | ||
| return () => observer.disconnect() | ||
| }, [hasNextPage, fetchNextPage]) |
There was a problem hiding this comment.
무한 스크롤 콜백에서 중복 요청 방지가 필요합니다.
Line 94는 관찰 이벤트가 연속으로 들어올 때 fetchNextPage()를 중복 호출할 수 있습니다. isFetchingNextPage 가드를 추가해 요청 폭주를 막는 게 안전합니다.
🚦 제안 수정안
useEffect(() => {
@@
const observer = new IntersectionObserver(
- (entries) => { if (entries[0].isIntersecting && hasNextPage) fetchNextPage() },
+ (entries) => {
+ if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
+ fetchNextPage()
+ }
+ },
{ threshold: 0.1 },
)
@@
- }, [hasNextPage, fetchNextPage])
+ }, [hasNextPage, isFetchingNextPage, fetchNextPage])🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Week08/SJ01-max/Mission1/src/pages/LpListPage.tsx` around lines 93 - 99, The
IntersectionObserver callback may call fetchNextPage() multiple times when
entries fire rapidly; modify the observer callback in the LpListPage useEffect
to guard with isFetchingNextPage (and hasNextPage) so you only call
fetchNextPage() when hasNextPage is true AND isFetchingNextPage is false; locate
the observer creation where IntersectionObserver is constructed and update the
entries[0].isIntersecting branch to check isFetchingNextPage before calling
fetchNextPage(), keeping observer.observe(el) and observer.disconnect() behavior
unchanged.
| mutationFn: async () => { | ||
| let thumbnail: string | null = editLp?.thumbnail ?? null | ||
| if (file) { | ||
| try { thumbnail = await apiUploadFile(file) } catch { /* 업로드 실패 시 유지 */ } | ||
| } |
There was a problem hiding this comment.
파일 업로드 실패를 무시하면 LP 생성/수정 결과가 불완전해집니다.
Line 49에서 업로드 오류를 삼켜 요청을 계속 보내면, 사용자가 이미지를 선택했는데도 썸네일 없이 성공 처리될 수 있습니다.
수정 예시
if (file) {
- try { thumbnail = await apiUploadFile(file) } catch { /* 업로드 실패 시 유지 */ }
+ thumbnail = await apiUploadFile(file)
}📝 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.
| mutationFn: async () => { | |
| let thumbnail: string | null = editLp?.thumbnail ?? null | |
| if (file) { | |
| try { thumbnail = await apiUploadFile(file) } catch { /* 업로드 실패 시 유지 */ } | |
| } | |
| mutationFn: async () => { | |
| let thumbnail: string | null = editLp?.thumbnail ?? null | |
| if (file) { | |
| thumbnail = await apiUploadFile(file) | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Week08/SJ01-max/Mission3/src/components/LpCreateModal.tsx` around lines 46 -
50, The mutationFn currently swallows apiUploadFile errors causing the mutation
to proceed without the chosen thumbnail; change the upload error handling so
that if file is present and apiUploadFile throws, the mutation aborts and
surfaces the error instead of continuing. In practice, update mutationFn to
await apiUploadFile(file) inside a try/catch where the catch either rethrows the
error (or returns a rejected Promise) so the calling mutation handler fails, and
optionally log the error; ensure the thumbnail variable (declared as let
thumbnail: string | null = editLp?.thumbnail ?? null) is only used when upload
succeeds, and do not silently keep the old or null thumbnail on upload failure.
| if (isEdit) return apiUpdateLp(editLp.id, { title, content, thumbnail, published: true }) | ||
| return apiCreateLp({ title, content, thumbnail, tags, published: true }) | ||
| }, |
There was a problem hiding this comment.
수정 모드의 태그 변경이 실제 저장 payload에 반영되지 않습니다.
Line 111153에서 태그를 수정할 수 있는데, Line 5153의 수정 요청에는 tags가 포함되지 않아 UI와 저장 결과가 불일치합니다. 수정 모드에서는 태그 UI를 숨기거나, API가 지원하면 payload에 태그를 포함하세요.
Also applies to: 111-153
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Week08/SJ01-max/Mission3/src/components/LpCreateModal.tsx` around lines 51 -
53, The update path omits tags so edits to tags in the UI aren't persisted;
modify the isEdit branch in the submit handler to include tags in the payload
passed to apiUpdateLp (e.g., apiUpdateLp(editLp.id, { title, content, thumbnail,
tags, published: true })), and ensure the submit uses the same tags state used
by the tag UI; alternatively, if the backend doesn't accept tags on update, hide
or disable the tag UI in edit mode (check isEdit, editLp, tags, apiUpdateLp,
apiCreateLp, title, content, thumbnail symbols).
| const setValue: SetValue<T> = (value) => { | ||
| try { | ||
| const next = value instanceof Function ? value(state) : value | ||
| setState(next as T) | ||
| if (next == null) { | ||
| localStorage.removeItem(key) | ||
| } else { | ||
| localStorage.setItem(key, JSON.stringify(next)) | ||
| } |
There was a problem hiding this comment.
setValue가 stale state를 사용하고 null을 T로 강제 캐스팅합니다.
Line [21]에서 updater를 클로저 state로 계산하면 연속 호출 시 값 유실이 생길 수 있고, Line [22]는 null 입력 시 타입 계약([T, ...])을 깨뜨립니다. setState(prev => ...) 내부에서 계산하고 null이면 defaultValue로 되돌려 주세요.
수정 예시
const setValue: SetValue<T> = (value) => {
- try {
- const next = value instanceof Function ? value(state) : value
- setState(next as T)
- if (next == null) {
- localStorage.removeItem(key)
- } else {
- localStorage.setItem(key, JSON.stringify(next))
- }
- } catch (error) {
- console.error(`useLocalStorage setValue error [${key}]:`, error)
- }
+ setState((prev) => {
+ try {
+ const next = value instanceof Function ? value(prev) : value
+ if (next == null) {
+ localStorage.removeItem(key)
+ return defaultValue
+ }
+ localStorage.setItem(key, JSON.stringify(next))
+ return next
+ } catch (error) {
+ console.error(`useLocalStorage setValue error [${key}]:`, error)
+ return prev
+ }
+ })
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Week08/SJ01-max/Mission3/src/hooks/useLocalStorage.ts` around lines 19 - 27,
The updater setValue currently reads the stale closure variable state and
force-casts null to T; change it to use the functional updater form of setState
so the new value is computed from prev (use setState(prev => { const next =
value instanceof Function ? value(prev) : value })), treat next === null by
returning defaultValue (do not cast null to T) and reflect that in storage
logic: removeItem(key) when next is null/undefined, otherwise setItem(key,
JSON.stringify(next)); update use of localStorage (removeItem/setItem)
accordingly so state and storage stay consistent.
| <div | ||
| onClick={() => navigate(`/lps/${id}`)} | ||
| className="group relative aspect-square cursor-pointer overflow-hidden rounded-sm bg-gray-900" | ||
| > |
There was a problem hiding this comment.
클릭 가능한 카드가 키보드 접근 불가입니다.
Line 43의 div + onClick 패턴은 키보드 사용자가 상세 페이지로 이동하기 어렵습니다. button/Link로 바꾸거나 최소 role="button", tabIndex, onKeyDown을 추가해 주세요.
수정 예시 (Link 사용)
+import { Link, useNavigate } from 'react-router-dom'
...
- <div
- onClick={() => navigate(`/lps/${id}`)}
- className="group relative aspect-square cursor-pointer overflow-hidden rounded-sm bg-gray-900"
- >
+ <Link
+ to={`/lps/${id}`}
+ className="group relative block aspect-square cursor-pointer overflow-hidden rounded-sm bg-gray-900"
+ aria-label={`${title} 상세 보기`}
+ >
...
- </div>
+ </Link>🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Week08/SJ01-max/Mission3/src/pages/LpListPage.tsx` around lines 43 - 46, The
clickable card uses a plain <div> with onClick (the element containing
onClick={() => navigate(`/lps/${id}`)} and className="group relative..."), which
is not keyboard-accessible; replace the <div> with a semantic interactive
element such as a React Router <Link> (pointing to `/lps/${id}`) or a <button>,
or if you must keep a div add role="button", tabIndex={0}, and an onKeyDown
handler that calls navigate(`/lps/${id}`) when Enter or Space is pressed; ensure
the navigate usage and the id identifier are preserved and that focus styles
remain visible for keyboard users.
| if (bio.trim()) payload.bio = bio.trim() | ||
| payload.avatar = avatar | ||
| return apiUpdateMe(payload) | ||
| }, | ||
| onMutate: async () => { | ||
| const prev = user | ||
| if (user) { | ||
| updateUser({ | ||
| ...user, | ||
| name: name.trim() || user.name, | ||
| bio: bio.trim() || user.bio, | ||
| avatar: avatarPreview ?? user.avatar ?? null, |
There was a problem hiding this comment.
소개(bio)를 빈 값으로 초기화할 수 없습니다.
Line 34와 Line 44 로직 때문에 사용자가 소개를 지워도 기존 값이 유지됩니다. “소개 삭제”가 기능적으로 막혀 있습니다.
수정 예시
- if (bio.trim()) payload.bio = bio.trim()
+ payload.bio = bio.trim() === '' ? null : bio.trim()
@@
- bio: bio.trim() || user.bio,
+ bio: bio.trim() === '' ? null : bio.trim(),🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Week08/SJ01-max/Mission3/src/pages/MyPage.tsx` around lines 34 - 45, The
current onMutate/update flow prevents clearing bio because bio.trim() ||
user.bio preserves the old value and payload.bio is only set when bio.trim() is
non-empty; change both the optimistic update and payload construction to allow
an explicit empty string or null: in the mutation function that builds payload
use payload.bio = bio.trim() === "" ? "" : bio.trim() (or null if you prefer)
instead of conditionally setting it, and in onMutate call updateUser({...user,
name: name.trim() || user.name, bio: bio.trim() === "" ? "" : bio.trim(),
avatar: avatarPreview ?? user.avatar ?? null}) so deleting the bio is reflected
immediately and sent to the API.
📝 미션 번호
8주차 Misson 1
📋 구현 사항
useDebounce<T>(value, delay)커스텀 훅 직접 구현setTimeout/clearTimeout으로 타이머 관리useDebounce적용 (delay: 500ms)debouncedQuery로useInfiniteQueryqueryKey 및 enabled 조건 설정useInfiniteQuery연계queryKey: ["search", debouncedQuery]로 지연된 값 기준 캐싱getNextPageParam으로 cursor 기반 무한스크롤 구현staleTime: 1분 / gcTime: 5분으로 불필요한 재요청 방지📎 스크린샷
📝 미션 번호
8주차 Misson 2
📋 구현 사항
useThrottle(fn, interval)커스텀 훅 직접 구현useRef로shouldWait플래그 관리fetchNextPage에useThrottle적용 (interval: 1000ms)IntersectionObserver에서throttledFetchNextPage직접 호출📎 스크린샷
📝 미션 번호
8주차 Misson 3
📋 구현 사항
useSidebar커스텀 훅 구현isOpen상태 및open()/close()/toggle()함수 제공keydown이벤트 리스너 등록/해제overflow: hidden으로 배경 스크롤 방지Sidebar컴포넌트 구현translate-x+transition-transform duration-300으로 슬라이드 애니메이션useSidebar연결📎 스크린샷
✅ 체크리스트
🤔 질문 사항
Summary by CodeRabbit
돌려돌려LP판 - 초기 프로젝트 설정
새로운 기능
문서