From 961ca9792045c73bf4f84fbf922cb9c34e218159 Mon Sep 17 00:00:00 2001 From: caadiq Date: Fri, 2 Jan 2026 09:38:04 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=95=A8=EB=B2=94=20=EC=82=AC=EC=A7=84?= =?UTF-8?q?=20=EB=8B=A4=EC=A4=91=20=ED=95=B4=EC=83=81=EB=8F=84=20URL=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=20=EB=B0=8F=20=EA=B0=A4=EB=9F=AC=EB=A6=AC=20?= =?UTF-8?q?UI=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - album_photos, album_teasers 테이블에 original_url, medium_url, thumb_url 컬럼 추가 - API에서 3가지 해상도 URL 및 width/height 반환 - AlbumDetail: 티저는 thumb_url(400), 컨셉포토는 medium_url(800) 사용 - AlbumGallery: 동적 비율 + CSS hover 효과 추가 - react-photo-album rowConstraints로 마지막 row 표시 문제 개선 --- backend/routes/albums.js | 17 ++++-- frontend/src/pages/pc/AlbumDetail.jsx | 21 ++----- frontend/src/pages/pc/AlbumGallery.jsx | 80 ++++++++++++-------------- 3 files changed, 53 insertions(+), 65 deletions(-) diff --git a/backend/routes/albums.js b/backend/routes/albums.js index bbc0bcb..9840652 100644 --- a/backend/routes/albums.js +++ b/backend/routes/albums.js @@ -12,17 +12,18 @@ async function getAlbumDetails(album) { ); album.tracks = tracks; - // 티저 이미지 조회 + // 티저 이미지 조회 (3개 해상도 URL 포함) const [teasers] = await pool.query( - "SELECT image_url FROM album_teasers WHERE album_id = ? ORDER BY sort_order", + "SELECT original_url, medium_url, thumb_url FROM album_teasers WHERE album_id = ? ORDER BY sort_order", [album.id] ); - album.teasers = teasers.map((t) => t.image_url); + album.teasers = teasers; - // 컨셉 포토 조회 (멤버 정보 포함) + // 컨셉 포토 조회 (멤버 정보 + 3개 해상도 URL + 크기 정보 포함) const [photos] = await pool.query( `SELECT - p.id, p.photo_url, p.photo_type, p.concept_name, p.sort_order, + p.id, p.original_url, p.medium_url, p.thumb_url, p.photo_type, p.concept_name, p.sort_order, + p.width, p.height, GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ', ') as members FROM album_photos p LEFT JOIN album_photo_members pm ON p.id = pm.photo_id @@ -42,7 +43,11 @@ async function getAlbumDetails(album) { } conceptPhotos[concept].push({ id: photo.id, - url: photo.photo_url, + original_url: photo.original_url, + medium_url: photo.medium_url, + thumb_url: photo.thumb_url, + width: photo.width, + height: photo.height, type: photo.photo_type, members: photo.members, sortOrder: photo.sort_order, diff --git a/frontend/src/pages/pc/AlbumDetail.jsx b/frontend/src/pages/pc/AlbumDetail.jsx index 6b626e0..39fd0e9 100644 --- a/frontend/src/pages/pc/AlbumDetail.jsx +++ b/frontend/src/pages/pc/AlbumDetail.jsx @@ -110,18 +110,7 @@ function AlbumDetail() { }); }, [name]); - // URL을 썸네일/원본 버전으로 변환하는 헬퍼 - const getThumbUrl = (url) => { - const parts = url.split('/'); - const filename = parts.pop(); - return [...parts, 'thumb_400', filename].join('/'); - }; - - const getOriginalUrl = (url) => { - const parts = url.split('/'); - const filename = parts.pop(); - return [...parts, 'original', filename].join('/'); - }; + // URL 헬퍼 함수는 더 이상 필요 없음 - API에서 직접 제공 // 날짜 포맷팅 const formatDate = (dateStr) => { @@ -248,11 +237,11 @@ function AlbumDetail() { {album.teasers.map((teaser, index) => (
setLightbox({ open: true, images: album.teasers.map(t => getOriginalUrl(t)), 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-200 hover:scale-110 hover:shadow-xl hover:z-10" > {`Teaser @@ -357,11 +346,11 @@ function AlbumDetail() { {previewPhotos.map((photo, idx) => (
setLightbox({ open: true, images: [getOriginalUrl(photo.url)], index: 0 })} + onClick={() => setLightbox({ open: true, images: [photo.original_url], index: 0 })} className="aspect-square bg-gray-200 rounded-xl overflow-hidden cursor-pointer transition-all duration-200 hover:scale-105 hover:shadow-xl hover:z-10" > {`컨셉 diff --git a/frontend/src/pages/pc/AlbumGallery.jsx b/frontend/src/pages/pc/AlbumGallery.jsx index e6bc633..75bff93 100644 --- a/frontend/src/pages/pc/AlbumGallery.jsx +++ b/frontend/src/pages/pc/AlbumGallery.jsx @@ -5,6 +5,26 @@ import { X, ChevronLeft, ChevronRight, Download } from 'lucide-react'; import { RowsPhotoAlbum } from 'react-photo-album'; import 'react-photo-album/rows.css'; +// CSS로 호버 효과 추가 + overflow 문제 수정 +const galleryStyles = ` +.react-photo-album { + overflow: visible !important; +} +.react-photo-album--row { + overflow: visible !important; +} +.react-photo-album--photo { + transition: transform 0.3s ease, filter 0.3s ease !important; + cursor: pointer; + overflow: visible !important; +} +.react-photo-album--photo:hover { + transform: scale(1.05); + filter: brightness(0.9); + z-index: 10; +} +`; + function AlbumGallery() { const { name } = useParams(); const navigate = useNavigate(); @@ -15,57 +35,28 @@ function AlbumGallery() { const [imageLoaded, setImageLoaded] = useState(false); const [slideDirection, setSlideDirection] = useState(0); - // URL을 썸네일/원본 버전으로 변환하는 헬퍼 - const getThumbUrl = (url) => { - // https://s3.../photo/01.webp → https://s3.../photo/thumb_400/01.webp - const parts = url.split('/'); - const filename = parts.pop(); - return [...parts, 'thumb_400', filename].join('/'); - }; - - const getOriginalUrl = (url) => { - const parts = url.split('/'); - const filename = parts.pop(); - return [...parts, 'original', filename].join('/'); - }; - - // 이미지 dimensions 로드 - const loadImageDimensions = (url) => { - return new Promise((resolve) => { - const img = new Image(); - img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight }); - img.onerror = () => resolve({ width: 3, height: 4 }); // 기본 3:4 비율 - img.src = url; - }); - }; - useEffect(() => { fetch(`/api/albums/by-name/${name}`) .then(res => res.json()) - .then(async data => { + .then(data => { setAlbum(data); const allPhotos = []; if (data.conceptPhotos && typeof data.conceptPhotos === 'object') { Object.entries(data.conceptPhotos).forEach(([concept, photos]) => { photos.forEach(p => allPhotos.push({ - thumbUrl: getThumbUrl(p.url), - originalUrl: getOriginalUrl(p.url), + // API에서 직접 제공하는 URL 및 크기 정보 사용 + mediumUrl: p.medium_url, + originalUrl: p.original_url, + width: p.width || 800, + height: p.height || 1200, title: concept, members: p.members ? p.members.split(', ') : [] })); }); } - // 모든 이미지 dimensions 로드 - const photosWithDimensions = await Promise.all( - allPhotos.map(async (photo) => { - const dims = await loadImageDimensions(photo.thumbUrl); - return { ...photo, width: dims.width, height: dims.height }; - }) - ); - - setPhotos(photosWithDimensions); + setPhotos(allPhotos); setLoading(false); }) .catch(error => { @@ -201,22 +192,25 @@ function AlbumGallery() {

{photos.length}장의 사진

- {/* Justified 갤러리 - react-photo-album */} + {/* CSS 스타일 주입 */} + + + {/* Justified 갤러리 - 동적 비율 + 호버 */} ({ - src: photo.thumbUrl, - width: photo.width || 300, - height: photo.height || 400, + src: photo.mediumUrl, + width: photo.width || 800, + height: photo.height || 1200, key: idx.toString() }))} - targetRowHeight={300} + targetRowHeight={280} spacing={8} + rowConstraints={{ singleRowMaxHeight: 400, minPhotos: 1 }} onClick={({ index }) => openLightbox(index)} componentsProps={{ - container: { style: { cursor: 'pointer' } }, image: { loading: 'lazy', - style: { borderRadius: '8px', transition: 'transform 0.3s' } + style: { borderRadius: '12px' } } }} />