-
Notifications
You must be signed in to change notification settings - Fork 3
[refactor] 관리자 페이지 활동사진 업로드 UI/UX 개선 #1360
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop-fe
Are you sure you want to change the base?
Changes from all commits
555b29f
49f4060
4d7d029
8292748
003486d
9141885
5008638
fe242c2
d820915
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,8 @@ import { handleResponse } from './utils/apiHelpers'; | |
| interface PresignedData { | ||
| presignedUrl: string; | ||
| finalUrl: string; | ||
| success: boolean; | ||
| failureReason: string | null; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 실패이유도 이제 추가되나보군요 |
||
| } | ||
|
|
||
| interface FeedUploadRequest { | ||
|
|
@@ -16,11 +18,13 @@ interface FeedUploadRequest { | |
| export async function uploadToStorage( | ||
| presignedUrl: string, | ||
| file: File, | ||
| contentType?: string, | ||
| ): Promise<void> { | ||
| const resolvedContentType = contentType || file.type || 'image/jpeg'; | ||
| const response = await fetch(presignedUrl, { | ||
| method: 'PUT', | ||
| body: file, | ||
| headers: { 'Content-Type': file.type }, | ||
| headers: { 'Content-Type': resolvedContentType }, | ||
| }); | ||
| await handleResponse(response, `S3 업로드 실패 : ${response.status}`); | ||
| } | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 저장 로직이 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,10 +2,13 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; | |
| import { feedApi, logoApi, uploadToStorage } from '@/apis/image'; | ||
| import { queryKeys } from '@/constants/queryKeys'; | ||
|
|
||
| type ItemStatus = 'pending' | 'uploading' | 'failed'; | ||
|
|
||
| interface FeedUploadParams { | ||
| clubId: string; | ||
| files: File[]; | ||
| existingUrls: string[]; | ||
| onItemStatusChange?: (index: number, status: ItemStatus) => void; | ||
| } | ||
|
|
||
| interface FeedUpdateParams { | ||
|
|
@@ -22,11 +25,26 @@ export const useUploadFeed = () => { | |
| const queryClient = useQueryClient(); | ||
|
|
||
| return useMutation({ | ||
| 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', | ||
| ]; | ||
| const uploadRequests = files.map((file) => ({ | ||
| fileName: file.name, | ||
| contentType: file.type, | ||
| contentType: ALLOWED_TYPES.includes(file.type) | ||
| ? file.type | ||
| : 'image/jpeg', | ||
|
Comment on lines
43
to
+47
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 서명 요청 MIME과 실제 업로드 MIME을 동일하게 맞춰주세요. Line 45-47에서 presigned 요청은 정규화된 🔧 제안 수정안 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 |
||
| })); | ||
| const feedResArr = await feedApi.getUploadUrls(clubId, uploadRequests); | ||
|
|
||
|
|
@@ -35,10 +53,18 @@ export const useUploadFeed = () => { | |
| } | ||
|
|
||
| // 2. r2에 병렬 업로드 (개별 성공/실패 추적) | ||
| // presigned URL 생성 자체가 실패한 항목은 업로드 건너뜀 | ||
| const uploadResults = await Promise.allSettled( | ||
| files.map((file, i) => | ||
| uploadToStorage(feedResArr[i].presignedUrl, file), | ||
| ), | ||
| files.map((file, i) => { | ||
| if (!feedResArr[i].success || !feedResArr[i].presignedUrl) { | ||
| return Promise.reject( | ||
| new Error( | ||
| feedResArr[i].failureReason ?? 'presigned URL 생성 실패', | ||
| ), | ||
|
Comment on lines
+58
to
+63
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. presigned 응답 인덱스 접근에 방어 로직이 필요합니다. Line 59와 Line 76에서 🔧 제안 수정안 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 |
||
| ); | ||
| } | ||
| return uploadToStorage(feedResArr[i].presignedUrl, file); | ||
| }), | ||
| ); | ||
|
|
||
| // 3. 성공한 파일만 추출 | ||
|
|
@@ -50,6 +76,7 @@ export const useUploadFeed = () => { | |
| successfulUrls.push(feedResArr[i].finalUrl); | ||
| } else { | ||
| failedFiles.push(files[i].name); | ||
| onItemStatusChange?.(i, 'failed'); | ||
| } | ||
| }); | ||
|
|
||
|
|
@@ -64,8 +91,8 @@ export const useUploadFeed = () => { | |
| // 6. 서버에 전체 배열 PUT으로 갱신 | ||
| await feedApi.updateFeeds(clubId, allUrls); | ||
|
|
||
| // 7. 실패한 파일 정보 반환 | ||
| return { clubId, failedFiles }; | ||
| // 7. 실패한 파일 정보 및 성공 URL 반환 | ||
| return { clubId, failedFiles, successfulUrls }; | ||
| }, | ||
|
|
||
| onSuccess: (data) => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| import { MemoryRouter, Outlet, Route, Routes } from 'react-router-dom'; | ||
| import type { Meta, StoryObj } from '@storybook/react'; | ||
| import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; | ||
| import type { ClubDetail } from '@/types/club'; | ||
| import PhotoEditTab from './PhotoEditTab'; | ||
|
|
||
| const SAMPLE_FEEDS = [ | ||
| 'https://picsum.photos/seed/a/400/500', | ||
| 'https://picsum.photos/seed/b/400/500', | ||
| 'https://picsum.photos/seed/c/400/500', | ||
| 'https://picsum.photos/seed/d/400/500', | ||
| 'https://picsum.photos/seed/e/400/500', | ||
| ]; | ||
|
|
||
| const mockClubDetail = (feeds: string[]): ClubDetail => ({ | ||
| id: 'club-1', | ||
| name: '테스트 동아리', | ||
| logo: '', | ||
| tags: [], | ||
| recruitmentStatus: 'OPEN', | ||
| division: '', | ||
| category: '', | ||
| introduction: '', | ||
| description: { | ||
| introDescription: '', | ||
| activityDescription: '', | ||
| awards: [], | ||
| idealCandidate: { tags: [], content: '' }, | ||
| benefits: '', | ||
| faqs: [], | ||
| }, | ||
| state: '', | ||
| feeds, | ||
| presidentName: '', | ||
| presidentPhoneNumber: '', | ||
| recruitmentForm: '', | ||
| recruitmentStart: '', | ||
| recruitmentEnd: '', | ||
| recruitmentTarget: '', | ||
| socialLinks: {} as ClubDetail['socialLinks'], | ||
| }); | ||
|
|
||
| const makeQueryClient = () => | ||
| new QueryClient({ defaultOptions: { queries: { retry: false } } }); | ||
|
|
||
| const Wrapper = ({ feeds = SAMPLE_FEEDS }: { feeds?: string[] }) => ( | ||
| <QueryClientProvider client={makeQueryClient()}> | ||
| <div style={{ maxWidth: 600, padding: 24 }}> | ||
| <MemoryRouter initialEntries={['/admin/photo']}> | ||
| <Routes> | ||
| <Route | ||
| path='/admin/photo' | ||
| element={<Outlet context={mockClubDetail(feeds)} />} | ||
| > | ||
| <Route index element={<PhotoEditTab />} /> | ||
| </Route> | ||
| </Routes> | ||
| </MemoryRouter> | ||
| </div> | ||
| </QueryClientProvider> | ||
| ); | ||
|
|
||
| const meta = { | ||
| title: 'Admin/PhotoEditTab', | ||
| parameters: { layout: 'centered' }, | ||
| } satisfies Meta; | ||
|
|
||
| export default meta; | ||
| type Story = StoryObj; | ||
|
|
||
| export const WithPhotos: Story = { | ||
| render: () => <Wrapper />, | ||
| }; | ||
|
|
||
| export const Empty: Story = { | ||
| render: () => <Wrapper feeds={[]} />, | ||
| }; | ||
|
|
||
| export const ManyPhotos: Story = { | ||
| render: () => ( | ||
| <Wrapper | ||
| feeds={Array.from( | ||
| { length: 9 }, | ||
| (_, i) => `https://picsum.photos/seed/${i + 10}/400/500`, | ||
| )} | ||
| /> | ||
| ), | ||
| }; |
There was a problem hiding this comment.
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(서명 불일치) 리스크가 있습니다.