feat: 관리자 페이지 일괄 편집 도구 추가
- 사진 목록 우측에 sticky 일괄 편집 패널 추가 - 번호 범위로 여러 사진에 타입/멤버/컨셉명 일괄 적용 - 표시 번호(startNumber) 기준으로 범위 입력 가능 - 순수 CSS sticky로 성능 최적화
This commit is contained in:
parent
961ca97920
commit
57fa0e1393
1 changed files with 225 additions and 16 deletions
|
|
@ -13,6 +13,7 @@ function AdminAlbumPhotos() {
|
||||||
const { albumId } = useParams();
|
const { albumId } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
|
const photoListRef = useRef(null); // 사진 목록 영역 ref
|
||||||
|
|
||||||
const [album, setAlbum] = useState(null);
|
const [album, setAlbum] = useState(null);
|
||||||
const [photos, setPhotos] = useState([]);
|
const [photos, setPhotos] = useState([]);
|
||||||
|
|
@ -38,6 +39,98 @@ function AdminAlbumPhotos() {
|
||||||
const [processingProgress, setProcessingProgress] = useState({ current: 0, total: 0 }); // 서버 처리 진행률
|
const [processingProgress, setProcessingProgress] = useState({ current: 0, total: 0 }); // 서버 처리 진행률
|
||||||
const [pendingDeleteId, setPendingDeleteId] = useState(null); // 삭제 대기 파일 ID
|
const [pendingDeleteId, setPendingDeleteId] = useState(null); // 삭제 대기 파일 ID
|
||||||
|
|
||||||
|
// 일괄 편집 도구 상태
|
||||||
|
const [bulkEdit, setBulkEdit] = useState({
|
||||||
|
range: '', // 예: "1-5, 8, 10-12"
|
||||||
|
groupType: '', // 'group' | 'solo' | 'unit' | ''(미적용)
|
||||||
|
members: [], // 선택된 멤버들
|
||||||
|
conceptName: '', // 컨셉명
|
||||||
|
});
|
||||||
|
|
||||||
|
// 범위 문자열 파싱 (표시 번호 기준, 예: "18-22" → [0, 1, 2, 3, 4])
|
||||||
|
const parseRange = (rangeStr, baseNumber = 1) => {
|
||||||
|
if (!rangeStr.trim()) return [];
|
||||||
|
const indices = new Set();
|
||||||
|
const parts = rangeStr.split(',').map(s => s.trim());
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.includes('-')) {
|
||||||
|
const [start, end] = part.split('-').map(n => parseInt(n.trim()));
|
||||||
|
if (!isNaN(start) && !isNaN(end)) {
|
||||||
|
for (let i = Math.min(start, end); i <= Math.max(start, end); i++) {
|
||||||
|
// 표시 번호를 0-indexed로 변환
|
||||||
|
const idx = i - baseNumber;
|
||||||
|
if (idx >= 0) indices.add(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const num = parseInt(part);
|
||||||
|
if (!isNaN(num)) {
|
||||||
|
const idx = num - baseNumber;
|
||||||
|
if (idx >= 0) indices.add(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(indices).sort((a, b) => a - b);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 일괄 편집 적용
|
||||||
|
const applyBulkEdit = () => {
|
||||||
|
const indices = parseRange(bulkEdit.range, startNumber);
|
||||||
|
if (indices.length === 0) {
|
||||||
|
setToast({ message: '적용할 번호 범위를 입력하세요.', type: 'warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validIndices = indices.filter(i => i < pendingFiles.length);
|
||||||
|
if (validIndices.length === 0) {
|
||||||
|
setToast({ message: '유효한 번호가 없습니다.', type: 'error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingFiles(prev => prev.map((file, idx) => {
|
||||||
|
if (!validIndices.includes(idx)) return file;
|
||||||
|
|
||||||
|
const updates = {};
|
||||||
|
if (bulkEdit.groupType) {
|
||||||
|
updates.groupType = bulkEdit.groupType;
|
||||||
|
// 단체 선택 시 멤버 비움
|
||||||
|
if (bulkEdit.groupType === 'group') {
|
||||||
|
updates.members = [];
|
||||||
|
} else if (bulkEdit.members.length > 0) {
|
||||||
|
// 솔로일 경우 첫 번째만
|
||||||
|
updates.members = bulkEdit.groupType === 'solo'
|
||||||
|
? [bulkEdit.members[0]]
|
||||||
|
: [...bulkEdit.members];
|
||||||
|
}
|
||||||
|
} else if (bulkEdit.members.length > 0) {
|
||||||
|
// 타입 미선택이지만 멤버 선택한 경우
|
||||||
|
updates.members = [...bulkEdit.members];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bulkEdit.conceptName) {
|
||||||
|
updates.conceptName = bulkEdit.conceptName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...file, ...updates };
|
||||||
|
}));
|
||||||
|
|
||||||
|
setToast({ message: `${validIndices.length}개 사진에 일괄 적용되었습니다.`, type: 'success' });
|
||||||
|
|
||||||
|
// 입력 초기화
|
||||||
|
setBulkEdit({ range: '', groupType: '', members: [], conceptName: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 일괄 편집 멤버 토글
|
||||||
|
const toggleBulkMember = (memberId) => {
|
||||||
|
setBulkEdit(prev => ({
|
||||||
|
...prev,
|
||||||
|
members: prev.members.includes(memberId)
|
||||||
|
? prev.members.filter(m => m !== memberId)
|
||||||
|
: [...prev.members, memberId]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
// Toast 자동 숨김
|
// Toast 자동 숨김
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (toast) {
|
if (toast) {
|
||||||
|
|
@ -687,6 +780,8 @@ function AdminAlbumPhotos() {
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 2-column 레이아웃: 사진 목록 + 일괄 편집 패널 */}
|
||||||
|
<div ref={photoListRef} className="flex gap-6">
|
||||||
{/* 드래그 앤 드롭 영역 + 파일 목록 */}
|
{/* 드래그 앤 드롭 영역 + 파일 목록 */}
|
||||||
<motion.div
|
<motion.div
|
||||||
onDragEnter={handleDragEnter}
|
onDragEnter={handleDragEnter}
|
||||||
|
|
@ -696,7 +791,7 @@ function AdminAlbumPhotos() {
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.25 }}
|
transition={{ delay: 0.25 }}
|
||||||
className={`relative rounded-2xl border-2 border-dashed transition-colors bg-white ${
|
className={`flex-1 relative rounded-2xl border-2 border-dashed transition-colors bg-white ${
|
||||||
dragOver
|
dragOver
|
||||||
? 'border-primary bg-primary/5'
|
? 'border-primary bg-primary/5'
|
||||||
: 'border-gray-200'
|
: 'border-gray-200'
|
||||||
|
|
@ -876,7 +971,121 @@ function AdminAlbumPhotos() {
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 일괄 편집 도구 - CSS sticky (네이티브 성능) */}
|
||||||
|
{pendingFiles.length > 0 && photoType === 'concept' && (
|
||||||
|
<div className="w-72 flex-shrink-0">
|
||||||
|
<div className="sticky top-24 bg-white rounded-2xl shadow-lg border border-gray-100 p-5">
|
||||||
|
<h3 className="font-bold text-gray-900 mb-4 flex items-center gap-2">
|
||||||
|
<Tag size={18} className="text-primary" />
|
||||||
|
일괄 편집
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* 번호 범위 */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">번호 범위</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={bulkEdit.range}
|
||||||
|
onChange={(e) => setBulkEdit(prev => ({ ...prev, range: e.target.value }))}
|
||||||
|
placeholder={`예: ${startNumber}-${startNumber + 4}, ${startNumber + 7}`}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
{startNumber}~{startNumber + pendingFiles.length - 1}번 중 {parseRange(bulkEdit.range, startNumber).filter(i => i < pendingFiles.length).length}개 선택
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 타입 선택 */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">타입</label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{[
|
||||||
|
{ value: 'group', icon: Users, label: '단체' },
|
||||||
|
{ value: 'solo', icon: User, label: '솔로' },
|
||||||
|
{ value: 'unit', icon: Users2, label: '유닛' },
|
||||||
|
].map(({ value, icon: Icon, label }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
onClick={() => setBulkEdit(prev => ({
|
||||||
|
...prev,
|
||||||
|
groupType: prev.groupType === value ? '' : value,
|
||||||
|
members: value === 'group' ? [] : prev.members
|
||||||
|
}))}
|
||||||
|
className={`flex-1 py-1.5 px-2 rounded-lg text-xs font-medium transition-colors flex items-center justify-center gap-1 ${
|
||||||
|
bulkEdit.groupType === value
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={14} />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 멤버 선택 */}
|
||||||
|
{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.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'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
|
||||||
{/* 삭제 확인 다이얼로그 */}
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue