diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 6a1a60f..526e2a2 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -19,12 +19,13 @@ const JWT_EXPIRES_IN = "30d"; // Multer 설정 (메모리 저장) const upload = multer({ storage: multer.memoryStorage(), - limits: { fileSize: 10 * 1024 * 1024 }, // 10MB + limits: { fileSize: 50 * 1024 * 1024 }, // 50MB (동영상 지원) fileFilter: (req, file, cb) => { - if (file.mimetype.startsWith("image/")) { + // 이미지 또는 MP4 비디오 허용 + if (file.mimetype.startsWith("image/") || file.mimetype === "video/mp4") { cb(null, true); } else { - cb(new Error("이미지 파일만 업로드 가능합니다."), false); + cb(new Error("이미지 또는 MP4 파일만 업로드 가능합니다."), false); } }, }); @@ -471,7 +472,7 @@ router.get("/albums/:albumId/teasers", async (req, res) => { // 티저 조회 const [teasers] = await pool.query( - `SELECT id, original_url, medium_url, thumb_url, sort_order + `SELECT id, original_url, medium_url, thumb_url, sort_order, media_type FROM album_teasers WHERE album_id = ? ORDER BY sort_order ASC`, @@ -548,7 +549,7 @@ router.delete( router.post( "/albums/:albumId/photos", authenticateToken, - upload.array("photos", 50), + upload.array("photos", 200), async (req, res) => { // SSE 헤더 설정 res.setHeader("Content-Type", "text/event-stream"); @@ -598,14 +599,39 @@ router.post( const file = req.files[i]; const meta = metadata[i] || {}; const orderNum = String(nextOrder + i).padStart(2, "0"); - const filename = `${orderNum}.webp`; + const isVideo = file.mimetype === "video/mp4"; + const extension = isVideo ? "mp4" : "webp"; + const filename = `${orderNum}.${extension}`; // 진행률 전송 sendProgress(i + 1, totalFiles, `${filename} 처리 중...`); - // Sharp로 이미지 처리 (병렬) - const [originalBuffer, medium800Buffer, thumb400Buffer] = - await Promise.all([ + let originalUrl, mediumUrl, thumbUrl; + let originalBuffer, originalMeta; + + // 컨셉 포토: photo/, 티저: teaser/ + const subFolder = photoType === "teaser" ? "teaser" : "photo"; + const basePath = `album/${folderName}/${subFolder}`; + + if (isVideo) { + // ===== 비디오 파일 처리 (티저 전용) ===== + // 원본 MP4만 업로드 (리사이즈 없음) + await s3Client.send( + new PutObjectCommand({ + Bucket: BUCKET, + Key: `${basePath}/original/${filename}`, + Body: file.buffer, + ContentType: "video/mp4", + }) + ); + + originalUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/original/${filename}`; + mediumUrl = originalUrl; // 비디오는 원본만 사용 + thumbUrl = originalUrl; + } else { + // ===== 이미지 파일 처리 ===== + // Sharp로 이미지 처리 (병렬) + const [origBuf, medium800Buffer, thumb400Buffer] = await Promise.all([ sharp(file.buffer).webp({ lossless: true }).toBuffer(), sharp(file.buffer) .resize(800, null, { withoutEnlargement: true }) @@ -617,59 +643,64 @@ router.post( .toBuffer(), ]); - const originalMeta = await sharp(originalBuffer).metadata(); + originalBuffer = origBuf; + originalMeta = await sharp(originalBuffer).metadata(); - // RustFS 업로드 (병렬) - // 컨셉 포토: photo/, 티저 이미지: teaser/ - const subFolder = photoType === "teaser" ? "teaser" : "photo"; - const basePath = `album/${folderName}/${subFolder}`; + // RustFS 업로드 (병렬) + 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", + }) + ), + ]); - 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}`; + originalUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/original/${filename}`; + mediumUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/medium_800/${filename}`; + thumbUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/thumb_400/${filename}`; + } let photoId; // DB 저장 - 티저와 컨셉 포토 분기 if (photoType === "teaser") { - // 티저 이미지 → album_teasers 테이블 + // 티저 이미지/비디오 → album_teasers 테이블 + const mediaType = isVideo ? "video" : "image"; 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] + (album_id, original_url, medium_url, thumb_url, sort_order, media_type) + VALUES (?, ?, ?, ?, ?, ?)`, + [ + albumId, + originalUrl, + mediumUrl, + thumbUrl, + nextOrder + i, + mediaType, + ] ); photoId = result.insertId; } else { - // 컨셉 포토 → album_photos 테이블 + // 컨셉 포토 → 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) @@ -706,6 +737,7 @@ router.post( medium_url: mediumUrl, thumb_url: thumbUrl, filename, + media_type: isVideo ? "video" : "image", }); } diff --git a/backend/routes/albums.js b/backend/routes/albums.js index 9840652..669de07 100644 --- a/backend/routes/albums.js +++ b/backend/routes/albums.js @@ -12,9 +12,9 @@ async function getAlbumDetails(album) { ); album.tracks = tracks; - // 티저 이미지 조회 (3개 해상도 URL 포함) + // 티저 이미지/비디오 조회 (3개 해상도 URL + media_type 포함) const [teasers] = await pool.query( - "SELECT original_url, medium_url, thumb_url FROM album_teasers WHERE album_id = ? ORDER BY sort_order", + "SELECT original_url, medium_url, thumb_url, media_type FROM album_teasers WHERE album_id = ? ORDER BY sort_order", [album.id] ); album.teasers = teasers; diff --git a/frontend/src/pages/pc/AlbumDetail.jsx b/frontend/src/pages/pc/AlbumDetail.jsx index 8840458..9482afb 100644 --- a/frontend/src/pages/pc/AlbumDetail.jsx +++ b/frontend/src/pages/pc/AlbumDetail.jsx @@ -1,8 +1,42 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, memo } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { Calendar, Music2, Clock, X, ChevronLeft, ChevronRight, Download, MoreVertical, FileText } from 'lucide-react'; +// 인디케이터 컴포넌트 - CSS transition 사용으로 JS 블로킹에 영향받지 않음 +const LightboxIndicator = memo(function LightboxIndicator({ count, currentIndex, setLightbox }) { + const translateX = -(currentIndex * 18) + 100 - 6; + + return ( +
+ {/* 양옆 페이드 그라데이션 */} +
+ {/* 슬라이딩 컨테이너 - CSS transition으로 GPU 가속 */} +
+ {Array.from({ length: count }).map((_, i) => ( +
+
+ ); +}); function AlbumDetail() { const { name } = useParams(); const navigate = useNavigate(); @@ -11,6 +45,7 @@ function AlbumDetail() { const [lightbox, setLightbox] = useState({ open: false, images: [], index: 0 }); const [slideDirection, setSlideDirection] = useState(0); const [imageLoaded, setImageLoaded] = useState(false); + const [preloadedImages] = useState(() => new Set()); // 프리로드된 이미지 URL 추적 const [showDescriptionModal, setShowDescriptionModal] = useState(false); const [showMenu, setShowMenu] = useState(false); @@ -99,20 +134,27 @@ function AlbumDetail() { return () => window.removeEventListener('keydown', handleKeyDown); }, [lightbox.open, goToPrev, goToNext, closeLightbox]); - // 이미지 프리로딩 (이전/다음 이미지) + // 이미지 프리로딩 (이전/다음 2개씩) - 백그라운드에서 프리로드만 useEffect(() => { if (!lightbox.open || lightbox.images.length <= 1) return; - const preloadImages = []; - const prevIdx = (lightbox.index - 1 + lightbox.images.length) % lightbox.images.length; - const nextIdx = (lightbox.index + 1) % lightbox.images.length; + // 양옆 이미지 프리로드 + const indicesToPreload = []; + for (let offset = -2; offset <= 2; offset++) { + if (offset === 0) continue; + const idx = (lightbox.index + offset + lightbox.images.length) % lightbox.images.length; + indicesToPreload.push(idx); + } - [prevIdx, nextIdx].forEach(idx => { + indicesToPreload.forEach(idx => { + const url = lightbox.images[idx]; + if (preloadedImages.has(url)) return; + const img = new Image(); - img.src = lightbox.images[idx]; - preloadImages.push(img); + img.onload = () => preloadedImages.add(url); + img.src = url; }); - }, [lightbox.open, lightbox.index, lightbox.images]); + }, [lightbox.open, lightbox.index, lightbox.images, preloadedImages]); useEffect(() => { fetch(`/api/albums/by-name/${name}`) @@ -287,7 +329,7 @@ function AlbumDetail() {

- {/* 앨범 티저 이미지 */} + {/* 앨범 티저 이미지/영상 */} {album.teasers && album.teasers.length > 0 && (

티저 포토

@@ -295,14 +337,35 @@ function AlbumDetail() { {album.teasers.map((teaser, index) => (
setLightbox({ open: true, images: album.teasers.map(t => t.original_url), index })} - className="w-24 h-24 bg-gray-200 rounded-lg overflow-hidden cursor-pointer transition-all duration-300 ease-out hover:scale-105 hover:shadow-xl hover:z-10" + onClick={() => setLightbox({ + open: true, + images: album.teasers.map(t => t.original_url), + index, + teasers: album.teasers // media_type 정보 전달 + })} + className="w-24 h-24 bg-gray-200 rounded-lg overflow-hidden cursor-pointer transition-all duration-300 ease-out hover:scale-105 hover:shadow-xl hover:z-10 relative" > - {`Teaser + {teaser.media_type === 'video' ? ( + <> +
@@ -456,19 +519,34 @@ function AlbumDetail() {
)} - {/* 이미지 */} + {/* 이미지 또는 비디오 */}
- e.stopPropagation()} - onLoad={() => setImageLoaded(true)} - initial={{ x: slideDirection * 100 }} - animate={{ x: 0 }} - transition={{ duration: 0.25, ease: 'easeOut' }} - /> + {lightbox.teasers?.[lightbox.index]?.media_type === 'video' ? ( + e.stopPropagation()} + onCanPlay={() => setImageLoaded(true)} + initial={{ x: slideDirection * 100 }} + animate={{ x: 0 }} + transition={{ duration: 0.25, ease: 'easeOut' }} + controls + autoPlay + /> + ) : ( + e.stopPropagation()} + onLoad={() => setImageLoaded(true)} + initial={{ x: slideDirection * 100 }} + animate={{ x: 0 }} + transition={{ duration: 0.25, ease: 'easeOut' }} + /> + )}
{/* 다음 버튼 */} @@ -484,21 +562,13 @@ function AlbumDetail() { )} - {/* 인디케이터 */} + {/* 인디케이터 - memo 컴포넌트로 분리 */} {lightbox.images.length > 1 && ( -
- {lightbox.images.map((_, i) => ( -
+ )} diff --git a/frontend/src/pages/pc/AlbumGallery.jsx b/frontend/src/pages/pc/AlbumGallery.jsx index fa3c083..f53c081 100644 --- a/frontend/src/pages/pc/AlbumGallery.jsx +++ b/frontend/src/pages/pc/AlbumGallery.jsx @@ -1,10 +1,45 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, memo } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { X, ChevronLeft, ChevronRight, Download } from 'lucide-react'; import { RowsPhotoAlbum } from 'react-photo-album'; import 'react-photo-album/rows.css'; +// 인디케이터 컴포넌트 - CSS transition 사용으로 JS 블로킹에 영향받지 않음 +const LightboxIndicator = memo(function LightboxIndicator({ count, currentIndex, setLightbox }) { + const translateX = -(currentIndex * 18) + 100 - 6; + + return ( +
+ {/* 양옆 페이드 그라데이션 */} +
+ {/* 슬라이딩 컨테이너 - CSS transition으로 GPU 가속 */} +
+ {Array.from({ length: count }).map((_, i) => ( +
+
+ ); +}); + // CSS로 호버 효과 추가 + overflow 문제 수정 + 로드 애니메이션 const galleryStyles = ` @keyframes fadeInUp { @@ -57,6 +92,7 @@ function AlbumGallery() { const [lightbox, setLightbox] = useState({ open: false, index: 0 }); const [imageLoaded, setImageLoaded] = useState(false); const [slideDirection, setSlideDirection] = useState(0); + const [preloadedImages] = useState(() => new Set()); // 프리로드된 이미지 URL 추적 useEffect(() => { fetch(`/api/albums/by-name/${name}`) @@ -173,18 +209,27 @@ function AlbumGallery() { return () => window.removeEventListener('keydown', handleKeyDown); }, [lightbox.open, goToPrev, goToNext, closeLightbox]); - // 프리로딩 + // 프리로딩 (이전/다음 2개씩) - 백그라운드에서 프리로드만 useEffect(() => { if (!lightbox.open || photos.length <= 1) return; - const prevIdx = (lightbox.index - 1 + photos.length) % photos.length; - const nextIdx = (lightbox.index + 1) % photos.length; + // 양옆 이미지 프리로드 + const indicesToPreload = []; + for (let offset = -2; offset <= 2; offset++) { + if (offset === 0) continue; + const idx = (lightbox.index + offset + photos.length) % photos.length; + indicesToPreload.push(idx); + } - [prevIdx, nextIdx].forEach(idx => { + indicesToPreload.forEach(idx => { + const url = photos[idx].originalUrl; + if (preloadedImages.has(url)) return; + const img = new Image(); - img.src = photos[idx].originalUrl; + img.onload = () => preloadedImages.add(url); + img.src = url; }); - }, [lightbox.open, lightbox.index, photos]); + }, [lightbox.open, lightbox.index, photos, preloadedImages]); if (loading) { return ( @@ -305,7 +350,7 @@ function AlbumGallery() {
)} - {/* 이미지 + 컨셉 정보 - 양옆 margin으로 화살표와 간격 */} + {/* 이미지 + 컨셉 정보 */}
- {/* 컨셉 정보 + 멤버 - 하나라도 있으면 표시 */} + {/* 컨셉 정보 + 멤버 */} {imageLoaded && ( (() => { const title = photos[lightbox.index]?.title; @@ -329,13 +374,11 @@ function AlbumGallery() { return (
- {/* 컨셉명 - 있고 유효할 때만 */} {hasValidTitle && ( {title} )} - {/* 멤버 - 있으면 항상 표시 */} {hasMembers && (
{String(members).split(',').map((member, idx) => ( @@ -361,19 +404,12 @@ function AlbumGallery() { )} - {/* 하단 점 인디케이터 - 한 줄 고정, 스크롤바 숨김 */} -
- {photos.map((_, i) => ( -
+ {/* 하단 점 인디케이터 - memo 컴포넌트로 분리 */} +
)} diff --git a/frontend/src/pages/pc/admin/AdminAlbumPhotos.jsx b/frontend/src/pages/pc/admin/AdminAlbumPhotos.jsx index fb9d16c..d97e67a 100644 --- a/frontend/src/pages/pc/admin/AdminAlbumPhotos.jsx +++ b/frontend/src/pages/pc/admin/AdminAlbumPhotos.jsx @@ -178,8 +178,9 @@ function AdminAlbumPhotos() { const photosRes = await fetch(`/api/admin/albums/${albumId}/photos`, { headers: { 'Authorization': `Bearer ${token}` } }); + let photosData = []; if (photosRes.ok) { - const photosData = await photosRes.json(); + photosData = await photosRes.json(); setPhotos(photosData); } @@ -187,10 +188,21 @@ function AdminAlbumPhotos() { const teasersRes = await fetch(`/api/admin/albums/${albumId}/teasers`, { headers: { 'Authorization': `Bearer ${token}` } }); + let teasersData = []; if (teasersRes.ok) { - const teasersData = await teasersRes.json(); + teasersData = await teasersRes.json(); setTeasers(teasersData); } + + // 시작 번호 자동 설정 (컨셉 포토와 티저 중 더 큰 값 + 1) + const maxPhotoOrder = photosData.length > 0 + ? Math.max(...photosData.map(p => p.sort_order || 0)) + : 0; + const maxTeaserOrder = teasersData.length > 0 + ? Math.max(...teasersData.map(t => t.sort_order || 0)) + : 0; + const nextStartNumber = Math.max(maxPhotoOrder, maxTeaserOrder) + 1; + setStartNumber(nextStartNumber); setLoading(false); } catch (error) { @@ -281,6 +293,7 @@ function AdminAlbumPhotos() { file, preview: URL.createObjectURL(file), filename: file.name, + isVideo: file.type === 'video/mp4', // 비디오 여부 groupType: 'group', // 'group' | 'solo' | 'unit' members: [], // 태깅된 멤버들 (단체일 경우 빈 배열) conceptName: '', // 개별 컨셉명 @@ -293,13 +306,15 @@ function AdminAlbumPhotos() { setPendingFiles(newOrder); }; - // 직접 순서 변경 (입력으로) + // 직접 순서 변경 (입력으로) - 입력값은 startNumber 기준 const moveToPosition = (fileId, newPosition) => { const pos = parseInt(newPosition, 10); - if (isNaN(pos) || pos < 1) return; + // 시작 번호보다 작거나 유효하지 않으면 무시 + if (isNaN(pos) || pos < startNumber) return; setPendingFiles(prev => { - const targetIndex = Math.min(pos - 1, prev.length - 1); + // 입력값에서 startNumber를 빼서 배열 인덱스 계산 + const targetIndex = Math.min(pos - startNumber, prev.length - 1); const currentIndex = prev.findIndex(f => f.id === fileId); if (currentIndex === -1 || currentIndex === targetIndex) return prev; @@ -448,13 +463,16 @@ function AdminAlbumPhotos() { if (result) { setToast({ message: result.message, type: 'success' }); + + // 다음 업로드를 위해 시작 번호를 마지막 파일 + 1로 설정 + const nextStartNumber = startNumber + pendingFiles.length; + setStartNumber(nextStartNumber); } // 미리보기 URL 해제 pendingFiles.forEach(f => URL.revokeObjectURL(f.preview)); setPendingFiles([]); setConceptName(''); - setStartNumber(1); // 초기화 // 사진 목록 다시 로드 fetchAlbumData(); @@ -609,15 +627,28 @@ function AdminAlbumPhotos() { > - e.stopPropagation()} - /> + {previewPhoto.isVideo ? ( + e.stopPropagation()} + controls + autoPlay + /> + ) : ( + e.stopPropagation()} + /> + )} )} @@ -898,7 +929,9 @@ function AdminAlbumPhotos() {

사진을 드래그하여 업로드하세요

-

JPG, PNG, WebP 지원

+

+ {photoType === 'teaser' ? 'JPG, PNG, WebP, MP4 지원' : 'JPG, PNG, WebP 지원'} +

{/* 썸네일 (180px로 확대) */} - {file.filename} setPreviewPhoto(file)} - /> + {file.isVideo ? ( +
+