From 5610a337c55903ed952085150ff6f41ec5ee4b2b Mon Sep 17 00:00:00 2001
From: caadiq
Date: Sun, 4 Jan 2026 01:38:32 +0900
Subject: [PATCH] =?UTF-8?q?feat:=20=EB=9D=BC=EC=9D=B4=ED=8A=B8=EB=B0=95?=
=?UTF-8?q?=EC=8A=A4=20=EC=9D=B8=EB=94=94=EC=BC=80=EC=9D=B4=ED=84=B0=20?=
=?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=97=85=EB=A1=9C=EB=93=9C=20?=
=?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B0=95=ED=99=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 인디케이터 슬라이딩 애니메이션 개선 (CSS transition으로 GPU 가속)
- React.memo로 인디케이터 분리하여 이미지 로딩 시 리렌더링 방지
- 양옆 페이드 그라데이션 효과 추가
- 업로드 시 시작 번호 자동 계산 (기존 사진 마지막 번호 +1)
- 동영상 썸네일 및 미리보기 지원
- 이미지 프리로딩 범위 확장 (±2개)
- Multer 업로드 제한 50 → 200개로 증가
---
backend/routes/admin.js | 132 +++++++++------
backend/routes/albums.js | 4 +-
frontend/src/pages/pc/AlbumDetail.jsx | 156 +++++++++++++-----
frontend/src/pages/pc/AlbumGallery.jsx | 84 +++++++---
.../src/pages/pc/admin/AdminAlbumPhotos.jsx | 138 ++++++++++++----
5 files changed, 362 insertions(+), 152 deletions(-)
diff --git a/backend/routes/admin.js b/backend/routes/admin.js
index 6a1a60f..526e2a2 100644
--- a/backend/routes/admin.js
+++ b/backend/routes/admin.js
@@ -19,12 +19,13 @@ const JWT_EXPIRES_IN = "30d";
// Multer 설정 (메모리 저장)
const upload = multer({
storage: multer.memoryStorage(),
- limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
+ limits: { fileSize: 50 * 1024 * 1024 }, // 50MB (동영상 지원)
fileFilter: (req, file, cb) => {
- if (file.mimetype.startsWith("image/")) {
+ // 이미지 또는 MP4 비디오 허용
+ if (file.mimetype.startsWith("image/") || file.mimetype === "video/mp4") {
cb(null, true);
} else {
- cb(new Error("이미지 파일만 업로드 가능합니다."), false);
+ cb(new Error("이미지 또는 MP4 파일만 업로드 가능합니다."), false);
}
},
});
@@ -471,7 +472,7 @@ router.get("/albums/:albumId/teasers", async (req, res) => {
// 티저 조회
const [teasers] = await pool.query(
- `SELECT id, original_url, medium_url, thumb_url, sort_order
+ `SELECT id, original_url, medium_url, thumb_url, sort_order, media_type
FROM album_teasers
WHERE album_id = ?
ORDER BY sort_order ASC`,
@@ -548,7 +549,7 @@ router.delete(
router.post(
"/albums/:albumId/photos",
authenticateToken,
- upload.array("photos", 50),
+ upload.array("photos", 200),
async (req, res) => {
// SSE 헤더 설정
res.setHeader("Content-Type", "text/event-stream");
@@ -598,14 +599,39 @@ router.post(
const file = req.files[i];
const meta = metadata[i] || {};
const orderNum = String(nextOrder + i).padStart(2, "0");
- const filename = `${orderNum}.webp`;
+ const isVideo = file.mimetype === "video/mp4";
+ const extension = isVideo ? "mp4" : "webp";
+ const filename = `${orderNum}.${extension}`;
// 진행률 전송
sendProgress(i + 1, totalFiles, `${filename} 처리 중...`);
- // Sharp로 이미지 처리 (병렬)
- const [originalBuffer, medium800Buffer, thumb400Buffer] =
- await Promise.all([
+ let originalUrl, mediumUrl, thumbUrl;
+ let originalBuffer, originalMeta;
+
+ // 컨셉 포토: photo/, 티저: teaser/
+ const subFolder = photoType === "teaser" ? "teaser" : "photo";
+ const basePath = `album/${folderName}/${subFolder}`;
+
+ if (isVideo) {
+ // ===== 비디오 파일 처리 (티저 전용) =====
+ // 원본 MP4만 업로드 (리사이즈 없음)
+ await s3Client.send(
+ new PutObjectCommand({
+ Bucket: BUCKET,
+ Key: `${basePath}/original/${filename}`,
+ Body: file.buffer,
+ ContentType: "video/mp4",
+ })
+ );
+
+ originalUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/original/${filename}`;
+ mediumUrl = originalUrl; // 비디오는 원본만 사용
+ thumbUrl = originalUrl;
+ } else {
+ // ===== 이미지 파일 처리 =====
+ // Sharp로 이미지 처리 (병렬)
+ const [origBuf, medium800Buffer, thumb400Buffer] = await Promise.all([
sharp(file.buffer).webp({ lossless: true }).toBuffer(),
sharp(file.buffer)
.resize(800, null, { withoutEnlargement: true })
@@ -617,59 +643,64 @@ router.post(
.toBuffer(),
]);
- const originalMeta = await sharp(originalBuffer).metadata();
+ originalBuffer = origBuf;
+ originalMeta = await sharp(originalBuffer).metadata();
- // RustFS 업로드 (병렬)
- // 컨셉 포토: photo/, 티저 이미지: teaser/
- const subFolder = photoType === "teaser" ? "teaser" : "photo";
- const basePath = `album/${folderName}/${subFolder}`;
+ // RustFS 업로드 (병렬)
+ await Promise.all([
+ s3Client.send(
+ new PutObjectCommand({
+ Bucket: BUCKET,
+ Key: `${basePath}/original/${filename}`,
+ Body: originalBuffer,
+ ContentType: "image/webp",
+ })
+ ),
+ s3Client.send(
+ new PutObjectCommand({
+ Bucket: BUCKET,
+ Key: `${basePath}/medium_800/${filename}`,
+ Body: medium800Buffer,
+ ContentType: "image/webp",
+ })
+ ),
+ s3Client.send(
+ new PutObjectCommand({
+ Bucket: BUCKET,
+ Key: `${basePath}/thumb_400/${filename}`,
+ Body: thumb400Buffer,
+ ContentType: "image/webp",
+ })
+ ),
+ ]);
- await Promise.all([
- s3Client.send(
- new PutObjectCommand({
- Bucket: BUCKET,
- Key: `${basePath}/original/${filename}`,
- Body: originalBuffer,
- ContentType: "image/webp",
- })
- ),
- s3Client.send(
- new PutObjectCommand({
- Bucket: BUCKET,
- Key: `${basePath}/medium_800/${filename}`,
- Body: medium800Buffer,
- ContentType: "image/webp",
- })
- ),
- s3Client.send(
- new PutObjectCommand({
- Bucket: BUCKET,
- Key: `${basePath}/thumb_400/${filename}`,
- Body: thumb400Buffer,
- ContentType: "image/webp",
- })
- ),
- ]);
-
- // 3개 해상도별 URL 생성
- const originalUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/original/${filename}`;
- const mediumUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/medium_800/${filename}`;
- const thumbUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/thumb_400/${filename}`;
+ originalUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/original/${filename}`;
+ mediumUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/medium_800/${filename}`;
+ thumbUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/thumb_400/${filename}`;
+ }
let photoId;
// DB 저장 - 티저와 컨셉 포토 분기
if (photoType === "teaser") {
- // 티저 이미지 → album_teasers 테이블
+ // 티저 이미지/비디오 → album_teasers 테이블
+ const mediaType = isVideo ? "video" : "image";
const [result] = await connection.query(
`INSERT INTO album_teasers
- (album_id, original_url, medium_url, thumb_url, sort_order)
- VALUES (?, ?, ?, ?, ?)`,
- [albumId, originalUrl, mediumUrl, thumbUrl, nextOrder + i]
+ (album_id, original_url, medium_url, thumb_url, sort_order, media_type)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ [
+ albumId,
+ originalUrl,
+ mediumUrl,
+ thumbUrl,
+ nextOrder + i,
+ mediaType,
+ ]
);
photoId = result.insertId;
} else {
- // 컨셉 포토 → album_photos 테이블
+ // 컨셉 포토 → album_photos 테이블 (이미지만)
const [result] = await connection.query(
`INSERT INTO album_photos
(album_id, original_url, medium_url, thumb_url, photo_type, concept_name, sort_order, width, height, file_size)
@@ -706,6 +737,7 @@ router.post(
medium_url: mediumUrl,
thumb_url: thumbUrl,
filename,
+ media_type: isVideo ? "video" : "image",
});
}
diff --git a/backend/routes/albums.js b/backend/routes/albums.js
index 9840652..669de07 100644
--- a/backend/routes/albums.js
+++ b/backend/routes/albums.js
@@ -12,9 +12,9 @@ async function getAlbumDetails(album) {
);
album.tracks = tracks;
- // 티저 이미지 조회 (3개 해상도 URL 포함)
+ // 티저 이미지/비디오 조회 (3개 해상도 URL + media_type 포함)
const [teasers] = await pool.query(
- "SELECT original_url, medium_url, thumb_url FROM album_teasers WHERE album_id = ? ORDER BY sort_order",
+ "SELECT original_url, medium_url, thumb_url, media_type FROM album_teasers WHERE album_id = ? ORDER BY sort_order",
[album.id]
);
album.teasers = teasers;
diff --git a/frontend/src/pages/pc/AlbumDetail.jsx b/frontend/src/pages/pc/AlbumDetail.jsx
index 8840458..9482afb 100644
--- a/frontend/src/pages/pc/AlbumDetail.jsx
+++ b/frontend/src/pages/pc/AlbumDetail.jsx
@@ -1,8 +1,42 @@
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect, useCallback, memo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { Calendar, Music2, Clock, X, ChevronLeft, ChevronRight, Download, MoreVertical, FileText } from 'lucide-react';
+// 인디케이터 컴포넌트 - CSS transition 사용으로 JS 블로킹에 영향받지 않음
+const LightboxIndicator = memo(function LightboxIndicator({ count, currentIndex, setLightbox }) {
+ const translateX = -(currentIndex * 18) + 100 - 6;
+
+ return (
+
+ {/* 양옆 페이드 그라데이션 */}
+
+ {/* 슬라이딩 컨테이너 - CSS transition으로 GPU 가속 */}
+
+ {Array.from({ length: count }).map((_, i) => (
+ setLightbox(prev => ({ ...prev, index: i }))}
+ />
+ ))}
+
+
+ );
+});
function AlbumDetail() {
const { name } = useParams();
const navigate = useNavigate();
@@ -11,6 +45,7 @@ function AlbumDetail() {
const [lightbox, setLightbox] = useState({ open: false, images: [], index: 0 });
const [slideDirection, setSlideDirection] = useState(0);
const [imageLoaded, setImageLoaded] = useState(false);
+ const [preloadedImages] = useState(() => new Set()); // 프리로드된 이미지 URL 추적
const [showDescriptionModal, setShowDescriptionModal] = useState(false);
const [showMenu, setShowMenu] = useState(false);
@@ -99,20 +134,27 @@ function AlbumDetail() {
return () => window.removeEventListener('keydown', handleKeyDown);
}, [lightbox.open, goToPrev, goToNext, closeLightbox]);
- // 이미지 프리로딩 (이전/다음 이미지)
+ // 이미지 프리로딩 (이전/다음 2개씩) - 백그라운드에서 프리로드만
useEffect(() => {
if (!lightbox.open || lightbox.images.length <= 1) return;
- const preloadImages = [];
- const prevIdx = (lightbox.index - 1 + lightbox.images.length) % lightbox.images.length;
- const nextIdx = (lightbox.index + 1) % lightbox.images.length;
+ // 양옆 이미지 프리로드
+ const indicesToPreload = [];
+ for (let offset = -2; offset <= 2; offset++) {
+ if (offset === 0) continue;
+ const idx = (lightbox.index + offset + lightbox.images.length) % lightbox.images.length;
+ indicesToPreload.push(idx);
+ }
- [prevIdx, nextIdx].forEach(idx => {
+ indicesToPreload.forEach(idx => {
+ const url = lightbox.images[idx];
+ if (preloadedImages.has(url)) return;
+
const img = new Image();
- img.src = lightbox.images[idx];
- preloadImages.push(img);
+ img.onload = () => preloadedImages.add(url);
+ img.src = url;
});
- }, [lightbox.open, lightbox.index, lightbox.images]);
+ }, [lightbox.open, lightbox.index, lightbox.images, preloadedImages]);
useEffect(() => {
fetch(`/api/albums/by-name/${name}`)
@@ -287,7 +329,7 @@ function AlbumDetail() {
- {/* 앨범 티저 이미지 */}
+ {/* 앨범 티저 이미지/영상 */}
{album.teasers && album.teasers.length > 0 && (
티저 포토
@@ -295,14 +337,35 @@ function AlbumDetail() {
{album.teasers.map((teaser, index) => (
setLightbox({ open: true, images: album.teasers.map(t => t.original_url), index })}
- className="w-24 h-24 bg-gray-200 rounded-lg overflow-hidden cursor-pointer transition-all duration-300 ease-out hover:scale-105 hover:shadow-xl hover:z-10"
+ onClick={() => setLightbox({
+ open: true,
+ images: album.teasers.map(t => t.original_url),
+ index,
+ teasers: album.teasers // media_type 정보 전달
+ })}
+ className="w-24 h-24 bg-gray-200 rounded-lg overflow-hidden cursor-pointer transition-all duration-300 ease-out hover:scale-105 hover:shadow-xl hover:z-10 relative"
>
-
+ {teaser.media_type === 'video' ? (
+ <>
+
+ {/* 비디오 아이콘 오버레이 */}
+
+ >
+ ) : (
+
+ )}
))}
@@ -456,19 +519,34 @@ function AlbumDetail() {
)}
- {/* 이미지 */}
+ {/* 이미지 또는 비디오 */}
- e.stopPropagation()}
- onLoad={() => setImageLoaded(true)}
- initial={{ x: slideDirection * 100 }}
- animate={{ x: 0 }}
- transition={{ duration: 0.25, ease: 'easeOut' }}
- />
+ {lightbox.teasers?.[lightbox.index]?.media_type === 'video' ? (
+ e.stopPropagation()}
+ onCanPlay={() => setImageLoaded(true)}
+ initial={{ x: slideDirection * 100 }}
+ animate={{ x: 0 }}
+ transition={{ duration: 0.25, ease: 'easeOut' }}
+ controls
+ autoPlay
+ />
+ ) : (
+ e.stopPropagation()}
+ onLoad={() => setImageLoaded(true)}
+ initial={{ x: slideDirection * 100 }}
+ animate={{ x: 0 }}
+ transition={{ duration: 0.25, ease: 'easeOut' }}
+ />
+ )}
{/* 다음 버튼 */}
@@ -484,21 +562,13 @@ function AlbumDetail() {
)}
- {/* 인디케이터 */}
+ {/* 인디케이터 - memo 컴포넌트로 분리 */}
{lightbox.images.length > 1 && (
-
- {lightbox.images.map((_, i) => (
- {
- e.stopPropagation();
- setImageLoaded(false);
- setLightbox({ ...lightbox, index: i });
- }}
- />
- ))}
-
+
)}
diff --git a/frontend/src/pages/pc/AlbumGallery.jsx b/frontend/src/pages/pc/AlbumGallery.jsx
index fa3c083..f53c081 100644
--- a/frontend/src/pages/pc/AlbumGallery.jsx
+++ b/frontend/src/pages/pc/AlbumGallery.jsx
@@ -1,10 +1,45 @@
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect, useCallback, memo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { X, ChevronLeft, ChevronRight, Download } from 'lucide-react';
import { RowsPhotoAlbum } from 'react-photo-album';
import 'react-photo-album/rows.css';
+// 인디케이터 컴포넌트 - CSS transition 사용으로 JS 블로킹에 영향받지 않음
+const LightboxIndicator = memo(function LightboxIndicator({ count, currentIndex, setLightbox }) {
+ const translateX = -(currentIndex * 18) + 100 - 6;
+
+ return (
+
+ {/* 양옆 페이드 그라데이션 */}
+
+ {/* 슬라이딩 컨테이너 - CSS transition으로 GPU 가속 */}
+
+ {Array.from({ length: count }).map((_, i) => (
+ setLightbox(prev => ({ ...prev, index: i }))}
+ />
+ ))}
+
+
+ );
+});
+
// CSS로 호버 효과 추가 + overflow 문제 수정 + 로드 애니메이션
const galleryStyles = `
@keyframes fadeInUp {
@@ -57,6 +92,7 @@ function AlbumGallery() {
const [lightbox, setLightbox] = useState({ open: false, index: 0 });
const [imageLoaded, setImageLoaded] = useState(false);
const [slideDirection, setSlideDirection] = useState(0);
+ const [preloadedImages] = useState(() => new Set()); // 프리로드된 이미지 URL 추적
useEffect(() => {
fetch(`/api/albums/by-name/${name}`)
@@ -173,18 +209,27 @@ function AlbumGallery() {
return () => window.removeEventListener('keydown', handleKeyDown);
}, [lightbox.open, goToPrev, goToNext, closeLightbox]);
- // 프리로딩
+ // 프리로딩 (이전/다음 2개씩) - 백그라운드에서 프리로드만
useEffect(() => {
if (!lightbox.open || photos.length <= 1) return;
- const prevIdx = (lightbox.index - 1 + photos.length) % photos.length;
- const nextIdx = (lightbox.index + 1) % photos.length;
+ // 양옆 이미지 프리로드
+ const indicesToPreload = [];
+ for (let offset = -2; offset <= 2; offset++) {
+ if (offset === 0) continue;
+ const idx = (lightbox.index + offset + photos.length) % photos.length;
+ indicesToPreload.push(idx);
+ }
- [prevIdx, nextIdx].forEach(idx => {
+ indicesToPreload.forEach(idx => {
+ const url = photos[idx].originalUrl;
+ if (preloadedImages.has(url)) return;
+
const img = new Image();
- img.src = photos[idx].originalUrl;
+ img.onload = () => preloadedImages.add(url);
+ img.src = url;
});
- }, [lightbox.open, lightbox.index, photos]);
+ }, [lightbox.open, lightbox.index, photos, preloadedImages]);
if (loading) {
return (
@@ -305,7 +350,7 @@ function AlbumGallery() {
)}
- {/* 이미지 + 컨셉 정보 - 양옆 margin으로 화살표와 간격 */}
+ {/* 이미지 + 컨셉 정보 */}
- {/* 컨셉 정보 + 멤버 - 하나라도 있으면 표시 */}
+ {/* 컨셉 정보 + 멤버 */}
{imageLoaded && (
(() => {
const title = photos[lightbox.index]?.title;
@@ -329,13 +374,11 @@ function AlbumGallery() {
return (
- {/* 컨셉명 - 있고 유효할 때만 */}
{hasValidTitle && (
{title}
)}
- {/* 멤버 - 있으면 항상 표시 */}
{hasMembers && (
{String(members).split(',').map((member, idx) => (
@@ -361,19 +404,12 @@ function AlbumGallery() {
)}
- {/* 하단 점 인디케이터 - 한 줄 고정, 스크롤바 숨김 */}
-
- {photos.map((_, i) => (
- {
- setImageLoaded(false);
- setLightbox({ ...lightbox, index: i });
- }}
- />
- ))}
-
+ {/* 하단 점 인디케이터 - memo 컴포넌트로 분리 */}
+
)}
diff --git a/frontend/src/pages/pc/admin/AdminAlbumPhotos.jsx b/frontend/src/pages/pc/admin/AdminAlbumPhotos.jsx
index fb9d16c..d97e67a 100644
--- a/frontend/src/pages/pc/admin/AdminAlbumPhotos.jsx
+++ b/frontend/src/pages/pc/admin/AdminAlbumPhotos.jsx
@@ -178,8 +178,9 @@ function AdminAlbumPhotos() {
const photosRes = await fetch(`/api/admin/albums/${albumId}/photos`, {
headers: { 'Authorization': `Bearer ${token}` }
});
+ let photosData = [];
if (photosRes.ok) {
- const photosData = await photosRes.json();
+ photosData = await photosRes.json();
setPhotos(photosData);
}
@@ -187,10 +188,21 @@ function AdminAlbumPhotos() {
const teasersRes = await fetch(`/api/admin/albums/${albumId}/teasers`, {
headers: { 'Authorization': `Bearer ${token}` }
});
+ let teasersData = [];
if (teasersRes.ok) {
- const teasersData = await teasersRes.json();
+ teasersData = await teasersRes.json();
setTeasers(teasersData);
}
+
+ // 시작 번호 자동 설정 (컨셉 포토와 티저 중 더 큰 값 + 1)
+ const maxPhotoOrder = photosData.length > 0
+ ? Math.max(...photosData.map(p => p.sort_order || 0))
+ : 0;
+ const maxTeaserOrder = teasersData.length > 0
+ ? Math.max(...teasersData.map(t => t.sort_order || 0))
+ : 0;
+ const nextStartNumber = Math.max(maxPhotoOrder, maxTeaserOrder) + 1;
+ setStartNumber(nextStartNumber);
setLoading(false);
} catch (error) {
@@ -281,6 +293,7 @@ function AdminAlbumPhotos() {
file,
preview: URL.createObjectURL(file),
filename: file.name,
+ isVideo: file.type === 'video/mp4', // 비디오 여부
groupType: 'group', // 'group' | 'solo' | 'unit'
members: [], // 태깅된 멤버들 (단체일 경우 빈 배열)
conceptName: '', // 개별 컨셉명
@@ -293,13 +306,15 @@ function AdminAlbumPhotos() {
setPendingFiles(newOrder);
};
- // 직접 순서 변경 (입력으로)
+ // 직접 순서 변경 (입력으로) - 입력값은 startNumber 기준
const moveToPosition = (fileId, newPosition) => {
const pos = parseInt(newPosition, 10);
- if (isNaN(pos) || pos < 1) return;
+ // 시작 번호보다 작거나 유효하지 않으면 무시
+ if (isNaN(pos) || pos < startNumber) return;
setPendingFiles(prev => {
- const targetIndex = Math.min(pos - 1, prev.length - 1);
+ // 입력값에서 startNumber를 빼서 배열 인덱스 계산
+ const targetIndex = Math.min(pos - startNumber, prev.length - 1);
const currentIndex = prev.findIndex(f => f.id === fileId);
if (currentIndex === -1 || currentIndex === targetIndex) return prev;
@@ -448,13 +463,16 @@ function AdminAlbumPhotos() {
if (result) {
setToast({ message: result.message, type: 'success' });
+
+ // 다음 업로드를 위해 시작 번호를 마지막 파일 + 1로 설정
+ const nextStartNumber = startNumber + pendingFiles.length;
+ setStartNumber(nextStartNumber);
}
// 미리보기 URL 해제
pendingFiles.forEach(f => URL.revokeObjectURL(f.preview));
setPendingFiles([]);
setConceptName('');
- setStartNumber(1); // 초기화
// 사진 목록 다시 로드
fetchAlbumData();
@@ -609,15 +627,28 @@ function AdminAlbumPhotos() {
>
-
e.stopPropagation()}
- />
+ {previewPhoto.isVideo ? (
+ e.stopPropagation()}
+ controls
+ autoPlay
+ />
+ ) : (
+ e.stopPropagation()}
+ />
+ )}
)}
@@ -898,7 +929,9 @@ function AdminAlbumPhotos() {
사진을 드래그하여 업로드하세요
-
JPG, PNG, WebP 지원
+
+ {photoType === 'teaser' ? 'JPG, PNG, WebP, MP4 지원' : 'JPG, PNG, WebP 지원'}
+
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() {
{/* 썸네일 (180px로 확대) */}
- setPreviewPhoto(file)}
- />
+ {file.isVideo ? (
+
+
setPreviewPhoto(file)}
+ muted
+ />
+ {/* 재생 버튼 오버레이 */}
+ setPreviewPhoto(file)}
+ >
+
+
+
+ ) : (
+ setPreviewPhoto(file)}
+ />
+ )}
{/* 메타 정보 - 고정 높이 */}
@@ -1412,12 +1473,23 @@ function AdminAlbumPhotos() {
);
}}
>
-
+ {teaser.media_type === 'video' ? (
+
e.target.play()}
+ onMouseLeave={e => { e.target.pause(); e.target.currentTime = 0; }}
+ />
+ ) : (
+
+ )}
{/* 호버 시 반투명 오버레이 */}
@@ -1555,7 +1627,7 @@ function AdminAlbumPhotos() {
ref={fileInputRef}
type="file"
multiple
- accept="image/*"
+ accept={photoType === 'teaser' ? 'image/*,video/mp4' : 'image/*'}
onChange={handleFileSelect}
className="hidden"
/>