feat: 앨범 사진 다중 해상도 URL 지원 및 갤러리 UI 개선

- 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 표시 문제 개선
This commit is contained in:
caadiq 2026-01-02 09:38:04 +09:00
parent fd1807f38c
commit 961ca97920
3 changed files with 53 additions and 65 deletions

View file

@ -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,

View file

@ -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) => (
<div
key={index}
onClick={() => 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"
>
<img
src={getThumbUrl(teaser)}
src={teaser.thumb_url}
alt={`Teaser ${index + 1}`}
className="w-full h-full object-cover"
/>
@ -357,11 +346,11 @@ function AlbumDetail() {
{previewPhotos.map((photo, idx) => (
<div
key={photo.id}
onClick={() => 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"
>
<img
src={getThumbUrl(photo.url)}
src={photo.medium_url}
alt={`컨셉 포토 ${idx + 1}`}
className="w-full h-full object-cover"
/>

View file

@ -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() {
<p className="text-gray-500 mt-1">{photos.length}장의 사진</p>
</div>
{/* Justified 갤러리 - react-photo-album */}
{/* CSS 스타일 주입 */}
<style>{galleryStyles}</style>
{/* Justified 갤러리 - 동적 비율 + 호버 */}
<RowsPhotoAlbum
photos={photos.map((photo, idx) => ({
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' }
}
}}
/>