모바일 앨범 갤러리 UI 대폭 개선

- Swiper 라이브러리로 ViewPager 스타일 라이트박스 구현
- LightboxIndicator 컴포넌트에 width prop 추가 (모바일 120px)
- 2열 지그재그 Masonry 그리드 레이아웃
- 바텀시트 정보 표시 (드래그 핸들 지원)
- 뒤로가기 처리 (라이트박스/다이얼로그 닫기)
- 앨범 조회 API: folder_name 또는 title로 검색 (PC/모바일 호환)
This commit is contained in:
caadiq 2026-01-11 23:15:56 +09:00
parent de2e02fcd9
commit d6bc8d79ba
4 changed files with 417 additions and 207 deletions

View file

@ -81,13 +81,14 @@ router.get("/", async (req, res) => {
}
});
// 앨범 folder_name로 조회
// 앨범 folder_name 또는 title로 조회
router.get("/by-name/:name", async (req, res) => {
try {
const folderName = decodeURIComponent(req.params.name);
const name = decodeURIComponent(req.params.name);
// folder_name 또는 title로 검색 (PC는 title, 모바일은 folder_name 사용)
const [albums] = await pool.query(
"SELECT * FROM albums WHERE folder_name = ?",
[folderName]
"SELECT * FROM albums WHERE folder_name = ? OR title = ?",
[name, name]
);
if (albums.length === 0) {

View file

@ -5,11 +5,12 @@ import { memo } from 'react';
* 이미지 갤러리에서 현재 위치를 표시하는 슬라이딩 인디케이터
* CSS transition 사용으로 GPU 가속
*/
const LightboxIndicator = memo(function LightboxIndicator({ count, currentIndex, goToIndex }) {
const translateX = -(currentIndex * 18) + 100 - 6;
const LightboxIndicator = memo(function LightboxIndicator({ count, currentIndex, goToIndex, width = 200 }) {
const halfWidth = width / 2;
const translateX = -(currentIndex * 18) + halfWidth - 6;
return (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 overflow-hidden" style={{ width: '200px' }}>
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 overflow-hidden" style={{ width: `${width}px` }}>
{/* 양옆 페이드 그라데이션 */}
<div className="absolute inset-0 pointer-events-none z-10" style={{
background: 'linear-gradient(to right, rgba(0,0,0,1) 0%, transparent 20%, transparent 80%, rgba(0,0,0,1) 100%)'

View file

@ -1,9 +1,13 @@
import { motion, AnimatePresence } from 'framer-motion';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Play, Calendar, Music2, Clock, X, ChevronLeft, ChevronRight, Download, ChevronDown, ChevronUp, FileText } from 'lucide-react';
import { Play, Calendar, Music2, Clock, X, Download, ChevronDown, ChevronUp, FileText, ChevronRight } from 'lucide-react';
import { Swiper, SwiperSlide } from 'swiper/react';
import { Virtual } from 'swiper/modules';
import 'swiper/css';
import { getAlbumByName } from '../../../api/public/albums';
import { formatDate } from '../../../utils/date';
import LightboxIndicator from '../../../components/common/LightboxIndicator';
//
function MobileAlbumDetail() {
@ -12,33 +16,44 @@ function MobileAlbumDetail() {
const [album, setAlbum] = useState(null);
const [loading, setLoading] = useState(true);
const [lightbox, setLightbox] = useState({ open: false, images: [], index: 0, showNav: true });
const [imageLoaded, setImageLoaded] = useState(false);
const [showAllTracks, setShowAllTracks] = useState(false);
const [showDescriptionModal, setShowDescriptionModal] = useState(false);
const swiperRef = useRef(null);
//
const goToPrev = useCallback(() => {
if (lightbox.images.length <= 1) return;
setImageLoaded(false);
setLightbox(prev => ({
...prev,
index: (prev.index - 1 + prev.images.length) % prev.images.length
}));
}, [lightbox.images.length]);
const goToNext = useCallback(() => {
if (lightbox.images.length <= 1) return;
setImageLoaded(false);
setLightbox(prev => ({
...prev,
index: (prev.index + 1) % prev.images.length
}));
}, [lightbox.images.length]);
// -
const openLightbox = useCallback((images, index, options = {}) => {
setLightbox({ open: true, images, index, showNav: options.showNav !== false, teasers: options.teasers });
window.history.pushState({ lightbox: true }, '');
}, []);
const closeLightbox = useCallback(() => {
setLightbox(prev => ({ ...prev, open: false }));
}, []);
// -
const openDescriptionModal = useCallback(() => {
setShowDescriptionModal(true);
window.history.pushState({ description: true }, '');
}, []);
const closeDescriptionModal = useCallback(() => {
setShowDescriptionModal(false);
}, []);
//
useEffect(() => {
const handlePopState = () => {
if (showDescriptionModal) {
setShowDescriptionModal(false);
} else if (lightbox.open) {
setLightbox(prev => ({ ...prev, open: false }));
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [showDescriptionModal, lightbox.open]);
//
const downloadImage = useCallback(async () => {
const imageUrl = lightbox.images[lightbox.index];
@ -140,12 +155,11 @@ function MobileAlbumDetail() {
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="w-44 h-44 rounded-2xl overflow-hidden shadow-xl mb-4"
onClick={() => setLightbox({
open: true,
images: [album.cover_original_url || album.cover_medium_url],
index: 0,
showNav: false
})}
onClick={() => openLightbox(
[album.cover_original_url || album.cover_medium_url],
0,
{ showNav: false }
)}
>
<img
src={album.cover_medium_url}
@ -185,7 +199,7 @@ function MobileAlbumDetail() {
{/* 앨범 소개 버튼 */}
{album.description && (
<button
onClick={() => setShowDescriptionModal(true)}
onClick={openDescriptionModal}
className="mt-3 flex items-center gap-1.5 mx-auto px-3 py-1.5 text-xs text-gray-500 bg-white/80 rounded-full shadow-sm"
>
<FileText size={12} />
@ -209,13 +223,11 @@ function MobileAlbumDetail() {
{album.teasers.map((teaser, index) => (
<div
key={index}
onClick={() => setLightbox({
open: true,
images: album.teasers.map(t => t.original_url),
onClick={() => openLightbox(
album.teasers.map(t => t.original_url),
index,
teasers: album.teasers,
showNav: true
})}
{ teasers: album.teasers, showNav: true }
)}
className="w-24 h-24 flex-shrink-0 bg-gray-100 rounded-2xl overflow-hidden relative shadow-sm"
>
{teaser.media_type === 'video' ? (
@ -303,12 +315,11 @@ function MobileAlbumDetail() {
{allPhotos.slice(0, 6).map((photo, idx) => (
<div
key={photo.id}
onClick={() => setLightbox({
open: true,
images: [photo.original_url],
index: 0,
showNav: false
})}
onClick={() => openLightbox(
[photo.original_url],
0,
{ showNav: false }
)}
className="aspect-square bg-gray-100 rounded-xl overflow-hidden shadow-sm"
>
<img
@ -340,7 +351,7 @@ function MobileAlbumDetail() {
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/60 z-[60] flex items-end justify-center"
onClick={() => setShowDescriptionModal(false)}
onClick={() => window.history.back()}
>
<motion.div
initial={{ y: '100%' }}
@ -351,8 +362,8 @@ function MobileAlbumDetail() {
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.5 }}
onDragEnd={(_, info) => {
if (info.offset.y > 200 || info.velocity.y > 500) {
setShowDescriptionModal(false);
if (info.offset.y > 100 || info.velocity.y > 300) {
window.history.back();
}
}}
className="bg-white rounded-t-3xl w-full max-h-[80vh] overflow-hidden"
@ -366,7 +377,7 @@ function MobileAlbumDetail() {
<div className="flex items-center justify-between px-5 pb-3 border-b border-gray-100">
<h3 className="text-lg font-bold">앨범 소개</h3>
<button
onClick={() => setShowDescriptionModal(false)}
onClick={() => window.history.back()}
className="p-1.5"
>
<X size={20} className="text-gray-500" />
@ -383,97 +394,78 @@ function MobileAlbumDetail() {
)}
</AnimatePresence>
{/* 라이트박스 */}
{/* 라이트박스 - Swiper ViewPager 스타일 */}
<AnimatePresence>
{lightbox.open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black z-[60] flex items-center justify-center"
onClick={closeLightbox}
className="fixed inset-0 bg-black z-[60] flex flex-col"
>
{/* 상단 버튼 */}
<div className="absolute top-4 right-4 flex gap-3 z-10">
<button
className="text-white/70 p-2"
onClick={(e) => {
e.stopPropagation();
downloadImage();
}}
>
<Download size={22} />
</button>
<button
className="text-white/70 p-2"
onClick={closeLightbox}
>
<X size={24} />
</button>
{/* 상단 헤더 - 3등분 */}
<div className="absolute top-0 left-0 right-0 flex items-center px-4 py-3 z-20">
<div className="flex-1 flex justify-start">
<button onClick={() => window.history.back()} className="text-white/80 p-1">
<X size={24} />
</button>
</div>
{lightbox.showNav && lightbox.images.length > 1 && (
<span className="text-white/70 text-sm tabular-nums">
{lightbox.index + 1} / {lightbox.images.length}
</span>
)}
<div className="flex-1 flex justify-end">
<button onClick={downloadImage} className="text-white/80 p-1">
<Download size={22} />
</button>
</div>
</div>
{/* 이전 버튼 - showNav가 true일 때만 */}
{/* Swiper */}
<Swiper
modules={[Virtual]}
virtual
initialSlide={lightbox.index}
onSwiper={(swiper) => { swiperRef.current = swiper; }}
onSlideChange={(swiper) => setLightbox(prev => ({ ...prev, index: swiper.activeIndex }))}
className="w-full h-full"
spaceBetween={0}
slidesPerView={1}
resistance={true}
resistanceRatio={0.5}
>
{lightbox.images.map((url, index) => (
<SwiperSlide key={index} virtualIndex={index}>
<div className="w-full h-full flex items-center justify-center">
{lightbox.teasers?.[index]?.media_type === 'video' ? (
<video
src={url}
className="max-w-full max-h-full object-contain"
controls
autoPlay={index === lightbox.index}
/>
) : (
<img
src={url}
alt=""
className="max-w-full max-h-full object-contain"
loading={Math.abs(index - lightbox.index) <= 2 ? 'eager' : 'lazy'}
/>
)}
</div>
</SwiperSlide>
))}
</Swiper>
{/* 모바일용 인디케이터 */}
{lightbox.showNav && lightbox.images.length > 1 && (
<button
className="absolute left-2 p-2 text-white/70 z-10"
onClick={(e) => {
e.stopPropagation();
goToPrev();
}}
>
<ChevronLeft size={32} />
</button>
)}
{/* 로딩 스피너 */}
{!imageLoaded && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-white border-t-transparent rounded-full animate-spin" />
</div>
)}
{/* 이미지/비디오 */}
{lightbox.teasers?.[lightbox.index]?.media_type === 'video' ? (
<video
src={lightbox.images[lightbox.index]}
className={`max-w-full max-h-full object-contain transition-opacity ${imageLoaded ? 'opacity-100' : 'opacity-0'}`}
onClick={(e) => e.stopPropagation()}
onCanPlay={() => setImageLoaded(true)}
controls
autoPlay
<LightboxIndicator
count={lightbox.images.length}
currentIndex={lightbox.index}
goToIndex={(i) => swiperRef.current?.slideTo(i)}
width={120}
/>
) : (
<img
src={lightbox.images[lightbox.index]}
alt="확대 이미지"
className={`max-w-full max-h-full object-contain transition-opacity ${imageLoaded ? 'opacity-100' : 'opacity-0'}`}
onClick={(e) => e.stopPropagation()}
onLoad={() => setImageLoaded(true)}
/>
)}
{/* 다음 버튼 - showNav가 true일 때만 */}
{lightbox.showNav && lightbox.images.length > 1 && (
<button
className="absolute right-2 p-2 text-white/70 z-10"
onClick={(e) => {
e.stopPropagation();
goToNext();
}}
>
<ChevronRight size={32} />
</button>
)}
{/* 인디케이터 - showNav가 true일 때만 */}
{lightbox.showNav && lightbox.images.length > 1 && (
<div className="absolute bottom-8 left-0 right-0 flex justify-center">
<div className="bg-black/50 rounded-full px-3 py-1.5">
<span className="text-white text-sm tabular-nums">
{lightbox.index + 1} / {lightbox.images.length}
</span>
</div>
</div>
)}
</motion.div>
)}

View file

@ -1,8 +1,12 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, X, ChevronLeft, ChevronRight } from 'lucide-react';
import { X, Download, ChevronRight, Info, Users, Tag } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { getAlbums, getAlbumPhotos } from '../../../api/public/albums';
import { Swiper, SwiperSlide } from 'swiper/react';
import { Virtual } from 'swiper/modules';
import 'swiper/css';
import { getAlbumByName } from '../../../api/public/albums';
import LightboxIndicator from '../../../components/common/LightboxIndicator';
//
function MobileAlbumGallery() {
@ -12,27 +16,125 @@ function MobileAlbumGallery() {
const [photos, setPhotos] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedIndex, setSelectedIndex] = useState(null);
const [showInfo, setShowInfo] = useState(false);
const swiperRef = useRef(null);
useEffect(() => {
getAlbums()
getAlbumByName(name)
.then(data => {
const found = data.find(a => a.folder_name === name);
if (found) {
setAlbum(found);
getAlbumPhotos(found.id)
.then(setPhotos)
.catch(console.error);
setAlbum(data);
const allPhotos = [];
if (data.conceptPhotos && typeof data.conceptPhotos === 'object') {
Object.entries(data.conceptPhotos).forEach(([concept, photos]) => {
photos.forEach(p => allPhotos.push({
...p,
concept: concept !== 'Default' ? concept : null
}));
});
}
setPhotos(allPhotos);
setLoading(false);
})
.catch(console.error);
.catch(error => {
console.error('앨범 데이터 로드 오류:', error);
setLoading(false);
});
}, [name]);
//
const goToImage = (delta) => {
const newIndex = selectedIndex + delta;
if (newIndex >= 0 && newIndex < photos.length) {
setSelectedIndex(newIndex);
// -
const openLightbox = useCallback((index) => {
setSelectedIndex(index);
window.history.pushState({ lightbox: true }, '');
}, []);
//
const closeLightbox = useCallback(() => {
setSelectedIndex(null);
setShowInfo(false);
}, []);
// -
const openInfo = useCallback(() => {
setShowInfo(true);
window.history.pushState({ infoSheet: true }, '');
}, []);
//
const closeInfo = useCallback(() => {
setShowInfo(false);
}, []);
//
useEffect(() => {
const handlePopState = (e) => {
if (showInfo) {
setShowInfo(false);
} else if (selectedIndex !== null) {
setSelectedIndex(null);
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [showInfo, selectedIndex]);
//
const downloadImage = useCallback(async () => {
const photo = photos[selectedIndex];
if (!photo) return;
try {
const response = await fetch(photo.original_url);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `fromis9_${album?.title || 'photo'}_${String(selectedIndex + 1).padStart(2, '0')}.webp`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('다운로드 오류:', error);
}
}, [photos, selectedIndex, album?.title]);
//
useEffect(() => {
if (selectedIndex !== null) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => { document.body.style.overflow = ''; };
}, [selectedIndex]);
// 2
const distributePhotos = () => {
const leftColumn = [];
const rightColumn = [];
photos.forEach((photo, index) => {
if (index % 2 === 0) {
leftColumn.push({ ...photo, originalIndex: index });
} else {
rightColumn.push({ ...photo, originalIndex: index });
}
});
return { leftColumn, rightColumn };
};
const { leftColumn, rightColumn } = distributePhotos();
//
const currentPhoto = selectedIndex !== null ? photos[selectedIndex] : null;
const hasInfo = currentPhoto?.concept || currentPhoto?.members;
//
const handleInfoDragEnd = (_, info) => {
if (info.offset.y > 100 || info.velocity.y > 300) {
window.history.back();
}
};
@ -45,86 +147,200 @@ function MobileAlbumGallery() {
}
return (
<div className="pb-4">
{/* 헤더 */}
<div className="sticky top-14 z-40 bg-white/80 backdrop-blur-sm px-4 py-3 flex items-center gap-3 border-b">
<button onClick={() => navigate(-1)} className="p-1">
<ArrowLeft size={24} />
</button>
<span className="font-semibold truncate">{album?.title} 갤러리</span>
</div>
{/* 갤러리 그리드 */}
<div className="grid grid-cols-3 gap-0.5 p-0.5">
{photos.map((photo, index) => (
<motion.div
key={photo.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: index * 0.02 }}
onClick={() => setSelectedIndex(index)}
className="aspect-square bg-gray-200 cursor-pointer"
>
<>
<div className="pb-4">
{/* 앨범 헤더 카드 */}
<div
className="mx-4 mt-4 mb-4 p-4 bg-gradient-to-r from-primary/5 to-primary/10 rounded-2xl flex items-center gap-4"
onClick={() => navigate(-1)}
>
{album?.cover_thumb_url && (
<img
src={photo.thumb_url}
alt=""
className="w-full h-full object-cover"
loading="lazy"
src={album.cover_thumb_url}
alt={album.title}
className="w-14 h-14 rounded-xl object-cover shadow-sm"
/>
</motion.div>
))}
)}
<div className="flex-1 min-w-0">
<p className="text-xs text-primary font-medium mb-0.5">컨셉 포토</p>
<p className="font-bold truncate">{album?.title}</p>
<p className="text-xs text-gray-500">{photos.length}장의 사진</p>
</div>
<ChevronRight size={20} className="text-gray-400 rotate-180" />
</div>
{/* 2열 그리드 */}
<div className="px-3 flex gap-2">
<div className="flex-1 flex flex-col gap-2">
{leftColumn.map((photo) => (
<motion.div
key={photo.id || photo.originalIndex}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: Math.min(photo.originalIndex * 0.02, 0.4) }}
onClick={() => openLightbox(photo.originalIndex)}
className="cursor-pointer overflow-hidden rounded-xl bg-gray-100"
>
<img
src={photo.thumb_url || photo.medium_url}
alt=""
className="w-full h-auto object-cover"
loading="lazy"
/>
</motion.div>
))}
</div>
<div className="flex-1 flex flex-col gap-2">
{rightColumn.map((photo) => (
<motion.div
key={photo.id || photo.originalIndex}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: Math.min(photo.originalIndex * 0.02, 0.4) }}
onClick={() => openLightbox(photo.originalIndex)}
className="cursor-pointer overflow-hidden rounded-xl bg-gray-100"
>
<img
src={photo.thumb_url || photo.medium_url}
alt=""
className="w-full h-auto object-cover"
loading="lazy"
/>
</motion.div>
))}
</div>
</div>
</div>
{/* 풀스크린 뷰어 */}
{/* 풀스크린 라이트박스 */}
<AnimatePresence>
{selectedIndex !== null && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black z-50 flex flex-col"
className="fixed inset-0 bg-black z-[60] flex flex-col"
>
{/* 뷰어 헤더 */}
<div className="flex items-center justify-between p-4 text-white">
<button onClick={() => setSelectedIndex(null)}>
<X size={24} />
</button>
<span className="text-sm">
{/* 상단 헤더 - 3등분 */}
<div className="absolute top-0 left-0 right-0 flex items-center px-4 py-3 z-20">
<div className="flex-1 flex justify-start">
<button onClick={() => window.history.back()} className="text-white/80 p-1">
<X size={24} />
</button>
</div>
<span className="text-white/70 text-sm tabular-nums">
{selectedIndex + 1} / {photos.length}
</span>
<div className="w-6" />
<div className="flex-1 flex justify-end items-center gap-2">
{hasInfo && (
<button onClick={openInfo} className="text-white/80 p-1">
<Info size={22} />
</button>
)}
<button onClick={downloadImage} className="text-white/80 p-1">
<Download size={22} />
</button>
</div>
</div>
{/* 이미지 */}
<div className="flex-1 flex items-center justify-center relative">
<img
src={photos[selectedIndex]?.medium_url || photos[selectedIndex]?.original_url}
alt=""
className="max-w-full max-h-full object-contain"
/>
{/* 좌우 네비게이션 */}
{selectedIndex > 0 && (
<button
onClick={() => goToImage(-1)}
className="absolute left-2 p-2 text-white/80"
{/* Swiper */}
<Swiper
modules={[Virtual]}
virtual
initialSlide={selectedIndex}
onSwiper={(swiper) => { swiperRef.current = swiper; }}
onSlideChange={(swiper) => setSelectedIndex(swiper.activeIndex)}
className="w-full h-full"
spaceBetween={0}
slidesPerView={1}
resistance={true}
resistanceRatio={0.5}
>
{photos.map((photo, index) => (
<SwiperSlide key={photo.id || index} virtualIndex={index}>
<div className="w-full h-full flex items-center justify-center">
<img
src={photo.medium_url || photo.original_url}
alt=""
className="max-w-full max-h-full object-contain"
loading={Math.abs(index - selectedIndex) <= 2 ? 'eager' : 'lazy'}
/>
</div>
</SwiperSlide>
))}
</Swiper>
{/* 모바일용 인디케이터 (좁은 width) */}
<LightboxIndicator
count={photos.length}
currentIndex={selectedIndex}
goToIndex={(i) => swiperRef.current?.slideTo(i)}
width={120}
/>
{/* 정보 바텀시트 */}
<AnimatePresence>
{showInfo && hasInfo && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-black/60 z-30"
onClick={() => window.history.back()}
>
<ChevronLeft size={32} />
</button>
<motion.div
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
drag="y"
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.5 }}
onDragEnd={handleInfoDragEnd}
className="absolute bottom-0 left-0 right-0 bg-zinc-900 rounded-t-3xl"
onClick={(e) => e.stopPropagation()}
>
{/* 드래그 핸들 */}
<div className="flex justify-center pt-3 pb-2 cursor-grab active:cursor-grabbing">
<div className="w-10 h-1 bg-zinc-600 rounded-full" />
</div>
{/* 정보 내용 */}
<div className="px-5 pb-8 space-y-4">
<h3 className="text-white font-semibold text-lg">사진 정보</h3>
{currentPhoto?.members && (
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-primary/20 rounded-full flex items-center justify-center flex-shrink-0">
<Users size={16} className="text-primary" />
</div>
<div>
<p className="text-zinc-400 text-xs mb-1">멤버</p>
<p className="text-white">{currentPhoto.members}</p>
</div>
</div>
)}
{currentPhoto?.concept && (
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-white/10 rounded-full flex items-center justify-center flex-shrink-0">
<Tag size={16} className="text-zinc-400" />
</div>
<div>
<p className="text-zinc-400 text-xs mb-1">컨셉</p>
<p className="text-white">{currentPhoto.concept}</p>
</div>
</div>
)}
</div>
</motion.div>
</motion.div>
)}
{selectedIndex < photos.length - 1 && (
<button
onClick={() => goToImage(1)}
className="absolute right-2 p-2 text-white/80"
>
<ChevronRight size={32} />
</button>
)}
</div>
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>
</div>
</>
);
}