2026-01-03 10:01:34 +09:00
|
|
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
2026-01-01 22:31:38 +09:00
|
|
|
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);
|
2026-01-02 10:14:27 +09:00
|
|
|
const photoListRef = useRef(null); // 사진 목록 영역 ref
|
2026-01-01 22:31:38 +09:00
|
|
|
|
|
|
|
|
const [album, setAlbum] = useState(null);
|
|
|
|
|
const [photos, setPhotos] = useState([]);
|
2026-01-02 12:17:24 +09:00
|
|
|
const [teasers, setTeasers] = useState([]); // 티저 이미지
|
2026-01-01 22:31:38 +09:00
|
|
|
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);
|
2026-01-02 00:10:47 +09:00
|
|
|
const [members, setMembers] = useState([]); // DB에서 로드
|
2026-01-01 22:31:38 +09:00
|
|
|
|
|
|
|
|
// 업로드 대기 중인 파일들
|
|
|
|
|
const [pendingFiles, setPendingFiles] = useState([]);
|
|
|
|
|
const [photoType, setPhotoType] = useState('concept'); // 'concept' | 'teaser'
|
|
|
|
|
const [conceptName, setConceptName] = useState('');
|
2026-01-02 00:10:47 +09:00
|
|
|
const [startNumber, setStartNumber] = useState(1); // 시작 번호
|
2026-01-01 22:31:38 +09:00
|
|
|
const [saving, setSaving] = useState(false);
|
2026-01-02 00:10:47 +09:00
|
|
|
const [processingStatus, setProcessingStatus] = useState(''); // 처리 상태 메시지
|
|
|
|
|
const [processingProgress, setProcessingProgress] = useState({ current: 0, total: 0 }); // 서버 처리 진행률
|
2026-01-01 22:31:38 +09:00
|
|
|
const [pendingDeleteId, setPendingDeleteId] = useState(null); // 삭제 대기 파일 ID
|
2026-01-02 10:24:45 +09:00
|
|
|
const [uploadConfirmDialog, setUploadConfirmDialog] = useState(false); // 업로드 확인 다이얼로그
|
2026-01-02 12:17:24 +09:00
|
|
|
const [activeTab, setActiveTab] = useState('upload'); // 'upload' | 'manage'
|
|
|
|
|
const [manageSubTab, setManageSubTab] = useState('concept'); // 'concept' | 'teaser'
|
2026-01-01 22:31:38 +09:00
|
|
|
|
2026-01-02 10:14:27 +09:00
|
|
|
// 일괄 편집 도구 상태
|
|
|
|
|
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]
|
|
|
|
|
}));
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-01 22:31:38 +09:00
|
|
|
// 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 {
|
2026-01-02 12:17:24 +09:00
|
|
|
const token = localStorage.getItem('adminToken');
|
|
|
|
|
|
2026-01-01 22:31:38 +09:00
|
|
|
// 앨범 정보 로드
|
|
|
|
|
const albumRes = await fetch(`/api/albums/${albumId}`);
|
|
|
|
|
if (!albumRes.ok) throw new Error('앨범을 찾을 수 없습니다');
|
|
|
|
|
const albumData = await albumRes.json();
|
|
|
|
|
setAlbum(albumData);
|
|
|
|
|
|
2026-01-02 00:10:47 +09:00
|
|
|
// 멤버 목록 로드
|
|
|
|
|
const membersRes = await fetch('/api/members');
|
|
|
|
|
if (membersRes.ok) {
|
|
|
|
|
const membersData = await membersRes.json();
|
|
|
|
|
setMembers(membersData);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-02 12:17:24 +09:00
|
|
|
// 기존 컨셉 포토 목록 로드
|
|
|
|
|
const photosRes = await fetch(`/api/admin/albums/${albumId}/photos`, {
|
|
|
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
|
|
|
});
|
|
|
|
|
if (photosRes.ok) {
|
|
|
|
|
const photosData = await photosRes.json();
|
|
|
|
|
setPhotos(photosData);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 티저 이미지 목록 로드
|
|
|
|
|
const teasersRes = await fetch(`/api/admin/albums/${albumId}/teasers`, {
|
|
|
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
|
|
|
});
|
|
|
|
|
if (teasersRes.ok) {
|
|
|
|
|
const teasersData = await teasersRes.json();
|
|
|
|
|
setTeasers(teasersData);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-01 22:31:38 +09:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-02 00:10:47 +09:00
|
|
|
// 컨셉 포토일 때만 검증
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-01-01 22:31:38 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setSaving(true);
|
|
|
|
|
setUploadProgress(0);
|
2026-01-02 00:10:47 +09:00
|
|
|
setProcessingProgress({ current: 0, total: pendingFiles.length });
|
|
|
|
|
setProcessingStatus('');
|
2026-01-01 22:31:38 +09:00
|
|
|
|
|
|
|
|
try {
|
2026-01-02 00:10:47 +09:00
|
|
|
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 파싱 실패 무시
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-01 22:31:38 +09:00
|
|
|
}
|
|
|
|
|
|
2026-01-02 00:10:47 +09:00
|
|
|
if (result) {
|
|
|
|
|
setToast({ message: result.message, type: 'success' });
|
|
|
|
|
}
|
2026-01-01 22:31:38 +09:00
|
|
|
|
|
|
|
|
// 미리보기 URL 해제
|
|
|
|
|
pendingFiles.forEach(f => URL.revokeObjectURL(f.preview));
|
|
|
|
|
setPendingFiles([]);
|
|
|
|
|
setConceptName('');
|
2026-01-02 00:10:47 +09:00
|
|
|
setStartNumber(1); // 초기화
|
|
|
|
|
|
|
|
|
|
// 사진 목록 다시 로드
|
|
|
|
|
fetchAlbumData();
|
2026-01-01 22:31:38 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
console.error('업로드 오류:', error);
|
2026-01-02 00:10:47 +09:00
|
|
|
setToast({ message: error.message || '업로드 중 오류가 발생했습니다.', type: 'error' });
|
2026-01-01 22:31:38 +09:00
|
|
|
} finally {
|
|
|
|
|
setSaving(false);
|
|
|
|
|
setUploadProgress(0);
|
2026-01-02 00:10:47 +09:00
|
|
|
setProcessingProgress({ current: 0, total: 0 });
|
|
|
|
|
setProcessingStatus('');
|
2026-01-01 22:31:38 +09:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-02 12:17:24 +09:00
|
|
|
// 삭제 처리 (기존 사진/티저)
|
2026-01-01 22:31:38 +09:00
|
|
|
const handleDelete = async () => {
|
|
|
|
|
setDeleting(true);
|
2026-01-02 12:17:24 +09:00
|
|
|
const token = localStorage.getItem('adminToken');
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// 사진 ID와 티저 ID 분리
|
|
|
|
|
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) {
|
|
|
|
|
const res = await fetch(`/api/admin/albums/${albumId}/photos/${photoId}`, {
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
throw new Error('사진 삭제 실패');
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-01 22:31:38 +09:00
|
|
|
|
2026-01-02 12:17:24 +09:00
|
|
|
// 티저 삭제
|
|
|
|
|
for (const teaserId of teaserIds) {
|
|
|
|
|
const res = await fetch(`/api/admin/albums/${albumId}/teasers/${teaserId}`, {
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
throw new Error('티저 삭제 실패');
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-01 22:31:38 +09:00
|
|
|
|
2026-01-02 12:17:24 +09:00
|
|
|
// UI 상태 업데이트
|
|
|
|
|
setPhotos(prev => prev.filter(p => !photoIds.includes(p.id)));
|
|
|
|
|
setTeasers(prev => prev.filter(t => !teaserIds.includes(t.id)));
|
|
|
|
|
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);
|
|
|
|
|
}
|
2026-01-01 22:31:38 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
|
|
|
|
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent"></div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="min-h-screen bg-gray-50">
|
|
|
|
|
{/* Toast */}
|
|
|
|
|
<Toast toast={toast} onClose={() => setToast(null)} />
|
|
|
|
|
|
|
|
|
|
{/* 삭제 확인 다이얼로그 */}
|
|
|
|
|
<AnimatePresence>
|
|
|
|
|
{deleteDialog.show && (
|
|
|
|
|
<motion.div
|
|
|
|
|
initial={{ opacity: 0 }}
|
|
|
|
|
animate={{ opacity: 1 }}
|
|
|
|
|
exit={{ opacity: 0 }}
|
|
|
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
|
|
|
|
onClick={() => !deleting && setDeleteDialog({ show: false, photos: [] })}
|
|
|
|
|
>
|
|
|
|
|
<motion.div
|
|
|
|
|
initial={{ scale: 0.9, opacity: 0 }}
|
|
|
|
|
animate={{ scale: 1, opacity: 1 }}
|
|
|
|
|
exit={{ scale: 0.9, opacity: 0 }}
|
|
|
|
|
className="bg-white rounded-2xl p-6 max-w-md w-full mx-4 shadow-xl"
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center gap-3 mb-4">
|
|
|
|
|
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
|
|
|
|
|
<AlertTriangle className="text-red-500" size={20} />
|
|
|
|
|
</div>
|
|
|
|
|
<h3 className="text-lg font-bold text-gray-900">사진 삭제</h3>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<p className="text-gray-600 mb-6">
|
|
|
|
|
<span className="font-medium text-gray-900">{deleteDialog.photos.length}개</span>의 사진을 삭제하시겠습니까?
|
|
|
|
|
<br />
|
|
|
|
|
<span className="text-sm text-red-500">이 작업은 되돌릴 수 없습니다.</span>
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<div className="flex justify-end gap-3">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setDeleteDialog({ show: false, photos: [] })}
|
|
|
|
|
disabled={deleting}
|
|
|
|
|
className="px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50"
|
|
|
|
|
>
|
|
|
|
|
취소
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleDelete}
|
|
|
|
|
disabled={deleting}
|
|
|
|
|
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors disabled:opacity-50 flex items-center gap-2"
|
|
|
|
|
>
|
|
|
|
|
{deleting ? (
|
|
|
|
|
<>
|
|
|
|
|
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
|
|
|
|
삭제 중...
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<Trash2 size={16} />
|
|
|
|
|
삭제
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</motion.div>
|
|
|
|
|
</motion.div>
|
|
|
|
|
)}
|
|
|
|
|
</AnimatePresence>
|
|
|
|
|
|
|
|
|
|
{/* 이미지 미리보기 */}
|
|
|
|
|
<AnimatePresence>
|
|
|
|
|
{previewPhoto && (
|
|
|
|
|
<motion.div
|
|
|
|
|
initial={{ opacity: 0 }}
|
|
|
|
|
animate={{ opacity: 1 }}
|
|
|
|
|
exit={{ opacity: 0 }}
|
|
|
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
|
|
|
|
|
onClick={() => setPreviewPhoto(null)}
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setPreviewPhoto(null)}
|
|
|
|
|
className="absolute top-4 right-4 p-2 text-white/70 hover:text-white transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<X size={24} />
|
|
|
|
|
</button>
|
|
|
|
|
<motion.img
|
|
|
|
|
initial={{ scale: 0.9 }}
|
|
|
|
|
animate={{ scale: 1 }}
|
|
|
|
|
exit={{ scale: 0.9 }}
|
|
|
|
|
src={previewPhoto.preview || previewPhoto.url}
|
|
|
|
|
alt={previewPhoto.filename}
|
|
|
|
|
className="max-w-[90vw] max-h-[90vh] object-contain"
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
/>
|
|
|
|
|
</motion.div>
|
|
|
|
|
)}
|
|
|
|
|
</AnimatePresence>
|
|
|
|
|
|
|
|
|
|
{/* 헤더 */}
|
|
|
|
|
<header className="bg-white shadow-sm border-b border-gray-100">
|
|
|
|
|
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
|
|
|
|
<div className="flex items-center gap-4">
|
|
|
|
|
<Link to="/" className="text-2xl font-bold text-primary hover:opacity-80 transition-opacity">
|
|
|
|
|
fromis_9
|
|
|
|
|
</Link>
|
|
|
|
|
<span className="px-3 py-1 bg-primary/10 text-primary text-sm font-medium rounded-full">
|
|
|
|
|
Admin
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-4">
|
|
|
|
|
<span className="text-gray-500 text-sm">
|
|
|
|
|
안녕하세요, <span className="text-gray-900 font-medium">{user?.username}</span>님
|
|
|
|
|
</span>
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleLogout}
|
|
|
|
|
className="flex items-center gap-2 px-4 py-2 text-gray-500 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<LogOut size={18} />
|
|
|
|
|
<span>로그아웃</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
{/* 메인 콘텐츠 */}
|
|
|
|
|
<main className="max-w-7xl mx-auto px-6 py-8">
|
|
|
|
|
{/* 브레드크럼 */}
|
|
|
|
|
<motion.div
|
|
|
|
|
className="flex items-center gap-2 text-sm text-gray-400 mb-8"
|
|
|
|
|
initial={{ opacity: 0, x: -10 }}
|
|
|
|
|
animate={{ opacity: 1, x: 0 }}
|
|
|
|
|
transition={{ delay: 0.1 }}
|
|
|
|
|
>
|
|
|
|
|
<Link to="/admin/dashboard" className="hover:text-primary transition-colors">
|
|
|
|
|
<Home size={16} />
|
|
|
|
|
</Link>
|
|
|
|
|
<ChevronRight size={14} />
|
|
|
|
|
<Link to="/admin/albums" className="hover:text-primary transition-colors">앨범 관리</Link>
|
|
|
|
|
<ChevronRight size={14} />
|
|
|
|
|
<span className="text-gray-700">{album?.title} - 사진 관리</span>
|
|
|
|
|
</motion.div>
|
|
|
|
|
|
2026-01-02 00:10:47 +09:00
|
|
|
{/* 타이틀 + 액션 버튼 */}
|
2026-01-01 22:31:38 +09:00
|
|
|
<motion.div
|
2026-01-02 00:10:47 +09:00
|
|
|
className="flex items-center justify-between mb-8"
|
2026-01-01 22:31:38 +09:00
|
|
|
initial={{ opacity: 0, y: 10 }}
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
transition={{ delay: 0.15 }}
|
|
|
|
|
>
|
2026-01-02 00:10:47 +09:00
|
|
|
<div className="flex items-center gap-4">
|
|
|
|
|
<img
|
|
|
|
|
src={album?.cover_url}
|
|
|
|
|
alt={album?.title}
|
|
|
|
|
className="w-14 h-14 rounded-xl object-cover"
|
|
|
|
|
/>
|
|
|
|
|
<div>
|
|
|
|
|
<h1 className="text-2xl font-bold text-gray-900">{album?.title}</h1>
|
|
|
|
|
<p className="text-gray-500">사진 업로드 및 관리</p>
|
|
|
|
|
</div>
|
2026-01-01 22:31:38 +09:00
|
|
|
</div>
|
2026-01-02 00:10:47 +09:00
|
|
|
{pendingFiles.length > 0 && (
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => {
|
|
|
|
|
pendingFiles.forEach(f => URL.revokeObjectURL(f.preview));
|
|
|
|
|
setPendingFiles([]);
|
|
|
|
|
}}
|
|
|
|
|
className="px-4 py-2 text-gray-600 hover:text-gray-900 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
취소
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
2026-01-02 10:24:45 +09:00
|
|
|
onClick={() => setUploadConfirmDialog(true)}
|
|
|
|
|
disabled={pendingFiles.length === 0 || saving}
|
2026-01-02 00:10:47 +09:00
|
|
|
className="flex items-center gap-2 px-5 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors disabled:opacity-50"
|
|
|
|
|
>
|
|
|
|
|
{saving ? (
|
|
|
|
|
<>
|
|
|
|
|
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
|
|
|
|
업로드 중...
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<Save size={18} />
|
|
|
|
|
{pendingFiles.length}개 사진 업로드
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-01-01 22:31:38 +09:00
|
|
|
</motion.div>
|
|
|
|
|
|
2026-01-02 12:17:24 +09:00
|
|
|
{/* 탭 UI */}
|
|
|
|
|
<div className="flex gap-1 p-1 bg-gray-100 rounded-xl mb-6">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setActiveTab('upload')}
|
|
|
|
|
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-all ${
|
|
|
|
|
activeTab === 'upload'
|
|
|
|
|
? 'bg-white text-primary shadow-sm'
|
|
|
|
|
: 'text-gray-500 hover:text-gray-700'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<Upload size={18} />
|
|
|
|
|
업로드
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setActiveTab('manage')}
|
|
|
|
|
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-all ${
|
|
|
|
|
activeTab === 'manage'
|
|
|
|
|
? 'bg-white text-primary shadow-sm'
|
|
|
|
|
: 'text-gray-500 hover:text-gray-700'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<FolderOpen size={18} />
|
|
|
|
|
관리
|
|
|
|
|
{photos.length > 0 && (
|
|
|
|
|
<span className="px-2 py-0.5 bg-primary/10 text-primary text-xs rounded-full">
|
|
|
|
|
{photos.length}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 업로드 탭 */}
|
|
|
|
|
{activeTab === 'upload' && (
|
|
|
|
|
<>
|
|
|
|
|
|
2026-01-01 22:31:38 +09:00
|
|
|
{/* 업로드 설정 */}
|
|
|
|
|
<motion.div
|
|
|
|
|
className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6 mb-6"
|
|
|
|
|
initial={{ opacity: 0, y: 10 }}
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
>
|
|
|
|
|
<h2 className="text-lg font-bold text-gray-900 mb-4 flex items-center gap-2">
|
|
|
|
|
<FolderOpen size={20} />
|
|
|
|
|
업로드 설정
|
|
|
|
|
</h2>
|
|
|
|
|
|
|
|
|
|
{/* 타입 선택 */}
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">사진 타입 *</label>
|
|
|
|
|
<div className="flex gap-2 max-w-xs">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setPhotoType('concept')}
|
2026-01-02 00:10:47 +09:00
|
|
|
disabled={pendingFiles.length > 0}
|
2026-01-01 22:31:38 +09:00
|
|
|
className={`flex-1 py-2 px-4 rounded-lg text-sm font-medium transition-colors ${
|
|
|
|
|
photoType === 'concept'
|
|
|
|
|
? 'bg-primary text-white'
|
|
|
|
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
2026-01-02 00:10:47 +09:00
|
|
|
} ${pendingFiles.length > 0 ? 'opacity-50 cursor-not-allowed' : ''}`}
|
2026-01-01 22:31:38 +09:00
|
|
|
>
|
|
|
|
|
컨셉 포토
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setPhotoType('teaser')}
|
2026-01-02 00:10:47 +09:00
|
|
|
disabled={pendingFiles.length > 0}
|
2026-01-01 22:31:38 +09:00
|
|
|
className={`flex-1 py-2 px-4 rounded-lg text-sm font-medium transition-colors ${
|
|
|
|
|
photoType === 'teaser'
|
|
|
|
|
? 'bg-primary text-white'
|
|
|
|
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
2026-01-02 00:10:47 +09:00
|
|
|
} ${pendingFiles.length > 0 ? 'opacity-50 cursor-not-allowed' : ''}`}
|
2026-01-01 22:31:38 +09:00
|
|
|
>
|
|
|
|
|
티저 이미지
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-xs text-gray-400 mt-2">
|
2026-01-02 00:10:47 +09:00
|
|
|
{pendingFiles.length > 0
|
|
|
|
|
? '파일이 추가된 상태에서는 타입을 변경할 수 없습니다.'
|
|
|
|
|
: photoType === 'teaser'
|
|
|
|
|
? '티저 이미지는 순서만 지정하면 됩니다.'
|
|
|
|
|
: '컨셉/티저 이름은 각 사진별로 입력합니다.'
|
|
|
|
|
}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 시작 번호 설정 */}
|
|
|
|
|
<div className="mt-4 pt-4 border-t border-gray-100">
|
|
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">시작 번호</label>
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
min="1"
|
|
|
|
|
value={startNumber}
|
|
|
|
|
onChange={(e) => 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"
|
|
|
|
|
/>
|
|
|
|
|
<span className="text-sm text-gray-500">
|
|
|
|
|
→ {String(startNumber).padStart(2, '0')}.webp ~ {String(startNumber + Math.max(0, pendingFiles.length - 1)).padStart(2, '0')}.webp
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-xs text-gray-400 mt-2">
|
|
|
|
|
추가 업로드 시 기존 사진 다음 번호로 설정하세요.
|
2026-01-01 22:31:38 +09:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</motion.div>
|
|
|
|
|
|
|
|
|
|
{/* 업로드 진행률 */}
|
|
|
|
|
{saving && (
|
|
|
|
|
<motion.div
|
|
|
|
|
initial={{ opacity: 0, y: -10 }}
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
className="mb-6 bg-white rounded-xl p-4 border border-gray-100 shadow-sm"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center justify-between mb-2">
|
2026-01-02 00:10:47 +09:00
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="w-4 h-4 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
|
|
|
|
|
{uploadProgress < 100 ? (
|
|
|
|
|
<span className="text-sm text-gray-600">파일 업로드 중...</span>
|
|
|
|
|
) : processingProgress.current > 0 ? (
|
|
|
|
|
<span className="text-sm text-gray-600">
|
|
|
|
|
{processingStatus || `${processingProgress.current}/${processingProgress.total} 처리 중...`}
|
|
|
|
|
</span>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-sm text-gray-600">서버 연결 중...</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<span className="text-sm font-medium text-primary">
|
|
|
|
|
{uploadProgress < 100
|
|
|
|
|
? `${uploadProgress}%`
|
|
|
|
|
: processingProgress.total > 0
|
|
|
|
|
? `${Math.round((processingProgress.current / processingProgress.total) * 100)}%`
|
|
|
|
|
: '0%'
|
|
|
|
|
}
|
|
|
|
|
</span>
|
2026-01-01 22:31:38 +09:00
|
|
|
</div>
|
|
|
|
|
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
|
|
|
|
<motion.div
|
|
|
|
|
initial={{ width: 0 }}
|
2026-01-02 00:10:47 +09:00
|
|
|
animate={{
|
|
|
|
|
width: uploadProgress < 100
|
|
|
|
|
? `${uploadProgress}%`
|
|
|
|
|
: processingProgress.total > 0
|
|
|
|
|
? `${(processingProgress.current / processingProgress.total) * 100}%`
|
|
|
|
|
: '0%'
|
|
|
|
|
}}
|
|
|
|
|
transition={{ duration: 0.3 }}
|
2026-01-01 22:31:38 +09:00
|
|
|
className="h-full bg-primary rounded-full"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</motion.div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-01-02 10:14:27 +09:00
|
|
|
{/* 2-column 레이아웃: 사진 목록 + 일괄 편집 패널 */}
|
|
|
|
|
<div ref={photoListRef} className="flex gap-6">
|
|
|
|
|
{/* 드래그 앤 드롭 영역 + 파일 목록 */}
|
|
|
|
|
<motion.div
|
|
|
|
|
onDragEnter={handleDragEnter}
|
|
|
|
|
onDragOver={handleDragOver}
|
|
|
|
|
onDragLeave={handleDragLeave}
|
|
|
|
|
onDrop={handleDrop}
|
|
|
|
|
initial={{ opacity: 0, y: 20 }}
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
transition={{ delay: 0.25 }}
|
|
|
|
|
className={`flex-1 relative rounded-2xl border-2 border-dashed transition-colors bg-white ${
|
|
|
|
|
dragOver
|
|
|
|
|
? 'border-primary bg-primary/5'
|
|
|
|
|
: 'border-gray-200'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
2026-01-01 22:31:38 +09:00
|
|
|
{/* 드래그 오버레이 */}
|
|
|
|
|
{dragOver && (
|
|
|
|
|
<div className="absolute inset-0 flex items-center justify-center bg-primary/10 rounded-2xl z-10">
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
<Upload size={48} className="mx-auto text-primary mb-2" />
|
|
|
|
|
<p className="text-primary font-medium">여기에 사진을 놓으세요</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{pendingFiles.length === 0 ? (
|
|
|
|
|
/* 빈 상태 */
|
|
|
|
|
<div className="py-20 text-center">
|
|
|
|
|
<Image size={48} className="mx-auto text-gray-300 mb-4" />
|
|
|
|
|
<p className="text-gray-500 mb-2">사진을 드래그하여 업로드하세요</p>
|
|
|
|
|
<p className="text-gray-400 text-sm mb-4">JPG, PNG, WebP 지원</p>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => fileInputRef.current?.click()}
|
|
|
|
|
className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<Plus size={18} />
|
|
|
|
|
파일 선택
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
/* 파일 목록 (드래그 정렬 가능) */
|
|
|
|
|
<div className="p-6">
|
|
|
|
|
<div className="flex items-center justify-between mb-4">
|
|
|
|
|
<p className="text-sm text-gray-500">
|
|
|
|
|
드래그하여 순서를 변경할 수 있습니다. 순서대로 <code className="bg-gray-100 px-1 rounded">01.webp</code>, <code className="bg-gray-100 px-1 rounded">02.webp</code>... 로 저장됩니다.
|
|
|
|
|
</p>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => fileInputRef.current?.click()}
|
|
|
|
|
className="text-sm text-primary hover:text-primary-dark transition-colors"
|
|
|
|
|
>
|
|
|
|
|
+ 더 추가
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Reorder.Group
|
|
|
|
|
axis="y"
|
|
|
|
|
values={pendingFiles}
|
|
|
|
|
onReorder={handleReorder}
|
|
|
|
|
className="space-y-3"
|
|
|
|
|
>
|
|
|
|
|
{pendingFiles.map((file, index) => (
|
|
|
|
|
<Reorder.Item
|
|
|
|
|
key={file.id}
|
|
|
|
|
value={file}
|
|
|
|
|
className="bg-gray-50 rounded-xl p-4 cursor-grab active:cursor-grabbing"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex gap-4 items-center">
|
|
|
|
|
{/* 드래그 핸들 + 순서 번호 (직접 입력 가능) */}
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<GripVertical size={18} className="text-gray-300" />
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
inputMode="numeric"
|
2026-01-02 00:10:47 +09:00
|
|
|
defaultValue={String(startNumber + index).padStart(2, '0')}
|
|
|
|
|
key={`order-${file.id}-${index}-${startNumber}`}
|
2026-01-01 22:31:38 +09:00
|
|
|
onBlur={(e) => {
|
|
|
|
|
const val = e.target.value.trim();
|
2026-01-02 23:35:36 +09:00
|
|
|
// 스크롤 위치 저장
|
|
|
|
|
const scrollY = window.scrollY;
|
2026-01-01 22:31:38 +09:00
|
|
|
if (val && !isNaN(val)) {
|
|
|
|
|
moveToPosition(file.id, val);
|
|
|
|
|
}
|
|
|
|
|
e.target.value = String(pendingFiles.findIndex(f => f.id === file.id) + 1).padStart(2, '0');
|
2026-01-02 23:35:36 +09:00
|
|
|
// 스크롤 위치 복원
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
window.scrollTo(0, scrollY);
|
|
|
|
|
});
|
2026-01-01 22:31:38 +09:00
|
|
|
}}
|
|
|
|
|
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]"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-01-03 10:01:34 +09:00
|
|
|
{/* 썸네일 (180px로 확대) */}
|
2026-01-01 22:31:38 +09:00
|
|
|
<img
|
|
|
|
|
src={file.preview}
|
|
|
|
|
alt={file.filename}
|
|
|
|
|
draggable="false"
|
2026-01-03 10:01:34 +09:00
|
|
|
loading="lazy"
|
|
|
|
|
className="w-[180px] h-[180px] rounded-lg object-cover cursor-pointer hover:opacity-80 transition-opacity flex-shrink-0 select-none"
|
2026-01-01 22:31:38 +09:00
|
|
|
onClick={() => setPreviewPhoto(file)}
|
|
|
|
|
/>
|
|
|
|
|
|
2026-01-03 10:01:34 +09:00
|
|
|
{/* 메타 정보 - 고정 높이 */}
|
|
|
|
|
<div className="flex-1 space-y-3 h-[200px] overflow-hidden">
|
2026-01-01 22:31:38 +09:00
|
|
|
{/* 파일명 */}
|
|
|
|
|
<p className="text-base font-medium text-gray-900 truncate">{file.filename}</p>
|
|
|
|
|
|
2026-01-02 00:10:47 +09:00
|
|
|
{/* 컨셉 포토일 때만 메타 정보 입력 표시 */}
|
|
|
|
|
{photoType === 'concept' && (
|
|
|
|
|
<>
|
|
|
|
|
{/* 단체/솔로/유닛 선택 */}
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="text-sm text-gray-500 w-16">타입:</span>
|
|
|
|
|
<div className="flex gap-1.5">
|
|
|
|
|
{[
|
|
|
|
|
{ value: 'group', icon: Users, label: '단체' },
|
|
|
|
|
{ value: 'solo', icon: User, label: '솔로' },
|
|
|
|
|
{ value: 'unit', icon: Users2, label: '유닛' },
|
|
|
|
|
].map(({ value, icon: Icon, label }) => (
|
|
|
|
|
<button
|
|
|
|
|
key={value}
|
|
|
|
|
onClick={() => changeGroupType(file.id, value)}
|
|
|
|
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors ${
|
|
|
|
|
file.groupType === value
|
|
|
|
|
? 'bg-primary text-white'
|
|
|
|
|
: 'bg-white text-gray-600 hover:bg-gray-100 border border-gray-200'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<Icon size={14} />
|
|
|
|
|
{label}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 멤버 태깅 (단체는 비활성화) */}
|
2026-01-02 23:35:36 +09:00
|
|
|
<div className="flex flex-col gap-2 min-h-8">
|
|
|
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
|
|
|
<span className="text-sm text-gray-500 w-16">멤버:</span>
|
|
|
|
|
{file.groupType === 'group' ? (
|
|
|
|
|
<span className="text-sm text-gray-400">단체 사진은 멤버 태깅이 필요 없습니다</span>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
{/* 현재 멤버 */}
|
|
|
|
|
{members.filter(m => !m.is_former).map(member => (
|
|
|
|
|
<button
|
|
|
|
|
key={member.id}
|
|
|
|
|
onClick={() => toggleMember(file.id, member.id)}
|
|
|
|
|
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
|
|
|
|
file.members.includes(member.id)
|
|
|
|
|
? 'bg-primary text-white'
|
|
|
|
|
: 'bg-white text-gray-600 hover:bg-gray-100 border border-gray-200'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{member.name}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2026-01-03 10:01:34 +09:00
|
|
|
|
2026-01-02 23:35:36 +09:00
|
|
|
</div>
|
|
|
|
|
{/* 전 멤버 (다음 줄) */}
|
|
|
|
|
{file.groupType !== 'group' && members.filter(m => m.is_former).length > 0 && (
|
|
|
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
|
|
|
<span className="text-sm text-gray-400 w-16"></span>
|
|
|
|
|
{members.filter(m => m.is_former).map(member => (
|
|
|
|
|
<button
|
|
|
|
|
key={member.id}
|
|
|
|
|
onClick={() => toggleMember(file.id, member.id)}
|
|
|
|
|
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
|
|
|
|
file.members.includes(member.id)
|
|
|
|
|
? 'bg-gray-500 text-white'
|
|
|
|
|
: 'bg-gray-100 text-gray-400 hover:bg-gray-200 border border-gray-200'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{member.name}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2026-01-02 00:10:47 +09:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 컨셉/티저 이름 (개별 입력) */}
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="text-sm text-gray-500 w-16">컨셉명:</span>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={file.conceptName}
|
|
|
|
|
onChange={(e) => 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="컨셉명을 입력하세요"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2026-01-01 22:31:38 +09:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 삭제 버튼 */}
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setPendingDeleteId(file.id)}
|
|
|
|
|
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors self-start"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 size={18} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</Reorder.Item>
|
|
|
|
|
))}
|
|
|
|
|
</Reorder.Group>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-01-02 10:14:27 +09:00
|
|
|
</motion.div>
|
|
|
|
|
|
|
|
|
|
{/* 일괄 편집 도구 - CSS sticky (네이티브 성능) */}
|
|
|
|
|
{pendingFiles.length > 0 && photoType === 'concept' && (
|
|
|
|
|
<div className="w-72 flex-shrink-0">
|
|
|
|
|
<div className="sticky top-24 bg-white rounded-2xl shadow-lg border border-gray-100 p-5">
|
|
|
|
|
<h3 className="font-bold text-gray-900 mb-4 flex items-center gap-2">
|
|
|
|
|
<Tag size={18} className="text-primary" />
|
|
|
|
|
일괄 편집
|
|
|
|
|
</h3>
|
|
|
|
|
|
|
|
|
|
{/* 번호 범위 */}
|
|
|
|
|
<div className="mb-4">
|
|
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">번호 범위</label>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={bulkEdit.range}
|
|
|
|
|
onChange={(e) => 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"
|
|
|
|
|
/>
|
|
|
|
|
<p className="text-xs text-gray-400 mt-1">
|
|
|
|
|
{startNumber}~{startNumber + pendingFiles.length - 1}번 중 {parseRange(bulkEdit.range, startNumber).filter(i => i < pendingFiles.length).length}개 선택
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 타입 선택 */}
|
|
|
|
|
<div className="mb-4">
|
|
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">타입</label>
|
|
|
|
|
<div className="flex gap-1">
|
|
|
|
|
{[
|
|
|
|
|
{ value: 'group', icon: Users, label: '단체' },
|
|
|
|
|
{ value: 'solo', icon: User, label: '솔로' },
|
|
|
|
|
{ value: 'unit', icon: Users2, label: '유닛' },
|
|
|
|
|
].map(({ value, icon: Icon, label }) => (
|
|
|
|
|
<button
|
|
|
|
|
key={value}
|
|
|
|
|
onClick={() => setBulkEdit(prev => ({
|
|
|
|
|
...prev,
|
|
|
|
|
groupType: prev.groupType === value ? '' : value,
|
|
|
|
|
members: value === 'group' ? [] : prev.members
|
|
|
|
|
}))}
|
|
|
|
|
className={`flex-1 py-1.5 px-2 rounded-lg text-xs font-medium transition-colors flex items-center justify-center gap-1 ${
|
|
|
|
|
bulkEdit.groupType === value
|
|
|
|
|
? 'bg-primary text-white'
|
|
|
|
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<Icon size={14} />
|
|
|
|
|
{label}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-01-01 22:31:38 +09:00
|
|
|
|
2026-01-02 10:14:27 +09:00
|
|
|
{/* 멤버 선택 */}
|
|
|
|
|
{bulkEdit.groupType !== 'group' && (
|
|
|
|
|
<div className="mb-4">
|
|
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
|
|
|
|
멤버 {bulkEdit.groupType === 'solo' ? '(1명)' : '(다중 선택)'}
|
|
|
|
|
</label>
|
|
|
|
|
<div className="flex flex-wrap gap-1.5">
|
2026-01-02 23:35:36 +09:00
|
|
|
{/* 현재 멤버 */}
|
|
|
|
|
{members.filter(m => !m.is_former).map(member => (
|
2026-01-02 10:14:27 +09:00
|
|
|
<button
|
|
|
|
|
key={member.id}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
if (bulkEdit.groupType === 'solo') {
|
|
|
|
|
setBulkEdit(prev => ({
|
|
|
|
|
...prev,
|
|
|
|
|
members: prev.members.includes(member.id) ? [] : [member.id]
|
|
|
|
|
}));
|
|
|
|
|
} else {
|
|
|
|
|
toggleBulkMember(member.id);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
|
|
|
|
|
bulkEdit.members.includes(member.id)
|
|
|
|
|
? 'bg-primary text-white'
|
|
|
|
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{member.name}
|
2026-01-02 23:35:36 +09:00
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
{/* 구분선 */}
|
|
|
|
|
{members.filter(m => m.is_former).length > 0 && (
|
|
|
|
|
<span className="text-gray-300 mx-1">|</span>
|
|
|
|
|
)}
|
|
|
|
|
{/* 탈퇴 멤버 */}
|
|
|
|
|
{members.filter(m => m.is_former).map(member => (
|
|
|
|
|
<button
|
|
|
|
|
key={member.id}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
if (bulkEdit.groupType === 'solo') {
|
|
|
|
|
setBulkEdit(prev => ({
|
|
|
|
|
...prev,
|
|
|
|
|
members: prev.members.includes(member.id) ? [] : [member.id]
|
|
|
|
|
}));
|
|
|
|
|
} else {
|
|
|
|
|
toggleBulkMember(member.id);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
|
|
|
|
|
bulkEdit.members.includes(member.id)
|
|
|
|
|
? 'bg-gray-500 text-white'
|
|
|
|
|
: 'bg-gray-100 text-gray-400 hover:bg-gray-200'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{member.name}
|
2026-01-02 10:14:27 +09:00
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 컨셉명 */}
|
|
|
|
|
<div className="mb-4">
|
|
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">컨셉명</label>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={bulkEdit.conceptName}
|
|
|
|
|
onChange={(e) => 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"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-01-02 00:10:47 +09:00
|
|
|
|
2026-01-02 10:14:27 +09:00
|
|
|
{/* 적용 버튼 */}
|
|
|
|
|
<button
|
|
|
|
|
onClick={applyBulkEdit}
|
|
|
|
|
disabled={!bulkEdit.range.trim()}
|
|
|
|
|
className={`w-full py-2.5 rounded-lg font-medium transition-colors flex items-center justify-center gap-2 ${
|
|
|
|
|
bulkEdit.range.trim()
|
|
|
|
|
? 'bg-primary text-white hover:bg-primary/90'
|
|
|
|
|
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<Check size={18} />
|
|
|
|
|
일괄 적용
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-01-02 12:17:24 +09:00
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 관리 탭 */}
|
|
|
|
|
{activeTab === 'manage' && (
|
|
|
|
|
<motion.div
|
|
|
|
|
initial={{ opacity: 0, y: 10 }}
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
transition={{ duration: 0.3 }}
|
|
|
|
|
className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6"
|
|
|
|
|
>
|
|
|
|
|
{/* 헤더 + 서브탭 */}
|
|
|
|
|
<div className="flex items-center justify-between mb-4">
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<FolderOpen className="text-primary" size={24} />
|
|
|
|
|
<div>
|
|
|
|
|
<h2 className="text-lg font-bold text-gray-900">등록된 미디어</h2>
|
|
|
|
|
<p className="text-sm text-gray-500">컨셉 포토 {photos.length}장 / 티저 {teasers.length}장</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{selectedPhotos.length > 0 && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setDeleteDialog({ show: true, photos: selectedPhotos })}
|
|
|
|
|
className="px-4 py-2 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-colors flex items-center gap-2"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 size={18} />
|
|
|
|
|
{selectedPhotos.length}개 삭제
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 서브탭 + 전체 선택 */}
|
|
|
|
|
<div className="flex items-center justify-between mb-6 border-b border-gray-100">
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => { setManageSubTab('concept'); setSelectedPhotos([]); }}
|
|
|
|
|
className={`px-4 py-2 font-medium transition-colors relative ${
|
|
|
|
|
manageSubTab === 'concept'
|
|
|
|
|
? 'text-primary'
|
|
|
|
|
: 'text-gray-500 hover:text-gray-700'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
컨셉 포토
|
|
|
|
|
<span className="ml-1.5 text-xs bg-gray-100 px-1.5 py-0.5 rounded">{photos.length}</span>
|
|
|
|
|
{manageSubTab === 'concept' && (
|
|
|
|
|
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => { setManageSubTab('teaser'); setSelectedPhotos([]); }}
|
|
|
|
|
className={`px-4 py-2 font-medium transition-colors relative ${
|
|
|
|
|
manageSubTab === 'teaser'
|
|
|
|
|
? 'text-primary'
|
|
|
|
|
: 'text-gray-500 hover:text-gray-700'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
티저 이미지
|
|
|
|
|
<span className="ml-1.5 text-xs bg-gray-100 px-1.5 py-0.5 rounded">{teasers.length}</span>
|
|
|
|
|
{manageSubTab === 'teaser' && (
|
|
|
|
|
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
{/* 전체 선택 버튼 */}
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => {
|
|
|
|
|
if (manageSubTab === 'concept') {
|
|
|
|
|
const allSelected = photos.length > 0 && photos.every(p => selectedPhotos.includes(p.id));
|
|
|
|
|
setSelectedPhotos(allSelected ? [] : photos.map(p => p.id));
|
|
|
|
|
} else {
|
|
|
|
|
const allSelected = teasers.length > 0 && teasers.every(t => selectedPhotos.includes(`teaser-${t.id}`));
|
|
|
|
|
setSelectedPhotos(allSelected ? [] : teasers.map(t => `teaser-${t.id}`));
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
className="text-sm text-gray-500 hover:text-primary transition-colors"
|
|
|
|
|
>
|
|
|
|
|
{manageSubTab === 'concept'
|
|
|
|
|
? (photos.length > 0 && photos.every(p => selectedPhotos.includes(p.id)) ? '선택 해제' : '전체 선택')
|
|
|
|
|
: (teasers.length > 0 && teasers.every(t => selectedPhotos.includes(`teaser-${t.id}`)) ? '선택 해제' : '전체 선택')
|
|
|
|
|
}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 컨셉 포토 그리드 */}
|
|
|
|
|
{manageSubTab === 'concept' && (
|
|
|
|
|
<>
|
|
|
|
|
{photos.length === 0 ? (
|
|
|
|
|
<div className="text-center py-16">
|
|
|
|
|
<Image className="mx-auto text-gray-300 mb-4" size={48} />
|
|
|
|
|
<p className="text-gray-500">등록된 컨셉 포토가 없습니다</p>
|
|
|
|
|
<p className="text-gray-400 text-sm mt-1">업로드 탭에서 사진을 추가하세요</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
|
|
|
|
|
{photos.map((photo, index) => (
|
|
|
|
|
<motion.div
|
|
|
|
|
key={photo.id}
|
|
|
|
|
initial={{ opacity: 0, scale: 0.9 }}
|
|
|
|
|
animate={{ opacity: 1, scale: 1 }}
|
2026-01-03 10:01:34 +09:00
|
|
|
transition={{ duration: 0.2, delay: index < 20 ? index * 0.02 : 0 }}
|
2026-01-02 12:17:24 +09:00
|
|
|
className={`relative group aspect-square rounded-lg overflow-hidden cursor-pointer border-2 transition-all duration-200 ${
|
|
|
|
|
selectedPhotos.includes(photo.id)
|
|
|
|
|
? 'border-primary ring-2 ring-primary/30 scale-[0.98]'
|
|
|
|
|
: 'border-transparent hover:border-primary/50 hover:shadow-lg'
|
|
|
|
|
}`}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setSelectedPhotos(prev =>
|
|
|
|
|
prev.includes(photo.id)
|
|
|
|
|
? prev.filter(id => id !== photo.id)
|
|
|
|
|
: [...prev, photo.id]
|
|
|
|
|
);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<img
|
|
|
|
|
src={photo.thumb_url || photo.medium_url}
|
|
|
|
|
alt={`사진 ${photo.sort_order}`}
|
2026-01-03 10:01:34 +09:00
|
|
|
loading="lazy"
|
2026-01-02 12:17:24 +09:00
|
|
|
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
|
|
|
|
|
/>
|
|
|
|
|
{/* 호버 시 반투명 오버레이 */}
|
|
|
|
|
<div className="absolute inset-0 bg-primary/10 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none" />
|
|
|
|
|
|
|
|
|
|
{/* 체크박스 오버레이 */}
|
|
|
|
|
<div className={`absolute top-2 left-2 w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all ${
|
|
|
|
|
selectedPhotos.includes(photo.id)
|
|
|
|
|
? 'bg-primary border-primary'
|
|
|
|
|
: 'bg-white/80 border-gray-300 opacity-0 group-hover:opacity-100'
|
|
|
|
|
}`}>
|
|
|
|
|
{selectedPhotos.includes(photo.id) && (
|
|
|
|
|
<Check size={14} className="text-white" />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 순서 번호 뱃지 */}
|
|
|
|
|
<div className="absolute top-2 right-2 px-2 py-0.5 bg-black/60 rounded text-white text-xs font-medium">
|
|
|
|
|
{String(photo.sort_order).padStart(2, '0')}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 하단 정보 */}
|
|
|
|
|
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2">
|
|
|
|
|
{photo.concept_name && (
|
|
|
|
|
<span className="text-white text-xs font-medium truncate block">
|
|
|
|
|
{photo.concept_name}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</motion.div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 티저 이미지 그리드 */}
|
|
|
|
|
{manageSubTab === 'teaser' && (
|
|
|
|
|
<>
|
|
|
|
|
{teasers.length === 0 ? (
|
|
|
|
|
<div className="text-center py-16">
|
|
|
|
|
<Image className="mx-auto text-gray-300 mb-4" size={48} />
|
|
|
|
|
<p className="text-gray-500">등록된 티저 이미지가 없습니다</p>
|
|
|
|
|
<p className="text-gray-400 text-sm mt-1">업로드 탭에서 티저를 추가하세요</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
|
|
|
|
|
{teasers.map((teaser, index) => (
|
|
|
|
|
<motion.div
|
|
|
|
|
key={teaser.id}
|
|
|
|
|
initial={{ opacity: 0, scale: 0.9 }}
|
|
|
|
|
animate={{ opacity: 1, scale: 1 }}
|
2026-01-03 10:01:34 +09:00
|
|
|
transition={{ duration: 0.2, delay: index < 20 ? index * 0.02 : 0 }}
|
2026-01-02 12:17:24 +09:00
|
|
|
className={`relative group aspect-square rounded-lg overflow-hidden cursor-pointer border-2 transition-all duration-200 ${
|
|
|
|
|
selectedPhotos.includes(`teaser-${teaser.id}`)
|
|
|
|
|
? 'border-primary ring-2 ring-primary/30 scale-[0.98]'
|
|
|
|
|
: 'border-transparent hover:border-primary/50 hover:shadow-lg'
|
|
|
|
|
}`}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
const teaserId = `teaser-${teaser.id}`;
|
|
|
|
|
setSelectedPhotos(prev =>
|
|
|
|
|
prev.includes(teaserId)
|
|
|
|
|
? prev.filter(id => id !== teaserId)
|
|
|
|
|
: [...prev, teaserId]
|
|
|
|
|
);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<img
|
|
|
|
|
src={teaser.thumb_url || teaser.medium_url}
|
|
|
|
|
alt={`티저 ${teaser.sort_order}`}
|
2026-01-03 10:01:34 +09:00
|
|
|
loading="lazy"
|
2026-01-02 12:17:24 +09:00
|
|
|
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
|
|
|
|
|
/>
|
|
|
|
|
{/* 호버 시 반투명 오버레이 */}
|
|
|
|
|
<div className="absolute inset-0 bg-purple-500/10 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none" />
|
|
|
|
|
|
|
|
|
|
{/* 체크박스 오버레이 */}
|
|
|
|
|
<div className={`absolute top-2 left-2 w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all ${
|
|
|
|
|
selectedPhotos.includes(`teaser-${teaser.id}`)
|
|
|
|
|
? 'bg-primary border-primary'
|
|
|
|
|
: 'bg-white/80 border-gray-300 opacity-0 group-hover:opacity-100'
|
|
|
|
|
}`}>
|
|
|
|
|
{selectedPhotos.includes(`teaser-${teaser.id}`) && (
|
|
|
|
|
<Check size={14} className="text-white" />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 순서 번호 뱃지 */}
|
|
|
|
|
<div className="absolute top-2 right-2 px-2 py-0.5 bg-purple-600/80 rounded text-white text-xs font-medium">
|
|
|
|
|
{String(teaser.sort_order).padStart(2, '0')}
|
|
|
|
|
</div>
|
|
|
|
|
</motion.div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</motion.div>
|
|
|
|
|
)}
|
2026-01-01 22:31:38 +09:00
|
|
|
|
|
|
|
|
{/* 삭제 확인 다이얼로그 */}
|
|
|
|
|
<AnimatePresence>
|
|
|
|
|
{pendingDeleteId && (
|
|
|
|
|
<motion.div
|
|
|
|
|
initial={{ opacity: 0 }}
|
|
|
|
|
animate={{ opacity: 1 }}
|
|
|
|
|
exit={{ opacity: 0 }}
|
|
|
|
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
|
|
|
|
onClick={() => setPendingDeleteId(null)}
|
|
|
|
|
>
|
|
|
|
|
<motion.div
|
|
|
|
|
initial={{ scale: 0.9, opacity: 0 }}
|
|
|
|
|
animate={{ scale: 1, opacity: 1 }}
|
|
|
|
|
exit={{ scale: 0.9, opacity: 0 }}
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
className="bg-white rounded-2xl p-6 max-w-sm w-full mx-4 shadow-xl"
|
|
|
|
|
>
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
|
|
|
<Trash2 className="text-red-500" size={24} />
|
|
|
|
|
</div>
|
|
|
|
|
<h3 className="text-lg font-bold text-gray-900 mb-2">사진을 삭제할까요?</h3>
|
|
|
|
|
<p className="text-gray-500 text-sm mb-6">
|
|
|
|
|
이 사진을 목록에서 제거합니다.
|
|
|
|
|
</p>
|
|
|
|
|
<div className="flex gap-3">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setPendingDeleteId(null)}
|
|
|
|
|
className="flex-1 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
취소
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={confirmDeletePendingFile}
|
|
|
|
|
className="flex-1 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
삭제
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</motion.div>
|
|
|
|
|
</motion.div>
|
|
|
|
|
)}
|
|
|
|
|
</AnimatePresence>
|
|
|
|
|
|
2026-01-02 10:24:45 +09:00
|
|
|
{/* 업로드 확인 다이얼로그 */}
|
|
|
|
|
<AnimatePresence>
|
|
|
|
|
{uploadConfirmDialog && (
|
|
|
|
|
<motion.div
|
|
|
|
|
initial={{ opacity: 0 }}
|
|
|
|
|
animate={{ opacity: 1 }}
|
|
|
|
|
exit={{ opacity: 0 }}
|
|
|
|
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
|
|
|
|
onClick={() => setUploadConfirmDialog(false)}
|
|
|
|
|
>
|
|
|
|
|
<motion.div
|
|
|
|
|
initial={{ scale: 0.9, opacity: 0 }}
|
|
|
|
|
animate={{ scale: 1, opacity: 1 }}
|
|
|
|
|
exit={{ scale: 0.9, opacity: 0 }}
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
className="bg-white rounded-2xl p-6 max-w-md w-full mx-4 shadow-xl"
|
|
|
|
|
>
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
|
|
|
<Upload className="text-primary" size={24} />
|
|
|
|
|
</div>
|
|
|
|
|
<h3 className="text-lg font-bold text-gray-900 mb-2">사진을 업로드할까요?</h3>
|
|
|
|
|
<div className="text-gray-500 text-sm mb-6 text-left bg-gray-50 rounded-lg p-4">
|
|
|
|
|
<div className="flex justify-between mb-1">
|
|
|
|
|
<span>사진 타입:</span>
|
|
|
|
|
<span className="font-medium text-gray-700">{photoType === 'concept' ? '컨셉 포토' : '티저 이미지'}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex justify-between mb-1">
|
|
|
|
|
<span>파일 개수:</span>
|
|
|
|
|
<span className="font-medium text-gray-700">{pendingFiles.length}개</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex justify-between">
|
|
|
|
|
<span>파일명 범위:</span>
|
|
|
|
|
<span className="font-medium text-gray-700">
|
|
|
|
|
{String(startNumber).padStart(2, '0')}.webp ~ {String(startNumber + pendingFiles.length - 1).padStart(2, '0')}.webp
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex gap-3">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setUploadConfirmDialog(false)}
|
|
|
|
|
className="flex-1 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
취소
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setUploadConfirmDialog(false);
|
|
|
|
|
handleUpload();
|
|
|
|
|
}}
|
|
|
|
|
className="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
|
|
|
|
|
>
|
|
|
|
|
업로드
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</motion.div>
|
|
|
|
|
</motion.div>
|
|
|
|
|
)}
|
|
|
|
|
</AnimatePresence>
|
|
|
|
|
|
2026-01-01 22:31:38 +09:00
|
|
|
<input
|
|
|
|
|
ref={fileInputRef}
|
|
|
|
|
type="file"
|
|
|
|
|
multiple
|
|
|
|
|
accept="image/*"
|
|
|
|
|
onChange={handleFileSelect}
|
|
|
|
|
className="hidden"
|
|
|
|
|
/>
|
|
|
|
|
</main>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default AdminAlbumPhotos;
|