feat: 관리 탭 UI 개선 - 탭 분리, 전체 선택, 삭제 기능, 애니메이션 추가

This commit is contained in:
caadiq 2026-01-02 12:17:24 +09:00
parent 4d8d18586c
commit 9f7548b4b4
2 changed files with 407 additions and 15 deletions

View file

@ -430,7 +430,7 @@ router.get("/albums/:albumId/photos", async (req, res) => {
const [photos] = await pool.query( const [photos] = await pool.query(
` `
SELECT SELECT
p.id, p.photo_url, p.photo_type, p.concept_name, p.id, p.original_url, p.medium_url, p.thumb_url, p.photo_type, p.concept_name,
p.sort_order, p.width, p.height, p.file_size, p.sort_order, p.width, p.height, p.file_size,
GROUP_CONCAT(pm.member_id) as member_ids GROUP_CONCAT(pm.member_id) as member_ids
FROM album_photos p FROM album_photos p
@ -442,12 +442,9 @@ router.get("/albums/:albumId/photos", async (req, res) => {
[albumId] [albumId]
); );
// URL 변환 및 멤버 배열 파싱 // 멤버 배열 파싱
const result = photos.map((photo) => ({ const result = photos.map((photo) => ({
...photo, ...photo,
photo_url: photo.photo_url,
thumb_url: photo.photo_url.replace("/original/", "/thumb_400/"),
medium_url: photo.photo_url.replace("/original/", "/medium_800/"),
members: photo.member_ids ? photo.member_ids.split(",").map(Number) : [], members: photo.member_ids ? photo.member_ids.split(",").map(Number) : [],
})); }));
@ -458,6 +455,95 @@ router.get("/albums/:albumId/photos", async (req, res) => {
} }
}); });
// 앨범 티저 목록 조회
router.get("/albums/:albumId/teasers", async (req, res) => {
try {
const { albumId } = req.params;
// 앨범 존재 확인
const [albums] = await pool.query(
"SELECT folder_name FROM albums WHERE id = ?",
[albumId]
);
if (albums.length === 0) {
return res.status(404).json({ error: "앨범을 찾을 수 없습니다." });
}
// 티저 조회
const [teasers] = await pool.query(
`SELECT id, original_url, medium_url, thumb_url, sort_order
FROM album_teasers
WHERE album_id = ?
ORDER BY sort_order ASC`,
[albumId]
);
res.json(teasers);
} catch (error) {
console.error("티저 조회 오류:", error);
res.status(500).json({ error: "티저 조회 중 오류가 발생했습니다." });
}
});
// 티저 삭제
router.delete(
"/albums/:albumId/teasers/:teaserId",
authenticateToken,
async (req, res) => {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const { albumId, teaserId } = req.params;
// 티저 정보 조회
const [teasers] = await connection.query(
"SELECT t.*, a.folder_name FROM album_teasers t JOIN albums a ON t.album_id = a.id WHERE t.id = ? AND t.album_id = ?",
[teaserId, albumId]
);
if (teasers.length === 0) {
return res.status(404).json({ error: "티저를 찾을 수 없습니다." });
}
const teaser = teasers[0];
const filename = teaser.original_url.split("/").pop();
const basePath = `album/${teaser.folder_name}/teaser`;
// RustFS에서 삭제 (3가지 크기 모두)
const sizes = ["original", "medium_800", "thumb_400"];
for (const size of sizes) {
try {
await s3Client.send(
new DeleteObjectCommand({
Bucket: BUCKET,
Key: `${basePath}/${size}/${filename}`,
})
);
} catch (s3Error) {
console.error(`S3 삭제 오류 (${size}):`, s3Error);
}
}
// 티저 삭제
await connection.query("DELETE FROM album_teasers WHERE id = ?", [
teaserId,
]);
await connection.commit();
res.json({ message: "티저가 삭제되었습니다." });
} catch (error) {
await connection.rollback();
console.error("티저 삭제 오류:", error);
res.status(500).json({ error: "티저 삭제 중 오류가 발생했습니다." });
} finally {
connection.release();
}
}
);
// 사진 업로드 (SSE로 실시간 진행률 전송) // 사진 업로드 (SSE로 실시간 진행률 전송)
router.post( router.post(
"/albums/:albumId/photos", "/albums/:albumId/photos",

View file

@ -17,6 +17,7 @@ function AdminAlbumPhotos() {
const [album, setAlbum] = useState(null); const [album, setAlbum] = useState(null);
const [photos, setPhotos] = useState([]); const [photos, setPhotos] = useState([]);
const [teasers, setTeasers] = useState([]); //
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
const [toast, setToast] = useState(null); const [toast, setToast] = useState(null);
@ -39,6 +40,8 @@ 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 [uploadConfirmDialog, setUploadConfirmDialog] = useState(false); // const [uploadConfirmDialog, setUploadConfirmDialog] = useState(false); //
const [activeTab, setActiveTab] = useState('upload'); // 'upload' | 'manage'
const [manageSubTab, setManageSubTab] = useState('concept'); // 'concept' | 'teaser'
// //
const [bulkEdit, setBulkEdit] = useState({ const [bulkEdit, setBulkEdit] = useState({
@ -156,6 +159,8 @@ function AdminAlbumPhotos() {
const fetchAlbumData = async () => { const fetchAlbumData = async () => {
try { try {
const token = localStorage.getItem('adminToken');
// //
const albumRes = await fetch(`/api/albums/${albumId}`); const albumRes = await fetch(`/api/albums/${albumId}`);
if (!albumRes.ok) throw new Error('앨범을 찾을 수 없습니다'); if (!albumRes.ok) throw new Error('앨범을 찾을 수 없습니다');
@ -169,8 +174,24 @@ function AdminAlbumPhotos() {
setMembers(membersData); setMembers(membersData);
} }
// TODO: (API ) //
setPhotos([]); const photosRes = await fetch(`/api/admin/albums/${albumId}/photos`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (photosRes.ok) {
const photosData = await photosRes.json();
setPhotos(photosData);
}
//
const teasersRes = await fetch(`/api/admin/albums/${albumId}/teasers`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (teasersRes.ok) {
const teasersData = await teasersRes.json();
setTeasers(teasersData);
}
setLoading(false); setLoading(false);
} catch (error) { } catch (error) {
console.error('앨범 로드 오류:', error); console.error('앨범 로드 오류:', error);
@ -455,18 +476,54 @@ function AdminAlbumPhotos() {
} }
}; };
// ( ) // ( /)
const handleDelete = async () => { const handleDelete = async () => {
setDeleting(true); setDeleting(true);
const token = localStorage.getItem('adminToken');
// TODO: API try {
await new Promise(r => setTimeout(r, 1000)); // ID ID
const photoIds = deleteDialog.photos.filter(id => typeof id === 'number' || !String(id).startsWith('teaser-'));
const teaserIds = deleteDialog.photos
.filter(id => String(id).startsWith('teaser-'))
.map(id => parseInt(String(id).replace('teaser-', '')));
setPhotos(prev => prev.filter(p => !deleteDialog.photos.includes(p.id))); //
setSelectedPhotos([]); for (const photoId of photoIds) {
setToast({ message: `${deleteDialog.photos.length}개의 사진이 삭제되었습니다.`, type: 'success' }); const res = await fetch(`/api/admin/albums/${albumId}/photos/${photoId}`, {
setDeleteDialog({ show: false, photos: [] }); method: 'DELETE',
setDeleting(false); headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) {
throw new Error('사진 삭제 실패');
}
}
//
for (const teaserId of teaserIds) {
const res = await fetch(`/api/admin/albums/${albumId}/teasers/${teaserId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) {
throw new Error('티저 삭제 실패');
}
}
// UI
setPhotos(prev => prev.filter(p => !photoIds.includes(p.id)));
setTeasers(prev => prev.filter(t => !teaserIds.includes(t.id)));
setSelectedPhotos([]);
const totalDeleted = photoIds.length + teaserIds.length;
setToast({ message: `${totalDeleted}개 항목이 삭제되었습니다.`, type: 'success' });
} catch (error) {
console.error('삭제 오류:', error);
setToast({ message: '삭제 중 오류가 발생했습니다.', type: 'error' });
} finally {
setDeleteDialog({ show: false, photos: [] });
setDeleting(false);
}
}; };
if (loading) { if (loading) {
@ -666,6 +723,41 @@ function AdminAlbumPhotos() {
)} )}
</motion.div> </motion.div>
{/* 탭 UI */}
<div className="flex gap-1 p-1 bg-gray-100 rounded-xl mb-6">
<button
onClick={() => setActiveTab('upload')}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-all ${
activeTab === 'upload'
? 'bg-white text-primary shadow-sm'
: 'text-gray-500 hover:text-gray-700'
}`}
>
<Upload size={18} />
업로드
</button>
<button
onClick={() => setActiveTab('manage')}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-all ${
activeTab === 'manage'
? 'bg-white text-primary shadow-sm'
: 'text-gray-500 hover:text-gray-700'
}`}
>
<FolderOpen size={18} />
관리
{photos.length > 0 && (
<span className="px-2 py-0.5 bg-primary/10 text-primary text-xs rounded-full">
{photos.length}
</span>
)}
</button>
</div>
{/* 업로드 탭 */}
{activeTab === 'upload' && (
<>
{/* 업로드 설정 */} {/* 업로드 설정 */}
<motion.div <motion.div
className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6 mb-6" className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6 mb-6"
@ -1087,6 +1179,220 @@ function AdminAlbumPhotos() {
</div> </div>
)} )}
</div> </div>
</>
)}
{/* 관리 탭 */}
{activeTab === 'manage' && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6"
>
{/* 헤더 + 서브탭 */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<FolderOpen className="text-primary" size={24} />
<div>
<h2 className="text-lg font-bold text-gray-900">등록된 미디어</h2>
<p className="text-sm text-gray-500">컨셉 포토 {photos.length} / 티저 {teasers.length}</p>
</div>
</div>
{selectedPhotos.length > 0 && (
<button
onClick={() => setDeleteDialog({ show: true, photos: selectedPhotos })}
className="px-4 py-2 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-colors flex items-center gap-2"
>
<Trash2 size={18} />
{selectedPhotos.length} 삭제
</button>
)}
</div>
{/* 서브탭 + 전체 선택 */}
<div className="flex items-center justify-between mb-6 border-b border-gray-100">
<div className="flex gap-2">
<button
onClick={() => { setManageSubTab('concept'); setSelectedPhotos([]); }}
className={`px-4 py-2 font-medium transition-colors relative ${
manageSubTab === 'concept'
? 'text-primary'
: 'text-gray-500 hover:text-gray-700'
}`}
>
컨셉 포토
<span className="ml-1.5 text-xs bg-gray-100 px-1.5 py-0.5 rounded">{photos.length}</span>
{manageSubTab === 'concept' && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
<button
onClick={() => { setManageSubTab('teaser'); setSelectedPhotos([]); }}
className={`px-4 py-2 font-medium transition-colors relative ${
manageSubTab === 'teaser'
? 'text-primary'
: 'text-gray-500 hover:text-gray-700'
}`}
>
티저 이미지
<span className="ml-1.5 text-xs bg-gray-100 px-1.5 py-0.5 rounded">{teasers.length}</span>
{manageSubTab === 'teaser' && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
</div>
{/* 전체 선택 버튼 */}
<button
onClick={() => {
if (manageSubTab === 'concept') {
const allSelected = photos.length > 0 && photos.every(p => selectedPhotos.includes(p.id));
setSelectedPhotos(allSelected ? [] : photos.map(p => p.id));
} else {
const allSelected = teasers.length > 0 && teasers.every(t => selectedPhotos.includes(`teaser-${t.id}`));
setSelectedPhotos(allSelected ? [] : teasers.map(t => `teaser-${t.id}`));
}
}}
className="text-sm text-gray-500 hover:text-primary transition-colors"
>
{manageSubTab === 'concept'
? (photos.length > 0 && photos.every(p => selectedPhotos.includes(p.id)) ? '선택 해제' : '전체 선택')
: (teasers.length > 0 && teasers.every(t => selectedPhotos.includes(`teaser-${t.id}`)) ? '선택 해제' : '전체 선택')
}
</button>
</div>
{/* 컨셉 포토 그리드 */}
{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 * 0.02 }}
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}`}
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' && (
<>
{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 * 0.02 }}
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]
);
}}
>
<img
src={teaser.thumb_url || teaser.medium_url}
alt={`티저 ${teaser.sort_order}`}
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>
)}
</>
)}
</motion.div>
)}
{/* 삭제 확인 다이얼로그 */} {/* 삭제 확인 다이얼로그 */}
<AnimatePresence> <AnimatePresence>