feat: 라이트박스 인디케이터 개선 및 업로드 기능 강화
- 인디케이터 슬라이딩 애니메이션 개선 (CSS transition으로 GPU 가속) - React.memo로 인디케이터 분리하여 이미지 로딩 시 리렌더링 방지 - 양옆 페이드 그라데이션 효과 추가 - 업로드 시 시작 번호 자동 계산 (기존 사진 마지막 번호 +1) - 동영상 썸네일 및 미리보기 지원 - 이미지 프리로딩 범위 확장 (±2개) - Multer 업로드 제한 50 → 200개로 증가
This commit is contained in:
parent
3ff912d1fe
commit
5610a337c5
5 changed files with 362 additions and 152 deletions
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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() {
|
||||
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);
|
||||
}
|
||||
|
||||
indicesToPreload.forEach(idx => {
|
||||
const url = lightbox.images[idx];
|
||||
if (preloadedImages.has(url)) return;
|
||||
|
||||
[prevIdx, nextIdx].forEach(idx => {
|
||||
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() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* 앨범 티저 이미지 */}
|
||||
{/* 앨범 티저 이미지/영상 */}
|
||||
{album.teasers && album.teasers.length > 0 && (
|
||||
<div className="mt-auto">
|
||||
<p className="text-xs text-gray-400 mb-2">티저 포토</p>
|
||||
|
|
@ -295,14 +337,35 @@ function AlbumDetail() {
|
|||
{album.teasers.map((teaser, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => 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"
|
||||
>
|
||||
<img
|
||||
src={teaser.thumb_url}
|
||||
alt={`Teaser ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{teaser.media_type === 'video' ? (
|
||||
<>
|
||||
<video
|
||||
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>
|
||||
|
|
@ -456,19 +519,34 @@ function AlbumDetail() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 이미지 */}
|
||||
{/* 이미지 또는 비디오 */}
|
||||
<div className="flex flex-col items-center mx-24">
|
||||
<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' }}
|
||||
/>
|
||||
{lightbox.teasers?.[lightbox.index]?.media_type === 'video' ? (
|
||||
<motion.video
|
||||
key={lightbox.index}
|
||||
src={lightbox.images[lightbox.index]}
|
||||
className={`max-w-[1100px] max-h-[75vh] object-contain transition-opacity duration-200 ${imageLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onCanPlay={() => setImageLoaded(true)}
|
||||
initial={{ x: slideDirection * 100 }}
|
||||
animate={{ x: 0 }}
|
||||
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>
|
||||
|
||||
{/* 다음 버튼 */}
|
||||
|
|
@ -484,21 +562,13 @@ function AlbumDetail() {
|
|||
</button>
|
||||
)}
|
||||
|
||||
{/* 인디케이터 */}
|
||||
{/* 인디케이터 - memo 컴포넌트로 분리 */}
|
||||
{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' }}>
|
||||
{lightbox.images.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
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>
|
||||
<LightboxIndicator
|
||||
count={lightbox.images.length}
|
||||
currentIndex={lightbox.index}
|
||||
setLightbox={setLightbox}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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 문제 수정 + 로드 애니메이션
|
||||
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);
|
||||
}
|
||||
|
||||
indicesToPreload.forEach(idx => {
|
||||
const url = photos[idx].originalUrl;
|
||||
if (preloadedImages.has(url)) return;
|
||||
|
||||
[prevIdx, nextIdx].forEach(idx => {
|
||||
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() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 이미지 + 컨셉 정보 - 양옆 margin으로 화살표와 간격 */}
|
||||
{/* 이미지 + 컨셉 정보 */}
|
||||
<div className="flex flex-col items-center mx-24">
|
||||
<motion.img
|
||||
key={lightbox.index}
|
||||
|
|
@ -317,7 +362,7 @@ function AlbumGallery() {
|
|||
animate={{ x: 0 }}
|
||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||
/>
|
||||
{/* 컨셉 정보 + 멤버 - 하나라도 있으면 표시 */}
|
||||
{/* 컨셉 정보 + 멤버 */}
|
||||
{imageLoaded && (
|
||||
(() => {
|
||||
const title = photos[lightbox.index]?.title;
|
||||
|
|
@ -329,13 +374,11 @@ function AlbumGallery() {
|
|||
|
||||
return (
|
||||
<div className="mt-6 flex flex-col items-center gap-2">
|
||||
{/* 컨셉명 - 있고 유효할 때만 */}
|
||||
{hasValidTitle && (
|
||||
<span className="px-4 py-2 bg-white/10 backdrop-blur-sm rounded-full text-white font-medium text-base">
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
{/* 멤버 - 있으면 항상 표시 */}
|
||||
{hasMembers && (
|
||||
<div className="flex items-center gap-2">
|
||||
{String(members).split(',').map((member, idx) => (
|
||||
|
|
@ -361,19 +404,12 @@ function AlbumGallery() {
|
|||
</button>
|
||||
)}
|
||||
|
||||
{/* 하단 점 인디케이터 - 한 줄 고정, 스크롤바 숨김 */}
|
||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 flex gap-1.5 overflow-x-auto scrollbar-hide" style={{ maxWidth: '1000px' }}>
|
||||
{photos.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
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>
|
||||
{/* 하단 점 인디케이터 - memo 컴포넌트로 분리 */}
|
||||
<LightboxIndicator
|
||||
count={photos.length}
|
||||
currentIndex={lightbox.index}
|
||||
setLightbox={setLightbox}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,11 +188,22 @@ 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) {
|
||||
console.error('앨범 로드 오류:', 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() {
|
|||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
<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()}
|
||||
/>
|
||||
{previewPhoto.isVideo ? (
|
||||
<motion.video
|
||||
initial={{ scale: 0.9 }}
|
||||
animate={{ scale: 1 }}
|
||||
exit={{ scale: 0.9 }}
|
||||
src={previewPhoto.preview || previewPhoto.url}
|
||||
className="max-w-[90vw] max-h-[90vh] object-contain"
|
||||
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>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
|
@ -898,7 +929,9 @@ function AdminAlbumPhotos() {
|
|||
<div className="py-20 text-center">
|
||||
<Image size={48} className="mx-auto text-gray-300 mb-4" />
|
||||
<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
|
||||
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"
|
||||
|
|
@ -947,10 +980,18 @@ function AdminAlbumPhotos() {
|
|||
const val = e.target.value.trim();
|
||||
// 스크롤 위치 저장
|
||||
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);
|
||||
}
|
||||
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(() => {
|
||||
window.scrollTo(0, scrollY);
|
||||
|
|
@ -966,14 +1007,34 @@ function AdminAlbumPhotos() {
|
|||
</div>
|
||||
|
||||
{/* 썸네일 (180px로 확대) */}
|
||||
<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)}
|
||||
/>
|
||||
{file.isVideo ? (
|
||||
<div className="relative w-[180px] h-[180px] flex-shrink-0">
|
||||
<video
|
||||
src={file.preview}
|
||||
className="w-full h-full rounded-lg object-cover cursor-pointer hover:opacity-80 transition-opacity select-none"
|
||||
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">
|
||||
|
|
@ -1412,12 +1473,23 @@ function AdminAlbumPhotos() {
|
|||
);
|
||||
}}
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
{teaser.media_type === 'video' ? (
|
||||
<video
|
||||
src={teaser.original_url}
|
||||
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" />
|
||||
|
||||
|
|
@ -1555,7 +1627,7 @@ function AdminAlbumPhotos() {
|
|||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
accept={photoType === 'teaser' ? 'image/*,video/mp4' : 'image/*'}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue