Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/api/feeds/getFeedDetail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export interface FeedDetailData {
creatorId: number;
creatorNickname: string;
creatorProfileImageUrl: string;
aliasName: string;
alias: string;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

속성명 변경이 하위 호환성을 깨뜨릴 수 있습니다

aliasName에서 alias로 속성명이 변경되었습니다. 이 인터페이스를 사용하는 다른 컴포넌트들이 여전히 aliasName을 참조하고 있을 수 있으니 확인이 필요합니다.


🏁 Script executed:

#!/bin/bash
# aliasName을 참조하는 다른 파일들 확인
rg "aliasName" --type ts --type tsx -A 2

Length of output: 68


아래와 같이 .ts.tsx 파일을 모두 검색하도록 명령을 수정해 다시 한 번 실행해 보시겠어요?


🏁 Script executed:

#!/bin/bash
# aliasName을 참조하는 모든 .ts/.tsx 파일 확인
rg "aliasName" --glob "*.ts" --glob "*.tsx" -n -C2

Length of output: 11834


속성명 변경에 따른 하위 호환성 주의
aliasNamealias로 변경되면서, 아래 위치들에서 여전히 aliasName을 참조하고 있어 런타임 오류가 발생할 수 있습니다. 모두 점검 후 alias로 이름을 통일하거나, 기존 속성을 매핑해 주세요.

– 타입 정의
• src/types/user.ts
• src/types/profile.ts
• src/types/follow.ts

– 샘플 데이터
• src/data/userData.ts

– API 인터페이스
• src/api/users/postSignup.ts
• src/api/users/patchProfile.ts
• src/api/users/getUsers.ts
• src/api/users/getMyProfile.ts
• src/api/users/getAlias.ts
• src/api/comments/getComments.ts (2곳)

– 컴포넌트
• src/components/feed/MyFeed.tsx
• src/components/feed/UserProfileItem.tsx
• src/components/feed/Profile.tsx
• src/components/feed/OtherFeed.tsx
• src/components/common/Post/SubReply.tsx
• src/components/common/Post/Reply.tsx
• src/components/common/Post/PostHeader.tsx

– 페이지
• src/pages/signup/SignupGenre.tsx
• src/pages/signup/SignupDone.tsx
• src/pages/mypage/Mypage.tsx
• src/pages/mypage/EditPage.tsx
• src/pages/feed/FollowerListPage.tsx

위치가 많으니 rg "aliasName" --glob "*.{ts,tsx}"로 한 번 더 전체 검색 후 일괄 리팩토링을 권장드립니다.

🤖 Prompt for AI Agents
In src/api/feeds/getFeedDetail.ts around line 9, the property name was changed
from aliasName to alias but many files still reference aliasName causing runtime
errors; search the repo for "aliasName" (e.g. rg "aliasName" --glob
"*.{ts,tsx}") and either rename those references to alias or add a compatibility
mapping (e.g. when reading objects, set alias = alias ?? aliasName) so both
shapes work; update the listed type definitions (src/types/user.ts,
src/types/profile.ts, src/types/follow.ts), sample data (src/data/userData.ts),
API handlers (src/api/users/* and src/api/comments/getComments.ts), components
(all listed feed and post components), and pages to use the unified alias
property.

aliasColor: string;
postDate: string;
isbn: string;
Expand All @@ -18,6 +18,7 @@ export interface FeedDetailData {
commentCount: number;
isSaved: boolean;
isLiked: boolean;
isPublic: boolean;
tagList: string[];
}

Expand All @@ -40,4 +41,5 @@ export const getFeedDetail = async (feedId: number) => {
const feedDetail = await getFeedDetail(123);
console.log(feedDetail.data.feedId); // 123
console.log(feedDetail.data.tagList); // ["태그1", "태그2"]
console.log(feedDetail.data.isPublic); // true or false
*/
88 changes: 88 additions & 0 deletions src/api/feeds/updateFeed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { apiClient } from '../index';

/** 피드 수정 요청 바디 타입 */
export interface UpdateFeedBody {
contentBody: string;
isPublic: boolean;
tagList?: string[];
remainImageUrls?: string[];
}

/** 성공 응답 */
export interface UpdateFeedSuccess {
isSuccess: true;
code: number;
message: string;
}

/** 실패 응답 */
export interface UpdateFeedFail {
isSuccess: false;
code: number;
message: string;
}

export type UpdateFeedResponse = UpdateFeedSuccess | UpdateFeedFail;

/**
* 피드 수정 API
* - multipart/form-data
* - request: application/json (Blob로 감싸 전송)
* - 이미지 추가는 불가능, 기존 이미지 삭제만 가능
*/
Comment on lines +29 to +32
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue

JSDoc 주석과 실제 구현의 불일치

JSDoc 주석에서는 multipart/form-data를 사용한다고 명시되어 있지만, 실제로는 application/json을 먼저 시도하고 실패 시에만 multipart/form-data로 폴백합니다. 주석을 정확하게 수정해야 합니다.

 /**
  * 피드 수정 API
- * - multipart/form-data
- *   - request: application/json (Blob로 감싸 전송)
- *   - 이미지 추가는 불가능, 기존 이미지 삭제만 가능
+ * - 먼저 application/json으로 요청 시도
+ * - 실패 시 multipart/form-data로 폴백 (request 필드에 JSON Blob 포함)
+ * - 이미지 추가는 불가능, 기존 이미지 삭제만 가능
  */
📝 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
* - multipart/form-data
* - request: application/json (Blob로 감싸 전송)
* - 이미지 추가는 불가능, 기존 이미지 삭제만 가능
*/
/**
* 피드 수정 API
* - 먼저 application/json으로 요청 시도
* - 실패 multipart/form-data로 폴백 (request 필드에 JSON Blob 포함)
* - 이미지 추가는 불가능, 기존 이미지 삭제만 가능
*/
🤖 Prompt for AI Agents
In src/api/feeds/updateFeed.ts around lines 29 to 32, the JSDoc incorrectly
states that the endpoint uses multipart/form-data while the implementation
actually tries application/json first (sending JSON wrapped in a Blob) and only
falls back to multipart/form-data; update the JSDoc to reflect the real behavior
by describing the actual request flow (primary: application/json via Blob,
fallback: multipart/form-data), and clarify that adding images is not
supported—only existing image deletion is allowed.

export const updateFeed = async (
feedId: number,
body: UpdateFeedBody,
): Promise<UpdateFeedResponse> => {
try {
const { data } = await apiClient.patch<UpdateFeedResponse>(`/feeds/${feedId}`, body, {
headers: { 'Content-Type': 'application/json' },
});

return data;
} catch (error) {
console.error('수정 API 에러:', error);

const form = new FormData();
form.append('request', new Blob([JSON.stringify(body)], { type: 'application/json' }));

const { data } = await apiClient.patch<UpdateFeedResponse>(`/feeds/${feedId}`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
Comment on lines +49 to +51
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue

Content-Type 헤더를 명시적으로 설정하지 마세요

FormData를 전송할 때 Content-Type: multipart/form-data를 명시적으로 설정하면 boundary 파라미터가 누락됩니다. axios가 자동으로 올바른 헤더를 설정하도록 해야 합니다.

 const { data } = await apiClient.patch<UpdateFeedResponse>(`/feeds/${feedId}`, form, {
-  headers: { 'Content-Type': 'multipart/form-data' },
+  // Content-Type 헤더를 제거하여 axios가 자동으로 설정하도록 함
 });
📝 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 { data } = await apiClient.patch<UpdateFeedResponse>(`/feeds/${feedId}`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
const { data } = await apiClient.patch<UpdateFeedResponse>(`/feeds/${feedId}`, form, {
// Content-Type 헤더를 제거하여 axios가 자동으로 설정하도록 함
});
🤖 Prompt for AI Agents
In src/api/feeds/updateFeed.ts around lines 49 to 51, the request explicitly
sets 'Content-Type: multipart/form-data', which omits the required boundary and
breaks FormData uploads; remove the explicit headers option (or omit the
Content-Type entry) so axios can automatically set the correct Content-Type
including the boundary when sending FormData.


return data;
}
Comment on lines +37 to +54
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

오류 처리 로직이 비직관적입니다

JSON 요청 실패 시 무조건 FormData로 재시도하는 현재 구조는 실제 네트워크 오류나 다른 문제를 숨길 수 있습니다. 서버가 500 에러를 반환하는 특정 상황에서만 FormData로 폴백하도록 개선이 필요합니다.

 export const updateFeed = async (
   feedId: number,
   body: UpdateFeedBody,
 ): Promise<UpdateFeedResponse> => {
   try {
     const { data } = await apiClient.patch<UpdateFeedResponse>(`/feeds/${feedId}`, body, {
       headers: { 'Content-Type': 'application/json' },
     });

     return data;
   } catch (error) {
-    console.error('수정 API 에러:', error);
+    // axios 에러이고 500 에러인 경우에만 FormData로 재시도
+    if (axios.isAxiosError(error) && error.response?.status === 500) {
+      console.log('500 에러 발생, FormData로 재시도');
 
-    const form = new FormData();
-    form.append('request', new Blob([JSON.stringify(body)], { type: 'application/json' }));
+      const form = new FormData();
+      form.append('request', new Blob([JSON.stringify(body)], { type: 'application/json' }));
 
-    const { data } = await apiClient.patch<UpdateFeedResponse>(`/feeds/${feedId}`, form, {
-      headers: { 'Content-Type': 'multipart/form-data' },
-    });
+      const { data } = await apiClient.patch<UpdateFeedResponse>(`/feeds/${feedId}`, form, {
+        headers: { 'Content-Type': 'multipart/form-data' },
+      });
 
-    return data;
+      return data;
+    }
+    
+    // 다른 에러는 그대로 전파
+    throw error;
   }
 };

axios import도 추가해야 합니다:

+import axios from 'axios';
 import { apiClient } from '../index';
📝 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
try {
const { data } = await apiClient.patch<UpdateFeedResponse>(`/feeds/${feedId}`, body, {
headers: { 'Content-Type': 'application/json' },
});
return data;
} catch (error) {
console.error('수정 API 에러:', error);
const form = new FormData();
form.append('request', new Blob([JSON.stringify(body)], { type: 'application/json' }));
const { data } = await apiClient.patch<UpdateFeedResponse>(`/feeds/${feedId}`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return data;
}
+import axios from 'axios';
import { apiClient } from '../index';
export const updateFeed = async (
feedId: number,
body: UpdateFeedBody,
): Promise<UpdateFeedResponse> => {
try {
const { data } = await apiClient.patch<UpdateFeedResponse>(`/feeds/${feedId}`, body, {
headers: { 'Content-Type': 'application/json' },
});
return data;
} catch (error) {
- console.error('수정 API 에러:', error);
+ // axios 에러이고 500 에러인 경우에만 FormData로 재시도
+ if (axios.isAxiosError(error) && error.response?.status === 500) {
+ console.log('500 에러 발생, FormData로 재시도');
+
+ const form = new FormData();
+ form.append('request', new Blob([JSON.stringify(body)], { type: 'application/json' }));
+
+ const { data } = await apiClient.patch<UpdateFeedResponse>(`/feeds/${feedId}`, form, {
+ headers: { 'Content-Type': 'multipart/form-data' },
+ });
+
+ return data;
+ }
+
+ // 다른 에러는 그대로 전파
+ throw error;
}
};

};
Comment on lines +33 to +55
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

에러 처리 로직 개선 필요

현재 구현은 첫 번째 요청이 실패하면 무조건 FormData로 재시도하는데, 이는 네트워크 오류나 권한 문제 등 다른 이유로 실패한 경우에도 불필요한 재시도를 하게 됩니다. 서버가 500 에러를 반환하는 특정 케이스에서만 FormData로 폴백하도록 개선이 필요합니다.

 export const updateFeed = async (
   feedId: number,
   body: UpdateFeedBody,
 ): Promise<UpdateFeedResponse> => {
   try {
     const { data } = await apiClient.patch<UpdateFeedResponse>(`/feeds/${feedId}`, body, {
       headers: { 'Content-Type': 'application/json' },
     });
 
     return data;
   } catch (error) {
-    console.error('수정 API 에러:', error);
+    // axios 에러 타입 체크 및 500 에러 확인
+    if (axios.isAxiosError(error) && error.response?.status === 500) {
+      console.warn('JSON 요청 실패 (500), FormData로 재시도:', error);
 
-    const form = new FormData();
-    form.append('request', new Blob([JSON.stringify(body)], { type: 'application/json' }));
+      const form = new FormData();
+      form.append('request', new Blob([JSON.stringify(body)], { type: 'application/json' }));
 
-    const { data } = await apiClient.patch<UpdateFeedResponse>(`/feeds/${feedId}`, form, {
-      headers: { 'Content-Type': 'multipart/form-data' },
-    });
+      const { data } = await apiClient.patch<UpdateFeedResponse>(`/feeds/${feedId}`, form, {
+        headers: { 'Content-Type': 'multipart/form-data' },
+      });
 
-    return data;
+      return data;
+    }
+    
+    // 다른 에러는 그대로 전파
+    throw error;
   }
 };

또한 파일 상단에 axios import 추가:

import axios from 'axios';


/*
사용 예시:

// 기존 이미지 일부 유지 (새 이미지 추가는 불가)
const updateBody: UpdateFeedBody = {
contentBody: "수정된 글 내용입니다!",
isPublic: true,
tagList: ["한국소설", "책추천", "역사"],
remainImageUrls: ["https://img.domain.com/1.jpg"] // 기존 이미지 중 유지할 것들
};

try {
const result = await updateFeed(123, updateBody);
if (result.isSuccess) {
console.log('피드 수정 성공:', result.message);
} else {
console.error('피드 수정 실패:', result.message);
}
} catch (error) {
console.error('네트워크 오류:', error);
}

// 모든 이미지 삭제 후 텍스트만 수정
const textOnlyUpdate: UpdateFeedBody = {
contentBody: "텍스트만 수정",
isPublic: false,
tagList: [],
remainImageUrls: [] // 모든 기존 이미지 삭제
};

const result = await updateFeed(123, textOnlyUpdate);
*/
30 changes: 26 additions & 4 deletions src/components/creategroup/BookSelectionSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,39 +16,61 @@ interface BookSelectionSectionProps {
selectedBook: { cover: string; title: string; author: string } | null;
onSearchClick: () => void;
onChangeClick: () => void;
readOnly?: boolean;
}

const BookSelectionSection = ({
selectedBook,
onSearchClick,
onChangeClick,
readOnly = false,
}: BookSelectionSectionProps) => {
return (
<Section>
<SectionTitle>책 선택</SectionTitle>
<SearchBox
hasSelectedBook={!!selectedBook}
onClick={selectedBook ? undefined : onSearchClick}
onClick={selectedBook || readOnly ? undefined : onSearchClick}
>
Comment on lines 31 to 34
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

읽기 전용일 때 커서/상호작용 불일치 수정 제안

readOnly이거나 이미 선택된 상태면 onClick을 제거하셨지만, SearchBox는 여전히 pointer 커서를 표시합니다. 사용성/접근성 측면에서 혼란을 줄 수 있으니 role/aria-disabled와 커서 스타일을 조건부로 맞춰주세요.

다음과 같이 보완을 제안드립니다.

-      <SearchBox
-        hasSelectedBook={!!selectedBook}
-        onClick={selectedBook || readOnly ? undefined : onSearchClick}
-      >
+      <SearchBox
+        hasSelectedBook={!!selectedBook}
+        onClick={selectedBook || readOnly ? undefined : onSearchClick}
+        role={!selectedBook && !readOnly ? 'button' : undefined}
+        aria-disabled={Boolean(selectedBook || readOnly)}
+        style={{ cursor: selectedBook || readOnly ? 'default' : 'pointer' }}
+      >
📝 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
<SearchBox
hasSelectedBook={!!selectedBook}
onClick={selectedBook ? undefined : onSearchClick}
onClick={selectedBook || readOnly ? undefined : onSearchClick}
>
<SearchBox
hasSelectedBook={!!selectedBook}
onClick={selectedBook || readOnly ? undefined : onSearchClick}
role={!selectedBook && !readOnly ? 'button' : undefined}
aria-disabled={Boolean(selectedBook || readOnly)}
style={{ cursor: selectedBook || readOnly ? 'default' : 'pointer' }}
>

{selectedBook ? (
<>
<SelectedBookContainer>
<SelectedBookCover>
<img src={selectedBook.cover} alt={selectedBook.title} />
{selectedBook.cover && selectedBook.cover.trim() !== '' ? (
<img src={selectedBook.cover} alt={selectedBook.title} />
) : (
<div
style={{
width: '60px',
height: '80px',
backgroundColor: '#333',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '10px',
color: '#999',
borderRadius: '4px',
}}
>
책표지
</div>
)}
</SelectedBookCover>
<SelectedBookInfo>
<SelectedBookTitle>{selectedBook.title}</SelectedBookTitle>
<SelectedBookAuthor>{selectedBook.author} 저</SelectedBookAuthor>
</SelectedBookInfo>
</SelectedBookContainer>
<ChangeButton onClick={onChangeClick}>변경</ChangeButton>
{!readOnly && <ChangeButton onClick={onChangeClick}>변경</ChangeButton>}
</>
) : (
<>
<SearchIcon>
<img src={searchIcon} alt="검색" />
</SearchIcon>
<span style={{ color: semanticColors.text.secondary }}>검색해서 찾기</span>
<span style={{ color: semanticColors.text.secondary }}>
{readOnly ? '책 정보' : '검색해서 찾기'}
</span>
</>
)}
</SearchBox>
Expand Down
80 changes: 61 additions & 19 deletions src/components/createpost/PhotoSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,31 @@ interface PhotoSectionProps {
photos: File[];
onPhotoAdd: (files: File[]) => void;
onPhotoRemove: (index: number) => void;
existingImageUrls?: string[];
onExistingImageRemove?: (imageUrl: string) => void;
readOnly?: boolean;
isEditMode?: boolean;
}

const PhotoSection = ({ photos, onPhotoAdd, onPhotoRemove }: PhotoSectionProps) => {
const PhotoSection = ({
photos,
onPhotoAdd,
onPhotoRemove,
existingImageUrls = [],
onExistingImageRemove,
readOnly = false,
isEditMode = false,
}: PhotoSectionProps) => {
const fileInputRef = useRef<HTMLInputElement>(null);

const handleFileInputClick = () => {
if (readOnly || isEditMode) return;
fileInputRef.current?.click();
};

const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (readOnly || isEditMode) return;

const files = Array.from(e.target.files || []);
if (files.length > 0) {
onPhotoAdd(files);
Expand All @@ -38,34 +53,61 @@ const PhotoSection = ({ photos, onPhotoAdd, onPhotoRemove }: PhotoSectionProps)
return URL.createObjectURL(file);
};

const isDisabled = photos.length >= 3;
const totalImageCount = existingImageUrls.length + photos.length;
const isDisabled = totalImageCount >= 3 || readOnly || isEditMode;

return (
<Section>
<SectionTitle>사진 추가</SectionTitle>
<PhotoContainer>
<PhotoGrid>
<AddPhotoButton onClick={handleFileInputClick} disabled={isDisabled}>
<img src={isDisabled ? plusDisabledIcon : plusIcon} alt="사진 추가" />
</AddPhotoButton>
{!readOnly && !isEditMode && (
<AddPhotoButton onClick={handleFileInputClick} disabled={isDisabled}>
<img src={isDisabled ? plusDisabledIcon : plusIcon} alt="사진 추가" />
</AddPhotoButton>
)}

{existingImageUrls.map((imageUrl, index) => (
<div
key={`existing-${index}`}
style={{ position: 'relative', width: '80px', height: '80px' }}
>
<PhotoImage src={imageUrl} alt={`기존 이미지 ${index + 1}`} />
{!readOnly && onExistingImageRemove && (
<RemoveButton onClick={() => onExistingImageRemove(imageUrl)}>
<img src={closeIcon} alt="삭제" />
</RemoveButton>
)}
</div>
))}
Comment on lines +70 to +82
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue

이미지 URL 메모리 누수 가능성

createImageUrl 함수로 생성된 객체 URL이 해제되지 않고 있습니다. 컴포넌트가 언마운트되거나 이미지가 제거될 때 URL.revokeObjectURL()을 호출해야 메모리 누수를 방지할 수 있습니다.

useEffect를 추가하여 cleanup 처리:

+import { useRef, useEffect } from 'react';

 const PhotoSection = ({
   // ... props
 }: PhotoSectionProps) => {
   const fileInputRef = useRef<HTMLInputElement>(null);
+  const objectUrlsRef = useRef<string[]>([]);

   const createImageUrl = (file: File) => {
-    return URL.createObjectURL(file);
+    const url = URL.createObjectURL(file);
+    objectUrlsRef.current.push(url);
+    return url;
   };

+  useEffect(() => {
+    return () => {
+      // 컴포넌트 언마운트 시 모든 객체 URL 해제
+      objectUrlsRef.current.forEach(url => URL.revokeObjectURL(url));
+    };
+  }, []);
📝 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
{existingImageUrls.map((imageUrl, index) => (
<div
key={`existing-${index}`}
style={{ position: 'relative', width: '80px', height: '80px' }}
>
<PhotoImage src={imageUrl} alt={`기존 이미지 ${index + 1}`} />
{!readOnly && onExistingImageRemove && (
<RemoveButton onClick={() => onExistingImageRemove(imageUrl)}>
<img src={closeIcon} alt="삭제" />
</RemoveButton>
)}
</div>
))}
// At the top of src/components/createpost/PhotoSection.tsx
+import { useRef, useEffect } from 'react';
import React from 'react';
// …other imports
const PhotoSection = ({
// … props
}: PhotoSectionProps) => {
const fileInputRef = useRef<HTMLInputElement>(null);
+ const objectUrlsRef = useRef<string[]>([]);
const createImageUrl = (file: File) => {
- return URL.createObjectURL(file);
+ const url = URL.createObjectURL(file);
+ objectUrlsRef.current.push(url);
+ return url;
};
+ useEffect(() => {
+ return () => {
+ // 컴포넌트 언마운트 시 모든 객체 URL 해제
+ objectUrlsRef.current.forEach(url => URL.revokeObjectURL(url));
+ };
+ }, []);
// …rest of component (rendering existingImageUrls, handling file input, etc.)
};
🤖 Prompt for AI Agents
In src/components/createpost/PhotoSection.tsx around lines 70-82, existing image
object URLs created by createImageUrl are never revoked; add logic to revoke
them when an image is removed and when the component unmounts to prevent memory
leaks. Track object URLs created or passed in (e.g., with a Set or ref), call
URL.revokeObjectURL(url) when onExistingImageRemove is invoked for a blob/object
URL (skip external http(s) URLs), remove the URL from the tracker, and add a
useEffect cleanup that iterates remaining tracked URLs and revokes them on
unmount.


{photos.map((photo, index) => (
<div key={index} style={{ position: 'relative', width: '80px', height: '80px' }}>
<PhotoImage src={createImageUrl(photo)} alt={`선택된 사진 ${index + 1}`} />
<RemoveButton onClick={() => onPhotoRemove(index)}>
<img src={closeIcon} alt="삭제" />
</RemoveButton>
<div
key={`new-${index}`}
style={{ position: 'relative', width: '80px', height: '80px' }}
>
<PhotoImage src={createImageUrl(photo)} alt={`새 이미지 ${index + 1}`} />
{!readOnly && (
<RemoveButton onClick={() => onPhotoRemove(index)}>
<img src={closeIcon} alt="삭제" />
</RemoveButton>
)}
</div>
))}
</PhotoGrid>
<PhotoCount>{photos.length}/3개</PhotoCount>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
style={{ display: 'none' }}
onChange={handleFileChange}
/>

<PhotoCount>{totalImageCount}/3개</PhotoCount>

{!readOnly && !isEditMode && (
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
style={{ display: 'none' }}
onChange={handleFileChange}
/>
)}
</PhotoContainer>
</Section>
);
Expand Down
78 changes: 78 additions & 0 deletions src/hooks/useUpdateFeed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { useState } from 'react';
import { updateFeed, type UpdateFeedBody, type UpdateFeedResponse } from '@/api/feeds/updateFeed';
import { usePopupActions } from './usePopupActions';

interface UseUpdateFeedProps {
onSuccess?: (feedId: number) => void;
}

export const useUpdateFeed = (options?: UseUpdateFeedProps) => {
const [loading, setLoading] = useState(false);
const { openSnackbar, closePopup } = usePopupActions();

const updateExistingFeed = async (feedId: number, body: UpdateFeedBody) => {
try {
setLoading(true);

// ===== 클라이언트 선검증 =====
if (body.tagList) {
// 최대 5개
if (body.tagList.length > 5) {
openSnackbar({
message: '태그는 최대 5개까지 입력할 수 있어요.',
variant: 'top',
onClose: closePopup,
});
return { success: false as const };
}
// 중복 제거 체크
const trimmed = body.tagList.map(t => t.trim()).filter(Boolean);
const uniq = new Set(trimmed);
if (uniq.size !== trimmed.length) {
openSnackbar({
message: '태그는 중복될 수 없어요.',
variant: 'top',
onClose: closePopup,
});
return { success: false as const };
}
}
// ===== 선검증 끝 =====

const res: UpdateFeedResponse = await updateFeed(feedId, body);

Comment on lines +17 to +43
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

태그 전처리를 실제 요청 본문에 반영하세요(공백 제거/중복 제거).

현재는 트리밍/중복 검사를 하지만, 정제된 결과를 body에 반영하지 않아 서버로 공백 포함 태그가 전송될 수 있습니다. 또한 최대 5개 검사는 dedupe 후의 개수 기준으로 하는 것이 안전합니다.

-      // ===== 클라이언트 선검증 =====
-      if (body.tagList) {
-        // 최대 5개
-        if (body.tagList.length > 5) {
+      // ===== 클라이언트 선검증 및 정규화 =====
+      let normalizedBody: UpdateFeedBody = body;
+      if (body.tagList) {
+        const trimmed = body.tagList.map(t => t.trim()).filter(Boolean);
+        const deduped = Array.from(new Set(trimmed));
+        // 최대 5개 (정규화 이후 기준)
+        if (deduped.length > 5) {
           openSnackbar({
             message: '태그는 최대 5개까지 입력할 수 있어요.',
             variant: 'top',
             onClose: closePopup,
           });
           return { success: false as const };
         }
-        // 중복 제거 체크
-        const trimmed = body.tagList.map(t => t.trim()).filter(Boolean);
-        const uniq = new Set(trimmed);
-        if (uniq.size !== trimmed.length) {
-          openSnackbar({
-            message: '태그는 중복될 수 없어요.',
-            variant: 'top',
-            onClose: closePopup,
-          });
-          return { success: false as const };
-        }
+        // 공백 제거 및 중복 제거 반영
+        normalizedBody = { ...body, tagList: deduped };
       }
-      // ===== 선검증 끝 =====
+      // ===== 선검증 끝 =====
 
-      const res: UpdateFeedResponse = await updateFeed(feedId, body);
+      const res: UpdateFeedResponse = await updateFeed(feedId, normalizedBody);

참고:

  • UX 측면에서 중복 발견 시 하드 실패 대신 자동 정규화(공백 제거 및 dedupe) 후 전송을 추천합니다. 필요 시 "중복 태그를 자동으로 정리했어요." 같은 토스트 안내를 추가할 수 있습니다.
📝 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
// ===== 클라이언트 선검증 =====
if (body.tagList) {
// 최대 5개
if (body.tagList.length > 5) {
openSnackbar({
message: '태그는 최대 5개까지 입력할 수 있어요.',
variant: 'top',
onClose: closePopup,
});
return { success: false as const };
}
// 중복 제거 체크
const trimmed = body.tagList.map(t => t.trim()).filter(Boolean);
const uniq = new Set(trimmed);
if (uniq.size !== trimmed.length) {
openSnackbar({
message: '태그는 중복될 수 없어요.',
variant: 'top',
onClose: closePopup,
});
return { success: false as const };
}
}
// ===== 선검증 끝 =====
const res: UpdateFeedResponse = await updateFeed(feedId, body);
// ===== 클라이언트 선검증 및 정규화 =====
let normalizedBody: UpdateFeedBody = body;
if (body.tagList) {
const trimmed = body.tagList.map(t => t.trim()).filter(Boolean);
const deduped = Array.from(new Set(trimmed));
// 최대 5개 (정규화 이후 기준)
if (deduped.length > 5) {
openSnackbar({
message: '태그는 최대 5개까지 입력할 수 있어요.',
variant: 'top',
onClose: closePopup,
});
return { success: false as const };
}
// 공백 제거 및 중복 제거 반영
normalizedBody = { ...body, tagList: deduped };
}
// ===== 선검증 끝 =====
const res: UpdateFeedResponse = await updateFeed(feedId, normalizedBody);
🤖 Prompt for AI Agents
In src/hooks/useUpdateFeed.ts around lines 17 to 43, the code trims and checks
tags but never updates body.tagList so the unclean tags are sent and the max-5
check runs before dedupe; modify the logic to first compute a normalized array
(trim each tag, filter out empty strings, dedupe while preserving order), then
set body.tagList = normalized; enforce the 5-tag limit after normalization
(either slice to first 5 or return failure—prefer slicing for auto-normalization
UX), and show an informational snackbar when tags were changed (e.g., "중복 태그를
자동으로 정리했어요.") before calling updateFeed.

if (res.isSuccess) {
openSnackbar({
message: '피드가 수정되었습니다.',
variant: 'top',
onClose: closePopup,
});

if (options?.onSuccess) {
options.onSuccess(feedId);
}

return { success: true as const, feedId };
} else {
openSnackbar({
message: res.message || '피드 수정에 실패했습니다.',
variant: 'top',
onClose: closePopup,
});
return { success: false as const, error: res.message };
}
} catch (error) {
console.error('피드 수정 실패:', error);
openSnackbar({
message: '피드 수정 중 오류가 발생했습니다.',
variant: 'top',
onClose: closePopup,
});
return { success: false as const, error: '피드 수정 중 오류가 발생했습니다.' };
} finally {
setLoading(false);
}
};

return { updateExistingFeed, loading };
};
5 changes: 4 additions & 1 deletion src/pages/feed/FeedDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,10 @@ const FeedDetailPage = () => {

const handleMoreClick = () => {
openMoreMenu({
onEdit: () => console.log('수정하기 클릭'),
onEdit: () => {
closePopup();
navigate(`/post/update/${feedId}`);
},
onClose: () => {
closePopup();
},
Expand Down
2 changes: 2 additions & 0 deletions src/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import SignupNickname from './signup/SignupNickname';
import SignupDone from './signup/SignupDone';
import CreateGroup from './group/CreateGroup';
import CreatePost from './post/CreatePost';
import UpdatePost from './post/UpdatePost';
import Group from './group/Group';
import Feed from './feed/Feed';
import GroupSearch from './groupSearch/GroupSearch';
Expand Down Expand Up @@ -51,6 +52,7 @@ const Router = () => {
<Route path="post/create" element={<CreatePost />} />
<Route path="group" element={<Group />} />
<Route path="group/create" element={<CreateGroup />} />
<Route path="post/update/:feedId" element={<UpdatePost />} />
<Route path="group/search" element={<GroupSearch />} />
<Route path="group/detail" element={<GroupDetail />} />
<Route path="group/detail/joined" element={<ParticipatedGroupDetail />} />
Expand Down
Loading