feat: 라이트박스 인디케이터 개선 및 업로드 기능 강화

- 인디케이터 슬라이딩 애니메이션 개선 (CSS transition으로 GPU 가속)
- React.memo로 인디케이터 분리하여 이미지 로딩 시 리렌더링 방지
- 양옆 페이드 그라데이션 효과 추가
- 업로드 시 시작 번호 자동 계산 (기존 사진 마지막 번호 +1)
- 동영상 썸네일 및 미리보기 지원
- 이미지 프리로딩 범위 확장 (±2개)
- Multer 업로드 제한 50 → 200개로 증가
This commit is contained in:
caadiq 2026-01-04 01:38:32 +09:00
parent 3ff912d1fe
commit 5610a337c5
5 changed files with 362 additions and 152 deletions

View file

@ -19,12 +19,13 @@ const JWT_EXPIRES_IN = "30d";
// Multer 설정 (메모리 저장) // Multer 설정 (메모리 저장)
const upload = multer({ const upload = multer({
storage: multer.memoryStorage(), storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB limits: { fileSize: 50 * 1024 * 1024 }, // 50MB (동영상 지원)
fileFilter: (req, file, cb) => { fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith("image/")) { // 이미지 또는 MP4 비디오 허용
if (file.mimetype.startsWith("image/") || file.mimetype === "video/mp4") {
cb(null, true); cb(null, true);
} else { } 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( 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 FROM album_teasers
WHERE album_id = ? WHERE album_id = ?
ORDER BY sort_order ASC`, ORDER BY sort_order ASC`,
@ -548,7 +549,7 @@ router.delete(
router.post( router.post(
"/albums/:albumId/photos", "/albums/:albumId/photos",
authenticateToken, authenticateToken,
upload.array("photos", 50), upload.array("photos", 200),
async (req, res) => { async (req, res) => {
// SSE 헤더 설정 // SSE 헤더 설정
res.setHeader("Content-Type", "text/event-stream"); res.setHeader("Content-Type", "text/event-stream");
@ -598,14 +599,39 @@ router.post(
const file = req.files[i]; const file = req.files[i];
const meta = metadata[i] || {}; const meta = metadata[i] || {};
const orderNum = String(nextOrder + i).padStart(2, "0"); 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} 처리 중...`); sendProgress(i + 1, totalFiles, `${filename} 처리 중...`);
// Sharp로 이미지 처리 (병렬) let originalUrl, mediumUrl, thumbUrl;
const [originalBuffer, medium800Buffer, thumb400Buffer] = let originalBuffer, originalMeta;
await Promise.all([
// 컨셉 포토: 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).webp({ lossless: true }).toBuffer(),
sharp(file.buffer) sharp(file.buffer)
.resize(800, null, { withoutEnlargement: true }) .resize(800, null, { withoutEnlargement: true })
@ -617,59 +643,64 @@ router.post(
.toBuffer(), .toBuffer(),
]); ]);
const originalMeta = await sharp(originalBuffer).metadata(); originalBuffer = origBuf;
originalMeta = await sharp(originalBuffer).metadata();
// RustFS 업로드 (병렬) // RustFS 업로드 (병렬)
// 컨셉 포토: photo/, 티저 이미지: teaser/ await Promise.all([
const subFolder = photoType === "teaser" ? "teaser" : "photo"; s3Client.send(
const basePath = `album/${folderName}/${subFolder}`; 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([ originalUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/original/${filename}`;
s3Client.send( mediumUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/medium_800/${filename}`;
new PutObjectCommand({ thumbUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/thumb_400/${filename}`;
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; let photoId;
// DB 저장 - 티저와 컨셉 포토 분기 // DB 저장 - 티저와 컨셉 포토 분기
if (photoType === "teaser") { if (photoType === "teaser") {
// 티저 이미지 → album_teasers 테이블 // 티저 이미지/비디오 → album_teasers 테이블
const mediaType = isVideo ? "video" : "image";
const [result] = await connection.query( const [result] = await connection.query(
`INSERT INTO album_teasers `INSERT INTO album_teasers
(album_id, original_url, medium_url, thumb_url, sort_order) (album_id, original_url, medium_url, thumb_url, sort_order, media_type)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?)`,
[albumId, originalUrl, mediumUrl, thumbUrl, nextOrder + i] [
albumId,
originalUrl,
mediumUrl,
thumbUrl,
nextOrder + i,
mediaType,
]
); );
photoId = result.insertId; photoId = result.insertId;
} else { } else {
// 컨셉 포토 → album_photos 테이블 // 컨셉 포토 → album_photos 테이블 (이미지만)
const [result] = await connection.query( const [result] = await connection.query(
`INSERT INTO album_photos `INSERT INTO album_photos
(album_id, original_url, medium_url, thumb_url, photo_type, concept_name, sort_order, width, height, file_size) (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, medium_url: mediumUrl,
thumb_url: thumbUrl, thumb_url: thumbUrl,
filename, filename,
media_type: isVideo ? "video" : "image",
}); });
} }

View file

@ -12,9 +12,9 @@ async function getAlbumDetails(album) {
); );
album.tracks = tracks; album.tracks = tracks;
// 티저 이미지 조회 (3개 해상도 URL 포함) // 티저 이미지/비디오 조회 (3개 해상도 URL + media_type 포함)
const [teasers] = await pool.query( 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.id]
); );
album.teasers = teasers; album.teasers = teasers;

View file

@ -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 { useParams, useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { Calendar, Music2, Clock, X, ChevronLeft, ChevronRight, Download, MoreVertical, FileText } from 'lucide-react'; 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 (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 overflow-hidden" style={{ width: '200px' }}>
{/* 양옆 페이드 그라데이션 */}
<div className="absolute inset-0 pointer-events-none z-10" style={{
background: 'linear-gradient(to right, rgba(0,0,0,1) 0%, transparent 20%, transparent 80%, rgba(0,0,0,1) 100%)'
}} />
{/* 슬라이딩 컨테이너 - CSS transition으로 GPU 가속 */}
<div
className="flex items-center gap-2 justify-center"
style={{
width: `${count * 18}px`,
transform: `translateX(${translateX}px)`,
transition: 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)'
}}
>
{Array.from({ length: count }).map((_, i) => (
<button
key={i}
className={`rounded-full flex-shrink-0 transition-all duration-300 ${
i === currentIndex
? 'w-3 h-3 bg-white'
: 'w-2.5 h-2.5 bg-white/40 hover:bg-white/60'
}`}
onClick={() => setLightbox(prev => ({ ...prev, index: i }))}
/>
))}
</div>
</div>
);
});
function AlbumDetail() { function AlbumDetail() {
const { name } = useParams(); const { name } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@ -11,6 +45,7 @@ function AlbumDetail() {
const [lightbox, setLightbox] = useState({ open: false, images: [], index: 0 }); const [lightbox, setLightbox] = useState({ open: false, images: [], index: 0 });
const [slideDirection, setSlideDirection] = useState(0); const [slideDirection, setSlideDirection] = useState(0);
const [imageLoaded, setImageLoaded] = useState(false); const [imageLoaded, setImageLoaded] = useState(false);
const [preloadedImages] = useState(() => new Set()); // URL
const [showDescriptionModal, setShowDescriptionModal] = useState(false); const [showDescriptionModal, setShowDescriptionModal] = useState(false);
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
@ -99,20 +134,27 @@ function AlbumDetail() {
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [lightbox.open, goToPrev, goToNext, closeLightbox]); }, [lightbox.open, goToPrev, goToNext, closeLightbox]);
// (/ ) // (/ 2) -
useEffect(() => { useEffect(() => {
if (!lightbox.open || lightbox.images.length <= 1) return; if (!lightbox.open || lightbox.images.length <= 1) return;
const preloadImages = []; //
const prevIdx = (lightbox.index - 1 + lightbox.images.length) % lightbox.images.length; const indicesToPreload = [];
const nextIdx = (lightbox.index + 1) % lightbox.images.length; 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(); const img = new Image();
img.src = lightbox.images[idx]; img.onload = () => preloadedImages.add(url);
preloadImages.push(img); img.src = url;
}); });
}, [lightbox.open, lightbox.index, lightbox.images]); }, [lightbox.open, lightbox.index, lightbox.images, preloadedImages]);
useEffect(() => { useEffect(() => {
fetch(`/api/albums/by-name/${name}`) fetch(`/api/albums/by-name/${name}`)
@ -287,7 +329,7 @@ function AlbumDetail() {
</p> </p>
</div> </div>
{/* 앨범 티저 이미지 */} {/* 앨범 티저 이미지/영상 */}
{album.teasers && album.teasers.length > 0 && ( {album.teasers && album.teasers.length > 0 && (
<div className="mt-auto"> <div className="mt-auto">
<p className="text-xs text-gray-400 mb-2">티저 포토</p> <p className="text-xs text-gray-400 mb-2">티저 포토</p>
@ -295,14 +337,35 @@ function AlbumDetail() {
{album.teasers.map((teaser, index) => ( {album.teasers.map((teaser, index) => (
<div <div
key={index} key={index}
onClick={() => setLightbox({ open: true, images: album.teasers.map(t => t.original_url), index })} onClick={() => setLightbox({
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" 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"
> >
<img {teaser.media_type === 'video' ? (
src={teaser.thumb_url} <>
alt={`Teaser ${index + 1}`} <video
className="w-full h-full object-cover" src={teaser.original_url}
/> className="w-full h-full object-cover"
muted
/>
{/* 비디오 아이콘 오버레이 */}
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<div className="w-8 h-8 bg-white/90 rounded-full flex items-center justify-center">
<div className="w-0 h-0 border-l-[10px] border-l-gray-800 border-y-[6px] border-y-transparent ml-1" />
</div>
</div>
</>
) : (
<img
src={teaser.thumb_url}
alt={`Teaser ${index + 1}`}
className="w-full h-full object-cover"
/>
)}
</div> </div>
))} ))}
</div> </div>
@ -456,19 +519,34 @@ function AlbumDetail() {
</div> </div>
)} )}
{/* 이미지 */} {/* 이미지 또는 비디오 */}
<div className="flex flex-col items-center mx-24"> <div className="flex flex-col items-center mx-24">
<motion.img {lightbox.teasers?.[lightbox.index]?.media_type === 'video' ? (
key={lightbox.index} <motion.video
src={lightbox.images[lightbox.index]} key={lightbox.index}
alt="확대 이미지" src={lightbox.images[lightbox.index]}
className={`max-w-[1100px] max-h-[75vh] object-contain transition-opacity duration-200 ${imageLoaded ? 'opacity-100' : 'opacity-0'}`} className={`max-w-[1100px] max-h-[75vh] object-contain transition-opacity duration-200 ${imageLoaded ? 'opacity-100' : 'opacity-0'}`}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onLoad={() => setImageLoaded(true)} onCanPlay={() => setImageLoaded(true)}
initial={{ x: slideDirection * 100 }} initial={{ x: slideDirection * 100 }}
animate={{ x: 0 }} animate={{ x: 0 }}
transition={{ duration: 0.25, ease: 'easeOut' }} transition={{ duration: 0.25, ease: 'easeOut' }}
/> controls
autoPlay
/>
) : (
<motion.img
key={lightbox.index}
src={lightbox.images[lightbox.index]}
alt="확대 이미지"
className={`max-w-[1100px] max-h-[75vh] object-contain transition-opacity duration-200 ${imageLoaded ? 'opacity-100' : 'opacity-0'}`}
onClick={(e) => e.stopPropagation()}
onLoad={() => setImageLoaded(true)}
initial={{ x: slideDirection * 100 }}
animate={{ x: 0 }}
transition={{ duration: 0.25, ease: 'easeOut' }}
/>
)}
</div> </div>
{/* 다음 버튼 */} {/* 다음 버튼 */}
@ -484,21 +562,13 @@ function AlbumDetail() {
</button> </button>
)} )}
{/* 인디케이터 */} {/* 인디케이터 - memo 컴포넌트로 분리 */}
{lightbox.images.length > 1 && ( {lightbox.images.length > 1 && (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 flex gap-1.5 overflow-x-auto scrollbar-hide" style={{ maxWidth: '1000px' }}> <LightboxIndicator
{lightbox.images.map((_, i) => ( count={lightbox.images.length}
<button currentIndex={lightbox.index}
key={i} setLightbox={setLightbox}
className={`w-2 h-2 rounded-full transition-colors flex-shrink-0 ${i === lightbox.index ? 'bg-white' : 'bg-white/40'}`} />
onClick={(e) => {
e.stopPropagation();
setImageLoaded(false);
setLightbox({ ...lightbox, index: i });
}}
/>
))}
</div>
)} )}
</div> </div>
</motion.div> </motion.div>

View file

@ -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 { useParams, useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { X, ChevronLeft, ChevronRight, Download } from 'lucide-react'; import { X, ChevronLeft, ChevronRight, Download } from 'lucide-react';
import { RowsPhotoAlbum } from 'react-photo-album'; import { RowsPhotoAlbum } from 'react-photo-album';
import 'react-photo-album/rows.css'; import 'react-photo-album/rows.css';
// - CSS transition JS
const LightboxIndicator = memo(function LightboxIndicator({ count, currentIndex, setLightbox }) {
const translateX = -(currentIndex * 18) + 100 - 6;
return (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 overflow-hidden" style={{ width: '200px' }}>
{/* 양옆 페이드 그라데이션 */}
<div className="absolute inset-0 pointer-events-none z-10" style={{
background: 'linear-gradient(to right, rgba(0,0,0,1) 0%, transparent 20%, transparent 80%, rgba(0,0,0,1) 100%)'
}} />
{/* 슬라이딩 컨테이너 - CSS transition으로 GPU 가속 */}
<div
className="flex items-center gap-2 justify-center"
style={{
width: `${count * 18}px`,
transform: `translateX(${translateX}px)`,
transition: 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)'
}}
>
{Array.from({ length: count }).map((_, i) => (
<button
key={i}
className={`rounded-full flex-shrink-0 transition-all duration-300 ${
i === currentIndex
? 'w-3 h-3 bg-white'
: 'w-2.5 h-2.5 bg-white/40 hover:bg-white/60'
}`}
onClick={() => setLightbox(prev => ({ ...prev, index: i }))}
/>
))}
</div>
</div>
);
});
// CSS + overflow + // CSS + overflow +
const galleryStyles = ` const galleryStyles = `
@keyframes fadeInUp { @keyframes fadeInUp {
@ -57,6 +92,7 @@ function AlbumGallery() {
const [lightbox, setLightbox] = useState({ open: false, index: 0 }); const [lightbox, setLightbox] = useState({ open: false, index: 0 });
const [imageLoaded, setImageLoaded] = useState(false); const [imageLoaded, setImageLoaded] = useState(false);
const [slideDirection, setSlideDirection] = useState(0); const [slideDirection, setSlideDirection] = useState(0);
const [preloadedImages] = useState(() => new Set()); // URL
useEffect(() => { useEffect(() => {
fetch(`/api/albums/by-name/${name}`) fetch(`/api/albums/by-name/${name}`)
@ -173,18 +209,27 @@ function AlbumGallery() {
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [lightbox.open, goToPrev, goToNext, closeLightbox]); }, [lightbox.open, goToPrev, goToNext, closeLightbox]);
// // (/ 2) -
useEffect(() => { useEffect(() => {
if (!lightbox.open || photos.length <= 1) return; 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(); 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) { if (loading) {
return ( return (
@ -305,7 +350,7 @@ function AlbumGallery() {
</div> </div>
)} )}
{/* 이미지 + 컨셉 정보 - 양옆 margin으로 화살표와 간격 */} {/* 이미지 + 컨셉 정보 */}
<div className="flex flex-col items-center mx-24"> <div className="flex flex-col items-center mx-24">
<motion.img <motion.img
key={lightbox.index} key={lightbox.index}
@ -317,7 +362,7 @@ function AlbumGallery() {
animate={{ x: 0 }} animate={{ x: 0 }}
transition={{ duration: 0.25, ease: 'easeOut' }} transition={{ duration: 0.25, ease: 'easeOut' }}
/> />
{/* 컨셉 정보 + 멤버 - 하나라도 있으면 표시 */} {/* 컨셉 정보 + 멤버 */}
{imageLoaded && ( {imageLoaded && (
(() => { (() => {
const title = photos[lightbox.index]?.title; const title = photos[lightbox.index]?.title;
@ -329,13 +374,11 @@ function AlbumGallery() {
return ( return (
<div className="mt-6 flex flex-col items-center gap-2"> <div className="mt-6 flex flex-col items-center gap-2">
{/* 컨셉명 - 있고 유효할 때만 */}
{hasValidTitle && ( {hasValidTitle && (
<span className="px-4 py-2 bg-white/10 backdrop-blur-sm rounded-full text-white font-medium text-base"> <span className="px-4 py-2 bg-white/10 backdrop-blur-sm rounded-full text-white font-medium text-base">
{title} {title}
</span> </span>
)} )}
{/* 멤버 - 있으면 항상 표시 */}
{hasMembers && ( {hasMembers && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{String(members).split(',').map((member, idx) => ( {String(members).split(',').map((member, idx) => (
@ -361,19 +404,12 @@ function AlbumGallery() {
</button> </button>
)} )}
{/* 하단 점 인디케이터 - 한 줄 고정, 스크롤바 숨김 */} {/* 하단 점 인디케이터 - memo 컴포넌트로 분리 */}
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 flex gap-1.5 overflow-x-auto scrollbar-hide" style={{ maxWidth: '1000px' }}> <LightboxIndicator
{photos.map((_, i) => ( count={photos.length}
<button currentIndex={lightbox.index}
key={i} setLightbox={setLightbox}
className={`w-2 h-2 rounded-full transition-colors flex-shrink-0 ${i === lightbox.index ? 'bg-white' : 'bg-white/40'}`} />
onClick={() => {
setImageLoaded(false);
setLightbox({ ...lightbox, index: i });
}}
/>
))}
</div>
</div> </div>
</motion.div> </motion.div>
)} )}

View file

@ -178,8 +178,9 @@ function AdminAlbumPhotos() {
const photosRes = await fetch(`/api/admin/albums/${albumId}/photos`, { const photosRes = await fetch(`/api/admin/albums/${albumId}/photos`, {
headers: { 'Authorization': `Bearer ${token}` } headers: { 'Authorization': `Bearer ${token}` }
}); });
let photosData = [];
if (photosRes.ok) { if (photosRes.ok) {
const photosData = await photosRes.json(); photosData = await photosRes.json();
setPhotos(photosData); setPhotos(photosData);
} }
@ -187,10 +188,21 @@ function AdminAlbumPhotos() {
const teasersRes = await fetch(`/api/admin/albums/${albumId}/teasers`, { const teasersRes = await fetch(`/api/admin/albums/${albumId}/teasers`, {
headers: { 'Authorization': `Bearer ${token}` } headers: { 'Authorization': `Bearer ${token}` }
}); });
let teasersData = [];
if (teasersRes.ok) { if (teasersRes.ok) {
const teasersData = await teasersRes.json(); teasersData = await teasersRes.json();
setTeasers(teasersData); 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); setLoading(false);
} catch (error) { } catch (error) {
@ -281,6 +293,7 @@ function AdminAlbumPhotos() {
file, file,
preview: URL.createObjectURL(file), preview: URL.createObjectURL(file),
filename: file.name, filename: file.name,
isVideo: file.type === 'video/mp4', //
groupType: 'group', // 'group' | 'solo' | 'unit' groupType: 'group', // 'group' | 'solo' | 'unit'
members: [], // ( ) members: [], // ( )
conceptName: '', // conceptName: '', //
@ -293,13 +306,15 @@ function AdminAlbumPhotos() {
setPendingFiles(newOrder); setPendingFiles(newOrder);
}; };
// () // () - startNumber
const moveToPosition = (fileId, newPosition) => { const moveToPosition = (fileId, newPosition) => {
const pos = parseInt(newPosition, 10); const pos = parseInt(newPosition, 10);
if (isNaN(pos) || pos < 1) return; //
if (isNaN(pos) || pos < startNumber) return;
setPendingFiles(prev => { 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); const currentIndex = prev.findIndex(f => f.id === fileId);
if (currentIndex === -1 || currentIndex === targetIndex) return prev; if (currentIndex === -1 || currentIndex === targetIndex) return prev;
@ -448,13 +463,16 @@ function AdminAlbumPhotos() {
if (result) { if (result) {
setToast({ message: result.message, type: 'success' }); setToast({ message: result.message, type: 'success' });
// + 1
const nextStartNumber = startNumber + pendingFiles.length;
setStartNumber(nextStartNumber);
} }
// URL // URL
pendingFiles.forEach(f => URL.revokeObjectURL(f.preview)); pendingFiles.forEach(f => URL.revokeObjectURL(f.preview));
setPendingFiles([]); setPendingFiles([]);
setConceptName(''); setConceptName('');
setStartNumber(1); //
// //
fetchAlbumData(); fetchAlbumData();
@ -609,15 +627,28 @@ function AdminAlbumPhotos() {
> >
<X size={24} /> <X size={24} />
</button> </button>
<motion.img {previewPhoto.isVideo ? (
initial={{ scale: 0.9 }} <motion.video
animate={{ scale: 1 }} initial={{ scale: 0.9 }}
exit={{ scale: 0.9 }} animate={{ scale: 1 }}
src={previewPhoto.preview || previewPhoto.url} exit={{ scale: 0.9 }}
alt={previewPhoto.filename} src={previewPhoto.preview || previewPhoto.url}
className="max-w-[90vw] max-h-[90vh] object-contain" className="max-w-[90vw] max-h-[90vh] object-contain"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> controls
autoPlay
/>
) : (
<motion.img
initial={{ scale: 0.9 }}
animate={{ scale: 1 }}
exit={{ scale: 0.9 }}
src={previewPhoto.preview || previewPhoto.url}
alt={previewPhoto.filename}
className="max-w-[90vw] max-h-[90vh] object-contain"
onClick={(e) => e.stopPropagation()}
/>
)}
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
@ -898,7 +929,9 @@ function AdminAlbumPhotos() {
<div className="py-20 text-center"> <div className="py-20 text-center">
<Image size={48} className="mx-auto text-gray-300 mb-4" /> <Image size={48} className="mx-auto text-gray-300 mb-4" />
<p className="text-gray-500 mb-2">사진을 드래그하여 업로드하세요</p> <p className="text-gray-500 mb-2">사진을 드래그하여 업로드하세요</p>
<p className="text-gray-400 text-sm mb-4">JPG, PNG, WebP 지원</p> <p className="text-gray-400 text-sm mb-4">
{photoType === 'teaser' ? 'JPG, PNG, WebP, MP4 지원' : 'JPG, PNG, WebP 지원'}
</p>
<button <button
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors" className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
@ -947,10 +980,18 @@ function AdminAlbumPhotos() {
const val = e.target.value.trim(); const val = e.target.value.trim();
// //
const scrollY = window.scrollY; const scrollY = window.scrollY;
if (val && !isNaN(val)) { const currentIndex = pendingFiles.findIndex(f => f.id === file.id);
const currentOrder = startNumber + currentIndex;
//
if (val && !isNaN(val) && parseInt(val) !== currentOrder) {
moveToPosition(file.id, val); moveToPosition(file.id, val);
} }
e.target.value = String(pendingFiles.findIndex(f => f.id === file.id) + 1).padStart(2, '0');
//
const newIndex = pendingFiles.findIndex(f => f.id === file.id);
e.target.value = String(startNumber + newIndex).padStart(2, '0');
// //
requestAnimationFrame(() => { requestAnimationFrame(() => {
window.scrollTo(0, scrollY); window.scrollTo(0, scrollY);
@ -966,14 +1007,34 @@ function AdminAlbumPhotos() {
</div> </div>
{/* 썸네일 (180px로 확대) */} {/* 썸네일 (180px로 확대) */}
<img {file.isVideo ? (
src={file.preview} <div className="relative w-[180px] h-[180px] flex-shrink-0">
alt={file.filename} <video
draggable="false" src={file.preview}
loading="lazy" className="w-full h-full rounded-lg object-cover cursor-pointer hover:opacity-80 transition-opacity select-none"
className="w-[180px] h-[180px] rounded-lg object-cover cursor-pointer hover:opacity-80 transition-opacity flex-shrink-0 select-none" onClick={() => setPreviewPhoto(file)}
onClick={() => setPreviewPhoto(file)} muted
/> />
{/* 재생 버튼 오버레이 */}
<div
className="absolute inset-0 flex items-center justify-center bg-black/30 rounded-lg cursor-pointer"
onClick={() => setPreviewPhoto(file)}
>
<div className="w-12 h-12 bg-white/90 rounded-full flex items-center justify-center">
<div className="w-0 h-0 border-l-[14px] border-l-gray-800 border-y-[8px] border-y-transparent ml-1" />
</div>
</div>
</div>
) : (
<img
src={file.preview}
alt={file.filename}
draggable="false"
loading="lazy"
className="w-[180px] h-[180px] rounded-lg object-cover cursor-pointer hover:opacity-80 transition-opacity flex-shrink-0 select-none"
onClick={() => setPreviewPhoto(file)}
/>
)}
{/* 메타 정보 - 고정 높이 */} {/* 메타 정보 - 고정 높이 */}
<div className="flex-1 space-y-3 h-[200px] overflow-hidden"> <div className="flex-1 space-y-3 h-[200px] overflow-hidden">
@ -1412,12 +1473,23 @@ function AdminAlbumPhotos() {
); );
}} }}
> >
<img {teaser.media_type === 'video' ? (
src={teaser.thumb_url || teaser.medium_url} <video
alt={`티저 ${teaser.sort_order}`} src={teaser.original_url}
loading="lazy" className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105" muted
/> loop
onMouseEnter={e => e.target.play()}
onMouseLeave={e => { e.target.pause(); e.target.currentTime = 0; }}
/>
) : (
<img
src={teaser.thumb_url || teaser.medium_url}
alt={`티저 ${teaser.sort_order}`}
loading="lazy"
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
/>
)}
{/* 호버 시 반투명 오버레이 */} {/* 호버 시 반투명 오버레이 */}
<div className="absolute inset-0 bg-purple-500/10 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none" /> <div className="absolute inset-0 bg-purple-500/10 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none" />
@ -1555,7 +1627,7 @@ function AdminAlbumPhotos() {
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
multiple multiple
accept="image/*" accept={photoType === 'teaser' ? 'image/*,video/mp4' : 'image/*'}
onChange={handleFileSelect} onChange={handleFileSelect}
className="hidden" className="hidden"
/> />