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:
parent
fd1807f38c
commit
961ca97920
3 changed files with 53 additions and 65 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue