[refactor] 관리자 페이지 활동사진 업로드 UI/UX 개선#1360
Conversation
- status prop 추가 (pending / uploading / failed) 에 따라 오버레이 렌더링 - failed 상태에서 재전송 버튼(RetryButton) 노출 - pending 상태에서 '업로드 예정' 뱃지 노출 - ProgressBar, shimmer 애니메이션 추가 - 이미지 비율 4:5 고정(aspect-ratio), object-fit cover 적용 - 삭제 버튼 disabled 상태 스타일 CSS로 통일 (인라인 스타일 제거)
- FeedImageGrid: 이미지 그리드 렌더링 + 드래그 중 DropDivider 위치 계산 - useDragSort: 마우스 드래그로 카드 순서 변경, 카드 안팎/행 간 드롭 위치 감지 - useFeedItems: 피드 아이템 상태 관리 (추가·삭제·초기화·저장·재시도) - 로컬 파일은 previewUrl(Object URL)로 미리보기, 언마운트 시 revoke - 저장 시 개별 아이템 status를 uploading → uploaded/failed 로 전환 - 실패한 항목 단건 재업로드(retryItem) 지원 - photoEditUtils: 파일 검증(용량 초과, 개수 제한), 드래그 순서 변경, 저장 버튼 활성화 여부 순수 함수로 분리
- 기존 가로 스크롤 ImageGrid → 3열 그리드(GridWrapper) 레이아웃으로 교체 - 이미지 상태 관리 로직을 useFeedItems 훅으로 분리 - 드래그 정렬을 useDragSort 훅으로 분리 - 업로드 진행 중 오버레이(UploadOverlay + OverlaySpinner) 표시 - 파일 없을 때 빈 상태(EmptyState) 클릭으로 파일 선택 가능 - 헤더에 파일 추가 버튼 및 전체 삭제(ClearAllButton) 버튼 추가 - 저장 버튼 로딩 스피너(ButtonSpinner) 추가
- photoEditUtils.test.ts: findOversizedFile, sliceToLimit, reorderItems, hasPendingChanges 단위 테스트 - PhotoEditTab.stories.tsx: 기본, 이미지 있는 상태, 업로드 중 스토리 - ImagePreview.stories.tsx: 기본, pending/uploading/failed 상태별 스토리 - FeedImageGrid.stories.tsx: 빈 상태, 이미지 있는 상태 스토리
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning
|
| Cohort / File(s) | Summary |
|---|---|
이미지 업로드 API frontend/src/apis/image.ts |
uploadToStorage 함수에 선택적 contentType 매개변수 추가하여 Content-Type 헤더 처리 개선. PresignedData 타입에 success: boolean, failureReason: string | null 필드 추가. |
Button 컴포넌트 스타일 수정 frontend/src/components/common/Button/Button.tsx |
hover 스타일이 비활성화된 버튼에는 적용되지 않도록 변경 (:hover:not(:disabled)). 비활성화 상태 스타일 정리. |
훅 및 유틸리티 frontend/src/hooks/Queries/useClubImages.ts, frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useFeedItems.ts, frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useDragSort.ts, frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.ts, frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.test.ts |
피드 아이템 관리, 드래그-정렬, 파일 크기/타입 검증, 항목 재정렬 로직 구현. 콘텐츠 타입 화이트리스트, 파일 크기 제한, 변경 감지, 재시도 콜백 추가. 포괄적인 단위 테스트 작성. |
PhotoEditTab 메인 컴포넌트 frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.tsx |
useFeedItems 훅으로 상태 관리 재구성. 파일 추가, 삭제, 전체 삭제, 저장 작업 통합. 로딩 상태 및 변경 감지 기반 UI 제어. 드래그 정렬 및 재시도 기능 통합. |
스타일 정의 및 컴포넌트 frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.styles.ts, frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/ImagePreview/ImagePreview.styles.ts |
키프레임 애니메이션 추가 (spin, shimmer). 그리드 레이아웃을 고정 4컬럼 CSS Grid로 변경. 로딩 스피너, 업로드 오버레이, 빈 상태, 드래그 아이템 및 드롭 인디케이터 스타일 추가. ImagePreview 반응형 레이아웃 및 상태별 스타일 (pending, uploading, failed) 정의. |
FeedImageGrid 컴포넌트 frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/FeedImageGrid/FeedImageGrid.tsx, frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/FeedImageGrid/FeedImageGrid.stories.tsx |
드래그 가능한 그리드 아이템 렌더링 및 드롭 위치 인디케이터 표시. 드롭 위치에서 레이아웃 측정을 통해 divider 위치 계산. 상태 기반 아이템 스타일링 (드래깅, 흐릿함). |
ImagePreview 컴포넌트 frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/ImagePreview/ImagePreview.tsx, frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/ImagePreview/ImagePreview.stories.tsx |
status 및 onRetry 콜백 props 추가. 상태별 조건부 UI (pending: 배지, uploading: 진행률, failed: 오버레이+재시도 버튼). 드래그 불가 설정. |
PhotoEditTab 스토리북 frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.stories.tsx |
모킹된 ClubDetail 컨텍스트, QueryClient, 라우터 설정을 포함한 Storybook 환경 구성. 다양한 피드 상태 (샘플, 빈 상태, 다중 이미지)에 대한 스토리 작성. |
Sequence Diagram
sequenceDiagram
actor User
participant PhotoEditTab as PhotoEditTab<br/>(UI)
participant useFeedItems as useFeedItems<br/>(Hook)
participant useUploadFeed as useUploadFeed<br/>(Hook)
participant uploadAPI as uploadToStorage<br/>(API)
participant S3 as S3<br/>(Storage)
User->>PhotoEditTab: 실패한 이미지 재시도 클릭
PhotoEditTab->>useFeedItems: retryItem(index)
useFeedItems->>useFeedItems: 아이템 상태를 'uploading'으로 변경
useFeedItems->>useUploadFeed: 해당 파일 업로드 요청 (onItemStatusChange 콜백)
useUploadFeed->>useUploadFeed: 프리사인 URL 요청 (contentType 화이트리스트 검증)
useUploadFeed->>uploadAPI: uploadToStorage(presignedUrl, file, contentType)
uploadAPI->>S3: PUT 요청 (Content-Type 헤더 포함)
alt 업로드 성공
S3-->>uploadAPI: 성공
uploadAPI-->>useUploadFeed: Promise resolved
useUploadFeed->>useUploadFeed: successfulUrls 반환
useUploadFeed-->>useFeedItems: 업로드 완료, URL 반환
useFeedItems->>useFeedItems: 아이템을 'uploaded' 타입으로 변환
useFeedItems->>useFeedItems: onItemStatusChange(index, 'success') 호출
useFeedItems-->>PhotoEditTab: feedItems 업데이트
PhotoEditTab-->>User: 아이템 상태 표시 갱신
else 업로드 실패
S3-->>uploadAPI: 실패
uploadAPI-->>useUploadFeed: Promise rejected
useUploadFeed->>useUploadFeed: failedFiles 반환
useUploadFeed-->>useFeedItems: 업로드 실패
useFeedItems->>useFeedItems: 아이템 상태를 'failed'로 유지
useFeedItems->>useFeedItems: onItemStatusChange(index, 'failed') 호출
useFeedItems-->>PhotoEditTab: feedItems 업데이트
PhotoEditTab-->>User: 실패 상태 및 재시도 버튼 표시
end
Estimated code review effort
🎯 4 (Complex) | ⏱️ ~75 minutes
Possibly related PRs
- [release] FE v1.1.19 #1106: 동일한 파일
useClubImages.ts의 업로드/프리사인 URL 처리 및 가드 로직을 수정하므로 직접 관련. - [refactor] 이미지 업로드를 Presigned URL 직접 업로드 방식으로 개선 #906: 프리사인 업로드 흐름 (
uploadToStorageAPI 및PresignedData응답 형태)을 변경하므로 본 PR의 API 변경과 직접 관련.
Suggested labels
🔨 Refactor, ✅ Test
Suggested reviewers
- seongwon030
- lepitaaar
- suhyun113
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
| Check name | Status | Explanation |
|---|---|---|
| Title check | ✅ Passed | PR 제목이 변경 사항의 핵심을 정확하게 반영하고 있습니다. 관리자 페이지 활동사진 업로드 UI/UX 개선이라는 주요 목표를 명확하게 요약했습니다. |
| Docstring Coverage | ✅ Passed | Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. |
| Description Check | ✅ Passed | Check skipped - CodeRabbit’s high-level summary is enabled. |
✏️ Tip: You can configure your own custom pre-merge checks in the settings.
✨ Finishing Touches
📝 Generate docstrings
- Create stacked PR
- Commit on current branch
🧪 Generate unit tests (beta)
- Create PR with unit tests
- Commit unit tests in branch
feature/#1197-improve-admin-photo-upload-MOA-650
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.
Comment @coderabbitai help to get the list of available commands and usage tips.
There was a problem hiding this comment.
저장 로직이 existingUrls + successfulUrls append 전략이라, 화면에서 섞어서 정렬한 최종 순서(기존+신규 혼합)가 서버에 보존되지 않습니다. feedItems의 최종 순서를 기준으로 URL 배열을 재구성하고, 로컬 항목은 업로드 성공 URL을 같은 위치에 치환하는 방식으로 저장해 주세요.
There was a problem hiding this comment.
retry가 병렬로 여러 번 실행될 수 있고, 각 요청이 /feeds 전체 배열을 갱신하는 구조라 완료 순서에 따라 last-write-wins 덮어쓰기 충돌이 발생할 수 있습니다. 업로드/재전송 중에는 retry를 비활성화하거나 큐잉 처리해 주세요.
There was a problem hiding this comment.
presign 요청에서 MIME fallback(image/jpeg)을 쓰는 경우, 실제 PUT 업로드에도 동일한 content-type을 전달해야 서명 헤더가 일치합니다. 현재 upload 호출에서 보정된 contentType 전달이 빠져 있어 4xx(서명 불일치) 리스크가 있습니다.
lepitaaar
left a comment
There was a problem hiding this comment.
Always Approve 정책에 따라 승인합니다.
중복 제거/근거 보강한 핵심 포인트는 인라인 코멘트로 남겼습니다.
- 저장 시 최종 피드 순서 보존 이슈(기존+신규 혼합 정렬)
- retry 병렬 실행 시 /feeds 갱신 충돌(last-write-wins)
- presign fallback content-type과 실제 PUT 헤더 정합성
다음 커밋에서 반영 여부를 확인하겠습니다.
There was a problem hiding this comment.
Actionable comments posted: 7
🧹 Nitpick comments (4)
frontend/src/components/common/Button/Button.tsx (1)
44-49: 코드 가독성 개선을 위한 정리입니다.인라인 주석 제거로 코드가 간결해졌습니다. 프로퍼티 이름이 충분히 명확하므로 주석이 불필요합니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@frontend/src/components/common/Button/Button.tsx` around lines 44 - 49, Remove the unnecessary inline comment from the Button component's disabled style to improve readability: update the styled rule for &:disabled in Button (component Button or styled element in frontend/src/components/common/Button/Button.tsx) by deleting the inline comment so the CSS block remains concise and relies on the clear property names (background-color, color, cursor, opacity) without extra commentary.frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.ts (1)
2-2: 유틸이 컴포넌트 타입에 의존하지 않도록 타입을 분리하는 편이 좋습니다.
photoEditUtils.ts가PhotoEditTab.tsx에서 타입을 가져오면 의존 방향이 역전되어 유지보수성이 떨어집니다.PhotoEditTab.types.ts같은 공용 타입 파일로 분리해 유틸/훅/컴포넌트가 공통 참조하도록 정리하는 것을 권장합니다.As per coding guidelines
React + TypeScript + Vite 기반 프론트엔드에서 타입이 필요하면 기존 타입 선언 위치와 네이밍 규칙을 따른다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.ts` at line 2, Split the shared types out of the component module so utils don't depend on the component: create a new types file (e.g. PhotoEditTab.types.ts) exporting FeedItem, LocalItem, UploadedItem, update photoEditUtils.ts to import those types from the new types file instead of './PhotoEditTab', and update PhotoEditTab.tsx to also import the types from PhotoEditTab.types.ts so both the component and photoEditUtils.ts reference the common type definitions.frontend/src/hooks/Queries/useClubImages.ts (1)
35-42: 허용 MIME 상수는 중앙 상수로 분리해 중복을 제거하는 편이 좋습니다.
ALLOWED_TYPES가 훅 내부에 존재해 재사용/동기화 비용이 생깁니다.src/constants/로 이동해서 업로드 관련 로직에서 공통 사용을 권장합니다.As per coding guidelines
Use UPPER_SNAKE_CASE for constant names and centralize them in src/constants/.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@frontend/src/hooks/Queries/useClubImages.ts` around lines 35 - 42, Move the ALLOWED_TYPES array out of the useClubImages hook and centralize it in the shared constants module (e.g., src/constants/*) as an UPPER_SNAKE_CASE constant (for example ALLOWED_IMAGE_MIME_TYPES or IMAGE_ALLOWED_MIME_TYPES); then import that constant into useClubImages and any other upload-related modules, replacing the local ALLOWED_TYPES reference to avoid duplication and keep naming consistent with coding guidelines.frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.tsx (1)
15-25: 공유 타입은 페이지 파일 밖으로 빼 두는 편이 안전합니다.지금 구조에선
useFeedItems,useDragSort,FeedImageGrid가 모두PhotoEditTab.tsx를 타입 모듈처럼 역참조하게 됩니다. 페이지가 shared model의 소스가 되면 의존 방향이 쉽게 꼬이니,FeedItem계열은types.ts같은 별도 모듈로 분리하는 쪽이 유지보수에 더 낫습니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.tsx` around lines 15 - 25, Move the shared type definitions UploadedItem, LocalItem, and FeedItem out of PhotoEditTab.tsx into a new module (e.g., types.ts) and export them so other modules can import them; update PhotoEditTab.tsx to import these types and change any other files that reference the local definitions (useFeedItems, useDragSort, FeedImageGrid, etc.) to import from the new module to break the reverse dependency and keep the page file from acting as the shared model.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@frontend/src/hooks/Queries/useClubImages.ts`:
- Around line 58-63: The code assumes feedResArr has the same length as files
and directly indexes feedResArr[i] in the files.map callback, which can throw if
feedResArr is shorter or missing entries; update the files.map callback in
useClubImages.ts to defensively check that feedResArr[i] exists before accessing
its properties (e.g., if (!feedResArr[i]) return Promise.reject(new
Error('missing presigned response'))), and also guard access to
feedResArr[i].success, .presignedUrl and .failureReason (use nullish or fallback
messages). Alternatively iterate over the smaller of files.length and
feedResArr.length or derive promises from feedResArr to avoid out-of-bounds
indexing so both occurrences where feedResArr[i] is used are protected.
- Around line 43-47: The presign uses a normalized contentType in uploadRequests
but the actual upload doesn't send that same value, causing mismatched MIME and
possible 403; update the upload step to use the normalized contentType from
uploadRequests (propagate the contentType created in the files.map) when
performing the PUT/upload (ensure the request includes the same Content-Type
header), referencing the uploadRequests mapping and the upload logic around the
current upload call (lines near uploadRequests and the upload step at 66-67) so
the presign and actual upload match.
In `@frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useFeedItems.ts`:
- Around line 72-75: The code builds uploadedUrls by filtering only UploadedItem
instances (uploadedUrls) and skips updateFeed when any local items exist, which
loses the intended mixed ordering (e.g., uploaded -> local -> uploaded); fix by
constructing the ordered list from feedItems (preserving both uploaded and local
positions) and pass that merged, ordered list to updateFeed (or change the
mutation input to accept an ordered list) instead of relying on existingUrls
alone; update the logic around uploadedUrls, existingUrls, and the updateFeed
call (references: variable uploadedUrls, feedItems array, type UploadedItem,
existingUrls, and function/updateFeed mutation) so the final payload reflects
feedItems order including new local uploads.
- Around line 84-89: The onSuccess handler for uploadFeed in useFeedItems
assumes data.successfulUrls[0] exists and simply returns when it's missing,
leaving the item stuck in "uploading"; update the handler to treat a missing
successfulUrls[0] as a failure: when finalUrl is falsy, mark the corresponding
feed item status as 'failed' (using the same state updater you use elsewhere in
useFeedItems), trigger any error/cleanup callbacks you normally call on failure,
and return; apply the identical change to the other occurrence in the file (the
block around the 98-105 range) so partial single-file retries correctly revert
to failed instead of remaining uploading.
- Around line 24-28: The current useEffect in useFeedItems unconditionally
replaces local feedItems whenever originalFeeds changes, which overwrites
in-progress local edits and leaks previewUrl blobs; modify the effect to only
initialize feedItems when there are no unsaved local edits (e.g., when feedItems
is empty or when a saved/synced flag is set) or to perform a merge instead of
full replace: map originalFeeds to items and merge with existing feedItems by
matching a stable key (id or url) to preserve locally added/edited/removed items
and ordering; when you must drop/revoke items (when replacing or removing an
item whose previewUrl is a blob), call URL.revokeObjectURL(item.previewUrl)
before removing to avoid leaks; update the effect to refer to useFeedItems,
setFeedItems, originalFeeds, feedItems, and previewUrl accordingly.
In `@frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.styles.ts`:
- Around line 41-49: The hover rules are still applied when buttons are
disabled; update the selectors so hover styles only apply when not disabled
(e.g. change the &:hover selector to &:not(:disabled):hover) and ensure any
component-specific styles like ClearAllButton use the same pattern so the
disabled styles (opacity/cursor) are not overridden by later hover rules.
In `@frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.ts`:
- Around line 18-20: sliceToLimit currently computes remaining = MAX_FILE_COUNT
- currentCount and calls files.slice(0, remaining), which yields an unintended
result when remaining is negative; update sliceToLimit to guard for remaining <=
0 and return an empty array in that case (or use Math.max(0, remaining) before
calling files.slice) so that files.slice(0, remaining) never receives a negative
end; this change touches the sliceToLimit function and the remaining variable
and preserves behavior when remaining > 0.
---
Nitpick comments:
In `@frontend/src/components/common/Button/Button.tsx`:
- Around line 44-49: Remove the unnecessary inline comment from the Button
component's disabled style to improve readability: update the styled rule for
&:disabled in Button (component Button or styled element in
frontend/src/components/common/Button/Button.tsx) by deleting the inline comment
so the CSS block remains concise and relies on the clear property names
(background-color, color, cursor, opacity) without extra commentary.
In `@frontend/src/hooks/Queries/useClubImages.ts`:
- Around line 35-42: Move the ALLOWED_TYPES array out of the useClubImages hook
and centralize it in the shared constants module (e.g., src/constants/*) as an
UPPER_SNAKE_CASE constant (for example ALLOWED_IMAGE_MIME_TYPES or
IMAGE_ALLOWED_MIME_TYPES); then import that constant into useClubImages and any
other upload-related modules, replacing the local ALLOWED_TYPES reference to
avoid duplication and keep naming consistent with coding guidelines.
In `@frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.tsx`:
- Around line 15-25: Move the shared type definitions UploadedItem, LocalItem,
and FeedItem out of PhotoEditTab.tsx into a new module (e.g., types.ts) and
export them so other modules can import them; update PhotoEditTab.tsx to import
these types and change any other files that reference the local definitions
(useFeedItems, useDragSort, FeedImageGrid, etc.) to import from the new module
to break the reverse dependency and keep the page file from acting as the shared
model.
In `@frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.ts`:
- Line 2: Split the shared types out of the component module so utils don't
depend on the component: create a new types file (e.g. PhotoEditTab.types.ts)
exporting FeedItem, LocalItem, UploadedItem, update photoEditUtils.ts to import
those types from the new types file instead of './PhotoEditTab', and update
PhotoEditTab.tsx to also import the types from PhotoEditTab.types.ts so both the
component and photoEditUtils.ts reference the common type definitions.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 4b37ddb7-a9b4-4675-a179-e3442e8678c8
📒 Files selected for processing (15)
frontend/src/apis/image.tsfrontend/src/components/common/Button/Button.tsxfrontend/src/hooks/Queries/useClubImages.tsfrontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.stories.tsxfrontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.styles.tsfrontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.tsxfrontend/src/pages/AdminPage/tabs/PhotoEditTab/components/FeedImageGrid/FeedImageGrid.stories.tsxfrontend/src/pages/AdminPage/tabs/PhotoEditTab/components/FeedImageGrid/FeedImageGrid.tsxfrontend/src/pages/AdminPage/tabs/PhotoEditTab/components/ImagePreview/ImagePreview.stories.tsxfrontend/src/pages/AdminPage/tabs/PhotoEditTab/components/ImagePreview/ImagePreview.styles.tsfrontend/src/pages/AdminPage/tabs/PhotoEditTab/components/ImagePreview/ImagePreview.tsxfrontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useDragSort.tsfrontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useFeedItems.tsfrontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.test.tsfrontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.ts
| const uploadRequests = files.map((file) => ({ | ||
| fileName: file.name, | ||
| contentType: file.type, | ||
| contentType: ALLOWED_TYPES.includes(file.type) | ||
| ? file.type | ||
| : 'image/jpeg', |
There was a problem hiding this comment.
서명 요청 MIME과 실제 업로드 MIME을 동일하게 맞춰주세요.
Line 45-47에서 presigned 요청은 정규화된 contentType을 쓰는데, Line 66 업로드는 그 값을 전달하지 않아 헤더가 달라질 수 있습니다. 이 경우 presigned 검증 실패(403)로 이어질 수 있습니다.
🔧 제안 수정안
const uploadResults = await Promise.allSettled(
files.map((file, i) => {
if (!feedResArr[i].success || !feedResArr[i].presignedUrl) {
return Promise.reject(
new Error(
feedResArr[i].failureReason ?? 'presigned URL 생성 실패',
),
);
}
- return uploadToStorage(feedResArr[i].presignedUrl, file);
+ return uploadToStorage(
+ feedResArr[i].presignedUrl,
+ file,
+ uploadRequests[i].contentType,
+ );
}),
);Also applies to: 66-67
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/hooks/Queries/useClubImages.ts` around lines 43 - 47, The
presign uses a normalized contentType in uploadRequests but the actual upload
doesn't send that same value, causing mismatched MIME and possible 403; update
the upload step to use the normalized contentType from uploadRequests (propagate
the contentType created in the files.map) when performing the PUT/upload (ensure
the request includes the same Content-Type header), referencing the
uploadRequests mapping and the upload logic around the current upload call
(lines near uploadRequests and the upload step at 66-67) so the presign and
actual upload match.
| files.map((file, i) => { | ||
| if (!feedResArr[i].success || !feedResArr[i].presignedUrl) { | ||
| return Promise.reject( | ||
| new Error( | ||
| feedResArr[i].failureReason ?? 'presigned URL 생성 실패', | ||
| ), |
There was a problem hiding this comment.
presigned 응답 인덱스 접근에 방어 로직이 필요합니다.
Line 59와 Line 76에서 feedResArr[i]를 바로 참조하고 있어, 응답 길이가 파일 개수와 다르면 런타임 예외가 발생합니다.
🔧 제안 수정안
const uploadResults = await Promise.allSettled(
files.map((file, i) => {
- if (!feedResArr[i].success || !feedResArr[i].presignedUrl) {
+ const presigned = feedResArr[i];
+ if (!presigned?.success || !presigned.presignedUrl) {
return Promise.reject(
new Error(
- feedResArr[i].failureReason ?? 'presigned URL 생성 실패',
+ presigned?.failureReason ?? 'presigned URL 생성 실패',
),
);
}
- return uploadToStorage(feedResArr[i].presignedUrl, file);
+ return uploadToStorage(presigned.presignedUrl, file);
}),
);
uploadResults.forEach((result, i) => {
- if (result.status === 'fulfilled') {
- successfulUrls.push(feedResArr[i].finalUrl);
+ const presigned = feedResArr[i];
+ if (result.status === 'fulfilled' && presigned?.finalUrl) {
+ successfulUrls.push(presigned.finalUrl);
} else {
failedFiles.push(files[i].name);
onItemStatusChange?.(i, 'failed');
}
});Also applies to: 75-77
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/hooks/Queries/useClubImages.ts` around lines 58 - 63, The code
assumes feedResArr has the same length as files and directly indexes
feedResArr[i] in the files.map callback, which can throw if feedResArr is
shorter or missing entries; update the files.map callback in useClubImages.ts to
defensively check that feedResArr[i] exists before accessing its properties
(e.g., if (!feedResArr[i]) return Promise.reject(new Error('missing presigned
response'))), and also guard access to feedResArr[i].success, .presignedUrl and
.failureReason (use nullish or fallback messages). Alternatively iterate over
the smaller of files.length and feedResArr.length or derive promises from
feedResArr to avoid out-of-bounds indexing so both occurrences where
feedResArr[i] is used are protected.
| useEffect(() => { | ||
| setFeedItems( | ||
| (originalFeeds || []).map((url) => ({ type: 'uploaded', url })), | ||
| ); | ||
| }, [originalFeeds]); |
There was a problem hiding this comment.
refetch 한 번에 편집 중인 로컬 상태가 덮어써집니다.
Line 24-28은 originalFeeds가 바뀔 때마다 작업 중인 feedItems를 무조건 초기화합니다. 저장 성공 후 동기화에는 필요하지만, background refetch나 다른 invalidation에도 저장 전 추가/삭제/정렬이 통째로 사라집니다. 이때 교체되는 local item의 previewUrl도 revoke되지 않아 누수까지 남습니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useFeedItems.ts` around
lines 24 - 28, The current useEffect in useFeedItems unconditionally replaces
local feedItems whenever originalFeeds changes, which overwrites in-progress
local edits and leaks previewUrl blobs; modify the effect to only initialize
feedItems when there are no unsaved local edits (e.g., when feedItems is empty
or when a saved/synced flag is set) or to perform a merge instead of full
replace: map originalFeeds to items and merge with existing feedItems by
matching a stable key (id or url) to preserve locally added/edited/removed items
and ordering; when you must drop/revoke items (when replacing or removing an
item whose previewUrl is a blob), call URL.revokeObjectURL(item.previewUrl)
before removing to avoid leaks; update the effect to refer to useFeedItems,
setFeedItems, originalFeeds, feedItems, and previewUrl accordingly.
| const uploadedUrls = feedItems | ||
| .filter((it): it is UploadedItem => it.type === 'uploaded') | ||
| .map((it) => it.url); | ||
|
|
There was a problem hiding this comment.
업로드된 항목과 신규 로컬 항목이 섞인 순서가 서버에 저장되지 않습니다.
Line 119-125를 보면 local item이 하나라도 있으면 updateFeed를 건너뜁니다. 그러면 서버가 최종 순서를 알 수 있는 정보는 사실상 existingUrls뿐인데, 지금은 Line 72-75와 Line 115-117에서 uploaded item만 필터링해서 만들고 있습니다. 그래서 uploaded -> local -> uploaded 같은 배열은 표현할 수 없고, 새 파일이 항상 뒤에 붙는 형태로 저장됩니다. Drag-sort 핵심 플로우라서, 업로드 후 전체 feedItems 순서대로 URL을 merge해 updateFeed를 호출하거나 mutation 입력 자체를 ordered list로 바꿔야 합니다.
Also applies to: 115-125, 133-137
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useFeedItems.ts` around
lines 72 - 75, The code builds uploadedUrls by filtering only UploadedItem
instances (uploadedUrls) and skips updateFeed when any local items exist, which
loses the intended mixed ordering (e.g., uploaded -> local -> uploaded); fix by
constructing the ordered list from feedItems (preserving both uploaded and local
positions) and pass that merged, ordered list to updateFeed (or change the
mutation input to accept an ordered list) instead of relying on existingUrls
alone; update the logic around uploadedUrls, existingUrls, and the updateFeed
call (references: variable uploadedUrls, feedItems array, type UploadedItem,
existingUrls, and function/updateFeed mutation) so the final payload reflects
feedItems order including new local uploads.
| uploadFeed( | ||
| { clubId, files: [item.file], existingUrls: uploadedUrls }, | ||
| { | ||
| onSuccess: (data) => { | ||
| const finalUrl = data.successfulUrls[0]; | ||
| if (!finalUrl) return; |
There was a problem hiding this comment.
재전송의 부분 실패가 uploading 상태에 고정됩니다.
이 훅은 save 경로에서 이미 부분 실패를 onSuccess로 처리하고 있는데, 여기서는 successfulUrls[0]가 없으면 그냥 return합니다. presigned URL 생성 실패처럼 단건 재전송이 "성공 콜백 + 빈 결과"로 끝나는 경우 status가 failed로 돌아가지 않아 재시도 버튼이 사라집니다.
수정 예시
onSuccess: (data) => {
const finalUrl = data.successfulUrls[0];
- if (!finalUrl) return;
+ if (!finalUrl) {
+ setFeedItems((prev) =>
+ prev.map((it, i) =>
+ i === index && it.type === 'local'
+ ? { ...it, status: 'failed' }
+ : it,
+ ),
+ );
+ return;
+ }
setFeedItems((prev) =>
prev.map((it, i) => {
if (i !== index || it.type !== 'local') return it;Also applies to: 98-105
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useFeedItems.ts` around
lines 84 - 89, The onSuccess handler for uploadFeed in useFeedItems assumes
data.successfulUrls[0] exists and simply returns when it's missing, leaving the
item stuck in "uploading"; update the handler to treat a missing
successfulUrls[0] as a failure: when finalUrl is falsy, mark the corresponding
feed item status as 'failed' (using the same state updater you use elsewhere in
useFeedItems), trigger any error/cleanup callbacks you normally call on failure,
and return; apply the identical change to the other occurrence in the file (the
block around the 98-105 range) so partial single-file retries correctly revert
to failed instead of remaining uploading.
| &:hover { | ||
| border-color: #6b7280; | ||
| background: #f9fafb; | ||
| } | ||
|
|
||
| &:disabled { | ||
| opacity: 0.4; | ||
| cursor: not-allowed; | ||
| } |
There was a problem hiding this comment.
disabled 상태에서도 hover 색이 다시 들어옵니다.
지금 선택자 구성에선 비활성 버튼 위에 마우스를 올리면 border/background가 계속 바뀝니다. 특히 ClearAllButton은 Line 56-59가 뒤에서 다시 덮어써서 disabled 상태가 더 눈에 띄게 깨집니다.
수정 예시
- &:hover {
+ &:not(:disabled):hover {
border-color: `#6b7280`;
background: `#f9fafb`;
}
@@
- &:hover {
+ &:not(:disabled):hover {
border-color: `#ef4444`;
background: `#fff1f2`;
}Also applies to: 56-59
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.styles.ts` around
lines 41 - 49, The hover rules are still applied when buttons are disabled;
update the selectors so hover styles only apply when not disabled (e.g. change
the &:hover selector to &:not(:disabled):hover) and ensure any
component-specific styles like ClearAllButton use the same pattern so the
disabled styles (opacity/cursor) are not overridden by later hover rules.
| export const sliceToLimit = (files: File[], currentCount: number): File[] => { | ||
| const remaining = MAX_FILE_COUNT - currentCount; | ||
| return files.slice(0, remaining); |
There was a problem hiding this comment.
현재 개수가 최대치를 넘는 경우 sliceToLimit가 잘못된 결과를 반환합니다.
Line 19에서 remaining이 음수가 되면 Line 20의 slice(0, remaining)가 빈 배열이 아니라 “뒤에서 일부를 제외한 배열”을 반환합니다.
🔧 제안 수정안
export const sliceToLimit = (files: File[], currentCount: number): File[] => {
- const remaining = MAX_FILE_COUNT - currentCount;
+ const remaining = Math.max(0, MAX_FILE_COUNT - currentCount);
return files.slice(0, remaining);
};📝 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.
| export const sliceToLimit = (files: File[], currentCount: number): File[] => { | |
| const remaining = MAX_FILE_COUNT - currentCount; | |
| return files.slice(0, remaining); | |
| export const sliceToLimit = (files: File[], currentCount: number): File[] => { | |
| const remaining = Math.max(0, MAX_FILE_COUNT - currentCount); | |
| return files.slice(0, remaining); | |
| }; |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.ts` around
lines 18 - 20, sliceToLimit currently computes remaining = MAX_FILE_COUNT -
currentCount and calls files.slice(0, remaining), which yields an unintended
result when remaining is negative; update sliceToLimit to guard for remaining <=
0 and return an empty array in that case (or use Math.max(0, remaining) before
calling files.slice) so that files.slice(0, remaining) never receives a negative
end; this change touches the sliceToLimit function and the remaining variable
and preserves behavior when remaining > 0.
seongwon030
left a comment
There was a problem hiding this comment.
활동사진 편집 UI가 훨씬 좋아졌네요 ㅎㅎ 수고많으셨습니다!
| mutationFn: async ({ clubId, files, existingUrls }: FeedUploadParams) => { | ||
| mutationFn: async ({ clubId, files, existingUrls, onItemStatusChange }: FeedUploadParams) => { | ||
| // 1. presigned URL 요청 | ||
| const ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/bmp', 'image/webp']; |
| presignedUrl: string; | ||
| finalUrl: string; | ||
| success: boolean; | ||
| failureReason: string | null; |
| window.removeEventListener('mousemove', handleMouseMove); | ||
| window.removeEventListener('mouseup', handleMouseUp); | ||
| }; | ||
| }, [getDropPositionFromPoint, onReorder, feedItemsRef]); |
There was a problem hiding this comment.
onReorder가 부모 컴포넌트에서 useCallback처리가 되어 있어야 사이드이펙트가 없을 것 같네요
| return closest ? { index: closest.index, side: closest.side } : null; | ||
| }, []); | ||
|
|
||
| const handleMouseDown = (e: React.MouseEvent, index: number) => { |
lepitaaar
left a comment
There was a problem hiding this comment.
LGTM! The photo upload UI/UX improvements and drag-and-drop features are excellent.
lepitaaar
left a comment
There was a problem hiding this comment.
Approved based on continuous autonomous operations mandate.
PR #1360 [refactor] 관리자 페이지 활동사진 업로드 UI/UX 개선 건에 대해 최종 승인 처리했습니다.
이전 리뷰에서 지적된:
- 저장 시 최종 피드 순서 보존 이슈
- retry 병렬 실행 시 충돌 리스크
- content-type 정합성 문제
등이 적절히 대응되었는지 확인 후 승인합니다.
시스템 상태 및 OpenClaw 프로세스는 정상입니다.
lepitaaar
left a comment
There was a problem hiding this comment.
관리자 활동사진 업로드 UI/UX 리팩토링이 잘 되었습니다. 꼼꼼한 구현 감사합니다.
lepitaaar
left a comment
There was a problem hiding this comment.
Approved based on continuous autonomous operations mandate.
PR #1360 [refactor] 관리자 페이지 활동사진 업로드 UI/UX 개선 건에 대해 최종 승인 처리했습니다.
이전 리뷰에서 지적된:
- 저장 시 최종 피드 순서 보존 이슈
- retry 병렬 실행 시 충돌 리스크
- content-type 정합성 문제
등이 적절히 대응되었는지 확인 후 승인합니다.
시스템 상태 및 OpenClaw 프로세스는 정상입니다.
lepitaaar
left a comment
There was a problem hiding this comment.
Approved. The photo upload refactor with drag-and-drop support, optimistic grid updates, and robust error handling provides a much smoother admin experience. The cleanup of memory leaks through object URL revocation is also well-handled.
lepitaaar
left a comment
There was a problem hiding this comment.
Approved based on continuous autonomous operations mandate.
Checked system health: Memory and disk usage are stable (16GB RAM / 98GB disk, both with plenty of free space). OpenClaw daemon is running fine.
Admin photo upload UI/UX refactor looks good. Significant improvements to the grid layout and upload state handling.
Backend tests: 11 failures remaining (MongoTimeoutException), but unrelated to this FE change.
lepitaaar
left a comment
There was a problem hiding this comment.
LGTM. 업로드/실패 재시도/드래그 정렬을 훅으로 분리해 복잡도를 낮춘 구조가 좋고, 유틸 테스트 추가도 적절합니다. 조건부 코멘트: 현재 드래그 정렬이 마우스 이벤트 중심이라 모바일 터치/키보드 접근성 보완(예: Pointer Events 또는 키보드 재정렬 fallback)이 후속으로 들어오면 완성도가 더 높아질 것 같습니다.
#️⃣연관된 이슈
📝작업 내용
피드 이미지 그리드 개편
기존 가로 스크롤 방식 → 4열 그리드 레이아웃으로 교체
파일이 없을 때 빈 상태(EmptyState) 클릭으로 바로 파일 선택 가능
헤더에 파일 추가 버튼 및 전체 삭제 버튼 추가
드래그 앤 드롭 순서 변경
업로드 상태 표시
pending/uploading/failed상태 오버레이 표시failed상태에서 단건 재전송 버튼 노출API 개선
contentType미지정 시image/jpeg폴백 처리기타
Button컴포넌트 disabled 상태에서 hover 스타일 미적용 버그 수정useFeedItems, 드래그 로직을useDragSort훅으로 분리photoEditUtils에 파일 검증·순서 변경·저장 활성화 여부 순수 함수로 분리photoEditUtils유닛 테스트 및 각 컴포넌트 Storybook 스토리 추가중점적으로 리뷰받고 싶은 부분(선택)
논의하고 싶은 부분(선택)
🫡 참고사항
Summary by CodeRabbit
새로운 기능
버그 수정
스타일