diff --git a/src/pages/travel/AlbumDetail.css b/src/pages/travel/AlbumDetail.css new file mode 100644 index 0000000..be3dca3 --- /dev/null +++ b/src/pages/travel/AlbumDetail.css @@ -0,0 +1,183 @@ +/* ───────────────────────────────────────────── + AlbumDetail — fixed overlay +───────────────────────────────────────────── */ +.album-detail { + position: fixed; + inset: 0; + z-index: 2000; + background: var(--tv-bg, #0f0c09); + display: flex; + flex-direction: column; + opacity: 0; + transform: scale(0.95); + transition: opacity 400ms cubic-bezier(0.4, 0, 0.2, 1), + transform 400ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.album-detail--open { + opacity: 1; + transform: scale(1); +} + +.album-detail--exit { + opacity: 0; + transform: scale(0.95); +} + +/* ── Header ── */ +.album-detail__header { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 20px; + border-bottom: 1px solid var(--tv-line, rgba(232, 221, 208, 0.1)); + flex-shrink: 0; +} + +.album-detail__back { + width: 36px; + height: 36px; + border-radius: 50%; + border: 1px solid var(--tv-line-bright, rgba(232, 221, 208, 0.22)); + background: transparent; + color: var(--tv-text, #e8ddd0); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + transition: background 200ms, border-color 200ms; +} + +.album-detail__back:hover { + background: var(--tv-line, rgba(232, 221, 208, 0.1)); + border-color: var(--tv-muted, rgba(232, 221, 208, 0.45)); +} + +.album-detail__title-group { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} + +.album-detail__name { + font-family: var(--tv-serif, Georgia, 'Times New Roman', serif); + font-size: 22px; + font-weight: 400; + color: var(--tv-text, #e8ddd0); + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.album-detail__region { + font-family: var(--tv-mono, 'SF Mono', 'Fira Code', monospace); + font-size: 9px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--tv-muted, rgba(232, 221, 208, 0.45)); + padding: 2px 6px; + border-radius: 3px; + background: var(--tv-line, rgba(232, 221, 208, 0.1)); + width: fit-content; +} + +.album-detail__count { + font-family: var(--tv-mono, 'SF Mono', 'Fira Code', monospace); + font-size: 11px; + color: var(--tv-muted, rgba(232, 221, 208, 0.45)); + white-space: nowrap; + flex-shrink: 0; +} + +/* ── Body ── */ +.album-detail__body { + flex: 1; + overflow-y: auto; + min-height: 0; + padding-bottom: env(safe-area-inset-bottom, 0); +} + +/* ── Loading dots ── */ +.album-detail__loading { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 80px 0; +} + +.album-detail__dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--tv-muted, rgba(232, 221, 208, 0.45)); + animation: albumDetailPulse 1.2s ease-in-out infinite; +} + +.album-detail__dot:nth-child(2) { + animation-delay: 0.2s; +} + +.album-detail__dot:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes albumDetailPulse { + 0%, 100% { opacity: 0.3; transform: scale(0.8); } + 50% { opacity: 1; transform: scale(1); } +} + +/* ── Error / Empty ── */ +.album-detail__error, +.album-detail__empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 80px 24px; + text-align: center; + gap: 12px; +} + +.album-detail__error-text { + font-family: var(--tv-mono, 'SF Mono', 'Fira Code', monospace); + font-size: 12px; + color: #c85a4a; +} + +.album-detail__empty-text { + font-family: var(--tv-mono, 'SF Mono', 'Fira Code', monospace); + font-size: 12px; + color: var(--tv-dim, rgba(232, 221, 208, 0.25)); +} + +/* ── Mobile ── */ +@media (max-width: 768px) { + .album-detail__header { + padding: 12px 16px; + } + + .album-detail__name { + font-size: 18px; + } + + .album-detail__body { + padding-bottom: calc(64px + env(safe-area-inset-bottom, 0)); + } +} + +/* ── Reduced motion ── */ +@media (prefers-reduced-motion: reduce) { + .album-detail { + transition: none; + } + + .album-detail__dot { + animation: none; + opacity: 0.6; + } +} diff --git a/src/pages/travel/AlbumDetail.jsx b/src/pages/travel/AlbumDetail.jsx new file mode 100644 index 0000000..18975a0 --- /dev/null +++ b/src/pages/travel/AlbumDetail.jsx @@ -0,0 +1,216 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import SwipeableView from '../../components/SwipeableView'; +import PullToRefresh from '../../components/PullToRefresh'; +import MasonryGrid from './MasonryGrid'; +import HeroLightbox from './HeroLightbox'; +import VideoTab from './VideoTab'; +import { getRegionAccent } from './MiniMap'; +import { useIsMobile } from '../../hooks/useIsMobile'; +import './AlbumDetail.css'; + +/* ───────────────────────────────────────────── + AlbumDetail — full-screen album overlay +───────────────────────────────────────────── */ +const ANIM_MS = 400; + +const prefersReduced = () => + typeof window !== 'undefined' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; + +export default function AlbumDetail({ + album, + sourceRect, + photos, + photoSummary, + loading, + loadingMore, + hasNext, + error, + onClose, + onLoadMore, + onReload, +}) { + const isMobile = useIsMobile(); + + /* ── Animation phases: enter → open → exit ── */ + const [phase, setPhase] = useState('enter'); + const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(null); + const [lightboxRect, setLightboxRect] = useState(null); + const closingRef = useRef(false); + + // Enter → open + useEffect(() => { + if (prefersReduced()) { + setPhase('open'); + return; + } + const raf = requestAnimationFrame(() => { + requestAnimationFrame(() => setPhase('open')); + }); + return () => cancelAnimationFrame(raf); + }, []); + + /* ── Body scroll lock (only when lightbox NOT open) ── */ + useEffect(() => { + if (selectedPhotoIndex != null) return; // lightbox handles its own + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { document.body.style.overflow = prev; }; + }, [selectedPhotoIndex]); + + /* ── ESC key (close album when lightbox not open) ── */ + useEffect(() => { + const handler = (e) => { + if (e.key === 'Escape' && selectedPhotoIndex == null) { + handleClose(); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [selectedPhotoIndex]); // eslint-disable-line react-hooks/exhaustive-deps + + /* ── Close with exit animation ── */ + const handleClose = useCallback(() => { + if (closingRef.current) return; + closingRef.current = true; + if (prefersReduced()) { + onClose(); + return; + } + setPhase('exit'); + setTimeout(() => onClose(), ANIM_MS); + }, [onClose]); + + /* ── Photo selection → open lightbox ── */ + const handleSelectPhoto = useCallback((e, index) => { + const el = e?.currentTarget || e?.target; + const rect = el ? el.getBoundingClientRect() : null; + setLightboxRect(rect); + setSelectedPhotoIndex(index); + }, []); + + const handleLightboxClose = useCallback(() => { + setSelectedPhotoIndex(null); + setLightboxRect(null); + }, []); + + const handleLightboxNavigate = useCallback((idx) => { + setSelectedPhotoIndex(idx); + }, []); + + /* ── Derived ── */ + const regionAccent = getRegionAccent(album?.region || album?.id || ''); + const photoCountLabel = photoSummary?.total + ? `${photoSummary.total} photos` + : photos?.length + ? `${photos.length}${hasNext ? '+' : ''}` + : ''; + + /* ── Phase → class ── */ + const cls = [ + 'album-detail', + phase === 'open' && 'album-detail--open', + phase === 'exit' && 'album-detail--exit', + ].filter(Boolean).join(' '); + + /* ── Tab content: Photos ── */ + const photosContent = ( +
+ {loading ? ( +
+ + + +
+ ) : error ? ( +
+ {error} +
+ ) : !photos || photos.length === 0 ? ( +
+ No photos +
+ ) : ( + + + + )} +
+ ); + + /* ── Tab content: Video ── */ + const videoContent = ( +
+ +
+ ); + + /* ── Tabs ── */ + const tabLabel = `사진${photoCountLabel ? ` (${photoCountLabel})` : ''}`; + const tabs = [ + { key: 'photos', label: tabLabel, content: photosContent }, + { key: 'video', label: '영상', content: videoContent }, + ]; + + return ( + <> +
+ {/* Header */} +
+ + +
+ {album?.name || ''} + {album?.regionName && ( + {album.regionName} + )} +
+ + {photoCountLabel && ( + {photoCountLabel} + )} +
+ + {/* Tabs */} + +
+ + {/* Lightbox */} + {selectedPhotoIndex != null && photos?.length > 0 && ( + + )} + + ); +}