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()}
/>
)}
{/* 헤더 */}
{/* 메인 콘텐츠 */}
{/* 브레드크럼 */}
앨범 관리
{album?.title} - 사진 관리
{/* 타이틀 + 액션 버튼 */}
{album?.title}
사진 업로드 및 관리
{pendingFiles.length > 0 && (
)}
{/* 업로드 설정 */}
업로드 설정
{/* 타입 선택 */}
{pendingFiles.length > 0
? '파일이 추가된 상태에서는 타입을 변경할 수 없습니다.'
: photoType === 'teaser'
? '티저 이미지는 순서만 지정하면 됩니다.'
: '컨셉/티저 이름은 각 사진별로 입력합니다.'
}
{/* 시작 번호 설정 */}
{/* 업로드 진행률 */}
{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="순서를 직접 입력할 수 있습니다"
/>
{/* 썸네일 (작게 축소하여 스크롤 성능 개선) */}

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' && (
일괄 편집
{/* 번호 범위 */}
{/* 타입 선택 */}
{[
{ 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;