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:
parent
f436cf4367
commit
cf8cdb7ec6
7 changed files with 694 additions and 560 deletions
|
|
@ -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)
|
||||
|
|
|
|||
211
frontend-temp/src/components/pc/admin/album/BulkEditPanel.jsx
Normal file
211
frontend-temp/src/components/pc/admin/album/BulkEditPanel.jsx
Normal 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;
|
||||
214
frontend-temp/src/components/pc/admin/album/PendingFileItem.jsx
Normal file
214
frontend-temp/src/components/pc/admin/album/PendingFileItem.jsx
Normal 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;
|
||||
142
frontend-temp/src/components/pc/admin/album/PhotoGrid.jsx
Normal file
142
frontend-temp/src/components/pc/admin/album/PhotoGrid.jsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue