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 대형 파일
|
### 1.4 대형 파일
|
||||||
| 파일 | 원래 라인 수 | 현재 라인 수 | 상태 |
|
| 파일 | 원래 라인 수 | 현재 라인 수 | 상태 |
|
||||||
|------|-------------|-------------|------|
|
|------|-------------|-------------|------|
|
||||||
| AlbumPhotos.jsx | 1536 | 1536 | 미분리 |
|
| AlbumPhotos.jsx | 1536 | 1033 | ✅ 분리 완료 |
|
||||||
| Schedules.jsx | 1465 | 1159 | ✅ 분리 완료 |
|
| Schedules.jsx | 1465 | 1159 | ✅ 분리 완료 |
|
||||||
| ScheduleForm.jsx | 1047 | 765 | ✅ 분리 완료 |
|
| ScheduleForm.jsx | 1047 | 765 | ✅ 분리 완료 |
|
||||||
| ScheduleDict.jsx | 714 | 572 | ✅ 분리 완료 |
|
| ScheduleDict.jsx | 714 | 572 | ✅ 분리 완료 |
|
||||||
|
|
@ -99,7 +99,11 @@ components/
|
||||||
│ │ ├── ImageUploader.jsx # 이미지 업로드
|
│ │ ├── ImageUploader.jsx # 이미지 업로드
|
||||||
│ │ └── WordItem.jsx # 사전 단어 아이템
|
│ │ └── WordItem.jsx # 사전 단어 아이템
|
||||||
│ └── album/ # ✅ 추가된 컴포넌트들:
|
│ └── album/ # ✅ 추가된 컴포넌트들:
|
||||||
│ └── TrackItem.jsx # 트랙 입력 폼
|
│ ├── TrackItem.jsx # 트랙 입력 폼
|
||||||
|
│ ├── PendingFileItem.jsx # 업로드 대기 파일
|
||||||
|
│ ├── BulkEditPanel.jsx # 일괄 편집 도구
|
||||||
|
│ ├── PhotoGrid.jsx # 사진/티저 그리드
|
||||||
|
│ └── PhotoPreviewModal.jsx # 미리보기 모달
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.3 중복 코드 제거
|
### 2.3 중복 코드 제거
|
||||||
|
|
@ -184,7 +188,11 @@ pages/pc/admin/schedules/
|
||||||
4. [x] AlbumForm.jsx 분리 (631줄 → 443줄, 188줄 감소)
|
4. [x] AlbumForm.jsx 분리 (631줄 → 443줄, 188줄 감소)
|
||||||
- `CustomSelect.jsx` 추출 (공통 드롭다운 → common/)
|
- `CustomSelect.jsx` 추출 (공통 드롭다운 → common/)
|
||||||
- `TrackItem.jsx` 추출 (트랙 입력 폼 → album/)
|
- `TrackItem.jsx` 추출 (트랙 입력 폼 → album/)
|
||||||
5. [ ] AlbumPhotos.jsx 분리 (1536줄, 구조 복잡)
|
5. [x] AlbumPhotos.jsx 분리 (1536줄 → 1033줄, 503줄 감소)
|
||||||
|
- `PendingFileItem.jsx` 추출 (업로드 대기 파일 아이템)
|
||||||
|
- `BulkEditPanel.jsx` 추출 (일괄 편집 도구)
|
||||||
|
- `PhotoGrid.jsx` 추출 (사진/티저 그리드)
|
||||||
|
- `PhotoPreviewModal.jsx` 추출 (미리보기 모달)
|
||||||
|
|
||||||
### Phase 3: 추가 개선
|
### Phase 3: 추가 개선
|
||||||
1. [x] 관리자 페이지용 에러 페이지 추가 (404)
|
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 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 { 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 { useQuery } from '@tanstack/react-query';
|
||||||
import { motion, AnimatePresence, Reorder } from 'framer-motion';
|
import { motion, AnimatePresence, Reorder } from 'framer-motion';
|
||||||
import {
|
import { Upload, Trash2, Image, Plus, Home, ChevronRight, FolderOpen, Save } from 'lucide-react';
|
||||||
Upload,
|
|
||||||
Trash2,
|
|
||||||
Image,
|
|
||||||
X,
|
|
||||||
Check,
|
|
||||||
Plus,
|
|
||||||
Home,
|
|
||||||
ChevronRight,
|
|
||||||
GripVertical,
|
|
||||||
Users,
|
|
||||||
User,
|
|
||||||
Users2,
|
|
||||||
Tag,
|
|
||||||
FolderOpen,
|
|
||||||
Save,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Toast } from '@/components/common';
|
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 { useAdminAuth } from '@/hooks/pc/admin';
|
||||||
import { useToast } from '@/hooks/common';
|
import { useToast } from '@/hooks/common';
|
||||||
import { adminAlbumApi, adminMemberApi } from '@/api/admin';
|
import { adminAlbumApi, adminMemberApi } from '@/api/admin';
|
||||||
|
|
@ -65,32 +57,6 @@ function AdminAlbumPhotos() {
|
||||||
conceptName: '',
|
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 applyBulkEdit = () => {
|
||||||
const indices = parseRange(bulkEdit.range, startNumber);
|
const indices = parseRange(bulkEdit.range, startNumber);
|
||||||
|
|
@ -134,16 +100,6 @@ function AdminAlbumPhotos() {
|
||||||
setBulkEdit({ range: '', groupType: '', members: [], conceptName: '' });
|
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 {
|
const {
|
||||||
data: album,
|
data: album,
|
||||||
|
|
@ -527,46 +483,7 @@ function AdminAlbumPhotos() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 이미지 미리보기 */}
|
{/* 이미지 미리보기 */}
|
||||||
<AnimatePresence>
|
<PhotoPreviewModal photo={previewPhoto} onClose={() => setPreviewPhoto(null)} />
|
||||||
{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="max-w-7xl mx-auto px-6 py-8">
|
||||||
{/* 브레드크럼 */}
|
{/* 브레드크럼 */}
|
||||||
|
|
@ -838,185 +755,21 @@ function AdminAlbumPhotos() {
|
||||||
className="space-y-3"
|
className="space-y-3"
|
||||||
>
|
>
|
||||||
{pendingFiles.map((file, index) => (
|
{pendingFiles.map((file, index) => (
|
||||||
<Reorder.Item
|
<PendingFileItem
|
||||||
key={file.id}
|
key={file.id}
|
||||||
value={file}
|
file={file}
|
||||||
className="bg-gray-50 rounded-xl p-4 cursor-grab active:cursor-grabbing"
|
index={index}
|
||||||
>
|
startNumber={startNumber}
|
||||||
<div className="flex gap-4 items-center">
|
photoType={photoType}
|
||||||
{/* 드래그 핸들 + 순서 번호 */}
|
members={members}
|
||||||
<div className="flex items-center gap-2">
|
pendingFiles={pendingFiles}
|
||||||
<GripVertical size={18} className="text-gray-300" />
|
onPreview={setPreviewPhoto}
|
||||||
<input
|
onDelete={setPendingDeleteId}
|
||||||
type="text"
|
onUpdateFile={updatePendingFile}
|
||||||
inputMode="numeric"
|
onToggleMember={toggleMember}
|
||||||
defaultValue={String(startNumber + index).padStart(2, '0')}
|
onChangeGroupType={changeGroupType}
|
||||||
key={`order-${file.id}-${index}-${startNumber}`}
|
onMoveToPosition={moveToPosition}
|
||||||
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>
|
</Reorder.Group>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1025,154 +778,14 @@ function AdminAlbumPhotos() {
|
||||||
|
|
||||||
{/* 일괄 편집 도구 */}
|
{/* 일괄 편집 도구 */}
|
||||||
{pendingFiles.length > 0 && photoType === 'concept' && (
|
{pendingFiles.length > 0 && photoType === 'concept' && (
|
||||||
<div className="w-72 flex-shrink-0">
|
<BulkEditPanel
|
||||||
<div className="sticky top-24 bg-white rounded-2xl shadow-lg border border-gray-100 p-5">
|
bulkEdit={bulkEdit}
|
||||||
<h3 className="font-bold text-gray-900 mb-4 flex items-center gap-2">
|
setBulkEdit={setBulkEdit}
|
||||||
<Tag size={18} className="text-primary" />
|
startNumber={startNumber}
|
||||||
일괄 편집
|
pendingFilesCount={pendingFiles.length}
|
||||||
</h3>
|
members={members}
|
||||||
|
onApply={applyBulkEdit}
|
||||||
{/* 번호 범위 */}
|
/>
|
||||||
<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>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
@ -1269,146 +882,30 @@ function AdminAlbumPhotos() {
|
||||||
|
|
||||||
{/* 컨셉 포토 그리드 */}
|
{/* 컨셉 포토 그리드 */}
|
||||||
{manageSubTab === 'concept' && (
|
{manageSubTab === 'concept' && (
|
||||||
<>
|
<PhotoGrid
|
||||||
{photos.length === 0 ? (
|
items={photos}
|
||||||
<div className="text-center py-16">
|
selectedItems={selectedPhotos}
|
||||||
<Image className="mx-auto text-gray-300 mb-4" size={48} />
|
onToggleSelect={(id) => {
|
||||||
<p className="text-gray-500">등록된 컨셉 포토가 없습니다</p>
|
setSelectedPhotos((prev) =>
|
||||||
<p className="text-gray-400 text-sm mt-1">업로드 탭에서 사진을 추가하세요</p>
|
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id]
|
||||||
</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">
|
type="concept"
|
||||||
{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' && (
|
{manageSubTab === 'teaser' && (
|
||||||
<>
|
<PhotoGrid
|
||||||
{teasers.length === 0 ? (
|
items={teasers}
|
||||||
<div className="text-center py-16">
|
selectedItems={selectedPhotos}
|
||||||
<Image className="mx-auto text-gray-300 mb-4" size={48} />
|
onToggleSelect={(id) => {
|
||||||
<p className="text-gray-500">등록된 티저 이미지가 없습니다</p>
|
setSelectedPhotos((prev) =>
|
||||||
<p className="text-gray-400 text-sm mt-1">업로드 탭에서 티저를 추가하세요</p>
|
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id]
|
||||||
</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">
|
type="teaser"
|
||||||
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue