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 설정 (메모리 저장)
|
// 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",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue