diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index fb87a6c..8bb2c18 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -14,6 +14,7 @@ import AdminLogin from './pages/pc/admin/AdminLogin'; import AdminDashboard from './pages/pc/admin/AdminDashboard'; import AdminAlbums from './pages/pc/admin/AdminAlbums'; import AdminAlbumForm from './pages/pc/admin/AdminAlbumForm'; +import AdminAlbumPhotos from './pages/pc/admin/AdminAlbumPhotos'; // PC 레이아웃 import PCLayout from './components/pc/Layout'; @@ -29,6 +30,7 @@ function App() { } /> } /> } /> + } /> {/* 일반 페이지 (레이아웃 포함) */} { + if (toast) { + const timer = setTimeout(() => setToast(null), 3000); + return () => clearTimeout(timer); + } + }, [toast]); + + useEffect(() => { + // 로그인 확인 + const token = localStorage.getItem('adminToken'); + const userData = localStorage.getItem('adminUser'); + + if (!token || !userData) { + navigate('/admin'); + return; + } + + setUser(JSON.parse(userData)); + fetchAlbumData(); + }, [navigate, albumId]); + + const fetchAlbumData = async () => { + try { + // 앨범 정보 로드 + const albumRes = await fetch(`/api/albums/${albumId}`); + if (!albumRes.ok) throw new Error('앨범을 찾을 수 없습니다'); + const albumData = await albumRes.json(); + setAlbum(albumData); + + // TODO: 기존 사진 목록 로드 (API 구현 후) + setPhotos([]); + setLoading(false); + } catch (error) { + console.error('앨범 로드 오류:', error); + setToast({ message: error.message, type: 'error' }); + setLoading(false); + } + }; + + const handleLogout = () => { + localStorage.removeItem('adminToken'); + localStorage.removeItem('adminUser'); + navigate('/admin'); + }; + + // 파일 선택 + const handleFileSelect = (e) => { + const files = Array.from(e.target.files); + addFilesToPending(files); + e.target.value = ''; // 같은 파일 재선택 가능하도록 + }; + + // 드래그 앤 드롭 (깜빡임 방지를 위해 카운터 사용) + const dragCounterRef = useRef(0); + + const handleDragEnter = (e) => { + e.preventDefault(); + e.stopPropagation(); + dragCounterRef.current++; + if (e.dataTransfer.types.includes('Files')) { + setDragOver(true); + } + }; + + const handleDragOver = (e) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDragLeave = (e) => { + e.preventDefault(); + e.stopPropagation(); + dragCounterRef.current--; + if (dragCounterRef.current === 0) { + setDragOver(false); + } + }; + + const handleDrop = (e) => { + e.preventDefault(); + e.stopPropagation(); + dragCounterRef.current = 0; + setDragOver(false); + const files = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('image/')); + if (files.length > 0) { + addFilesToPending(files); + } + }; + + // 대기 목록에 파일 추가 (중복 방지) + const addFilesToPending = (files) => { + // 기존 파일명 목록으로 중복 체크 (파일명 + 크기로 비교) + const existingKeys = new Set( + pendingFiles.map(f => `${f.filename}_${f.file.size}`) + ); + + const uniqueFiles = files.filter(file => { + const key = `${file.name}_${file.size}`; + if (existingKeys.has(key)) { + return false; // 중복 + } + existingKeys.add(key); + return true; + }); + + if (uniqueFiles.length < files.length) { + const duplicateCount = files.length - uniqueFiles.length; + setToast({ + message: `${duplicateCount}개의 중복 파일이 제외되었습니다.`, + type: 'warning' + }); + } + + if (uniqueFiles.length === 0) return; + + const newFiles = uniqueFiles.map((file, index) => ({ + id: Date.now() + index, + file, + preview: URL.createObjectURL(file), + filename: file.name, + groupType: 'group', // 'group' | 'solo' | 'unit' + members: [], // 태깅된 멤버들 (단체일 경우 빈 배열) + conceptName: '', // 개별 컨셉명 + })); + setPendingFiles(prev => [...prev, ...newFiles]); + }; + + // 대기 파일 순서 변경 (Reorder) + const handleReorder = (newOrder) => { + setPendingFiles(newOrder); + }; + + // 직접 순서 변경 (입력으로) + const moveToPosition = (fileId, newPosition) => { + const pos = parseInt(newPosition, 10); + if (isNaN(pos) || pos < 1) return; + + setPendingFiles(prev => { + const targetIndex = Math.min(pos - 1, prev.length - 1); + const currentIndex = prev.findIndex(f => f.id === fileId); + if (currentIndex === -1 || currentIndex === targetIndex) return prev; + + const newFiles = [...prev]; + const [removed] = newFiles.splice(currentIndex, 1); + newFiles.splice(targetIndex, 0, removed); + return newFiles; + }); + }; + + // 대기 파일 삭제 (확인 후) + const confirmDeletePendingFile = () => { + if (!pendingDeleteId) return; + setPendingFiles(prev => { + const file = prev.find(f => f.id === pendingDeleteId); + if (file) URL.revokeObjectURL(file.preview); + return prev.filter(f => f.id !== pendingDeleteId); + }); + setPendingDeleteId(null); + }; + + // 대기 파일 메타 정보 수정 + const updatePendingFile = (id, field, value) => { + setPendingFiles(prev => prev.map(f => + f.id === id ? { ...f, [field]: value } : f + )); + }; + + // 멤버 토글 (솔로일 경우 한 명만, 유닛일 경우 다중 선택) + const toggleMember = (fileId, member) => { + setPendingFiles(prev => prev.map(f => { + if (f.id !== fileId) return f; + + // 솔로일 경우 한 명만 선택 가능 + if (f.groupType === 'solo') { + return { ...f, members: f.members.includes(member) ? [] : [member] }; + } + + // 유닛일 경우 다중 선택 + const members = f.members.includes(member) + ? f.members.filter(m => m !== member) + : [...f.members, member]; + return { ...f, members }; + })); + }; + + // 타입 변경 시 멤버 초기화 + const changeGroupType = (fileId, newType) => { + setPendingFiles(prev => prev.map(f => { + if (f.id !== fileId) return f; + // 단체 선택 시 멤버 비움 (단체는 멤버 태깅 안 함) + if (newType === 'group') { + return { ...f, groupType: newType, members: [] }; + } + // 솔로/유닛 변경 시 멤버 유지 (솔로인 경우 첫 번째만) + if (newType === 'solo' && f.members.length > 1) { + return { ...f, groupType: newType, members: [f.members[0]] }; + } + return { ...f, groupType: newType }; + })); + }; + + // 업로드 처리 (임시) + const handleUpload = async () => { + if (pendingFiles.length === 0) { + setToast({ message: '업로드할 사진을 선택해주세요.', type: 'warning' }); + return; + } + + // 컨셉명 검증 (각 파일별로) + const missingConcept = pendingFiles.some(f => !f.conceptName.trim()); + if (missingConcept) { + setToast({ message: '모든 사진의 컨셉/티저 이름을 입력해주세요.', type: 'warning' }); + return; + } + + // 솔로/유닛인데 멤버 선택 안 한 경우 + const missingMembers = pendingFiles.some(f => + (f.groupType === 'solo' || f.groupType === 'unit') && f.members.length === 0 + ); + if (missingMembers) { + setToast({ message: '솔로/유닛 사진에는 멤버를 선택해주세요.', type: 'warning' }); + return; + } + + setSaving(true); + setUploadProgress(0); + + try { + // 임시 진행률 시뮬레이션 + for (let i = 0; i <= 100; i += 10) { + await new Promise(r => setTimeout(r, 100)); + setUploadProgress(i); + } + + // TODO: 실제 업로드 API 호출 + // const formData = new FormData(); + // pendingFiles.forEach((pf, idx) => { + // formData.append('photos', pf.file); + // formData.append(`meta_${idx}`, JSON.stringify({ + // order: idx + 1, + // groupType: pf.groupType, + // members: pf.members, + // })); + // }); + // formData.append('photoType', photoType); + // formData.append('conceptName', conceptName); + + setToast({ message: `${pendingFiles.length}개의 사진이 업로드되었습니다.`, type: 'success' }); + + // 미리보기 URL 해제 + pendingFiles.forEach(f => URL.revokeObjectURL(f.preview)); + setPendingFiles([]); + setConceptName(''); + } catch (error) { + console.error('업로드 오류:', error); + setToast({ message: '업로드 중 오류가 발생했습니다.', type: 'error' }); + } finally { + setSaving(false); + setUploadProgress(0); + } + }; + + // 삭제 처리 (기존 사진) + const handleDelete = async () => { + setDeleting(true); + + // TODO: 실제 삭제 API 호출 + await new Promise(r => setTimeout(r, 1000)); + + setPhotos(prev => prev.filter(p => !deleteDialog.photos.includes(p.id))); + setSelectedPhotos([]); + setToast({ message: `${deleteDialog.photos.length}개의 사진이 삭제되었습니다.`, type: 'success' }); + setDeleteDialog({ show: false, photos: [] }); + setDeleting(false); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+ {/* Toast */} + setToast(null)} /> + + {/* 삭제 확인 다이얼로그 */} + + {deleteDialog.show && ( + !deleting && setDeleteDialog({ show: false, photos: [] })} + > + e.stopPropagation()} + > +
+
+ +
+

사진 삭제

+
+ +

+ {deleteDialog.photos.length}개의 사진을 삭제하시겠습니까? +
+ 이 작업은 되돌릴 수 없습니다. +

+ +
+ + +
+
+
+ )} +
+ + {/* 이미지 미리보기 */} + + {previewPhoto && ( + setPreviewPhoto(null)} + > + + e.stopPropagation()} + /> + + )} + + + {/* 헤더 */} +
+
+
+ + fromis_9 + + + Admin + +
+
+ + 안녕하세요, {user?.username}님 + + +
+
+
+ + {/* 메인 콘텐츠 */} +
+ {/* 브레드크럼 */} + + + + + + 앨범 관리 + + {album?.title} - 사진 관리 + + + {/* 타이틀 */} + + {album?.title} +
+

{album?.title}

+

사진 업로드 및 관리

+
+
+ + {/* 업로드 설정 */} + +

+ + 업로드 설정 +

+ + {/* 타입 선택 */} +
+ +
+ + +
+

+ 컨셉/티저 이름은 각 사진별로 입력합니다. +

+
+
+ + {/* 업로드 진행률 */} + {saving && ( + +
+ 업로드 중... + {uploadProgress}% +
+
+ +
+
+ )} + + {/* 드래그 앤 드롭 영역 + 파일 목록 */} + + {/* 드래그 오버레이 */} + {dragOver && ( +
+
+ +

여기에 사진을 놓으세요

+
+
+ )} + + {pendingFiles.length === 0 ? ( + /* 빈 상태 */ +
+ +

사진을 드래그하여 업로드하세요

+

JPG, PNG, WebP 지원

+ +
+ ) : ( + /* 파일 목록 (드래그 정렬 가능) */ +
+
+

+ 드래그하여 순서를 변경할 수 있습니다. 순서대로 01.webp, 02.webp... 로 저장됩니다. +

+ +
+ + + {pendingFiles.map((file, index) => ( + +
+ {/* 드래그 핸들 + 순서 번호 (직접 입력 가능) */} +
+ + { + const val = e.target.value.trim(); + if (val && !isNaN(val)) { + moveToPosition(file.id, val); + } + e.target.value = String(pendingFiles.findIndex(f => f.id === file.id) + 1).padStart(2, '0'); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.target.blur(); + } + }} + className="w-10 h-8 bg-primary/10 text-primary rounded-lg text-center text-sm font-bold border-0 focus:outline-none focus:ring-2 focus:ring-primary [appearance:textfield]" + title="순서를 직접 입력할 수 있습니다" + /> +
+ + {/* 썸네일 (정사각형) */} + {file.filename} setPreviewPhoto(file)} + /> + + {/* 메타 정보 */} +
+ {/* 파일명 */} +

{file.filename}

+ + {/* 단체/솔로/유닛 선택 */} +
+ 타입: +
+ {[ + { value: 'group', icon: Users, label: '단체' }, + { value: 'solo', icon: User, label: '솔로' }, + { value: 'unit', icon: Users2, label: '유닛' }, + ].map(({ value, icon: Icon, label }) => ( + + ))} +
+
+ + {/* 멤버 태깅 (단체는 비활성화) */} +
+ 멤버: + {file.groupType === 'group' ? ( + 단체 사진은 멤버 태깅이 필요 없습니다 + ) : ( + MEMBERS.map(member => ( + + )) + )} + {file.groupType === 'solo' && ( + (한 명만 선택) + )} +
+ + {/* 컨셉/티저 이름 (개별 입력) */} +
+ 컨셉명: + updatePendingFile(file.id, 'conceptName', e.target.value)} + className="flex-1 px-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary" + placeholder="컨셉명을 입력하세요" + /> +
+
+ + {/* 삭제 버튼 */} + +
+
+ ))} +
+
+ )} +
+ + {/* 하단 액션 버튼 */} + {pendingFiles.length > 0 && ( + + + + + )} + + {/* 삭제 확인 다이얼로그 */} + + {pendingDeleteId && ( + setPendingDeleteId(null)} + > + e.stopPropagation()} + className="bg-white rounded-2xl p-6 max-w-sm w-full mx-4 shadow-xl" + > +
+
+ +
+

사진을 삭제할까요?

+

+ 이 사진을 목록에서 제거합니다. +

+
+ + +
+
+
+
+ )} +
+ + +
+
+ ); +} + +export default AdminAlbumPhotos;