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;
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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' }
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue