feat: 앨범 사진/티저 업로드 기능 구현

- SSE 기반 실시간 업로드 진행률 표시
- 컨셉 포토/티저 이미지 분리 (photo/ vs teaser/ 폴더)
- album_photos, album_teasers 테이블에 분리 저장
- 3개 해상도별 URL 컬럼 분리 (original_url, medium_url, thumb_url)
- 파일 업로드 시 타입 선택 잠금
- 티저 모드: 순서만 변경 가능, 메타 정보 입력 불필요
- 이미지 처리 병렬화로 성능 개선
- RUSTFS_PUBLIC_URL 환경변수 추가
This commit is contained in:
caadiq 2026-01-02 00:10:47 +09:00
parent ee23e5ffa4
commit fd1807f38c
3 changed files with 575 additions and 150 deletions

1
.env
View file

@ -12,6 +12,7 @@ JWT_SECRET=fromis9-admin-jwt-secret-2026-xK9mP2vL7nQ4w
# RustFS (S3 Compatible) # RustFS (S3 Compatible)
RUSTFS_ENDPOINT=https://rustfs.caadiq.co.kr RUSTFS_ENDPOINT=https://rustfs.caadiq.co.kr
RUSTFS_PUBLIC_URL=https://s3.caadiq.co.kr
RUSTFS_ACCESS_KEY=iOpbGJIn4VumvxXlSC6D RUSTFS_ACCESS_KEY=iOpbGJIn4VumvxXlSC6D
RUSTFS_SECRET_KEY=tDTwLkcHN5UVuWnea2s8OECrmiv013qoSQIpYbBd RUSTFS_SECRET_KEY=tDTwLkcHN5UVuWnea2s8OECrmiv013qoSQIpYbBd
RUSTFS_BUCKET=fromis-9 RUSTFS_BUCKET=fromis-9

View file

@ -406,4 +406,312 @@ router.delete("/albums/:id", authenticateToken, async (req, res) => {
} }
}); });
// ============================================
// 앨범 사진 관리 API
// ============================================
// 앨범 사진 목록 조회
router.get("/albums/:albumId/photos", 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 folderName = albums[0].folder_name;
// 사진 조회 (멤버 정보 포함)
const [photos] = await pool.query(
`
SELECT
p.id, p.photo_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
LEFT JOIN album_photo_members pm ON p.id = pm.photo_id
WHERE p.album_id = ?
GROUP BY p.id
ORDER BY p.sort_order ASC
`,
[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) : [],
}));
res.json(result);
} catch (error) {
console.error("사진 조회 오류:", error);
res.status(500).json({ error: "사진 조회 중 오류가 발생했습니다." });
}
});
// 사진 업로드 (SSE로 실시간 진행률 전송)
router.post(
"/albums/:albumId/photos",
authenticateToken,
upload.array("photos", 50),
async (req, res) => {
// SSE 헤더 설정
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
const sendProgress = (current, total, message) => {
res.write(`data: ${JSON.stringify({ current, total, message })}\n\n`);
};
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const { albumId } = req.params;
const metadata = JSON.parse(req.body.metadata || "[]");
const startNumber = parseInt(req.body.startNumber) || null;
const photoType = req.body.photoType || "concept"; // 'concept' | 'teaser'
// 앨범 정보 조회
const [albums] = await connection.query(
"SELECT folder_name FROM albums WHERE id = ?",
[albumId]
);
if (albums.length === 0) {
return res.status(404).json({ error: "앨범을 찾을 수 없습니다." });
}
const folderName = albums[0].folder_name;
// 시작 번호 결정 (클라이언트 지정 또는 기존 사진 다음 번호)
let nextOrder;
if (startNumber && startNumber > 0) {
nextOrder = startNumber;
} else {
const [existingPhotos] = await connection.query(
"SELECT MAX(sort_order) as maxOrder FROM album_photos WHERE album_id = ?",
[albumId]
);
nextOrder = (existingPhotos[0].maxOrder || 0) + 1;
}
const uploadedPhotos = [];
const totalFiles = req.files.length;
for (let i = 0; i < req.files.length; i++) {
const file = req.files[i];
const meta = metadata[i] || {};
const orderNum = String(nextOrder + i).padStart(2, "0");
const filename = `${orderNum}.webp`;
// 진행률 전송
sendProgress(i + 1, totalFiles, `${filename} 처리 중...`);
// Sharp로 이미지 처리 (병렬)
const [originalBuffer, medium800Buffer, thumb400Buffer] =
await Promise.all([
sharp(file.buffer).webp({ lossless: true }).toBuffer(),
sharp(file.buffer)
.resize(800, null, { withoutEnlargement: true })
.webp({ quality: 85 })
.toBuffer(),
sharp(file.buffer)
.resize(400, null, { withoutEnlargement: true })
.webp({ quality: 80 })
.toBuffer(),
]);
const originalMeta = await sharp(originalBuffer).metadata();
// RustFS 업로드 (병렬)
// 컨셉 포토: photo/, 티저 이미지: teaser/
const subFolder = photoType === "teaser" ? "teaser" : "photo";
const basePath = `album/${folderName}/${subFolder}`;
await Promise.all([
s3Client.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: `${basePath}/original/${filename}`,
Body: originalBuffer,
ContentType: "image/webp",
})
),
s3Client.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: `${basePath}/medium_800/${filename}`,
Body: medium800Buffer,
ContentType: "image/webp",
})
),
s3Client.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: `${basePath}/thumb_400/${filename}`,
Body: thumb400Buffer,
ContentType: "image/webp",
})
),
]);
// 3개 해상도별 URL 생성
const originalUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/original/${filename}`;
const mediumUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/medium_800/${filename}`;
const thumbUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/thumb_400/${filename}`;
let photoId;
// DB 저장 - 티저와 컨셉 포토 분기
if (photoType === "teaser") {
// 티저 이미지 → album_teasers 테이블
const [result] = await connection.query(
`INSERT INTO album_teasers
(album_id, original_url, medium_url, thumb_url, sort_order)
VALUES (?, ?, ?, ?, ?)`,
[albumId, originalUrl, mediumUrl, thumbUrl, nextOrder + i]
);
photoId = result.insertId;
} else {
// 컨셉 포토 → album_photos 테이블
const [result] = await connection.query(
`INSERT INTO album_photos
(album_id, original_url, medium_url, thumb_url, photo_type, concept_name, sort_order, width, height, file_size)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
albumId,
originalUrl,
mediumUrl,
thumbUrl,
meta.groupType || "group",
meta.conceptName || null,
nextOrder + i,
originalMeta.width,
originalMeta.height,
originalBuffer.length,
]
);
photoId = result.insertId;
// 멤버 태깅 저장 (컨셉 포토만)
if (meta.members && meta.members.length > 0) {
for (const memberId of meta.members) {
await connection.query(
"INSERT INTO album_photo_members (photo_id, member_id) VALUES (?, ?)",
[photoId, memberId]
);
}
}
}
uploadedPhotos.push({
id: photoId,
original_url: originalUrl,
medium_url: mediumUrl,
thumb_url: thumbUrl,
filename,
});
}
await connection.commit();
// 완료 이벤트 전송
res.write(
`data: ${JSON.stringify({
done: true,
message: `${uploadedPhotos.length}개의 사진이 업로드되었습니다.`,
photos: uploadedPhotos,
})}\n\n`
);
res.end();
} catch (error) {
await connection.rollback();
console.error("사진 업로드 오류:", error);
res.write(
`data: ${JSON.stringify({
error: "사진 업로드 중 오류가 발생했습니다.",
})}\n\n`
);
res.end();
} finally {
connection.release();
}
}
);
// 사진 삭제
router.delete(
"/albums/:albumId/photos/:photoId",
authenticateToken,
async (req, res) => {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const { albumId, photoId } = req.params;
// 사진 정보 조회
const [photos] = await connection.query(
"SELECT p.*, a.folder_name FROM album_photos p JOIN albums a ON p.album_id = a.id WHERE p.id = ? AND p.album_id = ?",
[photoId, albumId]
);
if (photos.length === 0) {
return res.status(404).json({ error: "사진을 찾을 수 없습니다." });
}
const photo = photos[0];
const filename = photo.photo_url.split("/").pop();
const basePath = `album/${photo.folder_name}/photo`;
// 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_photo_members WHERE photo_id = ?",
[photoId]
);
// 사진 삭제
await connection.query("DELETE FROM album_photos WHERE id = ?", [
photoId,
]);
await connection.commit();
res.json({ message: "사진이 삭제되었습니다." });
} catch (error) {
await connection.rollback();
console.error("사진 삭제 오류:", error);
res.status(500).json({ error: "사진 삭제 중 오류가 발생했습니다." });
} finally {
connection.release();
}
}
);
export default router; export default router;

View file

@ -9,9 +9,6 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import Toast from '../../../components/Toast'; import Toast from '../../../components/Toast';
//
const MEMBERS = ['이서연', '송하영', '장규리', '박지원', '이나경', '이채영', '백지헌'];
function AdminAlbumPhotos() { function AdminAlbumPhotos() {
const { albumId } = useParams(); const { albumId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@ -29,12 +26,16 @@ function AdminAlbumPhotos() {
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [previewPhoto, setPreviewPhoto] = useState(null); const [previewPhoto, setPreviewPhoto] = useState(null);
const [dragOver, setDragOver] = useState(false); const [dragOver, setDragOver] = useState(false);
const [members, setMembers] = useState([]); // DB
// //
const [pendingFiles, setPendingFiles] = useState([]); const [pendingFiles, setPendingFiles] = useState([]);
const [photoType, setPhotoType] = useState('concept'); // 'concept' | 'teaser' const [photoType, setPhotoType] = useState('concept'); // 'concept' | 'teaser'
const [conceptName, setConceptName] = useState(''); const [conceptName, setConceptName] = useState('');
const [startNumber, setStartNumber] = useState(1); //
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [processingStatus, setProcessingStatus] = useState(''); //
const [processingProgress, setProcessingProgress] = useState({ current: 0, total: 0 }); //
const [pendingDeleteId, setPendingDeleteId] = useState(null); // ID const [pendingDeleteId, setPendingDeleteId] = useState(null); // ID
// Toast // Toast
@ -67,6 +68,13 @@ function AdminAlbumPhotos() {
const albumData = await albumRes.json(); const albumData = await albumRes.json();
setAlbum(albumData); setAlbum(albumData);
//
const membersRes = await fetch('/api/members');
if (membersRes.ok) {
const membersData = await membersRes.json();
setMembers(membersData);
}
// TODO: (API ) // TODO: (API )
setPhotos([]); setPhotos([]);
setLoading(false); setLoading(false);
@ -246,57 +254,110 @@ function AdminAlbumPhotos() {
return; return;
} }
// ( ) //
const missingConcept = pendingFiles.some(f => !f.conceptName.trim()); if (photoType === 'concept') {
if (missingConcept) { // ( )
setToast({ message: '모든 사진의 컨셉/티저 이름을 입력해주세요.', type: 'warning' }); const missingConcept = pendingFiles.some(f => !f.conceptName.trim());
return; if (missingConcept) {
} setToast({ message: '모든 사진의 컨셉명을 입력해주세요.', type: 'warning' });
return;
// / }
const missingMembers = pendingFiles.some(f =>
(f.groupType === 'solo' || f.groupType === 'unit') && f.members.length === 0 // /
); const missingMembers = pendingFiles.some(f =>
if (missingMembers) { (f.groupType === 'solo' || f.groupType === 'unit') && f.members.length === 0
setToast({ message: '솔로/유닛 사진에는 멤버를 선택해주세요.', type: 'warning' }); );
return; if (missingMembers) {
setToast({ message: '솔로/유닛 사진에는 멤버를 선택해주세요.', type: 'warning' });
return;
}
} }
setSaving(true); setSaving(true);
setUploadProgress(0); setUploadProgress(0);
setProcessingProgress({ current: 0, total: pendingFiles.length });
setProcessingStatus('');
try { try {
// const token = localStorage.getItem('adminToken');
for (let i = 0; i <= 100; i += 10) {
await new Promise(r => setTimeout(r, 100)); // FormData
setUploadProgress(i); const formData = new FormData();
const metadata = pendingFiles.map(pf => ({
groupType: pf.groupType,
members: pf.members,
conceptName: pf.conceptName,
}));
pendingFiles.forEach(pf => {
formData.append('photos', pf.file);
});
formData.append('metadata', JSON.stringify(metadata));
formData.append('startNumber', startNumber);
formData.append('photoType', photoType);
// + SSE
const response = await fetch(`/api/admin/albums/${albumId}/photos`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
body: formData,
});
setUploadProgress(100);
// SSE
const reader = response.body.getReader();
const decoder = new TextDecoder();
let result = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
const lines = text.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
if (data.done) {
result = data;
} else if (data.error) {
throw new Error(data.error);
} else if (data.current) {
setProcessingProgress({ current: data.current, total: data.total });
setProcessingStatus(data.message);
}
} catch (e) {
// JSON
}
}
}
} }
// TODO: API if (result) {
// const formData = new FormData(); setToast({ message: result.message, type: 'success' });
// pendingFiles.forEach((pf, idx) => { }
// formData.append('photos', pf.file);
// formData.append(`meta_${idx}`, JSON.stringify({
// order: idx + 1,
// groupType: pf.groupType,
// members: pf.members,
// }));
// });
// formData.append('photoType', photoType);
// formData.append('conceptName', conceptName);
setToast({ message: `${pendingFiles.length}개의 사진이 업로드되었습니다.`, type: 'success' });
// URL // URL
pendingFiles.forEach(f => URL.revokeObjectURL(f.preview)); pendingFiles.forEach(f => URL.revokeObjectURL(f.preview));
setPendingFiles([]); setPendingFiles([]);
setConceptName(''); setConceptName('');
setStartNumber(1); //
//
fetchAlbumData();
} catch (error) { } catch (error) {
console.error('업로드 오류:', error); console.error('업로드 오류:', error);
setToast({ message: '업로드 중 오류가 발생했습니다.', type: 'error' }); setToast({ message: error.message || '업로드 중 오류가 발생했습니다.', type: 'error' });
} finally { } finally {
setSaving(false); setSaving(false);
setUploadProgress(0); setUploadProgress(0);
setProcessingProgress({ current: 0, total: 0 });
setProcessingStatus('');
} }
}; };
@ -461,22 +522,54 @@ function AdminAlbumPhotos() {
<span className="text-gray-700">{album?.title} - 사진 관리</span> <span className="text-gray-700">{album?.title} - 사진 관리</span>
</motion.div> </motion.div>
{/* 타이틀 */} {/* 타이틀 + 액션 버튼 */}
<motion.div <motion.div
className="flex items-center gap-4 mb-8" className="flex items-center justify-between mb-8"
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }} transition={{ delay: 0.15 }}
> >
<img <div className="flex items-center gap-4">
src={album?.cover_url} <img
alt={album?.title} src={album?.cover_url}
className="w-14 h-14 rounded-xl object-cover" alt={album?.title}
/> className="w-14 h-14 rounded-xl object-cover"
<div> />
<h1 className="text-2xl font-bold text-gray-900">{album?.title}</h1> <div>
<p className="text-gray-500">사진 업로드 관리</p> <h1 className="text-2xl font-bold text-gray-900">{album?.title}</h1>
<p className="text-gray-500">사진 업로드 관리</p>
</div>
</div> </div>
{pendingFiles.length > 0 && (
<div className="flex items-center gap-3">
<button
onClick={() => {
pendingFiles.forEach(f => URL.revokeObjectURL(f.preview));
setPendingFiles([]);
}}
className="px-4 py-2 text-gray-600 hover:text-gray-900 transition-colors"
>
취소
</button>
<button
onClick={handleUpload}
disabled={saving}
className="flex items-center gap-2 px-5 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors disabled:opacity-50"
>
{saving ? (
<>
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
업로드 ...
</>
) : (
<>
<Save size={18} />
{pendingFiles.length} 사진 업로드
</>
)}
</button>
</div>
)}
</motion.div> </motion.div>
{/* 업로드 설정 */} {/* 업로드 설정 */}
@ -496,27 +589,54 @@ function AdminAlbumPhotos() {
<div className="flex gap-2 max-w-xs"> <div className="flex gap-2 max-w-xs">
<button <button
onClick={() => setPhotoType('concept')} onClick={() => setPhotoType('concept')}
disabled={pendingFiles.length > 0}
className={`flex-1 py-2 px-4 rounded-lg text-sm font-medium transition-colors ${ className={`flex-1 py-2 px-4 rounded-lg text-sm font-medium transition-colors ${
photoType === 'concept' photoType === 'concept'
? 'bg-primary text-white' ? 'bg-primary text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`} } ${pendingFiles.length > 0 ? 'opacity-50 cursor-not-allowed' : ''}`}
> >
컨셉 포토 컨셉 포토
</button> </button>
<button <button
onClick={() => setPhotoType('teaser')} onClick={() => setPhotoType('teaser')}
disabled={pendingFiles.length > 0}
className={`flex-1 py-2 px-4 rounded-lg text-sm font-medium transition-colors ${ className={`flex-1 py-2 px-4 rounded-lg text-sm font-medium transition-colors ${
photoType === 'teaser' photoType === 'teaser'
? 'bg-primary text-white' ? 'bg-primary text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`} } ${pendingFiles.length > 0 ? 'opacity-50 cursor-not-allowed' : ''}`}
> >
티저 이미지 티저 이미지
</button> </button>
</div> </div>
<p className="text-xs text-gray-400 mt-2"> <p className="text-xs text-gray-400 mt-2">
컨셉/티저 이름은 사진별로 입력합니다. {pendingFiles.length > 0
? '파일이 추가된 상태에서는 타입을 변경할 수 없습니다.'
: photoType === 'teaser'
? '티저 이미지는 순서만 지정하면 됩니다.'
: '컨셉/티저 이름은 각 사진별로 입력합니다.'
}
</p>
</div>
{/* 시작 번호 설정 */}
<div className="mt-4 pt-4 border-t border-gray-100">
<label className="block text-sm font-medium text-gray-700 mb-2">시작 번호</label>
<div className="flex items-center gap-3">
<input
type="number"
min="1"
value={startNumber}
onChange={(e) => setStartNumber(Math.max(1, parseInt(e.target.value) || 1))}
className="w-20 px-3 py-2 border border-gray-200 rounded-lg text-center focus:outline-none focus:ring-2 focus:ring-primary [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<span className="text-sm text-gray-500">
{String(startNumber).padStart(2, '0')}.webp ~ {String(startNumber + Math.max(0, pendingFiles.length - 1)).padStart(2, '0')}.webp
</span>
</div>
<p className="text-xs text-gray-400 mt-2">
추가 업로드 기존 사진 다음 번호로 설정하세요.
</p> </p>
</div> </div>
</motion.div> </motion.div>
@ -529,13 +649,38 @@ function AdminAlbumPhotos() {
className="mb-6 bg-white rounded-xl p-4 border border-gray-100 shadow-sm" className="mb-6 bg-white rounded-xl p-4 border border-gray-100 shadow-sm"
> >
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-600">업로드 ...</span> <div className="flex items-center gap-2">
<span className="text-sm font-medium text-primary">{uploadProgress}%</span> <span className="w-4 h-4 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
{uploadProgress < 100 ? (
<span className="text-sm text-gray-600">파일 업로드 ...</span>
) : processingProgress.current > 0 ? (
<span className="text-sm text-gray-600">
{processingStatus || `${processingProgress.current}/${processingProgress.total} 처리 중...`}
</span>
) : (
<span className="text-sm text-gray-600">서버 연결 ...</span>
)}
</div>
<span className="text-sm font-medium text-primary">
{uploadProgress < 100
? `${uploadProgress}%`
: processingProgress.total > 0
? `${Math.round((processingProgress.current / processingProgress.total) * 100)}%`
: '0%'
}
</span>
</div> </div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden"> <div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<motion.div <motion.div
initial={{ width: 0 }} initial={{ width: 0 }}
animate={{ width: `${uploadProgress}%` }} animate={{
width: uploadProgress < 100
? `${uploadProgress}%`
: processingProgress.total > 0
? `${(processingProgress.current / processingProgress.total) * 100}%`
: '0%'
}}
transition={{ duration: 0.3 }}
className="h-full bg-primary rounded-full" className="h-full bg-primary rounded-full"
/> />
</div> </div>
@ -615,8 +760,8 @@ function AdminAlbumPhotos() {
<input <input
type="text" type="text"
inputMode="numeric" inputMode="numeric"
defaultValue={String(index + 1).padStart(2, '0')} defaultValue={String(startNumber + index).padStart(2, '0')}
key={`order-${file.id}-${index}`} key={`order-${file.id}-${index}-${startNumber}`}
onBlur={(e) => { onBlur={(e) => {
const val = e.target.value.trim(); const val = e.target.value.trim();
if (val && !isNaN(val)) { if (val && !isNaN(val)) {
@ -634,12 +779,12 @@ function AdminAlbumPhotos() {
/> />
</div> </div>
{/* 썸네일 (정사각형) */} {/* 썸네일 (작게 축소하여 스크롤 성능 개선) */}
<img <img
src={file.preview} src={file.preview}
alt={file.filename} alt={file.filename}
draggable="false" draggable="false"
className="w-36 h-36 rounded-xl object-cover cursor-pointer hover:opacity-80 transition-opacity flex-shrink-0 select-none" className="w-24 h-24 rounded-lg object-cover cursor-pointer hover:opacity-80 transition-opacity flex-shrink-0 select-none"
onClick={() => setPreviewPhoto(file)} onClick={() => setPreviewPhoto(file)}
/> />
@ -648,67 +793,72 @@ function AdminAlbumPhotos() {
{/* 파일명 */} {/* 파일명 */}
<p className="text-base font-medium text-gray-900 truncate">{file.filename}</p> <p className="text-base font-medium text-gray-900 truncate">{file.filename}</p>
{/* 단체/솔로/유닛 선택 */} {/* 컨셉 포토일 때만 메타 정보 입력 표시 */}
<div className="flex items-center gap-2"> {photoType === 'concept' && (
<span className="text-sm text-gray-500 w-16">타입:</span> <>
<div className="flex gap-1.5"> {/* 단체/솔로/유닛 선택 */}
{[ <div className="flex items-center gap-2">
{ value: 'group', icon: Users, label: '단체' }, <span className="text-sm text-gray-500 w-16">타입:</span>
{ value: 'solo', icon: User, label: '솔로' }, <div className="flex gap-1.5">
{ value: 'unit', icon: Users2, label: '유닛' }, {[
].map(({ value, icon: Icon, label }) => ( { value: 'group', icon: Users, label: '단체' },
<button { value: 'solo', icon: User, label: '솔로' },
key={value} { value: 'unit', icon: Users2, label: '유닛' },
onClick={() => changeGroupType(file.id, value)} ].map(({ value, icon: Icon, label }) => (
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors ${ <button
file.groupType === value key={value}
? 'bg-primary text-white' onClick={() => changeGroupType(file.id, value)}
: 'bg-white text-gray-600 hover:bg-gray-100 border border-gray-200' 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'
<Icon size={14} /> : 'bg-white text-gray-600 hover:bg-gray-100 border border-gray-200'
{label} }`}
</button> >
))} <Icon size={14} />
</div> {label}
</div> </button>
))}
</div>
</div>
{/* 멤버 태깅 (단체는 비활성화) */} {/* 멤버 태깅 (단체는 비활성화) */}
<div className="flex items-center gap-2 flex-wrap min-h-8"> <div className="flex items-center gap-2 flex-wrap min-h-8">
<span className="text-sm text-gray-500 w-16">멤버:</span> <span className="text-sm text-gray-500 w-16">멤버:</span>
{file.groupType === 'group' ? ( {file.groupType === 'group' ? (
<span className="text-sm text-gray-400">단체 사진은 멤버 태깅이 필요 없습니다</span> <span className="text-sm text-gray-400">단체 사진은 멤버 태깅이 필요 없습니다</span>
) : ( ) : (
MEMBERS.map(member => ( members.map(member => (
<button <button
key={member} key={member.id}
onClick={() => toggleMember(file.id, member)} onClick={() => toggleMember(file.id, member.id)}
className={`px-3 py-1 rounded-full text-sm transition-colors ${ className={`px-3 py-1 rounded-full text-sm transition-colors ${
file.members.includes(member) file.members.includes(member.id)
? 'bg-primary text-white' ? 'bg-primary text-white'
: 'bg-white text-gray-600 hover:bg-gray-100 border border-gray-200' : 'bg-white text-gray-600 hover:bg-gray-100 border border-gray-200'
}`} }`}
> >
{member} {member.name}
</button> </button>
)) ))
)} )}
{file.groupType === 'solo' && ( {file.groupType === 'solo' && (
<span className="text-xs text-gray-400 ml-2">( 명만 선택)</span> <span className="text-xs text-gray-400 ml-2">( 명만 선택)</span>
)} )}
</div> </div>
{/* 컨셉/티저 이름 (개별 입력) */} {/* 컨셉/티저 이름 (개별 입력) */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-gray-500 w-16">컨셉명:</span> <span className="text-sm text-gray-500 w-16">컨셉명:</span>
<input <input
type="text" type="text"
value={file.conceptName} value={file.conceptName}
onChange={(e) => updatePendingFile(file.id, 'conceptName', e.target.value)} 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" 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="컨셉명을 입력하세요" placeholder="컨셉명을 입력하세요"
/> />
</div> </div>
</>
)}
</div> </div>
{/* 삭제 버튼 */} {/* 삭제 버튼 */}
@ -726,41 +876,7 @@ function AdminAlbumPhotos() {
)} )}
</motion.div> </motion.div>
{/* 하단 액션 버튼 */}
{pendingFiles.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="mt-6 flex justify-end gap-4"
>
<button
onClick={() => {
pendingFiles.forEach(f => URL.revokeObjectURL(f.preview));
setPendingFiles([]);
}}
className="px-6 py-2.5 text-gray-600 hover:text-gray-900 transition-colors"
>
취소
</button>
<button
onClick={handleUpload}
disabled={saving}
className="flex items-center gap-2 px-6 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors disabled:opacity-50"
>
{saving ? (
<>
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
업로드 ...
</>
) : (
<>
<Save size={18} />
{pendingFiles.length} 사진 업로드
</>
)}
</button>
</motion.div>
)}
{/* 삭제 확인 다이얼로그 */} {/* 삭제 확인 다이얼로그 */}
<AnimatePresence> <AnimatePresence>