refactor: AlbumPhotos.jsx 분리 - 4개 컴포넌트 추출

- PendingFileItem.jsx 추출 (업로드 대기 파일 아이템)
- BulkEditPanel.jsx 추출 (일괄 편집 도구 + parseRange 함수)
- PhotoGrid.jsx 추출 (컨셉 포토/티저 그리드)
- PhotoPreviewModal.jsx 추출 (이미지/비디오 미리보기)
- AlbumPhotos.jsx: 1536줄 → 1033줄 (503줄 감소)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-22 23:46:52 +09:00
parent f436cf4367
commit cf8cdb7ec6
7 changed files with 694 additions and 560 deletions

View file

@ -44,7 +44,7 @@ components/
### 1.4 대형 파일
| 파일 | 원래 라인 수 | 현재 라인 수 | 상태 |
|------|-------------|-------------|------|
| AlbumPhotos.jsx | 1536 | 1536 | 미분리 |
| AlbumPhotos.jsx | 1536 | 1033 | ✅ 분리 완료 |
| Schedules.jsx | 1465 | 1159 | ✅ 분리 완료 |
| ScheduleForm.jsx | 1047 | 765 | ✅ 분리 완료 |
| ScheduleDict.jsx | 714 | 572 | ✅ 분리 완료 |
@ -99,7 +99,11 @@ components/
│ │ ├── ImageUploader.jsx # 이미지 업로드
│ │ └── WordItem.jsx # 사전 단어 아이템
│ └── album/ # ✅ 추가된 컴포넌트들:
│ └── TrackItem.jsx # 트랙 입력 폼
│ ├── TrackItem.jsx # 트랙 입력 폼
│ ├── PendingFileItem.jsx # 업로드 대기 파일
│ ├── BulkEditPanel.jsx # 일괄 편집 도구
│ ├── PhotoGrid.jsx # 사진/티저 그리드
│ └── PhotoPreviewModal.jsx # 미리보기 모달
```
### 2.3 중복 코드 제거
@ -184,7 +188,11 @@ pages/pc/admin/schedules/
4. [x] AlbumForm.jsx 분리 (631줄 → 443줄, 188줄 감소)
- `CustomSelect.jsx` 추출 (공통 드롭다운 → common/)
- `TrackItem.jsx` 추출 (트랙 입력 폼 → album/)
5. [ ] AlbumPhotos.jsx 분리 (1536줄, 구조 복잡)
5. [x] AlbumPhotos.jsx 분리 (1536줄 → 1033줄, 503줄 감소)
- `PendingFileItem.jsx` 추출 (업로드 대기 파일 아이템)
- `BulkEditPanel.jsx` 추출 (일괄 편집 도구)
- `PhotoGrid.jsx` 추출 (사진/티저 그리드)
- `PhotoPreviewModal.jsx` 추출 (미리보기 모달)
### Phase 3: 추가 개선
1. [x] 관리자 페이지용 에러 페이지 추가 (404)

View file

@ -0,0 +1,211 @@
/**
* 일괄 편집 패널 컴포넌트
*/
import { memo } from 'react';
import { Tag, Users, User, Users2, Check } from 'lucide-react';
/**
* 범위 문자열 파싱
*/
export 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);
};
/**
* @param {Object} props
* @param {Object} props.bulkEdit - 일괄 편집 상태
* @param {Function} props.setBulkEdit - 일괄 편집 상태 설정
* @param {number} props.startNumber - 시작 번호
* @param {number} props.pendingFilesCount - 대기 파일
* @param {Array} props.members - 멤버 목록
* @param {Function} props.onApply - 적용 핸들러
*/
const BulkEditPanel = memo(function BulkEditPanel({
bulkEdit,
setBulkEdit,
startNumber,
pendingFilesCount,
members,
onApply,
}) {
const groupTypes = [
{ value: 'group', icon: Users, label: '단체' },
{ value: 'solo', icon: User, label: '개인' },
{ value: 'unit', icon: Users2, label: '유닛' },
];
const toggleBulkMember = (memberId) => {
setBulkEdit((prev) => ({
...prev,
members: prev.members.includes(memberId)
? prev.members.filter((m) => m !== memberId)
: [...prev.members, memberId],
}));
};
return (
<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 + pendingFilesCount - 1} {' '}
{parseRange(bulkEdit.range, startNumber).filter((i) => i < pendingFilesCount).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">
{groupTypes.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={onApply}
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>
);
});
export default BulkEditPanel;

View file

@ -0,0 +1,214 @@
/**
* 업로드 대기 파일 아이템 컴포넌트
*/
import { memo } from 'react';
import { Reorder } from 'framer-motion';
import { GripVertical, Trash2, Users, User, Users2 } from 'lucide-react';
/**
* @param {Object} props
* @param {Object} props.file - 파일 데이터
* @param {number} props.index - 인덱스
* @param {number} props.startNumber - 시작 번호
* @param {string} props.photoType - 사진 타입 (concept/teaser)
* @param {Array} props.members - 멤버 목록
* @param {Function} props.onPreview - 미리보기 핸들러
* @param {Function} props.onDelete - 삭제 핸들러
* @param {Function} props.onUpdateFile - 파일 업데이트 핸들러
* @param {Function} props.onToggleMember - 멤버 토글 핸들러
* @param {Function} props.onChangeGroupType - 그룹 타입 변경 핸들러
* @param {Function} props.onMoveToPosition - 위치 이동 핸들러
* @param {Array} props.pendingFiles - 전체 대기 파일 목록 (위치 계산용)
*/
const PendingFileItem = memo(function PendingFileItem({
file,
index,
startNumber,
photoType,
members,
onPreview,
onDelete,
onUpdateFile,
onToggleMember,
onChangeGroupType,
onMoveToPosition,
pendingFiles,
}) {
const groupTypes = [
{ value: 'group', icon: Users, label: '단체' },
{ value: 'solo', icon: User, label: '개인' },
{ value: 'unit', icon: Users2, label: '유닛' },
];
return (
<Reorder.Item
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) {
onMoveToPosition(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={() => onPreview(file)}
muted
/>
<div
className="absolute inset-0 flex items-center justify-center bg-black/30 rounded-lg cursor-pointer"
onClick={() => onPreview(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={() => onPreview(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">
{groupTypes.map(({ value, icon: Icon, label }) => (
<button
key={value}
onClick={() => onChangeGroupType(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={() => onToggleMember(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={() => onToggleMember(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) => onUpdateFile(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={() => onDelete(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>
);
});
export default PendingFileItem;

View file

@ -0,0 +1,142 @@
/**
* 사진/티저 그리드 컴포넌트
*/
import { memo } from 'react';
import { motion } from 'framer-motion';
import { Image, Check } from 'lucide-react';
/**
* @param {Object} props
* @param {Array} props.items - 사진/티저 목록
* @param {Array} props.selectedItems - 선택된 아이템 ID 목록
* @param {Function} props.onToggleSelect - 선택 토글 핸들러
* @param {'concept'|'teaser'} props.type - 그리드 타입
*/
const PhotoGrid = memo(function PhotoGrid({ items, selectedItems, onToggleSelect, type }) {
if (items.length === 0) {
return (
<div className="text-center py-16">
<Image className="mx-auto text-gray-300 mb-4" size={48} />
<p className="text-gray-500">
등록된 {type === 'concept' ? '컨셉 포토' : '티저 이미지'} 없습니다
</p>
<p className="text-gray-400 text-sm mt-1">
업로드 탭에서 {type === 'concept' ? '사진' : '티저'} 추가하세요
</p>
</div>
);
}
if (type === 'concept') {
return (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
{items.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 ${
selectedItems.includes(photo.id)
? 'border-primary ring-2 ring-primary/30 scale-[0.98]'
: 'border-transparent hover:border-primary/50 hover:shadow-lg'
}`}
onClick={() => onToggleSelect(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 ${
selectedItems.includes(photo.id)
? 'bg-primary border-primary'
: 'bg-white/80 border-gray-300 opacity-0 group-hover:opacity-100'
}`}
>
{selectedItems.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>
);
}
// Teaser grid
return (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
{items.map((teaser, index) => {
const teaserId = `teaser-${teaser.id}`;
return (
<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 ${
selectedItems.includes(teaserId)
? 'border-primary ring-2 ring-primary/30 scale-[0.98]'
: 'border-transparent hover:border-primary/50 hover:shadow-lg'
}`}
onClick={() => onToggleSelect(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 ${
selectedItems.includes(teaserId)
? 'bg-primary border-primary'
: 'bg-white/80 border-gray-300 opacity-0 group-hover:opacity-100'
}`}
>
{selectedItems.includes(teaserId) && <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>
);
});
export default PhotoGrid;

View file

@ -0,0 +1,58 @@
/**
* 사진/비디오 미리보기 모달 컴포넌트
*/
import { memo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X } from 'lucide-react';
/**
* @param {Object} props
* @param {Object|null} props.photo - 미리보기할 사진/비디오 객체
* @param {Function} props.onClose - 닫기 핸들러
*/
const PhotoPreviewModal = memo(function PhotoPreviewModal({ photo, onClose }) {
return (
<AnimatePresence>
{photo && (
<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={onClose}
>
<button
onClick={onClose}
className="absolute top-4 right-4 p-2 text-white/70 hover:text-white transition-colors"
>
<X size={24} />
</button>
{photo.isVideo ? (
<motion.video
initial={{ scale: 0.9 }}
animate={{ scale: 1 }}
exit={{ scale: 0.9 }}
src={photo.preview || photo.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={photo.preview || photo.url}
alt={photo.filename}
className="max-w-[90vw] max-h-[90vh] object-contain"
onClick={(e) => e.stopPropagation()}
/>
)}
</motion.div>
)}
</AnimatePresence>
);
});
export default PhotoPreviewModal;

View file

@ -1 +1,5 @@
export { default as TrackItem } from './TrackItem';
export { default as PendingFileItem } from './PendingFileItem';
export { default as BulkEditPanel, parseRange } from './BulkEditPanel';
export { default as PhotoGrid } from './PhotoGrid';
export { default as PhotoPreviewModal } from './PhotoPreviewModal';

View file

@ -2,28 +2,20 @@
* 관리자 앨범 사진 관리 페이지
*/
import { useState, useEffect, useRef } from 'react';
import { useNavigate, Link, useParams } from 'react-router-dom';
import { 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 { Upload, Trash2, Image, Plus, Home, ChevronRight, FolderOpen, Save } from 'lucide-react';
import { Toast } from '@/components/common';
import { AdminLayout, ConfirmDialog } from '@/components/pc/admin';
import {
AdminLayout,
ConfirmDialog,
PendingFileItem,
BulkEditPanel,
PhotoGrid,
PhotoPreviewModal,
parseRange,
} from '@/components/pc/admin';
import { useAdminAuth } from '@/hooks/pc/admin';
import { useToast } from '@/hooks/common';
import { adminAlbumApi, adminMemberApi } from '@/api/admin';
@ -65,32 +57,6 @@ function AdminAlbumPhotos() {
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);
@ -134,16 +100,6 @@ function AdminAlbumPhotos() {
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,
@ -527,46 +483,7 @@ function AdminAlbumPhotos() {
/>
{/* 이미지 미리보기 */}
<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>
<PhotoPreviewModal photo={previewPhoto} onClose={() => setPreviewPhoto(null)} />
<div className="max-w-7xl mx-auto px-6 py-8">
{/* 브레드크럼 */}
@ -838,185 +755,21 @@ function AdminAlbumPhotos() {
className="space-y-3"
>
{pendingFiles.map((file, index) => (
<Reorder.Item
<PendingFileItem
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]"
file={file}
index={index}
startNumber={startNumber}
photoType={photoType}
members={members}
pendingFiles={pendingFiles}
onPreview={setPreviewPhoto}
onDelete={setPendingDeleteId}
onUpdateFile={updatePendingFile}
onToggleMember={toggleMember}
onChangeGroupType={changeGroupType}
onMoveToPosition={moveToPosition}
/>
</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>
@ -1025,154 +778,14 @@ function AdminAlbumPhotos() {
{/* 일괄 편집 도구 */}
{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"
<BulkEditPanel
bulkEdit={bulkEdit}
setBulkEdit={setBulkEdit}
startNumber={startNumber}
pendingFilesCount={pendingFiles.length}
members={members}
onApply={applyBulkEdit}
/>
<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>
</>
@ -1269,146 +882,30 @@ function AdminAlbumPhotos() {
{/* 컨셉 포토 그리드 */}
{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={() => {
<PhotoGrid
items={photos}
selectedItems={selectedPhotos}
onToggleSelect={(id) => {
setSelectedPhotos((prev) =>
prev.includes(photo.id)
? prev.filter((id) => id !== photo.id)
: [...prev, photo.id]
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, 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"
type="concept"
/>
<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}`;
<PhotoGrid
items={teasers}
selectedItems={selectedPhotos}
onToggleSelect={(id) => {
setSelectedPhotos((prev) =>
prev.includes(teaserId)
? prev.filter((id) => id !== teaserId)
: [...prev, teaserId]
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id]
);
}}
>
{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;
}}
type="teaser"
/>
) : (
<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>
)}