Skip to content
Open
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
6 changes: 5 additions & 1 deletion frontend/src/apis/image.ts
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(서명 불일치) 리스크가 있습니다.

Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { handleResponse } from './utils/apiHelpers';
interface PresignedData {
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.

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

}

interface FeedUploadRequest {
Expand All @@ -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}`);
}
Expand Down
41 changes: 34 additions & 7 deletions frontend/src/hooks/Queries/useClubImages.ts
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을 같은 위치에 치환하는 방식으로 저장해 주세요.

Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
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.

}));
const feedResArr = await feedApi.getUploadUrls(clubId, uploadRequests);

Expand All @@ -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
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.

);
}
return uploadToStorage(feedResArr[i].presignedUrl, file);
}),
);

// 3. 성공한 파일만 추출
Expand All @@ -50,6 +76,7 @@ export const useUploadFeed = () => {
successfulUrls.push(feedResArr[i].finalUrl);
} else {
failedFiles.push(files[i].name);
onItemStatusChange?.(i, 'failed');
}
});

Expand All @@ -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) => {
Expand Down
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`,
)}
/>
),
};
176 changes: 166 additions & 10 deletions frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.styles.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,177 @@
import styled from 'styled-components';
import styled, { keyframes } from 'styled-components';

const spin = keyframes`
to { transform: rotate(360deg); }
`;

export const ButtonSpinner = styled.span`
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.4);
border-top-color: #ffffff;
border-radius: 50%;
animation: ${spin} 0.7s linear infinite;
vertical-align: middle;
margin-right: 6px;
`;

export const Container = styled.div`
display: flex;
flex-direction: column;
gap: 60px;
`;

export const GridHeader = styled.div`
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
`;

export const AddButton = styled.button`
padding: 6px 14px;
border-radius: 8px;
border: 1.5px solid #d1d5db;
background: transparent;
font-size: 0.875rem;
color: #374151;
cursor: pointer;

&:hover {
border-color: #6b7280;
background: #f9fafb;
}

&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
Comment on lines +41 to +49
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.

`;

export const ClearAllButton = styled(AddButton)`
color: #ef4444;
border-color: #fca5a5;

&:hover {
border-color: #ef4444;
background: #fff1f2;
}
`;

export const GridWrapper = styled.div<{ $uploading?: boolean }>`
position: relative;
padding: 16px;
min-height: 320px;
border-radius: 16px;
background: #fafafa;
display: flex;
align-items: center;
justify-content: center;
border: ${({ $uploading, theme }) =>
$uploading
? `2px solid ${theme.colors.primary[900]}`
: '2px dashed #e5e7eb'};
transition: border-color 0.3s;
`;

export const UploadOverlay = styled.div`
position: absolute;
inset: 0;
border-radius: 14px;
background-color: rgba(255, 255, 255, 0.75);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
z-index: 20;
pointer-events: none;
backdrop-filter: blur(2px);

span {
font-size: 0.875rem;
font-weight: 600;
color: ${({ theme }) => theme.colors.primary[900]};
}
`;

export const OverlaySpinner = styled.span`
display: inline-block;
width: 36px;
height: 36px;
border: 3px solid ${({ theme }) => theme.colors.primary[600]};
border-top-color: ${({ theme }) => theme.colors.primary[900]};
border-radius: 50%;
animation: ${spin} 0.8s linear infinite;
`;

export const ImageGrid = styled.div`
overflow-x: auto;
white-space: nowrap;
overflow-y: hidden;
padding-bottom: 24px;
max-width: 770px;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
width: 100%;
align-self: flex-start;
position: relative;
`;

export const EmptyState = styled.button`
width: 100%;
min-height: 200px;
border-radius: 12px;
border: none;
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: #9ca3af;
font-size: 0.875rem;
cursor: pointer;

&:hover {
color: #6b7280;
}

span:first-child {
font-size: 2rem;
}
`;

export const DragItem = styled.div<{
$isDragging?: boolean;
$isDimmed?: boolean;
}>`
opacity: ${({ $isDragging, $isDimmed }) =>
$isDragging ? 1 : $isDimmed ? 0.35 : 1};
filter: ${({ $isDimmed }) => ($isDimmed ? 'blur(1.5px)' : 'none')};
cursor: grab;
transition:
opacity 0.2s,
filter 0.2s;

&:active {
cursor: grabbing;
}
`;

export const Label = styled.p`
font-size: 1.125rem;
margin-bottom: 8px;
font-weight: 600;
export const DropDivider = styled.div<{
$x: number;
$top: number;
$height: number;
$visible: boolean;
}>`
position: absolute;
left: ${({ $x }) => $x}px;
top: ${({ $top }) => $top}px;
height: ${({ $height }) => $height}px;
width: 3px;
transform: translateX(-50%);
border-radius: 2px;
background-color: ${({ $visible, theme }) =>
$visible ? theme.colors.primary[900] : 'transparent'};
transition: background-color 0.1s;
pointer-events: none;
z-index: 10;
`;
Loading
Loading