Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
6 changes: 3 additions & 3 deletions frontend/src/components/common/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const StyledButton = styled.button<ButtonProps>`
transition: background-color 0.2s;
width: ${({ width }) => width || 'auto'};

&:hover {
&:hover:not(:disabled) {
background-color: #333333;
${({ animated }) =>
animated &&
Expand All @@ -42,9 +42,9 @@ const StyledButton = styled.button<ButtonProps>`
}

&:disabled {
background-color: #cccccc; /* 비활성화된 느낌의 회색 */
background-color: #cccccc;
color: #666666;
cursor: not-allowed; /* 클릭할 수 없음을 나타내는 커서 */
cursor: not-allowed;
opacity: 0.7;
}
`;
Expand Down
23 changes: 16 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,12 @@ 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'];
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는 지원 안 하는거였죠??

const uploadRequests = files.map((file) => ({
fileName: file.name,
contentType: file.type,
contentType: ALLOWED_TYPES.includes(file.type) ? file.type : 'image/jpeg',
}));
const feedResArr = await feedApi.getUploadUrls(clubId, uploadRequests);

Expand All @@ -35,10 +39,14 @@ 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 생성 실패'));
}
return uploadToStorage(feedResArr[i].presignedUrl, file);
}),
);

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

Expand All @@ -64,8 +73,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,82 @@
import type { Meta, StoryObj } from '@storybook/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter, Routes, Route, Outlet } from 'react-router-dom';
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`)}
/>
),
};
164 changes: 154 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,165 @@
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