fromis_9/frontend-temp/src/pages/pc/admin/albums/AlbumPhotos.jsx
caadiq d5c54db86c refactor: API 폴더 구조 개선
변경 전:
api/
├── common/client.js
├── pc/admin/
├── pc/common/
└── pc/public/

변경 후:
api/
├── client.js
├── admin/
└── public/

- PC/Mobile 구분 제거 (같은 API 사용)
- 모든 import 경로 업데이트

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 21:39:01 +09:00

1536 lines
65 KiB
JavaScript

/**
* 관리자 앨범 사진 관리 페이지
*/
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 (
<AdminLayout user={user}>
<div
className="max-w-7xl mx-auto px-6 py-8 flex items-center justify-center"
style={{ minHeight: 'calc(100vh - 80px)' }}
/>
</AdminLayout>
);
}
return (
<AdminLayout user={user}>
<Toast toast={toast} onClose={() => setToast(null)} />
{/* 삭제 확인 다이얼로그 */}
<ConfirmDialog
isOpen={deleteDialog.show}
onClose={() => setDeleteDialog({ show: false, photos: [] })}
onConfirm={handleDelete}
title="사진 삭제"
message={
<>
<span className="font-medium text-gray-900">{deleteDialog.photos.length}</span>
삭제하시겠습니까?
<br />
<span className="text-sm text-red-500"> 작업은 되돌릴 없습니다.</span>
</>
}
loading={deleting}
/>
{/* 이미지 미리보기 */}
<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>
{previewPhoto.isVideo ? (
<motion.video
initial={{ scale: 0.9 }}
animate={{ scale: 1 }}
exit={{ scale: 0.9 }}
src={previewPhoto.preview || previewPhoto.url}
className="max-w-[90vw] max-h-[90vh] object-contain"
onClick={(e) => e.stopPropagation()}
controls
autoPlay
/>
) : (
<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>
<div className="max-w-7xl mx-auto px-6 py-8">
{/* 브레드크럼 */}
<div className="flex items-center gap-2 text-sm text-gray-400 mb-8">
<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>
</div>
{/* 타이틀 + 액션 버튼 */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.4,
ease: [0.25, 0.1, 0.25, 1],
delay: 0.15,
}}
className="flex items-center justify-between mb-8"
>
<div className="flex items-center gap-4">
<img
src={album?.cover_thumb_url || album?.cover_original_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>
</div>
{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
onClick={() => setUploadConfirmDialog(true)}
disabled={pendingFiles.length === 0 || saving}
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>
)}
</motion.div>
{/* 탭 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' && (
<>
{/* 업로드 설정 */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6 mb-6">
<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')}
disabled={pendingFiles.length > 0}
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'
} ${pendingFiles.length > 0 ? 'opacity-50 cursor-not-allowed' : ''}`}
>
컨셉 포토
</button>
<button
onClick={() => setPhotoType('teaser')}
disabled={pendingFiles.length > 0}
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'
} ${pendingFiles.length > 0 ? 'opacity-50 cursor-not-allowed' : ''}`}
>
티저 이미지
</button>
</div>
<p className="text-xs text-gray-400 mt-2">
{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">
추가 업로드 기존 사진 다음 번호로 설정하세요.
</p>
</div>
</div>
{/* 업로드 진행률 */}
{saving && (
<div className="mb-6 bg-white rounded-xl p-4 border border-gray-100 shadow-sm">
<div className="flex items-center justify-between mb-2">
<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>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{
width:
uploadProgress < 100
? `${uploadProgress}%`
: processingProgress.total > 0
? `${(processingProgress.current / processingProgress.total) * 100}%`
: '0%',
}}
transition={{ duration: 0.3 }}
className="h-full bg-primary rounded-full"
/>
</div>
</div>
)}
{/* 2-column 레이아웃 */}
<div ref={photoListRef} className="flex gap-6">
{/* 드래그 앤 드롭 영역 + 파일 목록 */}
<div
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`flex-1 relative rounded-2xl border-2 border-dashed transition-colors bg-white ${
dragOver ? 'border-primary bg-primary/5' : 'border-gray-200'
}`}
>
{/* 드래그 오버레이 */}
{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">
{photoType === 'teaser' ? 'JPG, PNG, WebP, MP4 지원' : '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"
defaultValue={String(startNumber + index).padStart(2, '0')}
key={`order-${file.id}-${index}-${startNumber}`}
onBlur={(e) => {
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]"
/>
</div>
{/* 썸네일 */}
{file.isVideo ? (
<div className="relative w-[180px] h-[180px] flex-shrink-0">
<video
src={file.preview}
className="w-full h-full rounded-lg object-cover cursor-pointer hover:opacity-80 transition-opacity select-none"
onClick={() => setPreviewPhoto(file)}
muted
/>
<div
className="absolute inset-0 flex items-center justify-center bg-black/30 rounded-lg cursor-pointer"
onClick={() => setPreviewPhoto(file)}
>
<div className="w-12 h-12 bg-white/90 rounded-full flex items-center justify-center">
<div className="w-0 h-0 border-l-[14px] border-l-gray-800 border-y-[8px] border-y-transparent ml-1" />
</div>
</div>
</div>
) : (
<img
src={file.preview}
alt={file.filename}
draggable="false"
loading="lazy"
className="w-[180px] h-[180px] rounded-lg object-cover cursor-pointer hover:opacity-80 transition-opacity flex-shrink-0 select-none"
onClick={() => setPreviewPhoto(file)}
/>
)}
{/* 메타 정보 */}
<div className="flex-1 space-y-3 h-[200px] overflow-hidden">
<p className="text-base font-medium text-gray-900 truncate">
{file.filename}
</p>
{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>
{/* 멤버 태깅 */}
<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 border border-primary'
: 'bg-white text-gray-600 hover:bg-gray-100 border border-gray-200'
}`}
>
{member.name}
</button>
))}
</>
)}
</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 border border-gray-500'
: 'bg-gray-100 text-gray-400 hover:bg-gray-200 border border-gray-200'
}`}
>
{member.name}
</button>
))}
</div>
)}
</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>
</>
)}
</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>
)}
</div>
{/* 일괄 편집 도구 */}
{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>
{/* 멤버 선택 */}
{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">
{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-primary text-white border border-primary'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 border border-gray-100'
}`}
>
{member.name}
</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 border border-gray-500'
: 'bg-gray-100 text-gray-400 hover:bg-gray-200 border border-gray-100'
}`}
>
{member.name}
</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>
{/* 적용 버튼 */}
<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>
</>
)}
{/* 관리 탭 */}
{activeTab === 'manage' && (
<div 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 }}
transition={{ duration: 0.2, delay: index < 20 ? index * 0.02 : 0 }}
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}`}
loading="lazy"
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 }}
transition={{ duration: 0.2, delay: index < 20 ? index * 0.02 : 0 }}
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]
);
}}
>
{teaser.media_type === 'video' ? (
<video
src={teaser.video_url || teaser.original_url}
poster={teaser.thumb_url}
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
muted
loop
onMouseEnter={(e) => e.target.play()}
onMouseLeave={(e) => {
e.target.pause();
e.target.currentTime = 0;
}}
/>
) : (
<img
src={teaser.thumb_url || teaser.medium_url}
alt={`티저 ${teaser.sort_order}`}
loading="lazy"
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>
)}
</>
)}
</div>
)}
{/* 삭제 확인 다이얼로그 */}
<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>
{/* 업로드 확인 다이얼로그 */}
<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>
<input
ref={fileInputRef}
type="file"
multiple
accept={photoType === 'teaser' ? 'image/*,video/mp4' : 'image/*'}
onChange={handleFileSelect}
className="hidden"
/>
</div>
</AdminLayout>
);
}
export default AdminAlbumPhotos;