import { useState, useEffect, useRef } from 'react'; import { useNavigate, Link, useParams } from 'react-router-dom'; import { motion, AnimatePresence, Reorder } from 'framer-motion'; import { Upload, Trash2, Image, X, Check, Plus, Home, ChevronRight, LogOut, ArrowLeft, Grid, List, ZoomIn, AlertTriangle, GripVertical, Users, User, Users2, Tag, FolderOpen, Save } from 'lucide-react'; import Toast from '../../../components/Toast'; // 멤버 목록 const MEMBERS = ['이서연', '송하영', '장규리', '박지원', '이나경', '이채영', '백지헌']; function AdminAlbumPhotos() { const { albumId } = useParams(); const navigate = useNavigate(); const fileInputRef = useRef(null); const [album, setAlbum] = useState(null); const [photos, setPhotos] = useState([]); const [loading, setLoading] = useState(true); const [user, setUser] = useState(null); const [toast, setToast] = useState(null); const [selectedPhotos, setSelectedPhotos] = useState([]); const [uploading, setUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); const [deleteDialog, setDeleteDialog] = useState({ show: false, photos: [] }); const [deleting, setDeleting] = useState(false); const [previewPhoto, setPreviewPhoto] = useState(null); const [dragOver, setDragOver] = useState(false); // 업로드 대기 중인 파일들 const [pendingFiles, setPendingFiles] = useState([]); const [photoType, setPhotoType] = useState('concept'); // 'concept' | 'teaser' const [conceptName, setConceptName] = useState(''); const [saving, setSaving] = useState(false); const [pendingDeleteId, setPendingDeleteId] = useState(null); // 삭제 대기 파일 ID // Toast 자동 숨김 useEffect(() => { 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;