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:
parent
ee23e5ffa4
commit
fd1807f38c
3 changed files with 575 additions and 150 deletions
1
.env
1
.env
|
|
@ -12,6 +12,7 @@ JWT_SECRET=fromis9-admin-jwt-secret-2026-xK9mP2vL7nQ4w
|
|||
|
||||
# RustFS (S3 Compatible)
|
||||
RUSTFS_ENDPOINT=https://rustfs.caadiq.co.kr
|
||||
RUSTFS_PUBLIC_URL=https://s3.caadiq.co.kr
|
||||
RUSTFS_ACCESS_KEY=iOpbGJIn4VumvxXlSC6D
|
||||
RUSTFS_SECRET_KEY=tDTwLkcHN5UVuWnea2s8OECrmiv013qoSQIpYbBd
|
||||
RUSTFS_BUCKET=fromis-9
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -9,9 +9,6 @@ import {
|
|||
} from 'lucide-react';
|
||||
import Toast from '../../../components/Toast';
|
||||
|
||||
// 멤버 목록
|
||||
const MEMBERS = ['이서연', '송하영', '장규리', '박지원', '이나경', '이채영', '백지헌'];
|
||||
|
||||
function AdminAlbumPhotos() {
|
||||
const { albumId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -29,12 +26,16 @@ function AdminAlbumPhotos() {
|
|||
const [deleting, setDeleting] = useState(false);
|
||||
const [previewPhoto, setPreviewPhoto] = useState(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [members, setMembers] = useState([]); // DB에서 로드
|
||||
|
||||
// 업로드 대기 중인 파일들
|
||||
const [pendingFiles, setPendingFiles] = useState([]);
|
||||
const [photoType, setPhotoType] = useState('concept'); // 'concept' | 'teaser'
|
||||
const [conceptName, setConceptName] = useState('');
|
||||
const [startNumber, setStartNumber] = useState(1); // 시작 번호
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [processingStatus, setProcessingStatus] = useState(''); // 처리 상태 메시지
|
||||
const [processingProgress, setProcessingProgress] = useState({ current: 0, total: 0 }); // 서버 처리 진행률
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState(null); // 삭제 대기 파일 ID
|
||||
|
||||
// Toast 자동 숨김
|
||||
|
|
@ -67,6 +68,13 @@ function AdminAlbumPhotos() {
|
|||
const albumData = await albumRes.json();
|
||||
setAlbum(albumData);
|
||||
|
||||
// 멤버 목록 로드
|
||||
const membersRes = await fetch('/api/members');
|
||||
if (membersRes.ok) {
|
||||
const membersData = await membersRes.json();
|
||||
setMembers(membersData);
|
||||
}
|
||||
|
||||
// TODO: 기존 사진 목록 로드 (API 구현 후)
|
||||
setPhotos([]);
|
||||
setLoading(false);
|
||||
|
|
@ -246,57 +254,110 @@ function AdminAlbumPhotos() {
|
|||
return;
|
||||
}
|
||||
|
||||
// 컨셉명 검증 (각 파일별로)
|
||||
const missingConcept = pendingFiles.some(f => !f.conceptName.trim());
|
||||
if (missingConcept) {
|
||||
setToast({ message: '모든 사진의 컨셉/티저 이름을 입력해주세요.', type: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 솔로/유닛인데 멤버 선택 안 한 경우
|
||||
const missingMembers = pendingFiles.some(f =>
|
||||
(f.groupType === 'solo' || f.groupType === 'unit') && f.members.length === 0
|
||||
);
|
||||
if (missingMembers) {
|
||||
setToast({ message: '솔로/유닛 사진에는 멤버를 선택해주세요.', type: 'warning' });
|
||||
return;
|
||||
// 컨셉 포토일 때만 검증
|
||||
if (photoType === 'concept') {
|
||||
// 컨셉명 검증 (각 파일별로)
|
||||
const missingConcept = pendingFiles.some(f => !f.conceptName.trim());
|
||||
if (missingConcept) {
|
||||
setToast({ message: '모든 사진의 컨셉명을 입력해주세요.', type: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 솔로/유닛인데 멤버 선택 안 한 경우
|
||||
const missingMembers = pendingFiles.some(f =>
|
||||
(f.groupType === 'solo' || f.groupType === 'unit') && f.members.length === 0
|
||||
);
|
||||
if (missingMembers) {
|
||||
setToast({ message: '솔로/유닛 사진에는 멤버를 선택해주세요.', type: 'warning' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setUploadProgress(0);
|
||||
setProcessingProgress({ current: 0, total: pendingFiles.length });
|
||||
setProcessingStatus('');
|
||||
|
||||
try {
|
||||
// 임시 진행률 시뮬레이션
|
||||
for (let i = 0; i <= 100; i += 10) {
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
setUploadProgress(i);
|
||||
const token = localStorage.getItem('adminToken');
|
||||
|
||||
// FormData 생성
|
||||
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 호출
|
||||
// const formData = new FormData();
|
||||
// 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' });
|
||||
if (result) {
|
||||
setToast({ message: result.message, type: 'success' });
|
||||
}
|
||||
|
||||
// 미리보기 URL 해제
|
||||
pendingFiles.forEach(f => URL.revokeObjectURL(f.preview));
|
||||
setPendingFiles([]);
|
||||
setConceptName('');
|
||||
setStartNumber(1); // 초기화
|
||||
|
||||
// 사진 목록 다시 로드
|
||||
fetchAlbumData();
|
||||
} catch (error) {
|
||||
console.error('업로드 오류:', error);
|
||||
setToast({ message: '업로드 중 오류가 발생했습니다.', type: 'error' });
|
||||
setToast({ message: error.message || '업로드 중 오류가 발생했습니다.', type: 'error' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setUploadProgress(0);
|
||||
setProcessingProgress({ current: 0, total: 0 });
|
||||
setProcessingStatus('');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -461,22 +522,54 @@ function AdminAlbumPhotos() {
|
|||
<span className="text-gray-700">{album?.title} - 사진 관리</span>
|
||||
</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 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
>
|
||||
<img
|
||||
src={album?.cover_url}
|
||||
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>
|
||||
<p className="text-gray-500">사진 업로드 및 관리</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
src={album?.cover_url}
|
||||
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>
|
||||
<p className="text-gray-500">사진 업로드 및 관리</p>
|
||||
</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>
|
||||
|
||||
{/* 업로드 설정 */}
|
||||
|
|
@ -496,27 +589,54 @@ function AdminAlbumPhotos() {
|
|||
<div className="flex gap-2 max-w-xs">
|
||||
<button
|
||||
onClick={() => setPhotoType('concept')}
|
||||
disabled={pendingFiles.length > 0}
|
||||
className={`flex-1 py-2 px-4 rounded-lg text-sm font-medium transition-colors ${
|
||||
photoType === 'concept'
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
} ${pendingFiles.length > 0 ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
컨셉 포토
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPhotoType('teaser')}
|
||||
disabled={pendingFiles.length > 0}
|
||||
className={`flex-1 py-2 px-4 rounded-lg text-sm font-medium transition-colors ${
|
||||
photoType === 'teaser'
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
} ${pendingFiles.length > 0 ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
티저 이미지
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
|
@ -529,13 +649,38 @@ function AdminAlbumPhotos() {
|
|||
className="mb-6 bg-white rounded-xl p-4 border border-gray-100 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-600">업로드 중...</span>
|
||||
<span className="text-sm font-medium text-primary">{uploadProgress}%</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<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 className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -615,8 +760,8 @@ function AdminAlbumPhotos() {
|
|||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
defaultValue={String(index + 1).padStart(2, '0')}
|
||||
key={`order-${file.id}-${index}`}
|
||||
defaultValue={String(startNumber + index).padStart(2, '0')}
|
||||
key={`order-${file.id}-${index}-${startNumber}`}
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value.trim();
|
||||
if (val && !isNaN(val)) {
|
||||
|
|
@ -634,12 +779,12 @@ function AdminAlbumPhotos() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* 썸네일 (정사각형) */}
|
||||
{/* 썸네일 (작게 축소하여 스크롤 성능 개선) */}
|
||||
<img
|
||||
src={file.preview}
|
||||
alt={file.filename}
|
||||
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)}
|
||||
/>
|
||||
|
||||
|
|
@ -648,67 +793,72 @@ function AdminAlbumPhotos() {
|
|||
{/* 파일명 */}
|
||||
<p className="text-base font-medium text-gray-900 truncate">{file.filename}</p>
|
||||
|
||||
{/* 단체/솔로/유닛 선택 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 w-16">타입:</span>
|
||||
<div className="flex gap-1.5">
|
||||
{[
|
||||
{ value: 'group', icon: Users, label: '단체' },
|
||||
{ value: 'solo', icon: User, label: '솔로' },
|
||||
{ value: 'unit', icon: Users2, label: '유닛' },
|
||||
].map(({ value, icon: Icon, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => changeGroupType(file.id, value)}
|
||||
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'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-100 border border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* 컨셉 포토일 때만 메타 정보 입력 표시 */}
|
||||
{photoType === 'concept' && (
|
||||
<>
|
||||
{/* 단체/솔로/유닛 선택 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 w-16">타입:</span>
|
||||
<div className="flex gap-1.5">
|
||||
{[
|
||||
{ value: 'group', icon: Users, label: '단체' },
|
||||
{ value: 'solo', icon: User, label: '솔로' },
|
||||
{ value: 'unit', icon: Users2, label: '유닛' },
|
||||
].map(({ value, icon: Icon, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => changeGroupType(file.id, value)}
|
||||
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'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-100 border border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 멤버 태깅 (단체는 비활성화) */}
|
||||
<div className="flex items-center gap-2 flex-wrap min-h-8">
|
||||
<span className="text-sm text-gray-500 w-16">멤버:</span>
|
||||
{file.groupType === 'group' ? (
|
||||
<span className="text-sm text-gray-400">단체 사진은 멤버 태깅이 필요 없습니다</span>
|
||||
) : (
|
||||
MEMBERS.map(member => (
|
||||
<button
|
||||
key={member}
|
||||
onClick={() => toggleMember(file.id, member)}
|
||||
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
||||
file.members.includes(member)
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-100 border border-gray-200'
|
||||
}`}
|
||||
>
|
||||
{member}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
{file.groupType === 'solo' && (
|
||||
<span className="text-xs text-gray-400 ml-2">(한 명만 선택)</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 멤버 태깅 (단체는 비활성화) */}
|
||||
<div className="flex items-center gap-2 flex-wrap min-h-8">
|
||||
<span className="text-sm text-gray-500 w-16">멤버:</span>
|
||||
{file.groupType === 'group' ? (
|
||||
<span className="text-sm text-gray-400">단체 사진은 멤버 태깅이 필요 없습니다</span>
|
||||
) : (
|
||||
members.map(member => (
|
||||
<button
|
||||
key={member.id}
|
||||
onClick={() => toggleMember(file.id, member.id)}
|
||||
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
||||
file.members.includes(member.id)
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-100 border border-gray-200'
|
||||
}`}
|
||||
>
|
||||
{member.name}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
{file.groupType === 'solo' && (
|
||||
<span className="text-xs text-gray-400 ml-2">(한 명만 선택)</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 컨셉/티저 이름 (개별 입력) */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 w-16">컨셉명:</span>
|
||||
<input
|
||||
type="text"
|
||||
value={file.conceptName}
|
||||
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"
|
||||
placeholder="컨셉명을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
{/* 컨셉/티저 이름 (개별 입력) */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 w-16">컨셉명:</span>
|
||||
<input
|
||||
type="text"
|
||||
value={file.conceptName}
|
||||
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"
|
||||
placeholder="컨셉명을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
|
|
@ -726,41 +876,7 @@ function AdminAlbumPhotos() {
|
|||
)}
|
||||
</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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue