/** * 관리자 앨범 사진 관리 페이지 */ import { useState, useEffect, useRef } from 'react'; import { useNavigate, Link, useParams } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import { motion, AnimatePresence, Reorder } from 'framer-motion'; import { Upload, Trash2, Image, X, Check, Plus, Home, ChevronRight, GripVertical, Users, User, Users2, Tag, FolderOpen, Save, } from 'lucide-react'; import { Toast } from '@/components/common'; import { AdminLayout, ConfirmDialog } from '@/components/pc/admin'; import { useAdminAuth } from '@/hooks/pc/admin'; import { useToast } from '@/hooks/common'; import { adminAlbumApi, adminMemberApi } from '@/api/admin'; function AdminAlbumPhotos() { const { albumId } = useParams(); const navigate = useNavigate(); const fileInputRef = useRef(null); const photoListRef = useRef(null); const { user, isAuthenticated } = useAdminAuth(); const { toast, setToast } = useToast(); 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'); 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); const [uploadConfirmDialog, setUploadConfirmDialog] = useState(false); const [activeTab, setActiveTab] = useState('upload'); const [manageSubTab, setManageSubTab] = useState('concept'); // 일괄 편집 도구 상태 const [bulkEdit, setBulkEdit] = useState({ range: '', groupType: '', members: [], conceptName: '', }); // 범위 문자열 파싱 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++) { 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], })); }; // 앨범 정보 로드 const { data: album, isLoading: albumLoading, error: albumError, refetch: refetchAlbum, } = useQuery({ queryKey: ['admin', 'album', albumId], queryFn: () => adminAlbumApi.getAlbum(albumId), enabled: isAuthenticated && !!albumId, staleTime: 0, }); // 멤버 목록 로드 const { data: members = [] } = useQuery({ queryKey: ['admin', 'members'], queryFn: adminMemberApi.getMembers, enabled: isAuthenticated, staleTime: 5 * 60 * 1000, }); // 컨셉 포토 목록 로드 const { data: photos = [], refetch: refetchPhotos } = useQuery({ queryKey: ['admin', 'album', albumId, 'photos'], queryFn: () => adminAlbumApi.getAlbumPhotos(albumId), enabled: isAuthenticated && !!albumId, staleTime: 0, }); // 티저 이미지 목록 로드 const { data: teasers = [], refetch: refetchTeasers } = useQuery({ queryKey: ['admin', 'album', albumId, 'teasers'], queryFn: () => adminAlbumApi.getAlbumTeasers(albumId), enabled: isAuthenticated && !!albumId, staleTime: 0, }); const loading = albumLoading; // 에러 처리 useEffect(() => { if (albumError) { console.error('앨범 로드 오류:', albumError); setToast({ message: albumError.message || '앨범 로드 중 오류가 발생했습니다.', type: 'error' }); } }, [albumError, setToast]); // 데이터 새로고침 함수 const fetchAlbumData = async () => { await Promise.all([refetchAlbum(), refetchPhotos(), refetchTeasers()]); }; // 타입 변경 시 시작 번호 자동 업데이트 useEffect(() => { if (photoType === 'concept') { const maxOrder = photos.length > 0 ? Math.max(...photos.map((p) => p.sort_order || 0)) : 0; setStartNumber(maxOrder + 1); } else if (photoType === 'teaser') { const maxOrder = teasers.length > 0 ? Math.max(...teasers.map((t) => t.sort_order || 0)) : 0; setStartNumber(maxOrder + 1); } }, [photoType, photos, teasers]); // 파일 선택 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, isVideo: file.type === 'video/mp4', groupType: 'group', members: [], conceptName: '', })); setPendingFiles((prev) => [...prev, ...newFiles]); }; // 대기 파일 순서 변경 const handleReorder = (newOrder) => { setPendingFiles(newOrder); }; // 직접 순서 변경 const moveToPosition = (fileId, newPosition) => { const pos = parseInt(newPosition, 10); if (isNaN(pos) || pos < startNumber) return; setPendingFiles((prev) => { const targetIndex = Math.min(pos - startNumber, 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 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'); 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); const response = await fetch(`/api/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' }); } pendingFiles.forEach((f) => URL.revokeObjectURL(f.preview)); setPendingFiles([]); setConceptName(''); 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); try { const photoIds = deleteDialog.photos.filter( (id) => typeof id === 'number' || !String(id).startsWith('teaser-') ); const teaserIds = deleteDialog.photos .filter((id) => String(id).startsWith('teaser-')) .map((id) => parseInt(String(id).replace('teaser-', ''))); for (const photoId of photoIds) { await adminAlbumApi.deleteAlbumPhoto(albumId, photoId); } for (const teaserId of teaserIds) { await adminAlbumApi.deleteAlbumTeaser(albumId, teaserId); } if (photoIds.length > 0) await refetchPhotos(); if (teaserIds.length > 0) await refetchTeasers(); setSelectedPhotos([]); const totalDeleted = photoIds.length + teaserIds.length; setToast({ message: `${totalDeleted}개 항목이 삭제되었습니다.`, type: 'success' }); } catch (error) { console.error('삭제 오류:', error); setToast({ message: '삭제 중 오류가 발생했습니다.', type: 'error' }); } finally { setDeleteDialog({ show: false, photos: [] }); setDeleting(false); } }; if (loading) { return (
); } return ( setToast(null)} /> {/* 삭제 확인 다이얼로그 */} setDeleteDialog({ show: false, photos: [] })} onConfirm={handleDelete} title="사진 삭제" message={ <> {deleteDialog.photos.length}개의 사진을 삭제하시겠습니까?
이 작업은 되돌릴 수 없습니다. } loading={deleting} /> {/* 이미지 미리보기 */} {previewPhoto && ( setPreviewPhoto(null)} > {previewPhoto.isVideo ? ( e.stopPropagation()} controls autoPlay /> ) : ( e.stopPropagation()} /> )} )}
{/* 브레드크럼 */}
앨범 관리 {album?.title} - 사진 관리
{/* 타이틀 + 액션 버튼 */}
{album?.title}

{album?.title}

사진 업로드 및 관리

{pendingFiles.length > 0 && (
)}
{/* 탭 UI */}
{/* 업로드 탭 */} {activeTab === 'upload' && ( <> {/* 업로드 설정 */}

업로드 설정

{/* 타입 선택 */}

{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 ? (

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

{photoType === 'teaser' ? 'JPG, PNG, WebP, MP4 지원' : 'JPG, PNG, WebP 지원'}

) : (

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

{pendingFiles.map((file, index) => (
{/* 드래그 핸들 + 순서 번호 */}
{ const val = e.target.value.trim(); const scrollY = window.scrollY; const currentIndex = pendingFiles.findIndex((f) => f.id === file.id); const currentOrder = startNumber + currentIndex; if (val && !isNaN(val) && parseInt(val) !== currentOrder) { moveToPosition(file.id, val); } const newIndex = pendingFiles.findIndex((f) => f.id === file.id); e.target.value = String(startNumber + newIndex).padStart(2, '0'); requestAnimationFrame(() => { window.scrollTo(0, scrollY); }); }} 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]" />
{/* 썸네일 */} {file.isVideo ? (
))}
)}
{/* 일괄 편집 도구 */} {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 .filter((m) => !m.is_former) .map((member) => ( ))} {members.filter((m) => m.is_former).length > 0 && ( | )} {members .filter((m) => m.is_former) .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" />
{/* 적용 버튼 */}
)}
)} {/* 관리 탭 */} {activeTab === 'manage' && (
{/* 헤더 + 서브탭 */}

등록된 미디어

컨셉 포토 {photos.length}장 / 티저 {teasers.length}장

{selectedPhotos.length > 0 && ( )}
{/* 서브탭 + 전체 선택 */}
{/* 컨셉 포토 그리드 */} {manageSubTab === 'concept' && ( <> {photos.length === 0 ? (

등록된 컨셉 포토가 없습니다

업로드 탭에서 사진을 추가하세요

) : (
{photos.map((photo, index) => ( { setSelectedPhotos((prev) => prev.includes(photo.id) ? prev.filter((id) => id !== photo.id) : [...prev, photo.id] ); }} > {`사진
{selectedPhotos.includes(photo.id) && ( )}
{String(photo.sort_order).padStart(2, '0')}
{photo.concept_name && ( {photo.concept_name} )}
))}
)} )} {/* 티저 이미지 그리드 */} {manageSubTab === 'teaser' && ( <> {teasers.length === 0 ? (

등록된 티저 이미지가 없습니다

업로드 탭에서 티저를 추가하세요

) : (
{teasers.map((teaser, index) => ( { const teaserId = `teaser-${teaser.id}`; setSelectedPhotos((prev) => prev.includes(teaserId) ? prev.filter((id) => id !== teaserId) : [...prev, teaserId] ); }} > {teaser.media_type === 'video' ? (
)} {/* 삭제 확인 다이얼로그 */} {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;