모바일 앨범 갤러리 UI 대폭 개선
- Swiper 라이브러리로 ViewPager 스타일 라이트박스 구현 - LightboxIndicator 컴포넌트에 width prop 추가 (모바일 120px) - 2열 지그재그 Masonry 그리드 레이아웃 - 바텀시트 정보 표시 (드래그 핸들 지원) - 뒤로가기 처리 (라이트박스/다이얼로그 닫기) - 앨범 조회 API: folder_name 또는 title로 검색 (PC/모바일 호환)
This commit is contained in:
parent
de2e02fcd9
commit
d6bc8d79ba
4 changed files with 417 additions and 207 deletions
|
|
@ -81,13 +81,14 @@ router.get("/", async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 앨범 folder_name으로 조회
|
// 앨범 folder_name 또는 title로 조회
|
||||||
router.get("/by-name/:name", async (req, res) => {
|
router.get("/by-name/:name", async (req, res) => {
|
||||||
try {
|
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(
|
const [albums] = await pool.query(
|
||||||
"SELECT * FROM albums WHERE folder_name = ?",
|
"SELECT * FROM albums WHERE folder_name = ? OR title = ?",
|
||||||
[folderName]
|
[name, name]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (albums.length === 0) {
|
if (albums.length === 0) {
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,12 @@ import { memo } from 'react';
|
||||||
* 이미지 갤러리에서 현재 위치를 표시하는 슬라이딩 점 인디케이터
|
* 이미지 갤러리에서 현재 위치를 표시하는 슬라이딩 점 인디케이터
|
||||||
* CSS transition 사용으로 GPU 가속
|
* CSS transition 사용으로 GPU 가속
|
||||||
*/
|
*/
|
||||||
const LightboxIndicator = memo(function LightboxIndicator({ count, currentIndex, goToIndex }) {
|
const LightboxIndicator = memo(function LightboxIndicator({ count, currentIndex, goToIndex, width = 200 }) {
|
||||||
const translateX = -(currentIndex * 18) + 100 - 6;
|
const halfWidth = width / 2;
|
||||||
|
const translateX = -(currentIndex * 18) + halfWidth - 6;
|
||||||
|
|
||||||
return (
|
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={{
|
<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%)'
|
background: 'linear-gradient(to right, rgba(0,0,0,1) 0%, transparent 20%, transparent 80%, rgba(0,0,0,1) 100%)'
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
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 { 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 { getAlbumByName } from '../../../api/public/albums';
|
||||||
import { formatDate } from '../../../utils/date';
|
import { formatDate } from '../../../utils/date';
|
||||||
|
import LightboxIndicator from '../../../components/common/LightboxIndicator';
|
||||||
|
|
||||||
// 모바일 앨범 상세 페이지
|
// 모바일 앨범 상세 페이지
|
||||||
function MobileAlbumDetail() {
|
function MobileAlbumDetail() {
|
||||||
|
|
@ -12,33 +16,44 @@ function MobileAlbumDetail() {
|
||||||
const [album, setAlbum] = useState(null);
|
const [album, setAlbum] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [lightbox, setLightbox] = useState({ open: false, images: [], index: 0, showNav: true });
|
const [lightbox, setLightbox] = useState({ open: false, images: [], index: 0, showNav: true });
|
||||||
const [imageLoaded, setImageLoaded] = useState(false);
|
|
||||||
const [showAllTracks, setShowAllTracks] = useState(false);
|
const [showAllTracks, setShowAllTracks] = useState(false);
|
||||||
const [showDescriptionModal, setShowDescriptionModal] = useState(false);
|
const [showDescriptionModal, setShowDescriptionModal] = useState(false);
|
||||||
|
const swiperRef = useRef(null);
|
||||||
|
|
||||||
// 라이트박스 네비게이션
|
// 라이트박스 열기 - 히스토리 추가
|
||||||
const goToPrev = useCallback(() => {
|
const openLightbox = useCallback((images, index, options = {}) => {
|
||||||
if (lightbox.images.length <= 1) return;
|
setLightbox({ open: true, images, index, showNav: options.showNav !== false, teasers: options.teasers });
|
||||||
setImageLoaded(false);
|
window.history.pushState({ lightbox: true }, '');
|
||||||
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 closeLightbox = useCallback(() => {
|
const closeLightbox = useCallback(() => {
|
||||||
setLightbox(prev => ({ ...prev, open: false }));
|
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 downloadImage = useCallback(async () => {
|
||||||
const imageUrl = lightbox.images[lightbox.index];
|
const imageUrl = lightbox.images[lightbox.index];
|
||||||
|
|
@ -140,12 +155,11 @@ function MobileAlbumDetail() {
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="w-44 h-44 rounded-2xl overflow-hidden shadow-xl mb-4"
|
className="w-44 h-44 rounded-2xl overflow-hidden shadow-xl mb-4"
|
||||||
onClick={() => setLightbox({
|
onClick={() => openLightbox(
|
||||||
open: true,
|
[album.cover_original_url || album.cover_medium_url],
|
||||||
images: [album.cover_original_url || album.cover_medium_url],
|
0,
|
||||||
index: 0,
|
{ showNav: false }
|
||||||
showNav: false
|
)}
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={album.cover_medium_url}
|
src={album.cover_medium_url}
|
||||||
|
|
@ -185,7 +199,7 @@ function MobileAlbumDetail() {
|
||||||
{/* 앨범 소개 버튼 */}
|
{/* 앨범 소개 버튼 */}
|
||||||
{album.description && (
|
{album.description && (
|
||||||
<button
|
<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"
|
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} />
|
<FileText size={12} />
|
||||||
|
|
@ -209,13 +223,11 @@ function MobileAlbumDetail() {
|
||||||
{album.teasers.map((teaser, index) => (
|
{album.teasers.map((teaser, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => setLightbox({
|
onClick={() => openLightbox(
|
||||||
open: true,
|
album.teasers.map(t => t.original_url),
|
||||||
images: album.teasers.map(t => t.original_url),
|
|
||||||
index,
|
index,
|
||||||
teasers: album.teasers,
|
{ teasers: album.teasers, showNav: true }
|
||||||
showNav: true
|
)}
|
||||||
})}
|
|
||||||
className="w-24 h-24 flex-shrink-0 bg-gray-100 rounded-2xl overflow-hidden relative shadow-sm"
|
className="w-24 h-24 flex-shrink-0 bg-gray-100 rounded-2xl overflow-hidden relative shadow-sm"
|
||||||
>
|
>
|
||||||
{teaser.media_type === 'video' ? (
|
{teaser.media_type === 'video' ? (
|
||||||
|
|
@ -303,12 +315,11 @@ function MobileAlbumDetail() {
|
||||||
{allPhotos.slice(0, 6).map((photo, idx) => (
|
{allPhotos.slice(0, 6).map((photo, idx) => (
|
||||||
<div
|
<div
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
onClick={() => setLightbox({
|
onClick={() => openLightbox(
|
||||||
open: true,
|
[photo.original_url],
|
||||||
images: [photo.original_url],
|
0,
|
||||||
index: 0,
|
{ showNav: false }
|
||||||
showNav: false
|
)}
|
||||||
})}
|
|
||||||
className="aspect-square bg-gray-100 rounded-xl overflow-hidden shadow-sm"
|
className="aspect-square bg-gray-100 rounded-xl overflow-hidden shadow-sm"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
|
|
@ -340,7 +351,7 @@ function MobileAlbumDetail() {
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
className="fixed inset-0 bg-black/60 z-[60] flex items-end justify-center"
|
className="fixed inset-0 bg-black/60 z-[60] flex items-end justify-center"
|
||||||
onClick={() => setShowDescriptionModal(false)}
|
onClick={() => window.history.back()}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ y: '100%' }}
|
initial={{ y: '100%' }}
|
||||||
|
|
@ -351,8 +362,8 @@ function MobileAlbumDetail() {
|
||||||
dragConstraints={{ top: 0, bottom: 0 }}
|
dragConstraints={{ top: 0, bottom: 0 }}
|
||||||
dragElastic={{ top: 0, bottom: 0.5 }}
|
dragElastic={{ top: 0, bottom: 0.5 }}
|
||||||
onDragEnd={(_, info) => {
|
onDragEnd={(_, info) => {
|
||||||
if (info.offset.y > 200 || info.velocity.y > 500) {
|
if (info.offset.y > 100 || info.velocity.y > 300) {
|
||||||
setShowDescriptionModal(false);
|
window.history.back();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="bg-white rounded-t-3xl w-full max-h-[80vh] overflow-hidden"
|
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">
|
<div className="flex items-center justify-between px-5 pb-3 border-b border-gray-100">
|
||||||
<h3 className="text-lg font-bold">앨범 소개</h3>
|
<h3 className="text-lg font-bold">앨범 소개</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDescriptionModal(false)}
|
onClick={() => window.history.back()}
|
||||||
className="p-1.5"
|
className="p-1.5"
|
||||||
>
|
>
|
||||||
<X size={20} className="text-gray-500" />
|
<X size={20} className="text-gray-500" />
|
||||||
|
|
@ -383,97 +394,78 @@ function MobileAlbumDetail() {
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* 라이트박스 */}
|
{/* 라이트박스 - Swiper ViewPager 스타일 */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{lightbox.open && (
|
{lightbox.open && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
className="fixed inset-0 bg-black z-[60] flex items-center justify-center"
|
className="fixed inset-0 bg-black z-[60] flex flex-col"
|
||||||
onClick={closeLightbox}
|
|
||||||
>
|
|
||||||
{/* 상단 버튼 */}
|
|
||||||
<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}
|
|
||||||
>
|
>
|
||||||
|
{/* 상단 헤더 - 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} />
|
<X size={24} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 이전 버튼 - showNav가 true일 때만 */}
|
|
||||||
{lightbox.showNav && lightbox.images.length > 1 && (
|
{lightbox.showNav && lightbox.images.length > 1 && (
|
||||||
<button
|
<span className="text-white/70 text-sm tabular-nums">
|
||||||
className="absolute left-2 p-2 text-white/70 z-10"
|
{lightbox.index + 1} / {lightbox.images.length}
|
||||||
onClick={(e) => {
|
</span>
|
||||||
e.stopPropagation();
|
)}
|
||||||
goToPrev();
|
<div className="flex-1 flex justify-end">
|
||||||
}}
|
<button onClick={downloadImage} className="text-white/80 p-1">
|
||||||
>
|
<Download size={22} />
|
||||||
<ChevronLeft size={32} />
|
|
||||||
</button>
|
</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>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* 이미지/비디오 */}
|
{/* Swiper */}
|
||||||
{lightbox.teasers?.[lightbox.index]?.media_type === 'video' ? (
|
<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
|
<video
|
||||||
src={lightbox.images[lightbox.index]}
|
src={url}
|
||||||
className={`max-w-full max-h-full object-contain transition-opacity ${imageLoaded ? 'opacity-100' : 'opacity-0'}`}
|
className="max-w-full max-h-full object-contain"
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onCanPlay={() => setImageLoaded(true)}
|
|
||||||
controls
|
controls
|
||||||
autoPlay
|
autoPlay={index === lightbox.index}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<img
|
||||||
src={lightbox.images[lightbox.index]}
|
src={url}
|
||||||
alt="확대 이미지"
|
alt=""
|
||||||
className={`max-w-full max-h-full object-contain transition-opacity ${imageLoaded ? 'opacity-100' : 'opacity-0'}`}
|
className="max-w-full max-h-full object-contain"
|
||||||
onClick={(e) => e.stopPropagation()}
|
loading={Math.abs(index - lightbox.index) <= 2 ? 'eager' : 'lazy'}
|
||||||
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>
|
</div>
|
||||||
|
</SwiperSlide>
|
||||||
|
))}
|
||||||
|
</Swiper>
|
||||||
|
|
||||||
|
{/* 모바일용 인디케이터 */}
|
||||||
|
{lightbox.showNav && lightbox.images.length > 1 && (
|
||||||
|
<LightboxIndicator
|
||||||
|
count={lightbox.images.length}
|
||||||
|
currentIndex={lightbox.index}
|
||||||
|
goToIndex={(i) => swiperRef.current?.slideTo(i)}
|
||||||
|
width={120}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
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 { 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() {
|
function MobileAlbumGallery() {
|
||||||
|
|
@ -12,27 +16,125 @@ function MobileAlbumGallery() {
|
||||||
const [photos, setPhotos] = useState([]);
|
const [photos, setPhotos] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedIndex, setSelectedIndex] = useState(null);
|
const [selectedIndex, setSelectedIndex] = useState(null);
|
||||||
|
const [showInfo, setShowInfo] = useState(false);
|
||||||
|
const swiperRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getAlbums()
|
getAlbumByName(name)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
const found = data.find(a => a.folder_name === name);
|
setAlbum(data);
|
||||||
if (found) {
|
const allPhotos = [];
|
||||||
setAlbum(found);
|
if (data.conceptPhotos && typeof data.conceptPhotos === 'object') {
|
||||||
getAlbumPhotos(found.id)
|
Object.entries(data.conceptPhotos).forEach(([concept, photos]) => {
|
||||||
.then(setPhotos)
|
photos.forEach(p => allPhotos.push({
|
||||||
.catch(console.error);
|
...p,
|
||||||
|
concept: concept !== 'Default' ? concept : null
|
||||||
|
}));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
setPhotos(allPhotos);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})
|
})
|
||||||
.catch(console.error);
|
.catch(error => {
|
||||||
|
console.error('앨범 데이터 로드 오류:', error);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
}, [name]);
|
}, [name]);
|
||||||
|
|
||||||
// 이미지 네비게이션
|
// 라이트박스 열기 - 히스토리 추가
|
||||||
const goToImage = (delta) => {
|
const openLightbox = useCallback((index) => {
|
||||||
const newIndex = selectedIndex + delta;
|
setSelectedIndex(index);
|
||||||
if (newIndex >= 0 && newIndex < photos.length) {
|
window.history.pushState({ lightbox: true }, '');
|
||||||
setSelectedIndex(newIndex);
|
}, []);
|
||||||
|
|
||||||
|
// 라이트박스 닫기
|
||||||
|
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 (
|
return (
|
||||||
|
<>
|
||||||
<div className="pb-4">
|
<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">
|
<div
|
||||||
<button onClick={() => navigate(-1)} className="p-1">
|
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"
|
||||||
<ArrowLeft size={24} />
|
onClick={() => navigate(-1)}
|
||||||
</button>
|
>
|
||||||
<span className="font-semibold truncate">{album?.title} 갤러리</span>
|
{album?.cover_thumb_url && (
|
||||||
|
<img
|
||||||
|
src={album.cover_thumb_url}
|
||||||
|
alt={album.title}
|
||||||
|
className="w-14 h-14 rounded-xl object-cover shadow-sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* 갤러리 그리드 */}
|
{/* 2열 그리드 */}
|
||||||
<div className="grid grid-cols-3 gap-0.5 p-0.5">
|
<div className="px-3 flex gap-2">
|
||||||
{photos.map((photo, index) => (
|
<div className="flex-1 flex flex-col gap-2">
|
||||||
|
{leftColumn.map((photo) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={photo.id}
|
key={photo.id || photo.originalIndex}
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: index * 0.02 }}
|
transition={{ delay: Math.min(photo.originalIndex * 0.02, 0.4) }}
|
||||||
onClick={() => setSelectedIndex(index)}
|
onClick={() => openLightbox(photo.originalIndex)}
|
||||||
className="aspect-square bg-gray-200 cursor-pointer"
|
className="cursor-pointer overflow-hidden rounded-xl bg-gray-100"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={photo.thumb_url}
|
src={photo.thumb_url || photo.medium_url}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-auto object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</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>
|
<AnimatePresence>
|
||||||
{selectedIndex !== null && (
|
{selectedIndex !== null && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
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"
|
||||||
>
|
>
|
||||||
{/* 뷰어 헤더 */}
|
{/* 상단 헤더 - 3등분 */}
|
||||||
<div className="flex items-center justify-between p-4 text-white">
|
<div className="absolute top-0 left-0 right-0 flex items-center px-4 py-3 z-20">
|
||||||
<button onClick={() => setSelectedIndex(null)}>
|
<div className="flex-1 flex justify-start">
|
||||||
|
<button onClick={() => window.history.back()} className="text-white/80 p-1">
|
||||||
<X size={24} />
|
<X size={24} />
|
||||||
</button>
|
</button>
|
||||||
<span className="text-sm">
|
</div>
|
||||||
|
<span className="text-white/70 text-sm tabular-nums">
|
||||||
{selectedIndex + 1} / {photos.length}
|
{selectedIndex + 1} / {photos.length}
|
||||||
</span>
|
</span>
|
||||||
<div className="w-6" />
|
<div className="flex-1 flex justify-end items-center gap-2">
|
||||||
</div>
|
{hasInfo && (
|
||||||
|
<button onClick={openInfo} className="text-white/80 p-1">
|
||||||
{/* 이미지 */}
|
<Info size={22} />
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<ChevronLeft size={32} />
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{selectedIndex < photos.length - 1 && (
|
<button onClick={downloadImage} className="text-white/80 p-1">
|
||||||
<button
|
<Download size={22} />
|
||||||
onClick={() => goToImage(1)}
|
|
||||||
className="absolute right-2 p-2 text-white/80"
|
|
||||||
>
|
|
||||||
<ChevronRight size={32} />
|
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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()}
|
||||||
|
>
|
||||||
|
<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>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue