feat: 관리자 페이지 일괄 편집 도구 추가

- 사진 목록 우측에 sticky 일괄 편집 패널 추가
- 번호 범위로 여러 사진에 타입/멤버/컨셉명 일괄 적용
- 표시 번호(startNumber) 기준으로 범위 입력 가능
- 순수 CSS sticky로 성능 최적화
This commit is contained in:
caadiq 2026-01-02 10:14:27 +09:00
parent 961ca97920
commit 57fa0e1393

View file

@ -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,21 +780,23 @@ function AdminAlbumPhotos() {
</motion.div> </motion.div>
)} )}
{/* 드래그 앤 드롭 영역 + 파일 목록 */} {/* 2-column 레이아웃: 사진 목록 + 일괄 편집 패널 */}
<motion.div <div ref={photoListRef} className="flex gap-6">
onDragEnter={handleDragEnter} {/* 드래그 앤 드롭 영역 + 파일 목록 */}
onDragOver={handleDragOver} <motion.div
onDragLeave={handleDragLeave} onDragEnter={handleDragEnter}
onDrop={handleDrop} onDragOver={handleDragOver}
initial={{ opacity: 0, y: 20 }} onDragLeave={handleDragLeave}
animate={{ opacity: 1, y: 0 }} onDrop={handleDrop}
transition={{ delay: 0.25 }} initial={{ opacity: 0, y: 20 }}
className={`relative rounded-2xl border-2 border-dashed transition-colors bg-white ${ animate={{ opacity: 1, y: 0 }}
dragOver transition={{ delay: 0.25 }}
? 'border-primary bg-primary/5' className={`flex-1 relative rounded-2xl border-2 border-dashed transition-colors bg-white ${
: 'border-gray-200' dragOver
}`} ? 'border-primary bg-primary/5'
> : 'border-gray-200'
}`}
>
{/* 드래그 오버레이 */} {/* 드래그 오버레이 */}
{dragOver && ( {dragOver && (
<div className="absolute inset-0 flex items-center justify-center bg-primary/10 rounded-2xl z-10"> <div className="absolute inset-0 flex items-center justify-center bg-primary/10 rounded-2xl z-10">
@ -874,9 +969,123 @@ function AdminAlbumPhotos() {
</Reorder.Group> </Reorder.Group>
</div> </div>
)} )}
</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>