diff --git a/src/pages/travel/Travel.css b/src/pages/travel/Travel.css index 4782a25..40600b2 100644 --- a/src/pages/travel/Travel.css +++ b/src/pages/travel/Travel.css @@ -166,95 +166,16 @@ } /* ═══════════════════════════════════════════════════ - MAP SECTION + ALBUMS SECTION — card grid ═══════════════════════════════════════════════════ */ -.tv-map-section { +.tv-albums { + min-height: 120px; +} + +.tv-albums__grid { display: grid; - gap: 28px; - transition: opacity 0.35s ease; -} - -.tv-map-section.is-dimmed { - opacity: 0.3; - pointer-events: none; -} - -.tv-map-wrap { - position: relative; - border-radius: var(--tv-r-lg); - overflow: hidden; - border: 1px solid var(--tv-line-bright); - box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6); -} - -.tv-map { - width: 100%; - height: 480px; -} - -@media (max-width: 768px) { - .tv-header { - grid-template-columns: 1fr; - } - - .tv-map { - height: 300px; - } - - /* 지도 높이 축소 */ - .tv-map { - height: 35vh !important; - } - - /* 라이트박스 풀스크린 */ - .lightbox { - border-radius: 0; - } - - .lightbox__inner { - max-width: 100vw; - max-height: 100vh; - width: 100vw; - height: 100vh; - border-radius: 0; - } -} - -/* Leaflet map tooltip override */ -.map-tooltip { - font-family: var(--tv-mono) !important; - font-size: 10px !important; - letter-spacing: 0.12em !important; - text-transform: uppercase !important; - background: rgba(15, 12, 9, 0.92) !important; - border: 1px solid rgba(232, 221, 208, 0.2) !important; - border-radius: 6px !important; - color: #e8ddd0 !important; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5) !important; -} - -.map-tooltip::before { - border-top-color: rgba(232, 221, 208, 0.15) !important; -} - -/* Map overlay hint */ -.tv-map__overlay-hint { - position: absolute; - bottom: 20px; - left: 50%; - transform: translateX(-50%); - background: rgba(15, 12, 9, 0.85); - border: 1px solid rgba(232, 221, 208, 0.2); - border-radius: 999px; - padding: 7px 18px; - pointer-events: none; -} - -.tv-map__overlay-hint span { - font-family: var(--tv-mono); - font-size: 9px; - letter-spacing: 0.24em; - color: var(--tv-muted); + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 20px; } /* ── Loading / Error states ──────────────────────── */ @@ -308,686 +229,15 @@ letter-spacing: 0.08em; } -/* ═══════════════════════════════════════════════════ - ALBUM HEADER -═══════════════════════════════════════════════════ */ -.tv-album-header { - display: flex; - justify-content: space-between; - align-items: baseline; - gap: 16px; - padding: 12px 0; - border-bottom: 1px solid var(--tv-line); -} - -.tv-album-header__left { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: 14px; -} - -.tv-album-header__region { - font-family: var(--tv-serif); - font-size: 24px; - font-weight: 600; - letter-spacing: -0.01em; -} - -.tv-album-header__albums { - font-family: var(--tv-mono); - font-size: 10px; - color: var(--tv-muted); - letter-spacing: 0.14em; - text-transform: uppercase; -} - -.tv-album-header__count { - font-family: var(--tv-mono); - font-size: 11px; - color: var(--tv-dim); - letter-spacing: 0.12em; - flex-shrink: 0; -} - -/* ═══════════════════════════════════════════════════ - PHOTO MOSAIC — 4-column editorial grid -═══════════════════════════════════════════════════ */ -.photo-mosaic { - display: grid; - grid-template-columns: repeat(4, 1fr); - grid-auto-rows: 240px; - grid-auto-flow: dense; - gap: 6px; -} - -@media (max-width: 1024px) { - .photo-mosaic { - grid-template-columns: repeat(3, 1fr); - grid-auto-rows: 200px; - } -} - -@media (max-width: 480px) { - .photo-mosaic { - grid-template-columns: repeat(2, 1fr); - grid-auto-rows: 180px; - gap: 4px; - } -} - -/* ═══════════════════════════════════════════════════ - PHOTO CARD -═══════════════════════════════════════════════════ */ -.photo-card { - position: relative; - overflow: hidden; - border-radius: var(--tv-r-sm); - cursor: pointer; - background: var(--tv-surface); - - /* Scroll-reveal */ - opacity: 0; - transform: scale(0.97) translateY(10px); - transition: - opacity 0.5s ease, - transform 0.5s ease, - box-shadow 0.25s ease; - transition-delay: var(--reveal-delay, 0ms); -} - -.photo-card[data-revealed='true'] { - opacity: 1; - transform: scale(1) translateY(0); -} - -/* Layout variants */ -.photo-card--hero { - grid-column: span 2; - grid-row: span 2; -} - -.photo-card--tall { - grid-row: span 2; -} - -.photo-card--wide { - grid-column: span 2; -} - -/* Image */ -.photo-card img { - width: 100%; - height: 100%; - object-fit: cover; - display: block; - transition: transform 0.6s cubic-bezier(0.25, 0, 0, 1), filter 0.4s ease; - filter: saturate(0.85) brightness(0.92); -} - -.photo-card:hover img { - transform: scale(1.04); - filter: saturate(1) brightness(1); -} - -/* Hover overlay */ -.photo-card__overlay { - position: absolute; - inset: 0; - background: linear-gradient( - 160deg, - rgba(15, 12, 9, 0) 40%, - rgba(15, 12, 9, 0.75) 100% - ); - opacity: 0; - transition: opacity 0.3s ease; - display: flex; - flex-direction: column; - justify-content: flex-end; - padding: 14px; -} - -.photo-card:hover .photo-card__overlay { - opacity: 1; -} - -.photo-card__overlay-inner { - display: flex; - flex-direction: column; - gap: 3px; -} - -.photo-card__index { - font-family: var(--tv-mono); - font-size: 9px; - letter-spacing: 0.2em; - color: var(--accent, var(--tv-accent)); -} - -.photo-card__label { - font-family: var(--tv-mono); - font-size: 10px; - color: rgba(232, 221, 208, 0.85); - margin: 0; - letter-spacing: 0.06em; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 100%; -} - -/* Decorative print-border effect */ -.photo-card__frame { - position: absolute; - inset: 0; - border-radius: var(--tv-r-sm); - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06); - pointer-events: none; - transition: box-shadow 0.3s ease; -} - -.photo-card:hover .photo-card__frame { - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16); -} - -.photo-card:focus-visible { - outline: 2px solid var(--tv-accent); - outline-offset: 2px; -} - -/* ═══════════════════════════════════════════════════ - MOSAIC FOOTER — sentinel + end message -═══════════════════════════════════════════════════ */ -.mosaic-footer { - display: flex; - justify-content: center; - align-items: center; - padding: 24px 0 8px; - min-height: 48px; - grid-column: 1 / -1; -} - -.mosaic-loading { - display: flex; - gap: 8px; -} - -.mosaic-loading__dot { - width: 5px; - height: 5px; - border-radius: 50%; - background: var(--tv-accent); - animation: tv-pulse 1.2s ease-in-out infinite; -} - -.mosaic-loading__dot:nth-child(2) { animation-delay: 0.2s; } -.mosaic-loading__dot:nth-child(3) { animation-delay: 0.4s; } - -.mosaic-end { - font-family: var(--tv-mono); - font-size: 10px; - letter-spacing: 0.22em; - color: var(--tv-dim); - text-transform: uppercase; - margin: 0; - display: flex; - align-items: center; - gap: 8px; -} - -.mosaic-end span { - color: var(--tv-line-bright); -} - -/* ═══════════════════════════════════════════════════ - FILM STRIP — thumbnail rail -═══════════════════════════════════════════════════ */ -.filmstrip { - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - align-items: stretch; - gap: 0; - background: #0a0806; - border-radius: 6px; - overflow: hidden; - border: 1px solid var(--tv-line); -} - -.filmstrip__nav { - width: 32px; - background: rgba(15, 12, 9, 0.9); - border: none; - color: var(--tv-muted); - font-size: 22px; - cursor: pointer; - transition: color 0.2s ease, background 0.2s ease; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; -} - -.filmstrip__nav:hover { - color: var(--tv-text); - background: rgba(15, 12, 9, 0.6); -} - -.filmstrip__rail { - display: flex; - flex-direction: column; - overflow: hidden; - position: relative; -} - -/* Perforation strip */ -.filmstrip__holes { - display: flex; - flex-direction: row; - gap: 0; - padding: 5px 8px; - background: #0a0806; - border-bottom: 1px solid rgba(255, 255, 255, 0.06); - overflow: hidden; -} - -.filmstrip__hole { - width: 10px; - height: 8px; - flex-shrink: 0; - margin-right: 14px; - border-radius: 2px; - background: var(--tv-surface); - border: 1px solid rgba(255, 255, 255, 0.08); - box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.6); -} - -/* Thumbnail frames */ -.filmstrip__frames { - display: flex; - gap: 3px; - padding: 5px 8px; - overflow-x: auto; - scroll-behavior: smooth; - scrollbar-width: none; -} - -.filmstrip__frames::-webkit-scrollbar { - display: none; -} - -.filmstrip__frame { - position: relative; - width: 68px; - height: 52px; - border-radius: 4px; - border: 1px solid rgba(255, 255, 255, 0.1); - background: var(--tv-surface-2); - padding: 0; - cursor: pointer; - flex-shrink: 0; - overflow: hidden; - transition: border-color 0.2s ease, transform 0.2s ease; -} - -.filmstrip__frame img { - width: 100%; - height: 100%; - object-fit: cover; - display: block; - filter: saturate(0.7); - transition: filter 0.2s ease; -} - -.filmstrip__frame:hover img, -.filmstrip__frame.is-active img { - filter: saturate(1); -} - -.filmstrip__frame:hover { - transform: scale(1.06); - border-color: rgba(255, 255, 255, 0.4); -} - -.filmstrip__frame.is-active { - border-color: var(--tv-accent); - box-shadow: 0 0 0 1px var(--tv-accent); -} - -.filmstrip__frame-num { - position: absolute; - bottom: 2px; - right: 3px; - font-family: var(--tv-mono); - font-size: 7px; - color: rgba(232, 221, 208, 0.6); - letter-spacing: 0.06em; - pointer-events: none; - line-height: 1; - text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8); -} - -/* ═══════════════════════════════════════════════════ - LIGHTBOX — cinematic full-screen viewer -═══════════════════════════════════════════════════ */ -.lightbox { - position: fixed; - inset: 0; - background: rgba(10, 8, 6, 0.9); - backdrop-filter: blur(var(--lb-blur, 6px)); - -webkit-backdrop-filter: blur(var(--lb-blur, 6px)); - z-index: 3000; - display: grid; - place-items: center; -} - -.lightbox__inner { - width: min(1280px, 98vw); - max-height: 100dvh; - display: grid; - grid-template-rows: auto 1fr auto auto auto; - gap: 0; - overflow: hidden; -} - -/* ── Top bar ──────────────────────────────────────── */ -.lightbox__topbar { - display: grid; - grid-template-columns: auto 1fr auto auto; - align-items: center; - gap: 16px; - padding: 14px 20px; - border-bottom: 1px solid var(--tv-line); - background: rgba(10, 8, 6, 0.7); -} - -.lightbox__counter { - display: flex; - align-items: baseline; - gap: 4px; - font-family: var(--tv-mono); -} - -.lightbox__counter-current { - font-size: 22px; - font-weight: 400; - line-height: 1; -} - -.lightbox__counter-sep { - font-size: 12px; - color: var(--tv-line-bright); -} - -.lightbox__counter-total { - font-size: 12px; - color: var(--tv-muted); -} - -.lightbox__region { - display: flex; - align-items: center; - gap: 8px; -} - -.lightbox__region-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--accent, var(--tv-accent)); - flex-shrink: 0; -} - -.lightbox__region-name { - font-family: var(--tv-serif); - font-size: 15px; - font-weight: 600; - color: var(--tv-text); - letter-spacing: 0.02em; -} - -.lightbox__album { - font-family: var(--tv-mono); - font-size: 9px; - text-transform: uppercase; - letter-spacing: 0.18em; - color: var(--tv-muted); - padding-left: 10px; - border-left: 1px solid var(--tv-line-bright); - margin-left: 2px; -} - -.lightbox__controls { - display: flex; - align-items: center; - gap: 12px; -} - -.lb-control { - display: flex; - align-items: center; - gap: 7px; - font-family: var(--tv-mono); - font-size: 9px; - text-transform: uppercase; - letter-spacing: 0.18em; - color: var(--tv-muted); - cursor: pointer; -} - -.lb-control input[type='range'] { - appearance: none; - -webkit-appearance: none; - width: 100px; - height: 3px; - background: rgba(232, 221, 208, 0.15); - border-radius: 999px; - outline: none; -} - -.lb-control input[type='range']::-webkit-slider-thumb { - appearance: none; - -webkit-appearance: none; - width: 12px; - height: 12px; - border-radius: 50%; - background: var(--tv-text); - cursor: pointer; - border: 1px solid rgba(255, 255, 255, 0.4); -} - -.lb-control input[type='range']::-moz-range-thumb { - width: 12px; - height: 12px; - border-radius: 50%; - background: var(--tv-text); - cursor: pointer; - border: 1px solid rgba(255, 255, 255, 0.4); -} - -.lb-control__val { - font-size: 9px; - min-width: 16px; - text-align: right; -} - -.lightbox__close { - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - border: 1px solid rgba(232, 221, 208, 0.18); - background: rgba(15, 12, 9, 0.8); - color: var(--tv-text); - cursor: pointer; - transition: border-color 0.2s ease, background 0.2s ease; - flex-shrink: 0; -} - -.lightbox__close:hover { - border-color: rgba(232, 221, 208, 0.5); - background: rgba(232, 221, 208, 0.08); -} - -/* ── Photo stage ──────────────────────────────────── */ -.lightbox__stage { - display: grid; - grid-template-columns: 56px 1fr 56px; - align-items: center; - gap: 0; - min-height: 0; - padding: 12px 0; -} - -.lightbox__frame { - position: relative; - display: flex; - align-items: center; - justify-content: center; - height: clamp(300px, 58vh, 700px); - overflow: hidden; -} - -.lightbox__photo { - max-width: 100%; - max-height: 100%; - object-fit: contain; - border-radius: 4px; - display: block; -} - -.lightbox__photo.slide-next { - animation: lb-slide-in-right 280ms cubic-bezier(0.25, 0, 0.25, 1) forwards; -} - -.lightbox__photo.slide-prev { - animation: lb-slide-in-left 280ms cubic-bezier(0.25, 0, 0.25, 1) forwards; -} - -@keyframes lb-slide-in-right { - from { opacity: 0; transform: translateX(24px) scale(0.98); } - to { opacity: 1; transform: translateX(0) scale(1); } -} - -@keyframes lb-slide-in-left { - from { opacity: 0; transform: translateX(-24px) scale(0.98); } - to { opacity: 1; transform: translateX(0) scale(1); } -} - -/* Decorative film frame border */ -.lightbox__photo-frame { - position: absolute; - inset: 0; - pointer-events: none; - border-radius: 4px; - box-shadow: - inset 0 0 0 1px rgba(255, 255, 255, 0.06), - 0 2px 24px rgba(0, 0, 0, 0.5); -} - -/* Navigation arrows */ -.lightbox__arrow { - width: 44px; - height: 44px; - border-radius: 12px; - border: 1px solid rgba(232, 221, 208, 0.18); - background: rgba(15, 12, 9, 0.85); - color: var(--tv-text); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - justify-self: center; - position: relative; - transition: border-color 0.2s ease, transform 0.2s ease, background 0.2s ease; -} - -.lightbox__arrow:hover { - border-color: rgba(232, 221, 208, 0.45); - background: rgba(232, 221, 208, 0.06); - transform: scale(1.05); -} - -.lightbox__arrow:disabled { - opacity: 0.25; - cursor: not-allowed; - transform: none; -} - -.lightbox__arrow.is-loading { - pointer-events: none; -} - -.lightbox__spinner { - width: 18px; - height: 18px; - border-radius: 50%; - border: 2px solid rgba(232, 221, 208, 0.25); - border-top-color: var(--tv-accent); - animation: tv-spin 0.7s linear infinite; -} - -@keyframes tv-spin { - to { transform: rotate(360deg); } -} - -/* Photo meta */ -.lightbox__meta { - padding: 6px 20px; - font-family: var(--tv-mono); - font-size: 9px; - text-transform: uppercase; - letter-spacing: 0.18em; - color: var(--tv-muted); - margin: 0; - border-top: 1px solid var(--tv-line); -} - -.lightbox__meta span { - color: var(--tv-dim); -} - -/* Toast */ -.lightbox__toast { - position: absolute; - left: 20px; - bottom: 16px; - background: rgba(15, 12, 9, 0.92); - border: 1px solid rgba(232, 221, 208, 0.2); - border-radius: 999px; - padding: 7px 14px; - font-family: var(--tv-mono); - font-size: 10px; - letter-spacing: 0.12em; - color: var(--tv-text); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); - pointer-events: none; - animation: lb-toast-in 0.22s ease; -} - -@keyframes lb-toast-in { - from { opacity: 0; transform: translateY(6px); } - to { opacity: 1; transform: translateY(0); } -} - -/* ═══════════════════════════════════════════════════ - SCROLL REVEAL -═══════════════════════════════════════════════════ */ -[data-reveal] { - opacity: 0; - transform: translateY(20px); - transition: opacity 0.6s ease, transform 0.6s ease; -} - -[data-reveal][data-revealed='true'] { - opacity: 1; - transform: translateY(0); -} - /* ═══════════════════════════════════════════════════ RESPONSIVE ═══════════════════════════════════════════════════ */ +@media (max-width: 768px) { + .tv-header { + grid-template-columns: 1fr; + } +} + @media (max-width: 480px) { .travel { gap: 28px; @@ -1002,45 +252,9 @@ font-size: clamp(40px, 12vw, 60px); } - .lightbox__topbar { - grid-template-columns: auto 1fr auto; - gap: 8px; - padding: 10px 12px; - } - - .lightbox__controls { - display: none; - } - - .lightbox__stage { - grid-template-columns: 44px 1fr 44px; - padding: 6px 0; - } - - .lightbox__frame { - height: clamp(240px, 50vh, 480px); - } - - .filmstrip__frame { - width: 56px; - height: 44px; - } - - .photo-mosaic { - grid-template-columns: repeat(2, 1fr); - } - - .photo-card--hero { - grid-column: span 2; - grid-row: span 1; - } - - .photo-card--wide { - grid-column: span 2; - } - - .photo-mosaic { + .tv-albums__grid { grid-template-columns: 1fr; + gap: 14px; } } @@ -1048,19 +262,8 @@ REDUCED MOTION ═══════════════════════════════════════════════════ */ @media (prefers-reduced-motion: reduce) { - .photo-card, - [data-reveal] { - opacity: 1 !important; - transform: none !important; - transition: none !important; - } - - .lightbox__photo.slide-next, - .lightbox__photo.slide-prev { + .tv-state__loader span { animation: none !important; - } - - .photo-card img { - transition: none !important; + opacity: 1 !important; } } diff --git a/src/pages/travel/Travel.jsx b/src/pages/travel/Travel.jsx index 14a5b29..47c507f 100644 --- a/src/pages/travel/Travel.jsx +++ b/src/pages/travel/Travel.jsx @@ -1,865 +1,83 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { GeoJSON, MapContainer, TileLayer, useMap } from 'react-leaflet'; -import 'leaflet/dist/leaflet.css'; +import React, { useState, useCallback } from 'react'; +import useTravelData from './useTravelData'; +import MiniMap, { getRegionAccent } from './MiniMap'; +import AlbumCard from './AlbumCard'; +import AlbumDetail from './AlbumDetail'; import './Travel.css'; -import { useIsMobile } from '../../hooks/useIsMobile'; -import PullToRefresh from '../../components/PullToRefresh'; - -/* ───────────────────────────────────────────── - Constants -───────────────────────────────────────────── */ -const PAGE_SIZE = 20; -const THUMB_STRIP_LIMIT = 36; - -/* ───────────────────────────────────────────── - Region accent palette — each destination - gets its own identity color -───────────────────────────────────────────── */ -const REGION_PALETTE = { - japan: '#e05c4b', // vermillion - korea: '#d64f6e', // rose - china: '#c84b3a', // crimson - europe: '#5b8fc4', // cobalt - france: '#6f8fc4', // slate blue - italy: '#78a46e', // olive - spain: '#c4844a', // terracotta - sea: '#4aad8b', // jade - thailand: '#4aad8b', - vietnam: '#5faa78', - bali: '#7aac5a', - indonesia: '#8aaa4a', - america: '#b4885c', // desert - usa: '#b4885c', - canada: '#6a9890', // glacier - africa: '#c47c3c', // ochre - middle: '#c4a24a', // sand gold - dubai: '#c4a24a', - default: '#c8905e', // warm amber -}; - -const getRegionAccent = (regionId = '') => { - const id = regionId.toLowerCase(); - for (const [key, color] of Object.entries(REGION_PALETTE)) { - if (key !== 'default' && id.includes(key)) return color; - } - return REGION_PALETTE.default; -}; - -/* ───────────────────────────────────────────── - Editorial card layout pattern - 4-column grid, repeating every 7 cards -───────────────────────────────────────────── */ -const LAYOUT_SEQ = ['hero', '', 'tall', '', '', 'wide', '']; -const getCardLayout = (index) => LAYOUT_SEQ[index % LAYOUT_SEQ.length]; - -/* ───────────────────────────────────────────── - Utility functions -───────────────────────────────────────────── */ -const normalizePhotos = (items = []) => - items - .map((item) => { - if (typeof item === 'string') return { src: item, title: '' }; - if (!item) return null; - return { - src: item.thumb || item.url || item.path || item.src || '', - title: item.title || item.name || item.file || '', - original: item.url || item.path || item.src || '', - file: item.file || '', - album: item.album || '', - }; - }) - .filter((item) => item && item.src); - -const hasSummaryInfo = (payload) => - payload && - (Object.prototype.hasOwnProperty.call(payload, 'total') || - Object.prototype.hasOwnProperty.call(payload, 'matched_albums')); - -const getPhotoLabel = (photo) => { - if (!photo) return ''; - if (photo.title) return photo.title; - if (photo.file) return photo.file; - if (!photo.src) return ''; - const parts = photo.src.split('/'); - return parts[parts.length - 1]; -}; - -const getStripRange = (length, center) => { - if (length <= THUMB_STRIP_LIMIT) return [0, length]; - const half = Math.floor(THUMB_STRIP_LIMIT / 2); - let start = Math.max(0, center - half); - let end = start + THUMB_STRIP_LIMIT; - if (end > length) { - end = length; - start = end - THUMB_STRIP_LIMIT; - } - return [start, end]; -}; - -/* ───────────────────────────────────────────── - PhotoCard — single photo tile -───────────────────────────────────────────── */ -const PhotoCard = ({ photo, index, onSelect, regionAccent }) => { - const label = getPhotoLabel(photo); - const layout = getCardLayout(index); - const isEager = index < 4; - - return ( -
onSelect(index, e)} - role="button" - tabIndex={0} - onKeyDown={(e) => e.key === 'Enter' && onSelect(index, e)} - aria-label={label || `Photo ${index + 1}`} - > - {/* Image */} - {label} { - if (photo.original && e.currentTarget.src !== photo.original) { - e.currentTarget.src = photo.original; - } - }} - /> - - {/* Hover overlay */} -
-
- - {String(index + 1).padStart(2, '0')} - - {label && ( -

{label}

- )} -
-
- - {/* Print border effect */} -
-
- ); -}; - -/* ───────────────────────────────────────────── - PhotoMosaic — grid with IntersectionObserver - lazy reveal + infinite scroll sentinel -───────────────────────────────────────────── */ -const PhotoMosaic = ({ - photos, - regionLabel, - regionAccent, - onSelectPhoto, - onLoadMore, - hasNext, - isLoadingMore, -}) => { - const sentinelRef = useRef(null); - const gridRef = useRef(null); - const revealObserverRef = useRef(null); - - /* Infinite scroll sentinel */ - useEffect(() => { - const sentinel = sentinelRef.current; - if (!sentinel || !onLoadMore) return; - const io = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting && !isLoadingMore && hasNext) onLoadMore(); - }, - { rootMargin: '300px' } - ); - io.observe(sentinel); - return () => io.disconnect(); - }, [hasNext, isLoadingMore, onLoadMore]); - - /* Scroll-reveal observer */ - useEffect(() => { - revealObserverRef.current = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (!entry.isIntersecting) return; - entry.target.dataset.revealed = 'true'; - revealObserverRef.current?.unobserve(entry.target); - }); - }, - { rootMargin: '120px', threshold: 0.05 } - ); - return () => revealObserverRef.current?.disconnect(); - }, []); - - useEffect(() => { - const observer = revealObserverRef.current; - const grid = gridRef.current; - if (!observer || !grid) return; - const cards = grid.querySelectorAll('.photo-card:not([data-revealed="true"])'); - cards.forEach((c) => observer.observe(c)); - const fallback = setTimeout(() => { - grid.querySelectorAll('.photo-card:not([data-revealed="true"])') - .forEach((c) => (c.dataset.revealed = 'true')); - }, 600); - return () => { - clearTimeout(fallback); - cards.forEach((c) => observer.unobserve(c)); - }; - }, [photos.length]); - - return ( - <> -
- {photos.map((photo, index) => ( - - ))} -
- -
- {isLoadingMore && ( -
- - - -
- )} - {!hasNext && photos.length > 0 && ( -

- -  {photos.length} frames developed  - -

- )} -
- - ); -}; - -/* ───────────────────────────────────────────── - MapLayer — GeoJSON region polygons -───────────────────────────────────────────── */ -const MapLayer = ({ geojson, selectedRegionId, onSelectRegion }) => { - const map = useMap(); - if (!geojson) return null; - - return ( - { - const isSelected = feature?.properties?.id === selectedRegionId; - const accent = getRegionAccent(feature?.properties?.id || ''); - return { - color: isSelected ? accent : 'rgba(200,160,100,0.4)', - weight: isSelected ? 2 : 1, - fillColor: isSelected ? accent : 'rgba(200,144,94,0.15)', - fillOpacity: isSelected ? 0.25 : 0.12, - }; - }} - onEachFeature={(feature, layer) => { - const name = feature?.properties?.name || feature?.properties?.id || ''; - if (name) layer.bindTooltip(name, { sticky: true, className: 'map-tooltip' }); - layer.on('click', () => { - if (!feature?.properties?.id) return; - map.fitBounds(layer.getBounds(), { padding: [40, 40], animate: true }); - onSelectRegion({ - id: feature.properties.id, - name: feature.properties.name || feature.properties.id, - }); - }); - }} - /> - ); -}; - -/* ───────────────────────────────────────────── - FilmStrip — horizontal thumbnail rail -───────────────────────────────────────────── */ -const FilmStrip = ({ - photos, - selectedIndex, - stripStart, - stripEnd, - thumbStripRef, - onSelect, - onScrollPrev, - onScrollNext, -}) => ( -
- - -
- {/* Film perforations — decorative */} -
- {Array.from({ length: 20 }).map((_, i) => ( - - ))} -
- -
- {photos.slice(stripStart, stripEnd).map((photo, idx) => { - const realIndex = stripStart + idx; - const isActive = realIndex === selectedIndex; - return ( - - ); - })} -
-
- - -
-); - -/* ───────────────────────────────────────────── - Lightbox — cinematic full-screen viewer -───────────────────────────────────────────── */ -const Lightbox = ({ - photos, - selectedIndex, - slideToken, - slideDirection, - loadingMore, - hasNext, - photoSummary, - selectedRegion, - backdropBlur, - setBackdropBlur, - thumbScrollDuration, - setThumbScrollDuration, - toastMessage, - stripStart, - stripEnd, - thumbStripRef, - onClose, - onPrev, - onNext, - onSelectThumb, - onScrollThumbPrev, - onScrollThumbNext, -}) => { - const photo = photos[selectedIndex]; - const regionAccent = getRegionAccent(selectedRegion?.id || ''); - const totalCount = photoSummary?.total ?? photos.length; - - return ( -
-
e.stopPropagation()}> - - {/* ── Top bar ──────────────────────────── */} -
-
- - {String(selectedIndex + 1).padStart(2, '0')} - - / - - {String(totalCount).padStart(2, '0')} - -
- -
- - - {selectedRegion?.name || 'Archive'} - - {photoSummary?.albums?.length > 0 && ( - - {photoSummary.albums[0].album} - - )} -
- -
- -
- - -
- - {/* ── Photo stage ──────────────────────── */} -
- - -
- {getPhotoLabel(photo)} { - if (photo?.original && e.currentTarget.src !== photo.original) - e.currentTarget.src = photo.original; - }} - /> - {/* Photo frame decoration */} -
-
- - -
- - {/* ── Photo metadata ───────────────────── */} - {(photo?.album || photo?.file) && ( -

- {photo.album} - {photo.file ? · {photo.file} : null} -

- )} - - {/* ── Film strip ───────────────────────── */} - - - {/* ── Toast ────────────────────────────── */} - {toastMessage && ( -
{toastMessage}
- )} -
-
- ); -}; /* ───────────────────────────────────────────── Travel — main page component ───────────────────────────────────────────── */ const Travel = () => { - const [photos, setPhotos] = useState([]); - const [photoSummary, setPhotoSummary] = useState(null); - const [loading, setLoading] = useState(false); - const [loadingMore, setLoadingMore] = useState(false); - const [error, setError] = useState(''); - const [selectedRegion, setSelectedRegion] = useState(null); - const [regionsGeojson, setRegionsGeojson] = useState(null); - const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(null); - const [slideDirection, setSlideDirection] = useState('next'); - const [slideToken, setSlideToken] = useState(0); - const [backdropBlur, setBackdropBlur] = useState(6); - const [thumbScrollDuration, setThumbScrollDuration] = useState(360); - const [toastMessage, setToastMessage] = useState(''); - const [page, setPage] = useState(1); - const [hasNext, setHasNext] = useState(true); + const { + regions, + albums, + loadingAlbums, + selectedRegion, + setSelectedRegion, + photos, + photoSummary, + loading, + loadingMore, + error, + hasNext, + loadAlbumPhotos, + loadMorePhotos, + reloadAlbumPhotos, + getFilteredAlbums, + } = useTravelData(); - const isMobile = useIsMobile(); + /* ── Local state ──────────────────────────── */ + const [selectedAlbum, setSelectedAlbum] = useState(null); + const [albumSourceRect, setAlbumSourceRect] = useState(null); - const touchStartXRef = useRef(null); - const pendingAdvanceRef = useRef(null); - const toastTimerRef = useRef(null); - const thumbStripRef = useRef(null); - const cacheRef = useRef(new Map()); - const cacheTtlMs = 10 * 60 * 1000; - const travelRef = useRef(null); + /* ── Computed ──────────────────────────────── */ + const regionAccent = getRegionAccent(selectedRegion || ''); + const filteredAlbums = getFilteredAlbums(selectedRegion); - const regionAccent = getRegionAccent(selectedRegion?.id || ''); + /* ── Handlers ─────────────────────────────── */ + const handleSelectRegion = useCallback( + (regionId) => { + setSelectedRegion(regionId); + }, + [setSelectedRegion], + ); - /* ── Scroll-reveal for page sections ─── */ - useEffect(() => { - const root = travelRef.current; - if (!root) return; - const io = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (!entry.isIntersecting) return; - entry.target.dataset.revealed = 'true'; - io.unobserve(entry.target); - }); - }, - { rootMargin: '140px' } - ); - root.querySelectorAll('[data-reveal]').forEach((n) => io.observe(n)); - return () => io.disconnect(); + const handleClearRegion = useCallback(() => { + setSelectedRegion(null); + }, [setSelectedRegion]); + + const handleOpenAlbum = useCallback( + (album, rect) => { + setSelectedAlbum(album); + setAlbumSourceRect(rect || null); + loadAlbumPhotos(album.region, album.name); + }, + [loadAlbumPhotos], + ); + + const handleCloseAlbum = useCallback(() => { + setSelectedAlbum(null); + setAlbumSourceRect(null); }, []); - /* ── Load GeoJSON regions ──────────────── */ - useEffect(() => { - const controller = new AbortController(); - (async () => { - try { - const res = await fetch('/api/travel/regions', { signal: controller.signal }); - if (!res.ok) throw new Error(`지역 정보 로딩 실패 (${res.status})`); - setRegionsGeojson(await res.json()); - } catch (err) { - if (err?.name !== 'AbortError') setError(err?.message ?? String(err)); - } - })(); - return () => controller.abort(); - }, []); + const handleLoadMore = useCallback(() => { + if (!selectedAlbum) return; + loadMorePhotos(selectedAlbum.region, selectedAlbum.name); + }, [selectedAlbum, loadMorePhotos]); - /* ── Load photos for selected region ──── */ - useEffect(() => { - if (!selectedRegion) { - setPhotos([]); setPhotoSummary(null); - setSelectedPhotoIndex(null); setPage(1); setHasNext(true); - return; - } - const controller = new AbortController(); - (async () => { - const cached = cacheRef.current.get(selectedRegion.id); - if (cached && Date.now() - cached.timestamp < cacheTtlMs) { - setPhotos(cached.items); setPhotoSummary(cached.summary ?? null); - setPage(cached.page ?? 1); setHasNext(cached.hasNext ?? true); - setLoading(false); setLoadingMore(false); setError(''); - setSelectedPhotoIndex(cached.items.length > 0 ? 0 : null); - return; - } - setLoading(true); setLoadingMore(false); setError(''); - setPhotos([]); setPhotoSummary(null); setSelectedPhotoIndex(null); - setPage(1); setHasNext(true); + const handleReload = useCallback(() => { + if (!selectedAlbum) return; + return reloadAlbumPhotos(selectedAlbum.region, selectedAlbum.name); + }, [selectedAlbum, reloadAlbumPhotos]); - try { - const res = await fetch( - `/api/travel/photos?region=${encodeURIComponent(selectedRegion.id)}&page=1&size=${PAGE_SIZE}`, - { signal: controller.signal } - ); - if (!res.ok) throw new Error(`지역 사진 로딩 실패 (${res.status})`); - const json = await res.json(); - const items = Array.isArray(json) ? json : json.items ?? []; - const meta = Array.isArray(json) ? {} : json ?? {}; - const normalized = normalizePhotos(items); - const nextHasNext = typeof meta.has_next === 'boolean' - ? meta.has_next - : typeof meta.hasNext === 'boolean' ? meta.hasNext : normalized.length >= PAGE_SIZE; - const summary = hasSummaryInfo(meta) - ? { total: meta.total, albums: meta.matched_albums ?? [] } - : null; - setPhotoSummary(summary); - setPhotos(normalized); - setHasNext(nextHasNext); - setPage(2); - cacheRef.current.set(selectedRegion.id, { - timestamp: Date.now(), - items: normalized, page: 2, hasNext: nextHasNext, summary, - }); - setSelectedPhotoIndex(normalized.length > 0 ? 0 : null); - } catch (err) { - if (err?.name === 'AbortError') return; - setError(err?.message ?? String(err)); - setPhotos([]); setPhotoSummary(null); - } finally { - setLoading(false); - } - })(); - return () => controller.abort(); - }, [selectedRegion]); - - /* ── Load more photos ──────────────────── */ - const loadMorePhotos = useCallback(async () => { - if (!selectedRegion || loading || loadingMore || !hasNext) return; - setLoadingMore(true); setError(''); - try { - const res = await fetch( - `/api/travel/photos?region=${encodeURIComponent(selectedRegion.id)}&page=${page}&size=${PAGE_SIZE}` - ); - if (!res.ok) throw new Error(`지역 사진 로딩 실패 (${res.status})`); - const json = await res.json(); - const items = Array.isArray(json) ? json : json.items ?? []; - const meta = Array.isArray(json) ? {} : json ?? {}; - const normalized = normalizePhotos(items); - const nextHasNext = typeof meta.has_next === 'boolean' - ? meta.has_next - : typeof meta.hasNext === 'boolean' ? meta.hasNext : normalized.length >= PAGE_SIZE; - const summary = hasSummaryInfo(meta) - ? { total: meta.total ?? photoSummary?.total, albums: meta.matched_albums ?? photoSummary?.albums ?? [] } - : null; - setPhotos((prev) => { - const merged = [...prev, ...normalized]; - cacheRef.current.set(selectedRegion.id, { - timestamp: Date.now(), - items: merged, page: page + 1, hasNext: nextHasNext, - summary: photoSummary ?? summary, - }); - return merged; - }); - if (!photoSummary && summary) setPhotoSummary(summary); - setHasNext(nextHasNext); - setPage((p) => p + 1); - } catch (err) { - setError(err?.message ?? String(err)); - } finally { - setLoadingMore(false); - } - }, [hasNext, loading, loadingMore, page, photoSummary, selectedRegion]); - - /* ── Reload photos (pull-to-refresh) ──── */ - const reloadPhotos = useCallback(async () => { - if (!selectedRegion) return; - cacheRef.current.delete(selectedRegion.id); - const res = await fetch( - `/api/travel/photos?region=${encodeURIComponent(selectedRegion.id)}&page=1&size=${PAGE_SIZE}` - ); - if (!res.ok) throw new Error(`지역 사진 로딩 실패 (${res.status})`); - const json = await res.json(); - const items = Array.isArray(json) ? json : json.items ?? []; - const meta = Array.isArray(json) ? {} : json ?? {}; - const normalized = normalizePhotos(items); - const nextHasNext = typeof meta.has_next === 'boolean' - ? meta.has_next - : typeof meta.hasNext === 'boolean' ? meta.hasNext : normalized.length >= PAGE_SIZE; - const summary = hasSummaryInfo(meta) - ? { total: meta.total, albums: meta.matched_albums ?? [] } - : null; - setPhotos(normalized); - setPage(2); - setHasNext(nextHasNext); - cacheRef.current.set(selectedRegion.id, { - timestamp: Date.now(), - items: normalized, page: 2, hasNext: nextHasNext, summary, - }); - if (summary) setPhotoSummary(summary); - }, [selectedRegion]); - - /* ── Slide helpers ─────────────────────── */ - const bumpSlide = (dir) => { - setSlideDirection(dir); - setSlideToken((t) => t + 1); - }; - - const showToast = useCallback((msg) => { - setToastMessage(msg); - if (toastTimerRef.current) clearTimeout(toastTimerRef.current); - toastTimerRef.current = setTimeout(() => setToastMessage(''), 1600); - }, []); - - useEffect(() => () => { if (toastTimerRef.current) clearTimeout(toastTimerRef.current); }, []); - - /* ── Navigation ────────────────────────── */ - const goPrev = useCallback(() => { - if (selectedPhotoIndex == null || selectedPhotoIndex <= 0) return; - bumpSlide('prev'); - setSelectedPhotoIndex((i) => i - 1); - }, [selectedPhotoIndex]); - - const goNext = useCallback(() => { - if (selectedPhotoIndex == null) return; - if (selectedPhotoIndex < photos.length - 1) { - bumpSlide('next'); - setSelectedPhotoIndex((i) => i + 1); - return; - } - if (hasNext && !loadingMore) { - pendingAdvanceRef.current = 'next'; - loadMorePhotos(); - return; - } - if (!hasNext) showToast('마지막 사진입니다'); - }, [hasNext, loadMorePhotos, loadingMore, photos.length, selectedPhotoIndex, showToast]); - - useEffect(() => { - if (pendingAdvanceRef.current !== 'next') return; - if (selectedPhotoIndex == null) { pendingAdvanceRef.current = null; return; } - if (selectedPhotoIndex < photos.length - 1) { - bumpSlide('next'); - setSelectedPhotoIndex((i) => (i == null ? i : i + 1)); - pendingAdvanceRef.current = null; - } - if (!hasNext && selectedPhotoIndex >= photos.length - 1) pendingAdvanceRef.current = null; - }, [hasNext, photos.length, selectedPhotoIndex]); - - /* ── Keyboard + swipe ──────────────────── */ - useEffect(() => { - if (selectedPhotoIndex == null) return; - const onKey = (e) => { - if (e.key === 'Escape') setSelectedPhotoIndex(null); - if (e.key === 'ArrowLeft') goPrev(); - if (e.key === 'ArrowRight') goNext(); - }; - const onTouchStart = (e) => { touchStartXRef.current = e.touches[0].clientX; }; - const onTouchEnd = (e) => { - if (touchStartXRef.current == null) return; - const dx = e.changedTouches[0].clientX - touchStartXRef.current; - if (Math.abs(dx) > 50) { dx > 0 ? goPrev() : goNext(); } - touchStartXRef.current = null; - }; - window.addEventListener('keydown', onKey); - window.addEventListener('touchstart', onTouchStart); - window.addEventListener('touchend', onTouchEnd); - return () => { - window.removeEventListener('keydown', onKey); - window.removeEventListener('touchstart', onTouchStart); - window.removeEventListener('touchend', onTouchEnd); - }; - }, [goNext, goPrev, selectedPhotoIndex]); - - /* ── Body scroll lock ──────────────────── */ - useEffect(() => { - if (selectedPhotoIndex == null) return; - const prev = document.body.style.overflow; - document.body.style.overflow = 'hidden'; - return () => { document.body.style.overflow = prev; }; - }, [selectedPhotoIndex]); - - /* ── Thumbnail strip range ─────────────── */ - const [stripStart, stripEnd] = selectedPhotoIndex == null - ? [0, 0] - : getStripRange(photos.length, selectedPhotoIndex); - - /* ── Smooth scroll helper ──────────────── */ - const scrollToX = (el, target, duration) => { - if (!el) return; - if (window.matchMedia('(prefers-reduced-motion: reduce)').matches || duration <= 0) { - el.scrollLeft = target; return; - } - const start = el.scrollLeft; - const diff = target - start; - if (!diff) return; - let t0 = null; - const ease = (t) => 0.5 - Math.cos(Math.PI * t) / 2; - const step = (ts) => { - if (!t0) t0 = ts; - const t = Math.min((ts - t0) / duration, 1); - el.scrollLeft = start + diff * ease(t); - if (t < 1) requestAnimationFrame(step); - }; - requestAnimationFrame(step); - }; - - const scrollThumbs = (dir) => { - const strip = thumbStripRef.current; - if (!strip) return; - scrollToX(strip, strip.scrollLeft + (dir === 'next' ? 1 : -1) * strip.clientWidth * 0.6, thumbScrollDuration); - }; - - /* Auto-center active thumb */ - useEffect(() => { - if (selectedPhotoIndex == null) return; - const strip = thumbStripRef.current; - if (!strip) return; - const thumb = strip.querySelector(`[data-thumb-index="${selectedPhotoIndex}"]`); - if (!thumb) return; - const sr = strip.getBoundingClientRect(); - const tr = thumb.getBoundingClientRect(); - scrollToX(strip, tr.left - sr.left + strip.scrollLeft + tr.width / 2 - sr.width / 2, thumbScrollDuration); - }, [selectedPhotoIndex, stripStart, stripEnd, thumbScrollDuration]); - - /* ── Photo selection ───────────────────── */ - const handleSelectPhoto = (index) => { - if (selectedPhotoIndex == null) bumpSlide('next'); - else if (index !== selectedPhotoIndex) bumpSlide(index > selectedPhotoIndex ? 'next' : 'prev'); - setSelectedPhotoIndex(index); - }; - - /* ───────────────────────────────────────────── - Render - ───────────────────────────────────────────── */ + /* ── Render ────────────────────────────────── */ return ( -
+
{/* ═══════════════════════════════════════ HEADER — editorial masthead ═══════════════════════════════════════ */} -
+
Visual Diary @@ -881,12 +99,7 @@ const Travel = () => {

Currently viewing

-

{selectedRegion.name}

- {photoSummary && ( -

- {photoSummary.total ?? photos.length} photos -

- )} +

{selectedRegion}

) : ( @@ -903,117 +116,65 @@ const Travel = () => {
{/* ═══════════════════════════════════════ - MAP — world map with region selection + MAP — collapsible mini-map ═══════════════════════════════════════ */} -
-
- - - - + - {/* Map overlay hint */} - {!selectedRegion && ( -
- CLICK A REGION -
- )} -
- - {/* State messages */} - {loading && ( + {/* ═══════════════════════════════════════ + ALBUM CARDS + ═══════════════════════════════════════ */} +
+ {loadingAlbums && filteredAlbums.length === 0 && (
-

Developing film…

+

Loading albums…

)} - {error &&

{error}

} - {!loading && !error && selectedRegion && photos.length === 0 && ( + + {!loadingAlbums && filteredAlbums.length === 0 && (

- 이 지역에는 아직 사진이 없습니다. + {selectedRegion + ? '이 지역에는 아직 앨범이 없습니다.' + : '지도에서 지역을 선택하거나 전체 앨범을 둘러보세요.'}

)} - {/* ── Photo Mosaic ─────────────────── */} - {!loading && !error && selectedRegion && photos.length > 0 && ( - -
-
- - {selectedRegion.name} - - {photoSummary?.albums?.length > 0 && ( - - {photoSummary.albums.map((a) => a.album).join(' · ')} - - )} -
- - {photos.length} - {hasNext && '+'} - -
- - -
+ {filteredAlbums.length > 0 && ( +
+ {filteredAlbums.map((album) => ( + + ))} +
)}
{/* ═══════════════════════════════════════ - LIGHTBOX — cinematic fullscreen viewer + ALBUM DETAIL OVERLAY ═══════════════════════════════════════ */} - {selectedPhotoIndex != null && ( - setSelectedPhotoIndex(null)} - onPrev={goPrev} - onNext={goNext} - onSelectThumb={setSelectedPhotoIndex} - onScrollThumbPrev={() => scrollThumbs('prev')} - onScrollThumbNext={() => scrollThumbs('next')} + error={error} + onClose={handleCloseAlbum} + onLoadMore={handleLoadMore} + onReload={handleReload} /> )}