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'; function AdminAlbumPhotos() { const { albumId } = useParams(); const navigate = useNavigate(); const fileInputRef = useRef(null); const photoListRef = useRef(null); // 사진 목록 영역 ref 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 [members, setMembers] = useState([]); // DB에서 로드 // 업로드 대기 중인 파일들 const [pendingFiles, setPendingFiles] = useState([]); const [photoType, setPhotoType] = useState('concept'); // 'concept' | 'teaser' const [conceptName, setConceptName] = useState(''); const [startNumber, setStartNumber] = useState(1); // 시작 번호 const [saving, setSaving] = useState(false); const [processingStatus, setProcessingStatus] = useState(''); // 처리 상태 메시지 const [processingProgress, setProcessingProgress] = useState({ current: 0, total: 0 }); // 서버 처리 진행률 const [pendingDeleteId, setPendingDeleteId] = useState(null); // 삭제 대기 파일 ID const [uploadConfirmDialog, setUploadConfirmDialog] = useState(false); // 업로드 확인 다이얼로그 // 일괄 편집 도구 상태 const [bulkEdit, setBulkEdit] = useState({ range: '', // 예: "1-5, 8, 10-12" groupType: '', // 'group' | 'solo' | 'unit' | ''(미적용) members: [], // 선택된 멤버들 conceptName: '', // 컨셉명 }); // 범위 문자열 파싱 (표시 번호 기준, 예: "18-22" → [0, 1, 2, 3, 4]) const parseRange = (rangeStr, baseNumber = 1) => { if (!rangeStr.trim()) return []; const indices = new Set(); const parts = rangeStr.split(',').map(s => s.trim()); for (const part of parts) { if (part.includes('-')) { const [start, end] = part.split('-').map(n => parseInt(n.trim())); if (!isNaN(start) && !isNaN(end)) { for (let i = Math.min(start, end); i <= Math.max(start, end); i++) { // 표시 번호를 0-indexed로 변환 const idx = i - baseNumber; if (idx >= 0) indices.add(idx); } } } else { const num = parseInt(part); if (!isNaN(num)) { const idx = num - baseNumber; if (idx >= 0) indices.add(idx); } } } return Array.from(indices).sort((a, b) => a - b); }; // 일괄 편집 적용 const applyBulkEdit = () => { const indices = parseRange(bulkEdit.range, startNumber); if (indices.length === 0) { setToast({ message: '적용할 번호 범위를 입력하세요.', type: 'warning' }); return; } const validIndices = indices.filter(i => i < pendingFiles.length); if (validIndices.length === 0) { setToast({ message: '유효한 번호가 없습니다.', type: 'error' }); return; } setPendingFiles(prev => prev.map((file, idx) => { if (!validIndices.includes(idx)) return file; const updates = {}; if (bulkEdit.groupType) { updates.groupType = bulkEdit.groupType; // 단체 선택 시 멤버 비움 if (bulkEdit.groupType === 'group') { updates.members = []; } else if (bulkEdit.members.length > 0) { // 솔로일 경우 첫 번째만 updates.members = bulkEdit.groupType === 'solo' ? [bulkEdit.members[0]] : [...bulkEdit.members]; } } else if (bulkEdit.members.length > 0) { // 타입 미선택이지만 멤버 선택한 경우 updates.members = [...bulkEdit.members]; } if (bulkEdit.conceptName) { updates.conceptName = bulkEdit.conceptName; } return { ...file, ...updates }; })); setToast({ message: `${validIndices.length}개 사진에 일괄 적용되었습니다.`, type: 'success' }); // 입력 초기화 setBulkEdit({ range: '', groupType: '', members: [], conceptName: '' }); }; // 일괄 편집 멤버 토글 const toggleBulkMember = (memberId) => { setBulkEdit(prev => ({ ...prev, members: prev.members.includes(memberId) ? prev.members.filter(m => m !== memberId) : [...prev.members, memberId] })); }; // 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); // 멤버 목록 로드 const membersRes = await fetch('/api/members'); if (membersRes.ok) { const membersData = await membersRes.json(); setMembers(membersData); } // 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; } // 컨셉 포토일 때만 검증 if (photoType === 'concept') { // 컨셉명 검증 (각 파일별로) 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); setProcessingProgress({ current: 0, total: pendingFiles.length }); setProcessingStatus(''); try { const token = localStorage.getItem('adminToken'); // FormData 생성 const formData = new FormData(); const metadata = pendingFiles.map(pf => ({ groupType: pf.groupType, members: pf.members, conceptName: pf.conceptName, })); pendingFiles.forEach(pf => { formData.append('photos', pf.file); }); formData.append('metadata', JSON.stringify(metadata)); formData.append('startNumber', startNumber); formData.append('photoType', photoType); // 업로드 진행률 + SSE로 서버 처리 진행률 const response = await fetch(`/api/admin/albums/${albumId}/photos`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, }, body: formData, }); setUploadProgress(100); // SSE 응답 읽기 const reader = response.body.getReader(); const decoder = new TextDecoder(); let result = null; while (true) { const { done, value } = await reader.read(); if (done) break; const text = decoder.decode(value); const lines = text.split('\n'); for (const line of lines) { if (line.startsWith('data: ')) { try { const data = JSON.parse(line.slice(6)); if (data.done) { result = data; } else if (data.error) { throw new Error(data.error); } else if (data.current) { setProcessingProgress({ current: data.current, total: data.total }); setProcessingStatus(data.message); } } catch (e) { // JSON 파싱 실패 무시 } } } } if (result) { setToast({ message: result.message, type: 'success' }); } // 미리보기 URL 해제 pendingFiles.forEach(f => URL.revokeObjectURL(f.preview)); setPendingFiles([]); setConceptName(''); setStartNumber(1); // 초기화 // 사진 목록 다시 로드 fetchAlbumData(); } catch (error) { console.error('업로드 오류:', error); setToast({ message: error.message || '업로드 중 오류가 발생했습니다.', type: 'error' }); } finally { setSaving(false); setUploadProgress(0); setProcessingProgress({ current: 0, total: 0 }); setProcessingStatus(''); } }; // 삭제 처리 (기존 사진) 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}

사진 업로드 및 관리

{pendingFiles.length > 0 && (
)}
{/* 업로드 설정 */}

업로드 설정

{/* 타입 선택 */}

{pendingFiles.length > 0 ? '파일이 추가된 상태에서는 타입을 변경할 수 없습니다.' : photoType === 'teaser' ? '티저 이미지는 순서만 지정하면 됩니다.' : '컨셉/티저 이름은 각 사진별로 입력합니다.' }

{/* 시작 번호 설정 */}
setStartNumber(Math.max(1, parseInt(e.target.value) || 1))} className="w-20 px-3 py-2 border border-gray-200 rounded-lg text-center focus:outline-none focus:ring-2 focus:ring-primary [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" /> → {String(startNumber).padStart(2, '0')}.webp ~ {String(startNumber + Math.max(0, pendingFiles.length - 1)).padStart(2, '0')}.webp

추가 업로드 시 기존 사진 다음 번호로 설정하세요.

{/* 업로드 진행률 */} {saving && (
{uploadProgress < 100 ? ( 파일 업로드 중... ) : processingProgress.current > 0 ? ( {processingStatus || `${processingProgress.current}/${processingProgress.total} 처리 중...`} ) : ( 서버 연결 중... )}
{uploadProgress < 100 ? `${uploadProgress}%` : processingProgress.total > 0 ? `${Math.round((processingProgress.current / processingProgress.total) * 100)}%` : '0%' }
0 ? `${(processingProgress.current / processingProgress.total) * 100}%` : '0%' }} transition={{ duration: 0.3 }} className="h-full bg-primary rounded-full" />
)} {/* 2-column 레이아웃: 사진 목록 + 일괄 편집 패널 */}
{/* 드래그 앤 드롭 영역 + 파일 목록 */} {/* 드래그 오버레이 */} {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}

{/* 컨셉 포토일 때만 메타 정보 입력 표시 */} {photoType === 'concept' && ( <> {/* 단체/솔로/유닛 선택 */}
타입:
{[ { 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="컨셉명을 입력하세요" />
)}
{/* 삭제 버튼 */}
))}
)}
{/* 일괄 편집 도구 - CSS sticky (네이티브 성능) */} {pendingFiles.length > 0 && photoType === 'concept' && (

일괄 편집

{/* 번호 범위 */}
setBulkEdit(prev => ({ ...prev, range: e.target.value }))} placeholder={`예: ${startNumber}-${startNumber + 4}, ${startNumber + 7}`} className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" />

{startNumber}~{startNumber + pendingFiles.length - 1}번 중 {parseRange(bulkEdit.range, startNumber).filter(i => i < pendingFiles.length).length}개 선택

{/* 타입 선택 */}
{[ { value: 'group', icon: Users, label: '단체' }, { value: 'solo', icon: User, label: '솔로' }, { value: 'unit', icon: Users2, label: '유닛' }, ].map(({ value, icon: Icon, label }) => ( ))}
{/* 멤버 선택 */} {bulkEdit.groupType !== 'group' && (
{members.map(member => ( ))}
)} {/* 컨셉명 */}
setBulkEdit(prev => ({ ...prev, conceptName: e.target.value }))} placeholder="컨셉명 입력" className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" />
{/* 적용 버튼 */}
)}
{/* 삭제 확인 다이얼로그 */} {pendingDeleteId && ( setPendingDeleteId(null)} > e.stopPropagation()} className="bg-white rounded-2xl p-6 max-w-sm w-full mx-4 shadow-xl" >

사진을 삭제할까요?

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

)}
{/* 업로드 확인 다이얼로그 */} {uploadConfirmDialog && ( setUploadConfirmDialog(false)} > e.stopPropagation()} className="bg-white rounded-2xl p-6 max-w-md w-full mx-4 shadow-xl" >

사진을 업로드할까요?

사진 타입: {photoType === 'concept' ? '컨셉 포토' : '티저 이미지'}
파일 개수: {pendingFiles.length}개
파일명 범위: {String(startNumber).padStart(2, '0')}.webp ~ {String(startNumber + pendingFiles.length - 1).padStart(2, '0')}.webp
)}
); } export default AdminAlbumPhotos;