Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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,111 @@
import { useLayoutEffect, useState } from 'react';
import { ImagePreview } from '@/pages/AdminPage/tabs/PhotoEditTab/components/ImagePreview/ImagePreview';
import { DropPosition } from '../../hooks/useDragSort';
import { FeedItem } from '../../PhotoEditTab';
import * as Styled from '../../PhotoEditTab.styles';

interface FeedImageGridProps {
feedItems: FeedItem[];
gridRef: React.RefObject<HTMLDivElement | null>;
dragIndex: number | null;
dropPosition: DropPosition;
isLoading: boolean;
onMouseDown: (e: React.MouseEvent, index: number) => void;
onDelete: (index: number) => void;
onRetry: (index: number) => void;
}

const calcDividerStyle = (
grid: HTMLDivElement,
cards: HTMLElement[],
idx: number,
) => {
const gridRect = grid.getBoundingClientRect();
const ref = cards[Math.min(idx, cards.length - 1)];
const refRect = ref.getBoundingClientRect();

let x: number;
if (idx === 0) {
x = refRect.left - gridRect.left;
} else if (idx === cards.length) {
x = refRect.right - gridRect.left;
} else {
const prevRect = cards[idx - 1].getBoundingClientRect();
const sameRow = Math.abs(prevRect.top - refRect.top) < refRect.height / 2;
x = sameRow
? (prevRect.right + refRect.left) / 2 - gridRect.left
: refRect.left - gridRect.left;
}

return { x, top: refRect.top - gridRect.top, height: refRect.height };
};

export const FeedImageGrid = ({
feedItems,
gridRef,
dragIndex,
dropPosition,
isLoading,
onMouseDown,
onDelete,
onRetry,
}: FeedImageGridProps) => {
const dividerIndex = dropPosition
? dropPosition.side === 'before'
? dropPosition.index
: dropPosition.index + 1
: null;

const [divider, setDivider] = useState<{
x: number;
top: number;
height: number;
} | null>(null);

useLayoutEffect(() => {
if (dividerIndex === null || !gridRef.current) {
setDivider(null);
return;
}
const cards = Array.from(
gridRef.current.querySelectorAll<HTMLElement>('[data-card-index]'),
);
if (cards.length === 0) return;
setDivider(calcDividerStyle(gridRef.current, cards, dividerIndex));
}, [dividerIndex]);

return (
<Styled.ImageGrid ref={gridRef}>
{feedItems.map((item, index) => (
<Styled.DragItem
key={item.type === 'uploaded' ? item.url : item.previewUrl}
data-card-index={index}
onMouseDown={(e) => onMouseDown(e, index)}
$isDragging={dragIndex === index}
$isDimmed={dragIndex !== null && dragIndex !== index}
>
<ImagePreview
image={item.type === 'uploaded' ? item.url : item.previewUrl}
status={item.type === 'local' ? item.status : undefined}
disabled={isLoading}
onDelete={() => onDelete(index)}
onRetry={
item.type === 'local' && item.status === 'failed'
? () => onRetry(index)
: undefined
}
/>
</Styled.DragItem>
))}

{divider && (
<Styled.DropDivider
$visible
$x={divider.x}
$top={divider.top}
$height={divider.height}
/>
)}
</Styled.ImageGrid>
);
};
Original file line number Diff line number Diff line change
@@ -1,33 +1,106 @@
import styled from 'styled-components';
import styled, { keyframes } from 'styled-components';

const shimmer = keyframes`
0% { transform: translateX(-100%); }
100% { transform: translateX(250%); }
`;

export const ImagePreviewContainer = styled.div`
display: inline-block;
width: 300px;
height: 300px;
border-radius: 18px;
width: 100%;
aspect-ratio: 4 / 5;
overflow: hidden;
position: relative;
margin-right: 20px;
user-select: none;
background-color: ${({ theme }) => theme.colors.gray[100]};

img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
pointer-events: none;
}
`;

export const ClearButton = styled.button`
position: absolute;
top: 20px;
right: 20px;
top: 8px;
right: 8px;
border: none;
cursor: pointer;
background-color: transparent;

img {
width: 32px;
height: 32px;
width: 28px;
height: 28px;
}

&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;

export const Overlay = styled.div<{ $error?: boolean }>`
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
background-color: ${({ $error }) =>
$error ? 'rgba(220, 38, 38, 0.6)' : 'rgba(0, 0, 0, 0.4)'};
color: white;
font-size: 0.875rem;
font-weight: 600;
`;

export const ProgressBar = styled.div`
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background-color: rgba(255, 255, 255, 0.3);
overflow: hidden;

&::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 40%;
height: 100%;
background-color: white;
animation: ${shimmer} 1.2s ease-in-out infinite;
}
`;

export const PendingBadge = styled.div`
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
padding: 2px 8px;
border-radius: 4px;
background-color: rgba(0, 0, 0, 0.5);
color: white;
font-size: 0.7rem;
font-weight: 500;
white-space: nowrap;
`;

export const RetryButton = styled.button`
padding: 4px 12px;
border: 1.5px solid white;
border-radius: 6px;
background: transparent;
color: white;
font-size: 0.75rem;
cursor: pointer;

&:hover {
opacity: 0.8;
background-color: rgba(255, 255, 255, 0.2);
}
`;
Original file line number Diff line number Diff line change
@@ -1,29 +1,43 @@
import clearButton from '@/assets/images/icons/input_clear_button_icon.svg';
import * as Styled from './ImagePreview.styles';

type ItemStatus = 'pending' | 'uploading' | 'failed';

interface ImagePreviewProps {
image: string;
onDelete: () => void;
disabled?: boolean;
status?: ItemStatus;
onRetry?: () => void;
}

export const ImagePreview = ({
image,
onDelete,
disabled = false,
status,
onRetry,
}: ImagePreviewProps) => {
return (
<Styled.ImagePreviewContainer>
<img src={image} alt='preview' />
<img src={image} alt='preview' draggable={false} />

{status === 'pending' && <Styled.PendingBadge>업로드 예정</Styled.PendingBadge>}

{status === 'failed' && (
<Styled.Overlay $error>
<span>실패</span>
{onRetry && (
<Styled.RetryButton onClick={onRetry}>재전송</Styled.RetryButton>
)}
</Styled.Overlay>
)}

<Styled.ClearButton
onClick={disabled ? undefined : onDelete}
disabled={disabled}
style={{
opacity: disabled ? 0.5 : 1,
cursor: disabled ? 'not-allowed' : 'pointer',
}}
onClick={onDelete}
disabled={disabled || status === 'uploading'}
>
<img src={clearButton} alt='삭제' />
<img src={clearButton} alt='삭제' draggable={false} />
</Styled.ClearButton>
</Styled.ImagePreviewContainer>
);
Expand Down
Loading