feat: 관리 탭 UI 개선 - 탭 분리, 전체 선택, 삭제 기능, 애니메이션 추가
This commit is contained in:
parent
4d8d18586c
commit
9f7548b4b4
2 changed files with 407 additions and 15 deletions
|
|
@ -430,7 +430,7 @@ router.get("/albums/:albumId/photos", async (req, res) => {
|
|||
const [photos] = await pool.query(
|
||||
`
|
||||
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,
|
||||
GROUP_CONCAT(pm.member_id) as member_ids
|
||||
FROM album_photos p
|
||||
|
|
@ -442,12 +442,9 @@ router.get("/albums/:albumId/photos", async (req, res) => {
|
|||
[albumId]
|
||||
);
|
||||
|
||||
// URL 변환 및 멤버 배열 파싱
|
||||
// 멤버 배열 파싱
|
||||
const result = photos.map((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) : [],
|
||||
}));
|
||||
|
||||
|
|
@ -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로 실시간 진행률 전송)
|
||||
router.post(
|
||||
"/albums/:albumId/photos",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ function AdminAlbumPhotos() {
|
|||
|
||||
const [album, setAlbum] = useState(null);
|
||||
const [photos, setPhotos] = useState([]);
|
||||
const [teasers, setTeasers] = useState([]); // 티저 이미지
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [user, setUser] = useState(null);
|
||||
const [toast, setToast] = useState(null);
|
||||
|
|
@ -39,6 +40,8 @@ function AdminAlbumPhotos() {
|
|||
const [processingProgress, setProcessingProgress] = useState({ current: 0, total: 0 }); // 서버 처리 진행률
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState(null); // 삭제 대기 파일 ID
|
||||
const [uploadConfirmDialog, setUploadConfirmDialog] = useState(false); // 업로드 확인 다이얼로그
|
||||
const [activeTab, setActiveTab] = useState('upload'); // 'upload' | 'manage'
|
||||
const [manageSubTab, setManageSubTab] = useState('concept'); // 'concept' | 'teaser'
|
||||
|
||||
// 일괄 편집 도구 상태
|
||||
const [bulkEdit, setBulkEdit] = useState({
|
||||
|
|
@ -156,6 +159,8 @@ function AdminAlbumPhotos() {
|
|||
|
||||
const fetchAlbumData = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
|
||||
// 앨범 정보 로드
|
||||
const albumRes = await fetch(`/api/albums/${albumId}`);
|
||||
if (!albumRes.ok) throw new Error('앨범을 찾을 수 없습니다');
|
||||
|
|
@ -169,8 +174,24 @@ function AdminAlbumPhotos() {
|
|||
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);
|
||||
} catch (error) {
|
||||
console.error('앨범 로드 오류:', error);
|
||||
|
|
@ -455,18 +476,54 @@ function AdminAlbumPhotos() {
|
|||
}
|
||||
};
|
||||
|
||||
// 삭제 처리 (기존 사진)
|
||||
// 삭제 처리 (기존 사진/티저)
|
||||
const handleDelete = async () => {
|
||||
setDeleting(true);
|
||||
const token = localStorage.getItem('adminToken');
|
||||
|
||||
// TODO: 실제 삭제 API 호출
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
try {
|
||||
// 사진 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([]);
|
||||
setToast({ message: `${deleteDialog.photos.length}개의 사진이 삭제되었습니다.`, type: 'success' });
|
||||
setDeleteDialog({ show: false, photos: [] });
|
||||
setDeleting(false);
|
||||
// 사진 삭제
|
||||
for (const photoId of photoIds) {
|
||||
const res = await fetch(`/api/admin/albums/${albumId}/photos/${photoId}`, {
|
||||
method: 'DELETE',
|
||||
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) {
|
||||
|
|
@ -666,6 +723,41 @@ function AdminAlbumPhotos() {
|
|||
)}
|
||||
</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
|
||||
className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6 mb-6"
|
||||
|
|
@ -1087,6 +1179,220 @@ function AdminAlbumPhotos() {
|
|||
</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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue