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/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; } `; diff --git a/frontend/src/hooks/Queries/useClubImages.ts b/frontend/src/hooks/Queries/useClubImages.ts index 02ce9cfab..5310ec3a0 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,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', })); const feedResArr = await feedApi.getUploadUrls(clubId, uploadRequests); @@ -35,10 +53,18 @@ export const useUploadFeed = () => { } // 2. r2에 병렬 업로드 (개별 성공/실패 추적) + // presigned URL 생성 자체가 실패한 항목은 업로드 건너뜀 const uploadResults = await Promise.allSettled( - files.map((file, i) => - uploadToStorage(feedResArr[i].presignedUrl, file), - ), + files.map((file, i) => { + if (!feedResArr[i].success || !feedResArr[i].presignedUrl) { + return Promise.reject( + new Error( + feedResArr[i].failureReason ?? 'presigned URL 생성 실패', + ), + ); + } + return uploadToStorage(feedResArr[i].presignedUrl, file); + }), ); // 3. 성공한 파일만 추출 @@ -50,6 +76,7 @@ export const useUploadFeed = () => { successfulUrls.push(feedResArr[i].finalUrl); } else { failedFiles.push(files[i].name); + onItemStatusChange?.(i, 'failed'); } }); @@ -64,8 +91,8 @@ export const useUploadFeed = () => { // 6. 서버에 전체 배열 PUT으로 갱신 await feedApi.updateFeeds(clubId, allUrls); - // 7. 실패한 파일 정보 반환 - return { clubId, failedFiles }; + // 7. 실패한 파일 정보 및 성공 URL 반환 + return { clubId, failedFiles, successfulUrls }; }, onSuccess: (data) => { 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..75766b4ef --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.stories.tsx @@ -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[] }) => ( + +
+ + + } + > + } /> + + + +
+
+); + +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/PhotoEditTab.styles.ts b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.styles.ts index a8c74b814..c70ca2983 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,156 @@ 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(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; `; diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.tsx b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.tsx index 222d33278..49f18eff0 100644 --- a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.tsx +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.tsx @@ -1,152 +1,148 @@ -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} /> - ))} - + )} +
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..528d27b64 --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/FeedImageGrid/FeedImageGrid.stories.tsx @@ -0,0 +1,136 @@ +import { useRef } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import type { FeedItem } from '../../PhotoEditTab'; +import { FeedImageGrid } from './FeedImageGrid'; + +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/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/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/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..e5e5da78d 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,45 @@ 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 && ( + 재전송 + )} + + )} + - 삭제 + 삭제 ); 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..8a0bd0ffb --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useDragSort.ts @@ -0,0 +1,145 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { FeedItem } from '../PhotoEditTab'; +import { reorderItems } from '../photoEditUtils'; + +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..722d734ab --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useFeedItems.ts @@ -0,0 +1,192 @@ +import { useEffect, useRef, useState } from 'react'; +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(); + 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.test.ts b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.test.ts new file mode 100644 index 000000000..a95c853c2 --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.test.ts @@ -0,0 +1,132 @@ +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); + }); +}); 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(); +};