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 (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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,10 +254,12 @@ function AdminAlbumPhotos() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 컨셉 포토일 때만 검증
|
||||||
|
if (photoType === 'concept') {
|
||||||
// 컨셉명 검증 (각 파일별로)
|
// 컨셉명 검증 (각 파일별로)
|
||||||
const missingConcept = pendingFiles.some(f => !f.conceptName.trim());
|
const missingConcept = pendingFiles.some(f => !f.conceptName.trim());
|
||||||
if (missingConcept) {
|
if (missingConcept) {
|
||||||
setToast({ message: '모든 사진의 컨셉/티저 이름을 입력해주세요.', type: 'warning' });
|
setToast({ message: '모든 사진의 컨셉명을 입력해주세요.', type: 'warning' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -261,42 +271,93 @@ function AdminAlbumPhotos() {
|
||||||
setToast({ message: '솔로/유닛 사진에는 멤버를 선택해주세요.', type: 'warning' });
|
setToast({ message: '솔로/유닛 사진에는 멤버를 선택해주세요.', type: 'warning' });
|
||||||
return;
|
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,13 +522,14 @@ 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 }}
|
||||||
>
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
<img
|
<img
|
||||||
src={album?.cover_url}
|
src={album?.cover_url}
|
||||||
alt={album?.title}
|
alt={album?.title}
|
||||||
|
|
@ -477,6 +539,37 @@ function AdminAlbumPhotos() {
|
||||||
<h1 className="text-2xl font-bold text-gray-900">{album?.title}</h1>
|
<h1 className="text-2xl font-bold text-gray-900">{album?.title}</h1>
|
||||||
<p className="text-gray-500">사진 업로드 및 관리</p>
|
<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,6 +793,9 @@ 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>
|
||||||
|
|
||||||
|
{/* 컨셉 포토일 때만 메타 정보 입력 표시 */}
|
||||||
|
{photoType === 'concept' && (
|
||||||
|
<>
|
||||||
{/* 단체/솔로/유닛 선택 */}
|
{/* 단체/솔로/유닛 선택 */}
|
||||||
<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>
|
||||||
|
|
@ -679,17 +827,17 @@ function AdminAlbumPhotos() {
|
||||||
{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>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
@ -709,6 +857,8 @@ function AdminAlbumPhotos() {
|
||||||
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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue