From 555b29f4d495c27545a8e5e8200757904f603411 Mon Sep 17 00:00:00 2001 From: Junseo Kim Date: Sun, 29 Mar 2026 11:56:36 +0900 Subject: [PATCH 1/8] =?UTF-8?q?fix:=20=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94?= =?UTF-8?q?=EB=90=9C=20Button=EC=97=90=EC=84=9C=20hover=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EC=A0=81=EC=9A=A9=20=EC=95=88=EB=90=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/common/Button/Button.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/common/Button/Button.tsx b/frontend/src/components/common/Button/Button.tsx index 61fe7eba5..495436868 100644 --- a/frontend/src/components/common/Button/Button.tsx +++ b/frontend/src/components/common/Button/Button.tsx @@ -28,7 +28,7 @@ const StyledButton = styled.button` transition: background-color 0.2s; width: ${({ width }) => width || 'auto'}; - &:hover { + &:hover:not(:disabled) { background-color: #333333; ${({ animated }) => animated && @@ -42,9 +42,9 @@ const StyledButton = styled.button` } &:disabled { - background-color: #cccccc; /* 비활성화된 느낌의 회색 */ + background-color: #cccccc; color: #666666; - cursor: not-allowed; /* 클릭할 수 없음을 나타내는 커서 */ + cursor: not-allowed; opacity: 0.7; } `; From 49f4060a318cd77dd6537e80fb0e22c93db0dd58 Mon Sep 17 00:00:00 2001 From: Junseo Kim Date: Sun, 29 Mar 2026 11:56:46 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20API=20presigned=20URL=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20contentType=20?= =?UTF-8?q?=ED=8F=B4=EB=B0=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/apis/image.ts | 6 +++++- frontend/src/hooks/Queries/useClubImages.ts | 23 ++++++++++++++------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/frontend/src/apis/image.ts b/frontend/src/apis/image.ts index fcb1082f1..ae8ea3d20 100644 --- a/frontend/src/apis/image.ts +++ b/frontend/src/apis/image.ts @@ -5,6 +5,8 @@ import { handleResponse } from './utils/apiHelpers'; interface PresignedData { presignedUrl: string; finalUrl: string; + success: boolean; + failureReason: string | null; } interface FeedUploadRequest { @@ -16,11 +18,13 @@ interface FeedUploadRequest { export async function uploadToStorage( presignedUrl: string, file: File, + contentType?: string, ): Promise { + 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}`); } diff --git a/frontend/src/hooks/Queries/useClubImages.ts b/frontend/src/hooks/Queries/useClubImages.ts index 02ce9cfab..e705f95d1 100644 --- a/frontend/src/hooks/Queries/useClubImages.ts +++ b/frontend/src/hooks/Queries/useClubImages.ts @@ -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,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']; 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); @@ -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. 성공한 파일만 추출 @@ -50,6 +58,7 @@ export const useUploadFeed = () => { successfulUrls.push(feedResArr[i].finalUrl); } else { failedFiles.push(files[i].name); + onItemStatusChange?.(i, 'failed'); } }); @@ -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) => { From 4d7d029957495d3a5e6603741d935c8223b51cf6 Mon Sep 17 00:00:00 2001 From: Junseo Kim Date: Sun, 29 Mar 2026 12:19:54 +0900 Subject: [PATCH 3/8] =?UTF-8?q?refactor:=20ImagePreview=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EC=83=81=ED=83=9C=20=ED=91=9C=EC=8B=9C=20?= =?UTF-8?q?=EB=B0=8F=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - status prop 추가 (pending / uploading / failed) 에 따라 오버레이 렌더링 - failed 상태에서 재전송 버튼(RetryButton) 노출 - pending 상태에서 '업로드 예정' 뱃지 노출 - ProgressBar, shimmer 애니메이션 추가 - 이미지 비율 4:5 고정(aspect-ratio), object-fit cover 적용 - 삭제 버튼 disabled 상태 스타일 CSS로 통일 (인라인 스타일 제거) --- .../ImagePreview/ImagePreview.styles.ts | 95 ++++++++++++++++--- .../components/ImagePreview/ImagePreview.tsx | 30 ++++-- 2 files changed, 106 insertions(+), 19 deletions(-) diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/ImagePreview/ImagePreview.styles.ts b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/ImagePreview/ImagePreview.styles.ts index 66eb39c2a..812439e43 100644 --- a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/ImagePreview/ImagePreview.styles.ts +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/ImagePreview/ImagePreview.styles.ts @@ -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); } `; diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/ImagePreview/ImagePreview.tsx b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/ImagePreview/ImagePreview.tsx index 901d6b956..5baaa78b1 100644 --- a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/ImagePreview/ImagePreview.tsx +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/ImagePreview/ImagePreview.tsx @@ -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 ( - preview + preview + + {status === 'pending' && 업로드 예정} + + {status === 'failed' && ( + + 실패 + {onRetry && ( + 재전송 + )} + + )} + - 삭제 + 삭제 ); From 82927481336affa33fc61623d91263335bf9159a Mon Sep 17 00:00:00 2001 From: Junseo Kim Date: Sun, 29 Mar 2026 12:20:08 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20FeedImageGrid=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20=ED=9B=85?= =?UTF-8?q?/=EC=9C=A0=ED=8B=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FeedImageGrid: 이미지 그리드 렌더링 + 드래그 중 DropDivider 위치 계산 - useDragSort: 마우스 드래그로 카드 순서 변경, 카드 안팎/행 간 드롭 위치 감지 - useFeedItems: 피드 아이템 상태 관리 (추가·삭제·초기화·저장·재시도) - 로컬 파일은 previewUrl(Object URL)로 미리보기, 언마운트 시 revoke - 저장 시 개별 아이템 status를 uploading → uploaded/failed 로 전환 - 실패한 항목 단건 재업로드(retryItem) 지원 - photoEditUtils: 파일 검증(용량 초과, 개수 제한), 드래그 순서 변경, 저장 버튼 활성화 여부 순수 함수로 분리 --- .../FeedImageGrid/FeedImageGrid.tsx | 111 +++++++++++ .../tabs/PhotoEditTab/hooks/useDragSort.ts | 114 ++++++++++++ .../tabs/PhotoEditTab/hooks/useFeedItems.ts | 175 ++++++++++++++++++ .../tabs/PhotoEditTab/photoEditUtils.ts | 46 +++++ 4 files changed, 446 insertions(+) create mode 100644 frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/FeedImageGrid/FeedImageGrid.tsx create mode 100644 frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useDragSort.ts create mode 100644 frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useFeedItems.ts create mode 100644 frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.ts diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/FeedImageGrid/FeedImageGrid.tsx b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/FeedImageGrid/FeedImageGrid.tsx new file mode 100644 index 000000000..4ff3bbd2b --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/FeedImageGrid/FeedImageGrid.tsx @@ -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; + 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('[data-card-index]'), + ); + if (cards.length === 0) return; + setDivider(calcDividerStyle(gridRef.current, cards, dividerIndex)); + }, [dividerIndex]); + + return ( + + {feedItems.map((item, index) => ( + onMouseDown(e, index)} + $isDragging={dragIndex === index} + $isDimmed={dragIndex !== null && dragIndex !== index} + > + onDelete(index)} + onRetry={ + item.type === 'local' && item.status === 'failed' + ? () => onRetry(index) + : undefined + } + /> + + ))} + + {divider && ( + + )} + + ); +}; diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useDragSort.ts b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useDragSort.ts new file mode 100644 index 000000000..1ffb36f84 --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useDragSort.ts @@ -0,0 +1,114 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { reorderItems } from '../photoEditUtils'; +import { FeedItem } from '../PhotoEditTab'; + +export type DropPosition = { index: number; side: 'before' | 'after' } | null; + +const DRAG_THRESHOLD = 5; + +interface UseDragSortOptions { + disabled?: boolean; + onReorder: (items: FeedItem[]) => void; + feedItemsRef: React.RefObject; +} + +export const useDragSort = ({ disabled, onReorder, feedItemsRef }: UseDragSortOptions) => { + const gridRef = useRef(null); + const dragStartRef = useRef<{ index: number; x: number; y: number } | null>(null); + const isDraggingRef = useRef(false); + + const [dragIndex, setDragIndex] = useState(null); + const [dropPosition, setDropPosition] = useState(null); + + const getDropPositionFromPoint = useCallback((clientX: number, clientY: number): DropPosition => { + if (!gridRef.current) return null; + const cards = Array.from(gridRef.current.querySelectorAll('[data-card-index]')); + if (cards.length === 0) return null; + + // 카드 위에 정확히 있는 경우: 카드 중앙 기준으로 before/after + for (const card of cards) { + const rect = card.getBoundingClientRect(); + if (clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom) continue; + const index = Number(card.dataset.cardIndex); + return { index, side: clientX < rect.left + rect.width / 2 ? 'before' : 'after' }; + } + + // 카드 밖 — 같은 행(Y 범위 겹침) 카드 중 X 거리 가장 가까운 끝으로 + let closest: { index: number; side: 'before' | 'after'; dist: number } | null = null; + for (const card of cards) { + const rect = card.getBoundingClientRect(); + if (clientY < rect.top || clientY > rect.bottom) continue; + const index = Number(card.dataset.cardIndex); + const distLeft = Math.abs(clientX - rect.left); + const distRight = Math.abs(clientX - rect.right); + if (distLeft < distRight) { + if (!closest || distLeft < closest.dist) closest = { index, side: 'before', dist: distLeft }; + } else { + if (!closest || distRight < closest.dist) closest = { index, side: 'after', dist: distRight }; + } + } + if (closest) return { index: closest.index, side: closest.side }; + + // 행 간 gap — Y 거리 기준 가장 가까운 카드로 + for (const card of cards) { + const rect = card.getBoundingClientRect(); + const index = Number(card.dataset.cardIndex); + const distY = Math.min(Math.abs(clientY - rect.top), Math.abs(clientY - rect.bottom)); + const side: 'before' | 'after' = clientX < rect.left + rect.width / 2 ? 'before' : 'after'; + if (!closest || distY < closest.dist) closest = { index, side, dist: distY }; + } + + return closest ? { index: closest.index, side: closest.side } : null; + }, []); + + const handleMouseDown = (e: React.MouseEvent, index: number) => { + if (disabled || e.button !== 0) return; + dragStartRef.current = { index, x: e.clientX, y: e.clientY }; + }; + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!dragStartRef.current) return; + const dx = e.clientX - dragStartRef.current.x; + const dy = e.clientY - dragStartRef.current.y; + + if (!isDraggingRef.current && Math.hypot(dx, dy) > DRAG_THRESHOLD) { + isDraggingRef.current = true; + setDragIndex(dragStartRef.current.index); + } + + if (!isDraggingRef.current) return; + setDropPosition(getDropPositionFromPoint(e.clientX, e.clientY)); + }; + + const handleMouseUp = (e: MouseEvent) => { + if (!dragStartRef.current) return; + + if (isDraggingRef.current) { + const pos = getDropPositionFromPoint(e.clientX, e.clientY); + if (pos !== null) { + const fromIndex = dragStartRef.current.index; + const targetIndex = pos.side === 'after' ? pos.index + 1 : pos.index; + const current = feedItemsRef.current; + if (fromIndex !== targetIndex && fromIndex < current.length) { + onReorder(reorderItems(current, fromIndex, targetIndex)); + } + } + } + + dragStartRef.current = null; + isDraggingRef.current = false; + setDragIndex(null); + setDropPosition(null); + }; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, [getDropPositionFromPoint, onReorder, feedItemsRef]); + + return { gridRef, dragIndex, dropPosition, handleMouseDown }; +}; diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useFeedItems.ts b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useFeedItems.ts new file mode 100644 index 000000000..bad604528 --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useFeedItems.ts @@ -0,0 +1,175 @@ +import { useEffect, useRef, useState } from 'react'; +import { useUploadFeed, useUpdateFeed } from '@/hooks/Queries/useClubImages'; +import { findOversizedFile, hasPendingChanges, sliceToLimit } from '../photoEditUtils'; +import { FeedItem, LocalItem, UploadedItem } from '../PhotoEditTab'; + +export const useFeedItems = (clubId: string, originalFeeds: string[]) => { + const { mutate: uploadFeed, isPending: isUploading } = useUploadFeed(); + const { mutate: updateFeed, isPending: isUpdating } = useUpdateFeed(); + + const [feedItems, setFeedItems] = useState([]); + const feedItemsRef = useRef(feedItems); + + const isLoading = isUploading || isUpdating; + const pendingChanges = hasPendingChanges(feedItems, originalFeeds); + + useEffect(() => { feedItemsRef.current = feedItems; }, [feedItems]); + + useEffect(() => { + setFeedItems((originalFeeds || []).map((url) => ({ type: 'uploaded', url }))); + }, [originalFeeds]); + + useEffect(() => { + return () => { + feedItemsRef.current.forEach((item) => { + if (item.type === 'local') URL.revokeObjectURL(item.previewUrl); + }); + }; + }, []); + + const addFiles = (fileList: File[]) => { + const selected = sliceToLimit(fileList, feedItems.length); + const oversized = findOversizedFile(selected); + if (oversized) { + alert(`${oversized.name}의 용량이 제한을 초과했습니다.`); + return; + } + const newItems: LocalItem[] = selected.map((file) => ({ + type: 'local', + file, + previewUrl: URL.createObjectURL(file), + status: 'pending', + })); + setFeedItems((prev) => [...prev, ...newItems]); + }; + + const deleteImage = (index: number) => { + if (isLoading) return; + const item = feedItems[index]; + if (item.type === 'local') URL.revokeObjectURL(item.previewUrl); + setFeedItems((prev) => prev.filter((_, i) => i !== index)); + }; + + const clearAll = () => { + feedItems.forEach((item) => { + if (item.type === 'local') URL.revokeObjectURL(item.previewUrl); + }); + setFeedItems([]); + }; + + const retryItem = (index: number) => { + const item = feedItems[index]; + if (item.type !== 'local' || item.status !== 'failed') return; + + const uploadedUrls = feedItems + .filter((it): it is UploadedItem => it.type === 'uploaded') + .map((it) => it.url); + + setFeedItems((prev) => + prev.map((it, i) => + i === index && it.type === 'local' ? { ...it, status: 'uploading' } : it, + ), + ); + + uploadFeed( + { clubId, files: [item.file], existingUrls: uploadedUrls }, + { + onSuccess: (data) => { + const finalUrl = data.successfulUrls[0]; + if (!finalUrl) return; + setFeedItems((prev) => + prev.map((it, i) => { + if (i !== index || it.type !== 'local') return it; + URL.revokeObjectURL(it.previewUrl); + return { type: 'uploaded', url: finalUrl } as UploadedItem; + }), + ); + }, + onError: () => { + setFeedItems((prev) => + prev.map((it, i) => + i === index && it.type === 'local' ? { ...it, status: 'failed' } : it, + ), + ); + }, + }, + ); + }; + + const save = () => { + const localItems = feedItems.filter((item): item is LocalItem => item.type === 'local'); + const uploadedUrls = feedItems + .filter((item): item is UploadedItem => item.type === 'uploaded') + .map((item) => item.url); + + if (localItems.length === 0) { + updateFeed( + { clubId, urls: uploadedUrls }, + { onError: () => alert('저장에 실패했어요. 다시 시도해주세요!') }, + ); + return; + } + + setFeedItems((prev) => + prev.map((item) => (item.type === 'local' ? { ...item, status: 'uploading' } : item)), + ); + + uploadFeed( + { + clubId, + files: localItems.map((item) => item.file), + existingUrls: uploadedUrls, + onItemStatusChange: (localIdx, status) => { + setFeedItems((prev) => { + let count = 0; + return prev.map((item) => { + if (item.type !== 'local') return item; + return count++ === localIdx ? { ...item, status } : item; + }); + }); + }, + }, + { + onSuccess: (data) => { + if (data.failedFiles.length > 0) { + alert( + `일부 파일 업로드에 실패했어요.\n실패한 파일: ${data.failedFiles.join(', ')}\n\n성공한 파일은 정상적으로 등록되었어요.`, + ); + } + setFeedItems((prev) => { + let successIdx = 0; + return prev.map((item) => { + if (item.type !== 'local' || item.status === 'failed') return item; + const finalUrl = data.successfulUrls[successIdx++]; + URL.revokeObjectURL(item.previewUrl); + return { type: 'uploaded', url: finalUrl } as UploadedItem; + }); + }); + }, + onError: () => { + alert('이미지 업로드에 실패했어요. 다시 시도해주세요!'); + setFeedItems((prev) => + prev.map((item) => + item.type === 'local' && item.status === 'uploading' + ? { ...item, status: 'failed' } + : item, + ), + ); + }, + }, + ); + }; + + return { + feedItems, + feedItemsRef, + setFeedItems, + isLoading, + pendingChanges, + addFiles, + deleteImage, + clearAll, + retryItem, + save, + }; +}; diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.ts b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.ts new file mode 100644 index 000000000..e59d04bfe --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.ts @@ -0,0 +1,46 @@ +import { MAX_FILE_COUNT, MAX_FILE_SIZE } from '@/constants/uploadLimit'; +import { FeedItem, LocalItem, UploadedItem } from './PhotoEditTab'; + +export const ALLOWED_IMAGE_TYPES = [ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/gif', + 'image/bmp', + 'image/webp', +]; + +// 파일 목록에서 용량 초과 파일 반환 +export const findOversizedFile = (files: File[]): File | undefined => + files.find((f) => f.size > MAX_FILE_SIZE); + +// 현재 아이템 수 기준으로 추가 가능한 파일만 슬라이스 +export const sliceToLimit = (files: File[], currentCount: number): File[] => { + const remaining = MAX_FILE_COUNT - currentCount; + return files.slice(0, remaining); +}; + +// 드래그 앤 드롭 순서 변경 +export const reorderItems = ( + items: FeedItem[], + dragIndex: number, + targetIndex: number, +): FeedItem[] => { + const next = [...items]; + const [moved] = next.splice(dragIndex, 1); + const insertAt = dragIndex < targetIndex ? targetIndex - 1 : targetIndex; + next.splice(insertAt, 0, moved); + return next; +}; + +// 저장 버튼 활성화 여부 — 로컬 파일이 있거나 uploaded URL 순서/삭제 변경 시 true +export const hasPendingChanges = ( + feedItems: FeedItem[], + originalFeeds: string[], +): boolean => { + if (feedItems.some((item) => item.type === 'local')) return true; + const currentUrls = feedItems + .filter((item): item is UploadedItem => item.type === 'uploaded') + .map((item) => item.url); + return currentUrls.join() !== originalFeeds.join(); +}; From 003486dd74f4924dd852d690c216dead6dc1df51 Mon Sep 17 00:00:00 2001 From: Junseo Kim Date: Sun, 29 Mar 2026 12:20:51 +0900 Subject: [PATCH 5/8] =?UTF-8?q?refactor:=20PhotoEditTab=20FeedImageGrid=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EC=A0=84=EB=A9=B4=20?= =?UTF-8?q?=EA=B0=9C=ED=8E=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 가로 스크롤 ImageGrid → 3열 그리드(GridWrapper) 레이아웃으로 교체 - 이미지 상태 관리 로직을 useFeedItems 훅으로 분리 - 드래그 정렬을 useDragSort 훅으로 분리 - 업로드 진행 중 오버레이(UploadOverlay + OverlaySpinner) 표시 - 파일 없을 때 빈 상태(EmptyState) 클릭으로 파일 선택 가능 - 헤더에 파일 추가 버튼 및 전체 삭제(ClearAllButton) 버튼 추가 - 저장 버튼 로딩 스피너(ButtonSpinner) 추가 --- .../tabs/PhotoEditTab/PhotoEditTab.styles.ts | 164 +++++++++++++- .../tabs/PhotoEditTab/PhotoEditTab.tsx | 207 ++++++++---------- 2 files changed, 251 insertions(+), 120 deletions(-) diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.styles.ts b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.styles.ts index a8c74b814..0a109be90 100644 --- a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.styles.ts +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.styles.ts @@ -1,4 +1,20 @@ -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; @@ -6,16 +22,144 @@ export const Container = styled.div` 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; + } +`; + +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(3, 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; `; diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.tsx b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.tsx index 222d33278..1a2891ef9 100644 --- a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.tsx +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.tsx @@ -1,152 +1,139 @@ -import { useEffect, useRef, useState } from 'react'; +import { useRef } from 'react'; import { useOutletContext } from 'react-router-dom'; import Button from '@/components/common/Button/Button'; import { ADMIN_EVENT, PAGE_VIEW } from '@/constants/eventName'; -import { MAX_FILE_COUNT, MAX_FILE_SIZE } from '@/constants/uploadLimit'; +import { MAX_FILE_COUNT } from '@/constants/uploadLimit'; import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import useTrackPageView from '@/hooks/Mixpanel/useTrackPageView'; -import { useUpdateFeed, useUploadFeed } from '@/hooks/Queries/useClubImages'; import { ContentSection } from '@/pages/AdminPage/components/ContentSection/ContentSection'; -import { ImagePreview } from '@/pages/AdminPage/tabs/PhotoEditTab/components/ImagePreview/ImagePreview'; import { ClubDetail } from '@/types/club'; +import { FeedImageGrid } from './components/FeedImageGrid/FeedImageGrid'; +import { useDragSort } from './hooks/useDragSort'; +import { useFeedItems } from './hooks/useFeedItems'; import * as Styled from './PhotoEditTab.styles'; +export interface UploadedItem { type: 'uploaded'; url: string } +export interface LocalItem { + type: 'local'; + file: File; + previewUrl: string; + status: 'pending' | 'uploading' | 'failed'; +} +export type FeedItem = UploadedItem | LocalItem; + const PhotoEditTab = () => { const trackEvent = useMixpanelTrack(); useTrackPageView(PAGE_VIEW.PHOTO_EDIT_PAGE); const clubDetail = useOutletContext(); - const { mutate: uploadFeed, isPending: isUploading } = useUploadFeed(); - const { mutate: updateFeed, isPending: isUpdating } = useUpdateFeed(); - - const [imageList, setImageList] = useState([]); + const { + feedItems, + feedItemsRef, + setFeedItems, + isLoading, + pendingChanges, + addFiles, + deleteImage, + clearAll, + retryItem, + save, + } = useFeedItems(clubDetail?.id, clubDetail?.feeds || []); + + const isFull = feedItems.length >= MAX_FILE_COUNT; const inputRef = useRef(null); - const isLoading = isUploading || isUpdating; - - useEffect(() => { - if (!clubDetail) return; - setImageList(clubDetail.feeds || []); - }, [clubDetail]); - - const handleFiles = (files: FileList | null) => { - if (!files || files.length === 0) return; - - uploadFeed( - { - clubId: clubDetail.id, - files: Array.from(files), - existingUrls: imageList, - }, - { - onSuccess: (data) => { - if (data.failedFiles.length > 0) { - const failedFileNames = data.failedFiles.join(', '); - alert( - `일부 파일 업로드에 실패했어요.\n실패한 파일: ${failedFileNames}\n\n성공한 파일은 정상적으로 등록되었어요.`, - ); - } - }, - onError: () => { - alert('이미지 업로드에 실패했어요. 다시 시도해주세요!'); - }, - }, - ); - }; - - const handleUploadClick = () => { - if (isLoading) return; + const { gridRef, dragIndex, dropPosition, handleMouseDown } = useDragSort({ + disabled: isLoading, + onReorder: setFeedItems, + feedItemsRef, + }); + const handleAddClick = () => { + if (isLoading || isFull) return; trackEvent(ADMIN_EVENT.IMAGE_UPLOAD_BUTTON_CLICKED); - - if (imageList.length >= MAX_FILE_COUNT) { - alert(`이미지는 최대 ${MAX_FILE_COUNT}장까지만 업로드할 수 있어요.`); - return; - } - inputRef.current?.click(); }; const handleFileChange = (e: React.ChangeEvent) => { - const files = e.target.files; - if (!files || files.length === 0) return; - - const oversizedFile = Array.from(files).find( - (file) => file.size > MAX_FILE_SIZE, - ); - - if (oversizedFile) { - alert( - `선택한 사진 중 ${oversizedFile.name}의 용량이 제한을 초과했습니다.`, - ); - e.target.value = ''; - return; - } - - handleFiles(files); + if (!e.target.files?.length) return; + addFiles(Array.from(e.target.files)); + e.target.value = ''; }; - const deleteImage = (index: number) => { - if (isLoading) return; - - const newList = imageList.filter((_, i) => i !== index); - setImageList(newList); - - updateFeed( - { - clubId: clubDetail.id, - urls: newList, - }, - { - onError: () => { - alert('이미지 삭제에 실패했어요. 다시 시도해주세요!'); - }, - }, - ); + const handleClearAll = () => { + if (isLoading || !window.confirm('모든 사진을 삭제하시겠어요?')) return; + clearAll(); }; return ( - - - - 활동사진 추가 (최대 {MAX_FILE_COUNT}장) - -
- - + - {isUploading ? '업로드 중...' : '이미지 업로드'} + {isLoading && } + {isLoading ? '저장 중...' : '저장하기'} -
+ } + /> - 활동사진 수정 - - {imageList.map((image, index) => ( - { + + + + {feedItems.length > 0 && ( + + + + 이미지 추가 + + + 전체 삭제 + + + )} + + + {isLoading && ( + + + 사진을 업로드하고 있어요 + + )} + {feedItems.length === 0 ? ( + + + + 사진을 추가해보세요 + 최대 {MAX_FILE_COUNT}장 + + ) : ( + { trackEvent(ADMIN_EVENT.IMAGE_DELETE_BUTTON_CLICKED); deleteImage(index); }} + onRetry={retryItem} /> - ))} - + )} +
From 9141885eb80ef1018af0f83522535c67e1a44016 Mon Sep 17 00:00:00 2001 From: Junseo Kim Date: Sun, 29 Mar 2026 12:21:25 +0900 Subject: [PATCH 6/8] =?UTF-8?q?test:=20PhotoEditTab=20=EC=9C=A0=EB=8B=9B?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B0=8F=20Storybook=20?= =?UTF-8?q?=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - photoEditUtils.test.ts: findOversizedFile, sliceToLimit, reorderItems, hasPendingChanges 단위 테스트 - PhotoEditTab.stories.tsx: 기본, 이미지 있는 상태, 업로드 중 스토리 - ImagePreview.stories.tsx: 기본, pending/uploading/failed 상태별 스토리 - FeedImageGrid.stories.tsx: 빈 상태, 이미지 있는 상태 스토리 --- .../PhotoEditTab/PhotoEditTab.stories.tsx | 82 +++++++++++ .../FeedImageGrid/FeedImageGrid.stories.tsx | 129 ++++++++++++++++++ .../ImagePreview/ImagePreview.stories.tsx | 45 ++++++ .../tabs/PhotoEditTab/photoEditUtils.test.ts | 122 +++++++++++++++++ 4 files changed, 378 insertions(+) create mode 100644 frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.stories.tsx create mode 100644 frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/FeedImageGrid/FeedImageGrid.stories.tsx create mode 100644 frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/ImagePreview/ImagePreview.stories.tsx create mode 100644 frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.test.ts diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.stories.tsx b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.stories.tsx new file mode 100644 index 000000000..1b72ca80f --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.stories.tsx @@ -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[] }) => ( + +
+ + + }> + } /> + + + +
+
+); + +const meta = { + title: 'Admin/PhotoEditTab', + parameters: { layout: 'centered' }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const WithPhotos: Story = { + render: () => , +}; + +export const Empty: Story = { + render: () => , +}; + +export const ManyPhotos: Story = { + render: () => ( + `https://picsum.photos/seed/${i + 10}/400/500`)} + /> + ), +}; diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/FeedImageGrid/FeedImageGrid.stories.tsx b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/FeedImageGrid/FeedImageGrid.stories.tsx new file mode 100644 index 000000000..e297967bc --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/FeedImageGrid/FeedImageGrid.stories.tsx @@ -0,0 +1,129 @@ +import { useRef } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { FeedImageGrid } from './FeedImageGrid'; +import type { FeedItem } from '../../PhotoEditTab'; + +const IMAGES = [ + '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 uploaded = (url: string): FeedItem => ({ type: 'uploaded', url }); +const local = (seed: string, status: 'pending' | 'uploading' | 'failed'): FeedItem => ({ + type: 'local', + file: new File([], `${seed}.jpg`), + previewUrl: `https://picsum.photos/seed/${seed}/400/500`, + status, +}); + +const Wrapper = ({ feedItems, isLoading = false, dragIndex = null }: { + feedItems: FeedItem[]; + isLoading?: boolean; + dragIndex?: number | null; +}) => { + const gridRef = useRef(null); + return ( +
+ {}} + onDelete={() => {}} + onRetry={() => {}} + /> +
+ ); +}; + +const meta = { + title: 'Admin/PhotoEditTab/FeedImageGrid', + parameters: { layout: 'centered' }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const AllUploaded: Story = { + render: () => , +}; + +export const WithPending: Story = { + render: () => ( + + ), +}; + +export const Uploading: Story = { + render: () => ( + + ), +}; + +export const WithFailure: Story = { + render: () => ( + + ), +}; + +export const MixedStatuses: Story = { + render: () => ( + + ), +}; + +export const Dragging: Story = { + render: () => { + const gridRef = useRef(null); + return ( +
+ {}} + onDelete={() => {}} + onRetry={() => {}} + /> +
+ ); + }, +}; diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/ImagePreview/ImagePreview.stories.tsx b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/ImagePreview/ImagePreview.stories.tsx new file mode 100644 index 000000000..9142d46e4 --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/ImagePreview/ImagePreview.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { ImagePreview } from './ImagePreview'; + +const SAMPLE_IMAGE = 'https://picsum.photos/seed/moadong/400/500'; + +const meta = { + title: 'Admin/PhotoEditTab/ImagePreview', + component: ImagePreview, + parameters: { layout: 'centered' }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + image: SAMPLE_IMAGE, + onDelete: () => {}, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Uploaded: Story = {}; + +export const Pending: Story = { + args: { status: 'pending' }, +}; + +export const Uploading: Story = { + args: { status: 'uploading' }, +}; + +export const Failed: Story = { + args: { + status: 'failed', + onRetry: () => {}, + }, +}; + +export const Disabled: Story = { + args: { disabled: true }, +}; diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.test.ts b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.test.ts new file mode 100644 index 000000000..68ac7091a --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.test.ts @@ -0,0 +1,122 @@ +import { MAX_FILE_COUNT, MAX_FILE_SIZE } from '@/constants/uploadLimit'; +import { FeedItem } from './PhotoEditTab'; +import { + findOversizedFile, + hasPendingChanges, + reorderItems, + sliceToLimit, +} from './photoEditUtils'; + +const makeUploaded = (url: string): FeedItem => ({ type: 'uploaded', url }); +const makeLocal = (name: string): FeedItem => ({ + type: 'local', + file: new File([''], name, { type: 'image/jpeg' }), + previewUrl: `blob:${name}`, + status: 'pending', +}); + +describe('sliceToLimit', () => { + const files = Array.from({ length: 10 }, (_, i) => + new File([''], `file${i}.jpg`, { type: 'image/jpeg' }), + ); + + it('현재 개수 + 파일 수가 MAX_FILE_COUNT 이하이면 전부 반환한다', () => { + const result = sliceToLimit(files.slice(0, 3), 10); + expect(result).toHaveLength(3); + }); + + it('현재 개수 + 파일 수가 MAX_FILE_COUNT 초과이면 남은 슬롯만큼만 반환한다', () => { + const result = sliceToLimit(files, MAX_FILE_COUNT - 2); + expect(result).toHaveLength(2); + }); + + it('이미 MAX_FILE_COUNT에 도달했으면 빈 배열을 반환한다', () => { + const result = sliceToLimit(files, MAX_FILE_COUNT); + expect(result).toHaveLength(0); + }); +}); + +describe('findOversizedFile', () => { + it('모든 파일이 MAX_FILE_SIZE 이하이면 undefined를 반환한다', () => { + const files = [ + new File(['a'.repeat(1024)], 'small.jpg', { type: 'image/jpeg' }), + ]; + expect(findOversizedFile(files)).toBeUndefined(); + }); + + it('MAX_FILE_SIZE 초과 파일이 있으면 해당 파일을 반환한다', () => { + const oversized = new File( + [new ArrayBuffer(MAX_FILE_SIZE + 1)], + 'big.jpg', + { type: 'image/jpeg' }, + ); + const normal = new File(['a'], 'small.jpg', { type: 'image/jpeg' }); + expect(findOversizedFile([normal, oversized])).toBe(oversized); + }); + + it('빈 배열이면 undefined를 반환한다', () => { + expect(findOversizedFile([])).toBeUndefined(); + }); +}); + +describe('reorderItems', () => { + const items: FeedItem[] = [ + makeUploaded('a'), + makeUploaded('b'), + makeUploaded('c'), + makeUploaded('d'), + ]; + + it('앞에서 뒤로 이동한다 (0 → 2)', () => { + const result = reorderItems(items, 0, 2); + expect(result.map((i) => (i as { url: string }).url)).toEqual([ + 'b', 'a', 'c', 'd', + ]); + }); + + it('뒤에서 앞으로 이동한다 (3 → 1)', () => { + const result = reorderItems(items, 3, 1); + expect(result.map((i) => (i as { url: string }).url)).toEqual([ + 'a', 'd', 'b', 'c', + ]); + }); + + it('같은 위치로 이동해도 순서가 유지된다', () => { + const result = reorderItems(items, 1, 1); + expect(result.map((i) => (i as { url: string }).url)).toEqual([ + 'a', 'b', 'c', 'd', + ]); + }); + + it('원본 배열을 변경하지 않는다 (불변성)', () => { + reorderItems(items, 0, 3); + expect(items).toHaveLength(4); + expect((items[0] as { url: string }).url).toBe('a'); + }); +}); + +describe('hasPendingChanges', () => { + it('local 아이템이 있으면 true를 반환한다', () => { + const feedItems: FeedItem[] = [makeUploaded('a'), makeLocal('new.jpg')]; + expect(hasPendingChanges(feedItems, ['a'])).toBe(true); + }); + + it('uploaded URL이 원본과 동일하면 false를 반환한다', () => { + const feedItems: FeedItem[] = [makeUploaded('a'), makeUploaded('b')]; + expect(hasPendingChanges(feedItems, ['a', 'b'])).toBe(false); + }); + + it('이미지가 삭제되면 true를 반환한다', () => { + const feedItems: FeedItem[] = [makeUploaded('a')]; + expect(hasPendingChanges(feedItems, ['a', 'b'])).toBe(true); + }); + + it('순서가 바뀌면 true를 반환한다', () => { + const feedItems: FeedItem[] = [makeUploaded('b'), makeUploaded('a')]; + expect(hasPendingChanges(feedItems, ['a', 'b'])).toBe(true); + }); + + it('아이템이 없고 원본도 비어있으면 false를 반환한다', () => { + expect(hasPendingChanges([], [])).toBe(false); + }); +}); From 5008638382d8c445d7dbf238bff6cd36dc2e4499 Mon Sep 17 00:00:00 2001 From: Junseo Kim Date: Sun, 29 Mar 2026 13:34:07 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20=ED=94=BC=EB=93=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EA=B7=B8=EB=A6=AC=EB=93=9C=203=EC=97=B4?= =?UTF-8?q?=EC=97=90=EC=84=9C=204=EC=97=B4=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.styles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.styles.ts b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.styles.ts index 0a109be90..06a8e9616 100644 --- a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.styles.ts +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.styles.ts @@ -107,7 +107,7 @@ export const OverlaySpinner = styled.span` export const ImageGrid = styled.div` display: grid; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(4, 1fr); gap: 12px; width: 100%; align-self: flex-start; From fe242c261a3f2ee3537f961b1e3f8f9561881bb5 Mon Sep 17 00:00:00 2001 From: Junseo Kim Date: Sun, 29 Mar 2026 13:41:18 +0900 Subject: [PATCH 8/8] =?UTF-8?q?style:=20=EC=BD=94=EB=93=9C=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EA=B0=80?= =?UTF-8?q?=EB=8F=85=EC=84=B1=20=ED=96=A5=EC=83=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/hooks/Queries/useClubImages.ts | 26 +++- .../PhotoEditTab/PhotoEditTab.stories.tsx | 12 +- .../tabs/PhotoEditTab/PhotoEditTab.styles.ts | 24 +++- .../tabs/PhotoEditTab/PhotoEditTab.tsx | 15 ++- .../FeedImageGrid/FeedImageGrid.stories.tsx | 13 +- .../components/ImagePreview/ImagePreview.tsx | 4 +- .../tabs/PhotoEditTab/hooks/useDragSort.ts | 115 +++++++++++------- .../tabs/PhotoEditTab/hooks/useFeedItems.ts | 35 ++++-- .../tabs/PhotoEditTab/photoEditUtils.test.ts | 20 ++- 9 files changed, 188 insertions(+), 76 deletions(-) diff --git a/frontend/src/hooks/Queries/useClubImages.ts b/frontend/src/hooks/Queries/useClubImages.ts index e705f95d1..5310ec3a0 100644 --- a/frontend/src/hooks/Queries/useClubImages.ts +++ b/frontend/src/hooks/Queries/useClubImages.ts @@ -25,12 +25,26 @@ export const useUploadFeed = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ clubId, files, existingUrls, onItemStatusChange }: 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 ALLOWED_TYPES = [ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/gif', + 'image/bmp', + 'image/webp', + ]; const uploadRequests = files.map((file) => ({ fileName: file.name, - contentType: ALLOWED_TYPES.includes(file.type) ? file.type : 'image/jpeg', + contentType: ALLOWED_TYPES.includes(file.type) + ? file.type + : 'image/jpeg', })); const feedResArr = await feedApi.getUploadUrls(clubId, uploadRequests); @@ -43,7 +57,11 @@ export const useUploadFeed = () => { 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 Promise.reject( + new Error( + feedResArr[i].failureReason ?? 'presigned URL 생성 실패', + ), + ); } return uploadToStorage(feedResArr[i].presignedUrl, file); }), diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.stories.tsx b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.stories.tsx index 1b72ca80f..75766b4ef 100644 --- a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.stories.tsx +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.stories.tsx @@ -1,6 +1,6 @@ +import { MemoryRouter, Outlet, Route, Routes } from 'react-router-dom'; 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'; @@ -48,7 +48,10 @@ const Wrapper = ({ feeds = SAMPLE_FEEDS }: { feeds?: string[] }) => (
- }> + } + > } /> @@ -76,7 +79,10 @@ export const Empty: Story = { export const ManyPhotos: Story = { render: () => ( `https://picsum.photos/seed/${i + 10}/400/500`)} + feeds={Array.from( + { length: 9 }, + (_, i) => `https://picsum.photos/seed/${i + 10}/400/500`, + )} /> ), }; diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.styles.ts b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.styles.ts index 06a8e9616..c70ca2983 100644 --- a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.styles.ts +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.styles.ts @@ -69,11 +69,12 @@ export const GridWrapper = styled.div<{ $uploading?: boolean }>` align-items: center; justify-content: center; border: ${({ $uploading, theme }) => - $uploading ? `2px solid ${theme.colors.primary[900]}` : '2px dashed #e5e7eb'}; + $uploading + ? `2px solid ${theme.colors.primary[900]}` + : '2px dashed #e5e7eb'}; transition: border-color 0.3s; `; - export const UploadOverlay = styled.div` position: absolute; inset: 0; @@ -138,19 +139,29 @@ export const EmptyState = styled.button` } `; -export const DragItem = styled.div<{ $isDragging?: boolean; $isDimmed?: boolean }>` +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; + transition: + opacity 0.2s, + filter 0.2s; &:active { cursor: grabbing; } `; -export const DropDivider = styled.div<{ $x: number; $top: number; $height: number; $visible: boolean }>` +export const DropDivider = styled.div<{ + $x: number; + $top: number; + $height: number; + $visible: boolean; +}>` position: absolute; left: ${({ $x }) => $x}px; top: ${({ $top }) => $top}px; @@ -158,7 +169,8 @@ export const DropDivider = styled.div<{ $x: number; $top: number; $height: numbe width: 3px; transform: translateX(-50%); border-radius: 2px; - background-color: ${({ $visible, theme }) => ($visible ? theme.colors.primary[900] : 'transparent')}; + background-color: ${({ $visible, theme }) => + $visible ? theme.colors.primary[900] : 'transparent'}; transition: background-color 0.1s; pointer-events: none; z-index: 10; diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.tsx b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.tsx index 1a2891ef9..49f18eff0 100644 --- a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.tsx +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.tsx @@ -12,7 +12,10 @@ import { useDragSort } from './hooks/useDragSort'; import { useFeedItems } from './hooks/useFeedItems'; import * as Styled from './PhotoEditTab.styles'; -export interface UploadedItem { type: 'uploaded'; url: string } +export interface UploadedItem { + type: 'uploaded'; + url: string; +} export interface LocalItem { type: 'local'; file: File; @@ -96,10 +99,16 @@ const PhotoEditTab = () => { {feedItems.length > 0 && ( - + + 이미지 추가 - + 전체 삭제 diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/FeedImageGrid/FeedImageGrid.stories.tsx b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/FeedImageGrid/FeedImageGrid.stories.tsx index e297967bc..528d27b64 100644 --- a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/FeedImageGrid/FeedImageGrid.stories.tsx +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/FeedImageGrid/FeedImageGrid.stories.tsx @@ -1,7 +1,7 @@ import { useRef } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; -import { FeedImageGrid } from './FeedImageGrid'; import type { FeedItem } from '../../PhotoEditTab'; +import { FeedImageGrid } from './FeedImageGrid'; const IMAGES = [ 'https://picsum.photos/seed/a/400/500', @@ -12,14 +12,21 @@ const IMAGES = [ ]; const uploaded = (url: string): FeedItem => ({ type: 'uploaded', url }); -const local = (seed: string, status: 'pending' | 'uploading' | 'failed'): FeedItem => ({ +const local = ( + seed: string, + status: 'pending' | 'uploading' | 'failed', +): FeedItem => ({ type: 'local', file: new File([], `${seed}.jpg`), previewUrl: `https://picsum.photos/seed/${seed}/400/500`, status, }); -const Wrapper = ({ feedItems, isLoading = false, dragIndex = null }: { +const Wrapper = ({ + feedItems, + isLoading = false, + dragIndex = null, +}: { feedItems: FeedItem[]; isLoading?: boolean; dragIndex?: number | null; diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/ImagePreview/ImagePreview.tsx b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/ImagePreview/ImagePreview.tsx index 5baaa78b1..e5e5da78d 100644 --- a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/ImagePreview/ImagePreview.tsx +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/ImagePreview/ImagePreview.tsx @@ -22,7 +22,9 @@ export const ImagePreview = ({ preview - {status === 'pending' && 업로드 예정} + {status === 'pending' && ( + 업로드 예정 + )} {status === 'failed' && ( diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useDragSort.ts b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useDragSort.ts index 1ffb36f84..8a0bd0ffb 100644 --- a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useDragSort.ts +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useDragSort.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { reorderItems } from '../photoEditUtils'; import { FeedItem } from '../PhotoEditTab'; +import { reorderItems } from '../photoEditUtils'; export type DropPosition = { index: number; side: 'before' | 'after' } | null; @@ -12,54 +12,85 @@ interface UseDragSortOptions { feedItemsRef: React.RefObject; } -export const useDragSort = ({ disabled, onReorder, feedItemsRef }: UseDragSortOptions) => { +export const useDragSort = ({ + disabled, + onReorder, + feedItemsRef, +}: UseDragSortOptions) => { const gridRef = useRef(null); - const dragStartRef = useRef<{ index: number; x: number; y: number } | null>(null); + const dragStartRef = useRef<{ index: number; x: number; y: number } | null>( + null, + ); const isDraggingRef = useRef(false); const [dragIndex, setDragIndex] = useState(null); const [dropPosition, setDropPosition] = useState(null); - const getDropPositionFromPoint = useCallback((clientX: number, clientY: number): DropPosition => { - if (!gridRef.current) return null; - const cards = Array.from(gridRef.current.querySelectorAll('[data-card-index]')); - if (cards.length === 0) return null; - - // 카드 위에 정확히 있는 경우: 카드 중앙 기준으로 before/after - for (const card of cards) { - const rect = card.getBoundingClientRect(); - if (clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom) continue; - const index = Number(card.dataset.cardIndex); - return { index, side: clientX < rect.left + rect.width / 2 ? 'before' : 'after' }; - } - - // 카드 밖 — 같은 행(Y 범위 겹침) 카드 중 X 거리 가장 가까운 끝으로 - let closest: { index: number; side: 'before' | 'after'; dist: number } | null = null; - for (const card of cards) { - const rect = card.getBoundingClientRect(); - if (clientY < rect.top || clientY > rect.bottom) continue; - const index = Number(card.dataset.cardIndex); - const distLeft = Math.abs(clientX - rect.left); - const distRight = Math.abs(clientX - rect.right); - if (distLeft < distRight) { - if (!closest || distLeft < closest.dist) closest = { index, side: 'before', dist: distLeft }; - } else { - if (!closest || distRight < closest.dist) closest = { index, side: 'after', dist: distRight }; + const getDropPositionFromPoint = useCallback( + (clientX: number, clientY: number): DropPosition => { + if (!gridRef.current) return null; + const cards = Array.from( + gridRef.current.querySelectorAll('[data-card-index]'), + ); + if (cards.length === 0) return null; + + // 카드 위에 정확히 있는 경우: 카드 중앙 기준으로 before/after + for (const card of cards) { + const rect = card.getBoundingClientRect(); + if ( + clientX < rect.left || + clientX > rect.right || + clientY < rect.top || + clientY > rect.bottom + ) + continue; + const index = Number(card.dataset.cardIndex); + return { + index, + side: clientX < rect.left + rect.width / 2 ? 'before' : 'after', + }; } - } - if (closest) return { index: closest.index, side: closest.side }; - - // 행 간 gap — Y 거리 기준 가장 가까운 카드로 - for (const card of cards) { - const rect = card.getBoundingClientRect(); - const index = Number(card.dataset.cardIndex); - const distY = Math.min(Math.abs(clientY - rect.top), Math.abs(clientY - rect.bottom)); - const side: 'before' | 'after' = clientX < rect.left + rect.width / 2 ? 'before' : 'after'; - if (!closest || distY < closest.dist) closest = { index, side, dist: distY }; - } - - return closest ? { index: closest.index, side: closest.side } : null; - }, []); + + // 카드 밖 — 같은 행(Y 범위 겹침) 카드 중 X 거리 가장 가까운 끝으로 + let closest: { + index: number; + side: 'before' | 'after'; + dist: number; + } | null = null; + for (const card of cards) { + const rect = card.getBoundingClientRect(); + if (clientY < rect.top || clientY > rect.bottom) continue; + const index = Number(card.dataset.cardIndex); + const distLeft = Math.abs(clientX - rect.left); + const distRight = Math.abs(clientX - rect.right); + if (distLeft < distRight) { + if (!closest || distLeft < closest.dist) + closest = { index, side: 'before', dist: distLeft }; + } else { + if (!closest || distRight < closest.dist) + closest = { index, side: 'after', dist: distRight }; + } + } + if (closest) return { index: closest.index, side: closest.side }; + + // 행 간 gap — Y 거리 기준 가장 가까운 카드로 + for (const card of cards) { + const rect = card.getBoundingClientRect(); + const index = Number(card.dataset.cardIndex); + const distY = Math.min( + Math.abs(clientY - rect.top), + Math.abs(clientY - rect.bottom), + ); + const side: 'before' | 'after' = + clientX < rect.left + rect.width / 2 ? 'before' : 'after'; + if (!closest || distY < closest.dist) + closest = { index, side, dist: distY }; + } + + return closest ? { index: closest.index, side: closest.side } : null; + }, + [], + ); const handleMouseDown = (e: React.MouseEvent, index: number) => { if (disabled || e.button !== 0) return; diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useFeedItems.ts b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useFeedItems.ts index bad604528..722d734ab 100644 --- a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useFeedItems.ts +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useFeedItems.ts @@ -1,7 +1,11 @@ import { useEffect, useRef, useState } from 'react'; -import { useUploadFeed, useUpdateFeed } from '@/hooks/Queries/useClubImages'; -import { findOversizedFile, hasPendingChanges, sliceToLimit } from '../photoEditUtils'; +import { useUpdateFeed, useUploadFeed } from '@/hooks/Queries/useClubImages'; import { FeedItem, LocalItem, UploadedItem } from '../PhotoEditTab'; +import { + findOversizedFile, + hasPendingChanges, + sliceToLimit, +} from '../photoEditUtils'; export const useFeedItems = (clubId: string, originalFeeds: string[]) => { const { mutate: uploadFeed, isPending: isUploading } = useUploadFeed(); @@ -13,10 +17,14 @@ export const useFeedItems = (clubId: string, originalFeeds: string[]) => { const isLoading = isUploading || isUpdating; const pendingChanges = hasPendingChanges(feedItems, originalFeeds); - useEffect(() => { feedItemsRef.current = feedItems; }, [feedItems]); + useEffect(() => { + feedItemsRef.current = feedItems; + }, [feedItems]); useEffect(() => { - setFeedItems((originalFeeds || []).map((url) => ({ type: 'uploaded', url }))); + setFeedItems( + (originalFeeds || []).map((url) => ({ type: 'uploaded', url })), + ); }, [originalFeeds]); useEffect(() => { @@ -67,7 +75,9 @@ export const useFeedItems = (clubId: string, originalFeeds: string[]) => { setFeedItems((prev) => prev.map((it, i) => - i === index && it.type === 'local' ? { ...it, status: 'uploading' } : it, + i === index && it.type === 'local' + ? { ...it, status: 'uploading' } + : it, ), ); @@ -88,7 +98,9 @@ export const useFeedItems = (clubId: string, originalFeeds: string[]) => { onError: () => { setFeedItems((prev) => prev.map((it, i) => - i === index && it.type === 'local' ? { ...it, status: 'failed' } : it, + i === index && it.type === 'local' + ? { ...it, status: 'failed' } + : it, ), ); }, @@ -97,7 +109,9 @@ export const useFeedItems = (clubId: string, originalFeeds: string[]) => { }; const save = () => { - const localItems = feedItems.filter((item): item is LocalItem => item.type === 'local'); + const localItems = feedItems.filter( + (item): item is LocalItem => item.type === 'local', + ); const uploadedUrls = feedItems .filter((item): item is UploadedItem => item.type === 'uploaded') .map((item) => item.url); @@ -111,7 +125,9 @@ export const useFeedItems = (clubId: string, originalFeeds: string[]) => { } setFeedItems((prev) => - prev.map((item) => (item.type === 'local' ? { ...item, status: 'uploading' } : item)), + prev.map((item) => + item.type === 'local' ? { ...item, status: 'uploading' } : item, + ), ); uploadFeed( @@ -139,7 +155,8 @@ export const useFeedItems = (clubId: string, originalFeeds: string[]) => { setFeedItems((prev) => { let successIdx = 0; return prev.map((item) => { - if (item.type !== 'local' || item.status === 'failed') return item; + if (item.type !== 'local' || item.status === 'failed') + return item; const finalUrl = data.successfulUrls[successIdx++]; URL.revokeObjectURL(item.previewUrl); return { type: 'uploaded', url: finalUrl } as UploadedItem; diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.test.ts b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.test.ts index 68ac7091a..a95c853c2 100644 --- a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.test.ts +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.test.ts @@ -16,8 +16,9 @@ const makeLocal = (name: string): FeedItem => ({ }); describe('sliceToLimit', () => { - const files = Array.from({ length: 10 }, (_, i) => - new File([''], `file${i}.jpg`, { type: 'image/jpeg' }), + const files = Array.from( + { length: 10 }, + (_, i) => new File([''], `file${i}.jpg`, { type: 'image/jpeg' }), ); it('현재 개수 + 파일 수가 MAX_FILE_COUNT 이하이면 전부 반환한다', () => { @@ -70,21 +71,30 @@ describe('reorderItems', () => { it('앞에서 뒤로 이동한다 (0 → 2)', () => { const result = reorderItems(items, 0, 2); expect(result.map((i) => (i as { url: string }).url)).toEqual([ - 'b', 'a', 'c', 'd', + 'b', + 'a', + 'c', + 'd', ]); }); it('뒤에서 앞으로 이동한다 (3 → 1)', () => { const result = reorderItems(items, 3, 1); expect(result.map((i) => (i as { url: string }).url)).toEqual([ - 'a', 'd', 'b', 'c', + 'a', + 'd', + 'b', + 'c', ]); }); it('같은 위치로 이동해도 순서가 유지된다', () => { const result = reorderItems(items, 1, 1); expect(result.map((i) => (i as { url: string }).url)).toEqual([ - 'a', 'b', 'c', 'd', + 'a', + 'b', + 'c', + 'd', ]); });