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; album.tracks = tracks;
// 티저 이미지 조회 // 티저 이미지 조회 (3개 해상도 URL 포함)
const [teasers] = await pool.query( 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.id]
); );
album.teasers = teasers.map((t) => t.image_url); album.teasers = teasers;
// 컨셉 포토 조회 (멤버 정보 포함) // 컨셉 포토 조회 (멤버 정보 + 3개 해상도 URL + 크기 정보 포함)
const [photos] = await pool.query( const [photos] = await pool.query(
`SELECT `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 GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ', ') as members
FROM album_photos p FROM album_photos p
LEFT JOIN album_photo_members pm ON p.id = pm.photo_id LEFT JOIN album_photo_members pm ON p.id = pm.photo_id
@ -42,7 +43,11 @@ async function getAlbumDetails(album) {
} }
conceptPhotos[concept].push({ conceptPhotos[concept].push({
id: photo.id, 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, type: photo.photo_type,
members: photo.members, members: photo.members,
sortOrder: photo.sort_order, sortOrder: photo.sort_order,

View file

@ -110,18 +110,7 @@ function AlbumDetail() {
}); });
}, [name]); }, [name]);
// URL / // URL - API
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('/');
};
// //
const formatDate = (dateStr) => { const formatDate = (dateStr) => {
@ -248,11 +237,11 @@ 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 => 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" 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 <img
src={getThumbUrl(teaser)} src={teaser.thumb_url}
alt={`Teaser ${index + 1}`} alt={`Teaser ${index + 1}`}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
@ -357,11 +346,11 @@ function AlbumDetail() {
{previewPhotos.map((photo, idx) => ( {previewPhotos.map((photo, idx) => (
<div <div
key={photo.id} 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" 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 <img
src={getThumbUrl(photo.url)} src={photo.medium_url}
alt={`컨셉 포토 ${idx + 1}`} alt={`컨셉 포토 ${idx + 1}`}
className="w-full h-full object-cover" 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 { RowsPhotoAlbum } from 'react-photo-album';
import 'react-photo-album/rows.css'; 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() { function AlbumGallery() {
const { name } = useParams(); const { name } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@ -15,57 +35,28 @@ function AlbumGallery() {
const [imageLoaded, setImageLoaded] = useState(false); const [imageLoaded, setImageLoaded] = useState(false);
const [slideDirection, setSlideDirection] = useState(0); 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(() => { useEffect(() => {
fetch(`/api/albums/by-name/${name}`) fetch(`/api/albums/by-name/${name}`)
.then(res => res.json()) .then(res => res.json())
.then(async data => { .then(data => {
setAlbum(data); setAlbum(data);
const allPhotos = []; const allPhotos = [];
if (data.conceptPhotos && typeof data.conceptPhotos === 'object') { if (data.conceptPhotos && typeof data.conceptPhotos === 'object') {
Object.entries(data.conceptPhotos).forEach(([concept, photos]) => { Object.entries(data.conceptPhotos).forEach(([concept, photos]) => {
photos.forEach(p => allPhotos.push({ photos.forEach(p => allPhotos.push({
thumbUrl: getThumbUrl(p.url), // API URL
originalUrl: getOriginalUrl(p.url), mediumUrl: p.medium_url,
originalUrl: p.original_url,
width: p.width || 800,
height: p.height || 1200,
title: concept, title: concept,
members: p.members ? p.members.split(', ') : [] members: p.members ? p.members.split(', ') : []
})); }));
}); });
} }
// dimensions setPhotos(allPhotos);
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);
setLoading(false); setLoading(false);
}) })
.catch(error => { .catch(error => {
@ -201,22 +192,25 @@ function AlbumGallery() {
<p className="text-gray-500 mt-1">{photos.length}장의 사진</p> <p className="text-gray-500 mt-1">{photos.length}장의 사진</p>
</div> </div>
{/* Justified 갤러리 - react-photo-album */} {/* CSS 스타일 주입 */}
<style>{galleryStyles}</style>
{/* Justified 갤러리 - 동적 비율 + 호버 */}
<RowsPhotoAlbum <RowsPhotoAlbum
photos={photos.map((photo, idx) => ({ photos={photos.map((photo, idx) => ({
src: photo.thumbUrl, src: photo.mediumUrl,
width: photo.width || 300, width: photo.width || 800,
height: photo.height || 400, height: photo.height || 1200,
key: idx.toString() key: idx.toString()
}))} }))}
targetRowHeight={300} targetRowHeight={280}
spacing={8} spacing={8}
rowConstraints={{ singleRowMaxHeight: 400, minPhotos: 1 }}
onClick={({ index }) => openLightbox(index)} onClick={({ index }) => openLightbox(index)}
componentsProps={{ componentsProps={{
container: { style: { cursor: 'pointer' } },
image: { image: {
loading: 'lazy', loading: 'lazy',
style: { borderRadius: '8px', transition: 'transform 0.3s' } style: { borderRadius: '12px' }
} }
}} }}
/> />