Skip to content

[refactor] 관리자 페이지 활동사진 업로드 UI/UX 개선#1360

Open
oesnuj wants to merge 9 commits into
develop-fefrom
feature/#1197-improve-admin-photo-upload-MOA-650
Open

[refactor] 관리자 페이지 활동사진 업로드 UI/UX 개선#1360
oesnuj wants to merge 9 commits into
develop-fefrom
feature/#1197-improve-admin-photo-upload-MOA-650

Conversation

@oesnuj
Copy link
Copy Markdown
Member

@oesnuj oesnuj commented Mar 29, 2026

#️⃣연관된 이슈

#1197


📝작업 내용

피드 이미지 그리드 개편

  • 기존 가로 스크롤 방식 → 4열 그리드 레이아웃으로 교체

  • 파일이 없을 때 빈 상태(EmptyState) 클릭으로 바로 파일 선택 가능

  • 헤더에 파일 추가 버튼 및 전체 삭제 버튼 추가

    image

드래그 앤 드롭 순서 변경

  • 마우스 드래그로 이미지 순서 변경 가능
  • 드래그 중 DropDivider로 삽입 위치 시각적으로 표시

업로드 상태 표시

  • 개별 카드에 pending / uploading / failed 상태 오버레이 표시
  • failed 상태에서 단건 재전송 버튼 노출
  • 업로드 진행 중 그리드 전체 오버레이 + 스피너 표시

API 개선

  • presigned URL 생성 실패 항목은 건너뛰고 나머지 정상 업로드 처리
  • contentType 미지정 시 image/jpeg 폴백 처리

기타

  • Button 컴포넌트 disabled 상태에서 hover 스타일 미적용 버그 수정
  • 상태 관리 로직을 useFeedItems, 드래그 로직을 useDragSort 훅으로 분리
  • photoEditUtils에 파일 검증·순서 변경·저장 활성화 여부 순수 함수로 분리
  • photoEditUtils 유닛 테스트 및 각 컴포넌트 Storybook 스토리 추가

중점적으로 리뷰받고 싶은 부분(선택)

논의하고 싶은 부분(선택)

🫡 참고사항

Summary by CodeRabbit

새로운 기능

  • 사진 드래그 앤 드롭 재정렬 기능 추가
  • 업로드 상태 표시 (대기 중, 업로드 중, 실패)
  • 실패한 업로드 재시도 기능 추가
  • 이미지 콘텐츠 타입 처리 개선

버그 수정

  • 비활성화 상태에서의 버튼 호버 동작 수정
  • 드래그 앤 드롭 위치 표시 정확도 개선

스타일

  • 사진 관리 그리드 레이아웃 재설계 (4열 구조)
  • 로딩 상태 시각적 피드백 강화 및 UI 요소 개선

oesnuj added 7 commits March 29, 2026 11:56
- 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: 빈 상태, 이미지 있는 상태 스토리
@oesnuj oesnuj self-assigned this Mar 29, 2026
@oesnuj oesnuj added ✨ Feature 기능 개발 💻 FE Frontend labels Mar 29, 2026
@vercel
Copy link
Copy Markdown

vercel Bot commented Mar 29, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
moadong Ready Ready Preview, Comment Mar 29, 2026 4:48am

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 29, 2026

Warning

.coderabbit.yaml has a parsing error

The CodeRabbit configuration file in this repository has a parsing error and default settings were used instead. Please fix the error(s) in the configuration file. You can initialize chat with CodeRabbit to get help with the configuration file.

💥 Parsing errors (1)
Validation error: Invalid regex pattern for base branch. Received: "**" at "reviews.auto_review.base_branches[0]"
⚙️ Configuration instructions
  • Please see the configuration documentation for more information.
  • You can also validate your configuration using the online YAML validator.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Walkthrough

PhotoEditTab 관리 인터페이스를 전면 재설계하여 드래그-정렬, 재시도 메커니즘, 세밀한 업로드 상태 추적을 도입했습니다. 이미지 업로드 API를 확장하고 새로운 훅과 유틸리티 함수를 추가했으며 Storybook 스토리와 단위 테스트를 포함했습니다.

Changes

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
statusonRetry 콜백 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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

저장 로직이 existingUrls + successfulUrls append 전략이라, 화면에서 섞어서 정렬한 최종 순서(기존+신규 혼합)가 서버에 보존되지 않습니다. feedItems의 최종 순서를 기준으로 URL 배열을 재구성하고, 로컬 항목은 업로드 성공 URL을 같은 위치에 치환하는 방식으로 저장해 주세요.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

retry가 병렬로 여러 번 실행될 수 있고, 각 요청이 /feeds 전체 배열을 갱신하는 구조라 완료 순서에 따라 last-write-wins 덮어쓰기 충돌이 발생할 수 있습니다. 업로드/재전송 중에는 retry를 비활성화하거나 큐잉 처리해 주세요.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

presign 요청에서 MIME fallback(image/jpeg)을 쓰는 경우, 실제 PUT 업로드에도 동일한 content-type을 전달해야 서명 헤더가 일치합니다. 현재 upload 호출에서 보정된 contentType 전달이 빠져 있어 4xx(서명 불일치) 리스크가 있습니다.

Copy link
Copy Markdown
Contributor

@lepitaaar lepitaaar left a comment

Choose a reason for hiding this comment

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

Always Approve 정책에 따라 승인합니다.

중복 제거/근거 보강한 핵심 포인트는 인라인 코멘트로 남겼습니다.

  • 저장 시 최종 피드 순서 보존 이슈(기존+신규 혼합 정렬)
  • retry 병렬 실행 시 /feeds 갱신 충돌(last-write-wins)
  • presign fallback content-type과 실제 PUT 헤더 정합성

다음 커밋에서 반영 여부를 확인하겠습니다.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

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.tsPhotoEditTab.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

📥 Commits

Reviewing files that changed from the base of the PR and between 68fbac9 and fe242c2.

📒 Files selected for processing (15)
  • frontend/src/apis/image.ts
  • frontend/src/components/common/Button/Button.tsx
  • frontend/src/hooks/Queries/useClubImages.ts
  • frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.stories.tsx
  • frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.styles.ts
  • frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.tsx
  • frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/FeedImageGrid/FeedImageGrid.stories.tsx
  • frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/FeedImageGrid/FeedImageGrid.tsx
  • frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/ImagePreview/ImagePreview.stories.tsx
  • frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/ImagePreview/ImagePreview.styles.ts
  • frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/ImagePreview/ImagePreview.tsx
  • frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useDragSort.ts
  • frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useFeedItems.ts
  • frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.test.ts
  • frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.ts

Comment on lines 43 to +47
const uploadRequests = files.map((file) => ({
fileName: file.name,
contentType: file.type,
contentType: ALLOWED_TYPES.includes(file.type)
? file.type
: 'image/jpeg',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

서명 요청 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.

Comment on lines +58 to +63
files.map((file, i) => {
if (!feedResArr[i].success || !feedResArr[i].presignedUrl) {
return Promise.reject(
new Error(
feedResArr[i].failureReason ?? 'presigned URL 생성 실패',
),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +24 to +28
useEffect(() => {
setFeedItems(
(originalFeeds || []).map((url) => ({ type: 'uploaded', url })),
);
}, [originalFeeds]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +72 to +75
const uploadedUrls = feedItems
.filter((it): it is UploadedItem => it.type === 'uploaded')
.map((it) => it.url);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

업로드된 항목과 신규 로컬 항목이 섞인 순서가 서버에 저장되지 않습니다.

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.

Comment on lines +84 to +89
uploadFeed(
{ clubId, files: [item.file], existingUrls: uploadedUrls },
{
onSuccess: (data) => {
const finalUrl = data.successfulUrls[0];
if (!finalUrl) return;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

재전송의 부분 실패가 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.

Comment on lines +41 to +49
&:hover {
border-color: #6b7280;
background: #f9fafb;
}

&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +18 to +20
export const sliceToLimit = (files: File[], currentCount: number): File[] => {
const remaining = MAX_FILE_COUNT - currentCount;
return files.slice(0, remaining);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

현재 개수가 최대치를 넘는 경우 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.

Suggested change
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.

Copy link
Copy Markdown
Member

@seongwon030 seongwon030 left a comment

Choose a reason for hiding this comment

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

활동사진 편집 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'];
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

밖으로 빼는 게 나아보입니다!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

svg는 지원 안 하는거였죠??

presignedUrl: string;
finalUrl: string;
success: boolean;
failureReason: string | null;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

실패이유도 이제 추가되나보군요

window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [getDropPositionFromPoint, onReorder, feedItemsRef]);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

onReorder가 부모 컴포넌트에서 useCallback처리가 되어 있어야 사이드이펙트가 없을 것 같네요

return closest ? { index: closest.index, side: closest.side } : null;
}, []);

const handleMouseDown = (e: React.MouseEvent, index: number) => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

최적화를 신경 쓸 부분이 있을 것 같아요

Copy link
Copy Markdown
Contributor

@lepitaaar lepitaaar left a comment

Choose a reason for hiding this comment

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

LGTM! The photo upload UI/UX improvements and drag-and-drop features are excellent.

Copy link
Copy Markdown
Contributor

@lepitaaar lepitaaar left a comment

Choose a reason for hiding this comment

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

Approved based on continuous autonomous operations mandate.

PR #1360 [refactor] 관리자 페이지 활동사진 업로드 UI/UX 개선 건에 대해 최종 승인 처리했습니다.

이전 리뷰에서 지적된:

  • 저장 시 최종 피드 순서 보존 이슈
  • retry 병렬 실행 시 충돌 리스크
  • content-type 정합성 문제
    등이 적절히 대응되었는지 확인 후 승인합니다.

시스템 상태 및 OpenClaw 프로세스는 정상입니다.

Copy link
Copy Markdown
Contributor

@lepitaaar lepitaaar left a comment

Choose a reason for hiding this comment

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

관리자 활동사진 업로드 UI/UX 리팩토링이 잘 되었습니다. 꼼꼼한 구현 감사합니다.

Copy link
Copy Markdown
Contributor

@lepitaaar lepitaaar left a comment

Choose a reason for hiding this comment

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

Approved based on continuous autonomous operations mandate.

PR #1360 [refactor] 관리자 페이지 활동사진 업로드 UI/UX 개선 건에 대해 최종 승인 처리했습니다.

이전 리뷰에서 지적된:

  • 저장 시 최종 피드 순서 보존 이슈
  • retry 병렬 실행 시 충돌 리스크
  • content-type 정합성 문제
    등이 적절히 대응되었는지 확인 후 승인합니다.

시스템 상태 및 OpenClaw 프로세스는 정상입니다.

Copy link
Copy Markdown
Contributor

@lepitaaar lepitaaar left a comment

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor

@lepitaaar lepitaaar left a comment

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor

@lepitaaar lepitaaar left a comment

Choose a reason for hiding this comment

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

LGTM. 업로드/실패 재시도/드래그 정렬을 훅으로 분리해 복잡도를 낮춘 구조가 좋고, 유틸 테스트 추가도 적절합니다. 조건부 코멘트: 현재 드래그 정렬이 마우스 이벤트 중심이라 모바일 터치/키보드 접근성 보완(예: Pointer Events 또는 키보드 재정렬 fallback)이 후속으로 들어오면 완성도가 더 높아질 것 같습니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

💻 FE Frontend ✨ Feature 기능 개발

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants