diff --git a/src/api.js b/src/api.js index 908fe50..bebcc46 100644 --- a/src/api.js +++ b/src/api.js @@ -246,6 +246,62 @@ export function deleteSellHistory(id) { return apiDelete(`/api/portfolio/sell-history/${id}`); } +// ── 로또 고도화 API ──────────────────────────────────────────────────────────── + +// GET /api/lotto/stats/performance +export function getPerformanceStats() { + return apiGet('/api/lotto/stats/performance'); +} + +// GET /api/lotto/report/latest +export function getLatestReport() { + return apiGet('/api/lotto/report/latest'); +} + +// GET /api/lotto/report/:drw_no +export function getReport(drwNo) { + return apiGet(`/api/lotto/report/${drwNo}`); +} + +// GET /api/lotto/report/history?limit=N +export function getReportHistory(limit = 10) { + return apiGet(`/api/lotto/report/history?limit=${limit}`); +} + +// GET /api/lotto/analysis/personal +export function getPersonalAnalysis() { + return apiGet('/api/lotto/analysis/personal'); +} + +// GET /api/lotto/purchase?draw_no=N&days=N +export function getPurchases({ draw_no, days } = {}) { + const qs = new URLSearchParams(); + if (draw_no) qs.set('draw_no', String(draw_no)); + if (days) qs.set('days', String(days)); + const q = qs.toString(); + return apiGet(`/api/lotto/purchase${q ? '?' + q : ''}`); +} + +// GET /api/lotto/purchase/stats +export function getPurchaseStats() { + return apiGet('/api/lotto/purchase/stats'); +} + +// POST /api/lotto/purchase +export function addPurchase(data) { + return apiPost('/api/lotto/purchase', data); +} + +// PUT /api/lotto/purchase/:id +export function updatePurchase(id, data) { + return apiPut(`/api/lotto/purchase/${id}`, data); +} + +// DELETE /api/lotto/purchase/:id +export function deletePurchase(id) { + return apiDelete(`/api/lotto/purchase/${id}`); +} + // ── 블로그 API ──────────────────────────────────────────────────────────────── // GET /api/blog/posts → { posts: [{id, title, tags, body, date, excerpt}] } // POST /api/blog/posts → 새 글 생성 diff --git a/src/pages/lotto/Functions.jsx b/src/pages/lotto/Functions.jsx index 7178d4f..f1d38fa 100644 --- a/src/pages/lotto/Functions.jsx +++ b/src/pages/lotto/Functions.jsx @@ -2,10 +2,21 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { deleteHistory, getHistory, getLatest, getStats, recommend, getBestPicks, getAnalysis, triggerSimulate, + getPerformanceStats, getLatestReport, getReportHistory, + getPersonalAnalysis, getPurchases, getPurchaseStats, + addPurchase, updatePurchase, deletePurchase, } from '../../api'; +/* ───────────────────────────────────────────── + 공통 유틸 +───────────────────────────────────────────── */ const fmtKST = (value) => value?.replace('T', ' ') ?? ''; +const fmtWon = (n) => { + if (n == null || isNaN(Number(n))) return '-'; + return new Intl.NumberFormat('ko-KR').format(Math.round(Number(n))) + '원'; +}; + const ballClass = (n) => { if (n <= 10) return 'lotto-ball range-a'; if (n <= 20) return 'lotto-ball range-b'; @@ -18,12 +29,13 @@ const Ball = ({ n }) => {n}; const NumberRow = ({ nums }) => (
- {nums.map((n) => ( - - ))} + {nums.map((n) => )}
); +/* ───────────────────────────────────────────── + 기존 통계 헬퍼 +───────────────────────────────────────────── */ const bucketOrder = ['1-10', '11-20', '21-30', '31-40', '41-45']; const STATS_CACHE_KEY = 'lotto_stats_v1'; const BEST_PICKS_DEFAULT_SHOW = 5; @@ -36,18 +48,12 @@ const readStatsCache = () => { const parsed = JSON.parse(raw); if (!parsed || !Array.isArray(parsed.frequency)) return null; return parsed; - } catch { - return null; - } + } catch { return null; } }; const writeStatsCache = (data) => { if (typeof window === 'undefined') return; - try { - localStorage.setItem(STATS_CACHE_KEY, JSON.stringify(data)); - } catch { - // ignore storage failures - } + try { localStorage.setItem(STATS_CACHE_KEY, JSON.stringify(data)); } catch {} }; const buildFrequencySeries = (frequency) => { @@ -55,13 +61,10 @@ const buildFrequencySeries = (frequency) => { (frequency ?? []).forEach((item) => { const number = Number(item?.number); const count = Number(item?.count) || 0; - if (Number.isFinite(number) && number >= 1 && number <= 45) { - map.set(number, count); - } + if (Number.isFinite(number) && number >= 1 && number <= 45) map.set(number, count); }); const series = Array.from({ length: 45 }, (_, idx) => ({ - number: idx + 1, - count: map.get(idx + 1) ?? 0, + number: idx + 1, count: map.get(idx + 1) ?? 0, })); const max = Math.max(1, ...series.map((item) => item.count)); return { series, max }; @@ -69,15 +72,10 @@ const buildFrequencySeries = (frequency) => { const buildMetricsFromCounts = (counts) => { if (!counts?.length) return null; - const total = counts.reduce((acc, value) => acc + value, 0); + const total = counts.reduce((acc, v) => acc + v, 0); if (!total) return null; - const min = Math.min(...counts); - const max = Math.max(...counts); - const range = max - min; - const odd = counts.reduce( - (acc, value, idx) => (idx % 2 === 0 ? acc + value : acc), - 0 - ); + const min = Math.min(...counts), max = Math.max(...counts); + const odd = counts.reduce((acc, v, idx) => (idx % 2 === 0 ? acc + v : acc), 0); const even = total - odd; const buckets = { '1-10': counts.slice(0, 10).reduce((a, b) => a + b, 0), @@ -86,18 +84,15 @@ const buildMetricsFromCounts = (counts) => { '31-40': counts.slice(30, 40).reduce((a, b) => a + b, 0), '41-45': counts.slice(40, 45).reduce((a, b) => a + b, 0), }; - return { sum: total, min, max, range, odd, even, buckets }; + return { sum: total, min, max, range: max - min, odd, even, buckets }; }; const buildMetricsFromFrequency = (frequency) => { if (!frequency?.length) return null; const counts = Array.from({ length: 45 }, () => 0); frequency.forEach((item) => { - const number = Number(item?.number); - const count = Number(item?.count) || 0; - if (number >= 1 && number <= 45) { - counts[number - 1] = count; - } + const number = Number(item?.number), count = Number(item?.count) || 0; + if (number >= 1 && number <= 45) counts[number - 1] = count; }); return buildMetricsFromCounts(counts); }; @@ -108,9 +103,7 @@ const buildMetricsFromHistory = (items) => { items.forEach((item) => { (item?.numbers ?? []).forEach((value) => { const number = Number(value); - if (number >= 1 && number <= 45) { - counts[number - 1] += 1; - } + if (number >= 1 && number <= 45) counts[number - 1] += 1; }); }); return buildMetricsFromCounts(counts); @@ -118,27 +111,22 @@ const buildMetricsFromHistory = (items) => { const toBucketEntries = (metrics) => { if (!metrics?.buckets) return []; - const entries = Object.entries(metrics.buckets); const ordered = bucketOrder .filter((key) => Object.prototype.hasOwnProperty.call(metrics.buckets, key)) .map((key) => [key, metrics.buckets[key]]); - const rest = entries + const rest = Object.entries(metrics.buckets) .filter(([key]) => !bucketOrder.includes(key)) - .sort((a, b) => { - const aStart = Number(a[0].split('-')[0]); - const bStart = Number(b[0].split('-')[0]); - if (Number.isNaN(aStart) || Number.isNaN(bStart)) return 0; - return aStart - bStart; - }); + .sort((a, b) => Number(a[0].split('-')[0]) - Number(b[0].split('-')[0])); return [...ordered, ...rest]; }; +/* ───────────────────────────────────────────── + SubComponents — 기존 +───────────────────────────────────────────── */ const MetricBlock = ({ title, metrics }) => { if (!metrics) return null; const buckets = toBucketEntries(metrics); - const maxBucket = buckets.length - ? Math.max(...buckets.map(([, value]) => Number(value) || 0), 1) - : 1; + const maxBucket = buckets.length ? Math.max(...buckets.map(([, v]) => Number(v) || 0), 1) : 1; const odd = Number(metrics.odd) || 0; const even = Number(metrics.even) || 0; const totalOE = odd + even || 1; @@ -148,9 +136,7 @@ const MetricBlock = ({ title, metrics }) => {

{title}

- - 총 출현 횟수 {metrics.sum ?? '-'} - + 총 출현 횟수 {metrics.sum ?? '-'}
@@ -168,15 +154,11 @@ const MetricBlock = ({ title, metrics }) => {
- 홀 {odd} - 짝 {even} + 홀 {odd}짝 {even}
- +
{buckets.length ? ( @@ -185,9 +167,7 @@ const MetricBlock = ({ title, metrics }) => {
{label}
- +
{value}
@@ -199,15 +179,8 @@ const MetricBlock = ({ title, metrics }) => { }; const FrequencyChart = ({ stats }) => { - const { series, max } = useMemo( - () => buildFrequencySeries(stats?.frequency), - [stats] - ); - const ticks = useMemo(() => { - const mid = Math.round(max * 0.5); - return [max, mid, 0]; - }, [max]); - + const { series, max } = useMemo(() => buildFrequencySeries(stats?.frequency), [stats]); + const ticks = useMemo(() => [max, Math.round(max * 0.5), 0], [max]); if (!stats) return null; return ( @@ -215,20 +188,14 @@ const FrequencyChart = ({ stats }) => {
횟수
- {ticks.map((value) => ( - {value} - ))} + {ticks.map((value) => {value})}
{series.map((item) => { const showLabel = item.number === 1 || item.number % 5 === 0; return ( -
+
{ ); }; +/* ───────────────────────────────────────────── + SubComponents — 신규 +───────────────────────────────────────────── */ + +/* 신뢰도 배너 */ +const PerformanceBanner = ({ perf }) => { + if (!perf || perf.total_checked === 0) return null; + const imp = perf.vs_random?.improvement_pct ?? 0; + const prizeHits = (perf.by_rank?.rank_3 ?? 0) + (perf.by_rank?.rank_4 ?? 0) + (perf.by_rank?.rank_5 ?? 0); + return ( +
+ 신뢰도 지표 +
+
+ {perf.total_checked} + 검증 회차 +
+
+
+ {(perf.avg_correct ?? 0).toFixed(1)} + 평균 일치수 +
+
+
+ 0 ? 'is-pos' : ''}`}> + {imp > 0 ? '+' : ''}{imp.toFixed(1)}% + + 무작위 대비 +
+
+
+ + {((perf.rate_3plus ?? 0) * 100).toFixed(1)}% + + 3개↑ 일치율 +
+ {prizeHits > 0 && ( + <> +
+
+ {prizeHits}건 + 3~5등 당첨 +
+ + )} +
+
+ ); +}; + +/* 신뢰도 링 SVG */ +const ConfidenceRing = ({ score }) => { + const r = 28, c = 2 * Math.PI * r; + const fill = (score / 100) * c; + const color = score >= 80 ? '#97c9aa' : score >= 60 ? '#fdd4b1' : '#f7a8a5'; + return ( + + + + + {score} + + + ); +}; + +/* 공략 리포트 패널 */ +const ReportPanel = ({ report, history, loading, onRefresh, onSelectDrw }) => { + const [histExpand, setHistExpand] = useState(false); + + return ( +
+
+
+

Weekly Report

+

이번 주 공략 리포트

+ {report && ( +

+ {report.target_drw_no}회 대상 · {report.based_on_draw}회 기준 +

+ )} +
+
+ {loading && 로딩 중} + + {history?.length > 0 && ( + + )} +
+
+ + {/* 지난 리포트 목록 */} + {histExpand && history?.length > 0 && ( +
+ {history.map((h) => ( + + ))} +
+ )} + + {!report && !loading && ( +

리포트 데이터가 없습니다.

+ )} + {loading && !report && ( +

불러오는 중...

+ )} + + {report && ( + <> + {/* 신뢰도 + 패턴 요약 */} +
+
+ +
+

신뢰도 점수

+
+ {Object.entries(report.confidence_factors ?? {}).map(([k, v]) => ( +
+ + {k === 'data_volume' ? '데이터 충분도' + : k === 'pattern_consistency' ? '패턴 안정성' + : k === 'recent_trend' ? '최근 트렌드' : k} + +
+ +
+ {v} +
+ ))} +
+
+
+ +
+

최근 패턴

+
+
+ 합계 평균 + {report.recent_pattern?.recent_sum_avg?.toFixed(1) ?? '-'} +
+
+ 홀수 평균 + {report.recent_pattern?.recent_odd_avg?.toFixed(1) ?? '-'} +
+ {(report.recent_pattern?.triple_appear ?? []).length > 0 && ( +
+ 3회 연속 출현 + +
+ )} +
+
+
+ + {/* 핫 / 콜드 / 오버듀 */} +
+
+

+ 🔥 핫 번호 최근 10회 과출현 +

+ +
+
+

+ 🧊 콜드 번호 역대 저빈도 10개 +

+ +
+
+

+ ⏰ 오버듀 가장 오래 미출현 +

+ +
+
+ + {/* 전략 추천 세트 */} + {(report.recommended_sets ?? []).length > 0 && ( +
+ {report.recommended_sets.map((set, i) => ( +
+

{set.strategy}

+ +

{set.description}

+
+ ))} +
+ )} + + )} +
+ ); +}; + +/* 개인 패턴 분석 */ +const PersonalAnalysisPanel = ({ data, loading }) => { + const zones = Object.entries(data?.pattern?.zone_avg ?? {}); + const maxZone = zones.length ? Math.max(...zones.map(([, v]) => Number(v) || 0), 1) : 1; + + return ( +
+
+
+

My Pattern

+

내 번호 패턴

+ {data && data.total_analyzed > 0 && ( +

총 {data.total_analyzed}회 추천 기반 분석

+ )} +
+
+ + {(loading || !data || data.total_analyzed === 0) ? ( +

+ {loading ? '불러오는 중...' : '추천 이력이 없습니다.'} +

+ ) : ( +
+
+
+

+ 내가 자주 선택한 번호 TOP 10 +

+ +
+ +
+

선택 성향

+
+ {data.vs_draw_avg?.odd_tendency && ( + + {data.vs_draw_avg.odd_tendency} + + )} + {data.vs_draw_avg?.sum_tendency && ( + + {data.vs_draw_avg.sum_tendency} + + )} +
+
+ 홀수 평균 {data.pattern?.avg_odd_count?.toFixed(1)} + 합계 평균 {data.pattern?.avg_sum?.toFixed(1)} + + 연속번호 포함률{' '} + + {((data.pattern?.consecutive_rate ?? 0) * 100).toFixed(0)}% + + +
+
+ + {zones.length > 0 && ( +
+

구간별 선택 비율

+
+ {zones.map(([zone, avg]) => ( +
+ {zone} +
+ +
+ {Number(avg).toFixed(1)} +
+ ))} +
+
+ )} +
+
+ )} +
+ ); +}; + +/* 구매 기록 패널 */ +const emptyPurchaseForm = () => ({ draw_no: '', amount: 5000, sets: 5, prize: 0, note: '' }); + +const PurchasePanel = ({ + records, stats, loading, + formOpen, form, formSaving, formError, editId, + onFormOpen, onFormClose, onFormChange, onFormSubmit, + onEditStart, onDelete, +}) => { + const winRate = stats?.total_records > 0 + ? ((stats.prize_count / stats.total_records) * 100).toFixed(1) + : '0.0'; + const netColor = (stats?.net ?? 0) >= 0 ? 'is-pos' : 'is-neg'; + + return ( +
+
+
+

Purchase Tracker

+

구매 기록

+

구매 내역 기록 및 수익률 추적

+
+
+ {loading && 로딩 중} + +
+
+ + {/* 통계 바 */} + {stats && stats.total_records > 0 && ( +
+
+ {fmtWon(stats.total_invested)} + 총 투자 +
+
+ {fmtWon(stats.total_prize)} + 총 당첨금 +
+
+ + {(stats.net ?? 0) >= 0 ? '+' : ''}{fmtWon(stats.net)} + + 순손익 +
+
+ {stats.return_rate?.toFixed(1)}% + 회수율 +
+
+ {winRate}% + 당첨률 +
+ {stats.max_prize > 0 && ( +
+ {fmtWon(stats.max_prize)} + 최대 당첨금 +
+ )} +
+ )} + + {/* 입력 폼 */} + {formOpen && ( +
+

+ {editId != null ? '기록 수정' : '구매 기록 추가'} +

+
+ + + + + +
+ {formError && ( +

{formError}

+ )} +
+ + +
+
+ )} + + {/* 기록 목록 */} + {records.length === 0 ? ( +

구매 기록이 없습니다.

+ ) : ( +
+
+ 회차 + 투자금 + 당첨금 + 손익 + 메모 + +
+ {records.map((rec) => { + const net = (rec.prize ?? 0) - (rec.amount ?? 0); + return ( +
+ {rec.draw_no}회 + {fmtWon(rec.amount)} + 0 ? 'is-prize' : ''}> + {fmtWon(rec.prize)} + + = 0 ? 'is-pos' : 'is-neg'}> + {net >= 0 ? '+' : ''}{fmtWon(net)} + + {rec.note || '-'} +
+ + +
+
+ ); + })} +
+ )} +
+ ); +}; + +/* ───────────────────────────────────────────── + Main Functions Component +───────────────────────────────────────────── */ export default function Functions() { // ── 기존 상태 ────────────────────────────────────────────────────────────── const [latest, setLatest] = useState(null); const [params, setParams] = useState({ - recent_window: 200, - recent_weight: 2.0, - avoid_recent_k: 5, + recent_window: 200, recent_weight: 2.0, avoid_recent_k: 5, }); - const presets = useMemo( - () => [ - { name: '기본', recent_window: 200, recent_weight: 2.0, avoid_recent_k: 5 }, - { name: '최근 가중치↑', recent_window: 100, recent_weight: 3.0, avoid_recent_k: 10 }, - { name: '안전(분산)', recent_window: 300, recent_weight: 1.6, avoid_recent_k: 8 }, - { name: '공격(최근)', recent_window: 80, recent_weight: 3.5, avoid_recent_k: 12 }, - ], - [] - ); + const presets = useMemo(() => [ + { name: '기본', recent_window: 200, recent_weight: 2.0, avoid_recent_k: 5 }, + { name: '최근 가중치↑', recent_window: 100, recent_weight: 3.0, avoid_recent_k: 10 }, + { name: '안전(분산)', recent_window: 300, recent_weight: 1.6, avoid_recent_k: 8 }, + { name: '공격(최근)', recent_window: 80, recent_weight: 3.5, avoid_recent_k: 12 }, + ], []); const [result, setResult] = useState(null); const [history, setHistory] = useState([]); const [historyExpanded, setHistoryExpanded] = useState(false); @@ -272,56 +700,53 @@ export default function Functions() { const [statsLoading, setStatsLoading] = useState(false); const [statsError, setStatsError] = useState(''); const [loading, setLoading] = useState({ - latest: false, - recommend: false, - history: false, - bestPicks: false, - analysis: false, + latest: false, recommend: false, history: false, bestPicks: false, analysis: false, }); const [error, setError] = useState(''); - - // ── 시뮬레이션 관련 상태 ─────────────────────────────────────────────────── const [bestPicks, setBestPicks] = useState([]); const [bestPicksExpanded, setBestPicksExpanded] = useState(false); const [analysis, setAnalysis] = useState(null); const [simulating, setSimulating] = useState(false); const [simResult, setSimResult] = useState(null); - // ── 파생 값 ──────────────────────────────────────────────────────────────── - const overallMetrics = useMemo( - () => buildMetricsFromFrequency(stats?.frequency), - [stats] - ); - const historyMetrics = useMemo( - () => buildMetricsFromHistory(history), - [history] - ); - const visibleHistory = historyExpanded ? history : history.slice(0, 5); - const visibleBestPicks = bestPicksExpanded - ? bestPicks - : bestPicks.slice(0, BEST_PICKS_DEFAULT_SHOW); + // ── 신규 상태 ────────────────────────────────────────────────────────────── + const [perfStats, setPerfStats] = useState(null); + const [report, setReport] = useState(null); + const [reportHistory, setReportHistory] = useState([]); + const [reportLoading, setReportLoading] = useState(false); + const [personalAnalysis, setPersonalAnalysis] = useState(null); + const [personalLoading, setPersonalLoading] = useState(false); + const [purchases, setPurchases] = useState([]); + const [purchaseStats, setPurchaseStats] = useState(null); + const [purchaseLoading, setPurchaseLoading] = useState(false); - // ── 기존 데이터 로드 함수 ────────────────────────────────────────────────── + // 구매 폼 상태 + const [purchaseFormOpen, setPurchaseFormOpen] = useState(false); + const [purchaseForm, setPurchaseForm] = useState(emptyPurchaseForm); + const [purchaseFormSaving, setPurchaseFormSaving] = useState(false); + const [purchaseFormError, setPurchaseFormError] = useState(''); + const [purchaseEditId, setPurchaseEditId] = useState(null); + + // ── 파생 값 ──────────────────────────────────────────────────────────────── + const overallMetrics = useMemo(() => buildMetricsFromFrequency(stats?.frequency), [stats]); + const historyMetrics = useMemo(() => buildMetricsFromHistory(history), [history]); + const visibleHistory = historyExpanded ? history : history.slice(0, 5); + const visibleBestPicks = bestPicksExpanded ? bestPicks : bestPicks.slice(0, BEST_PICKS_DEFAULT_SHOW); + + // ── 기존 로드 함수 ───────────────────────────────────────────────────────── const refreshLatest = async () => { setLoading((s) => ({ ...s, latest: true })); setError(''); - try { - const data = await getLatest(); - setLatest(data); - } catch (e) { - setError(e?.message ?? String(e)); - } finally { - setLoading((s) => ({ ...s, latest: false })); - } + try { setLatest(await getLatest()); } + catch (e) { setError(e?.message ?? String(e)); } + finally { setLoading((s) => ({ ...s, latest: false })); } }; const refreshHistory = async () => { setLoading((s) => ({ ...s, history: true })); setError(''); try { - const limit = 100; - let offset = 0; - const allItems = []; + const limit = 100; let offset = 0; const allItems = []; while (true) { const data = await getHistory(limit, offset); const items = data.items ?? []; @@ -330,114 +755,183 @@ export default function Functions() { offset += limit; } setHistory(allItems); - } catch (e) { - setError(e?.message ?? String(e)); - } finally { - setLoading((s) => ({ ...s, history: false })); - } + } catch (e) { setError(e?.message ?? String(e)); } + finally { setLoading((s) => ({ ...s, history: false })); } }; const refreshStats = async () => { - setStatsLoading(true); - setStatsError(''); + setStatsLoading(true); setStatsError(''); try { const cached = readStatsCache(); - if (cached && !stats) { - setStats(cached); - } + if (cached && !stats) setStats(cached); const data = await getStats(); - const shouldUpdate = - !cached || cached.total_draws !== data?.total_draws; - if (shouldUpdate) { - setStats(data); - writeStatsCache(data); + if (!cached || cached.total_draws !== data?.total_draws) { + setStats(data); writeStatsCache(data); } - } catch (e) { - setStatsError(e?.message ?? String(e)); - } finally { - setStatsLoading(false); - } + } catch (e) { setStatsError(e?.message ?? String(e)); } + finally { setStatsLoading(false); } }; - // ── 시뮬레이션 관련 함수 ─────────────────────────────────────────────────── const refreshBestPicks = async () => { setLoading((s) => ({ ...s, bestPicks: true })); - try { - const data = await getBestPicks(20); - setBestPicks(data.items ?? []); - } catch (e) { - // best picks 없을 수 있음 (첫 실행 전) - 전역 에러 표시 안 함 - } finally { - setLoading((s) => ({ ...s, bestPicks: false })); - } + try { setBestPicks((await getBestPicks(20)).items ?? []); } + catch {} + finally { setLoading((s) => ({ ...s, bestPicks: false })); } }; const refreshAnalysis = async () => { setLoading((s) => ({ ...s, analysis: true })); - try { - const data = await getAnalysis(); - setAnalysis(data); - } catch { - // 분석 데이터는 보조 정보이므로 에러 무시 - } finally { - setLoading((s) => ({ ...s, analysis: false })); - } + try { setAnalysis(await getAnalysis()); } + catch {} + finally { setLoading((s) => ({ ...s, analysis: false })); } }; + // ── 신규 로드 함수 ───────────────────────────────────────────────────────── + const refreshPerfStats = async () => { + try { setPerfStats(await getPerformanceStats()); } catch {} + }; + + const refreshReport = async () => { + setReportLoading(true); + try { + const [rep, hist] = await Promise.all([ + getLatestReport(), + getReportHistory(10), + ]); + setReport(rep); + setReportHistory(hist?.reports ?? []); + } catch {} + finally { setReportLoading(false); } + }; + + const loadSpecificReport = async (drwNo) => { + setReportLoading(true); + try { setReport(await getReport(drwNo)); } + catch {} + finally { setReportLoading(false); } + }; + + const refreshPersonalAnalysis = async () => { + setPersonalLoading(true); + try { setPersonalAnalysis(await getPersonalAnalysis()); } + catch {} + finally { setPersonalLoading(false); } + }; + + const refreshPurchases = async () => { + setPurchaseLoading(true); + try { + const [recs, st] = await Promise.all([getPurchases(), getPurchaseStats()]); + setPurchases(recs?.records ?? []); + setPurchaseStats(st); + } catch {} + finally { setPurchaseLoading(false); } + }; + + // ── 시뮬레이션 ───────────────────────────────────────────────────────────── const onSimulate = async () => { const ok = confirm('시뮬레이션을 즉시 실행할까요?\n20,000개 후보를 분석합니다. (약 1~3분 소요)'); if (!ok) return; - setSimulating(true); - setSimResult(null); - setError(''); + setSimulating(true); setSimResult(null); setError(''); try { const data = await triggerSimulate(); setSimResult(data); await refreshBestPicks(); - } catch (e) { - setError(e?.message ?? String(e)); - } finally { - setSimulating(false); - } + } catch (e) { setError(e?.message ?? String(e)); } + finally { setSimulating(false); } }; // ── 수동 추천 ────────────────────────────────────────────────────────────── const onRecommend = async () => { - setLoading((s) => ({ ...s, recommend: true })); - setError(''); - try { - const data = await recommend(params); - setResult(data); - await refreshHistory(); - } catch (e) { - setError(e?.message ?? String(e)); - } finally { - setLoading((s) => ({ ...s, recommend: false })); - } + setLoading((s) => ({ ...s, recommend: true })); setError(''); + try { const data = await recommend(params); setResult(data); await refreshHistory(); } + catch (e) { setError(e?.message ?? String(e)); } + finally { setLoading((s) => ({ ...s, recommend: false })); } }; const onDelete = async (id) => { - const ok = confirm(`히스토리 #${id}를 삭제할까요?`); - if (!ok) return; + if (!confirm(`히스토리 #${id}를 삭제할까요?`)) return; setError(''); - try { - await deleteHistory(id); - setHistory((prev) => prev.filter((item) => item.id !== id)); - } catch (e) { - setError(e?.message ?? String(e)); - } + try { await deleteHistory(id); setHistory((prev) => prev.filter((item) => item.id !== id)); } + catch (e) { setError(e?.message ?? String(e)); } }; const copyNumbers = async (nums) => { const text = nums.join(', '); + try { await navigator.clipboard.writeText(text); alert(`복사 완료: ${text}`); } + catch { prompt('복사해서 사용하세요:', text); } + }; + + // ── 구매 기록 CRUD ───────────────────────────────────────────────────────── + const handlePurchaseFormOpen = () => { + setPurchaseEditId(null); + setPurchaseForm(emptyPurchaseForm()); + setPurchaseFormError(''); + setPurchaseFormOpen(true); + }; + + const handlePurchaseFormClose = () => { + setPurchaseFormOpen(false); + setPurchaseEditId(null); + setPurchaseFormError(''); + }; + + const handlePurchaseFormChange = (field, value) => { + setPurchaseForm((prev) => ({ ...prev, [field]: value })); + }; + + const handlePurchaseEditStart = (rec) => { + setPurchaseEditId(rec.id); + setPurchaseForm({ + draw_no: String(rec.draw_no ?? ''), + amount: rec.amount ?? 5000, + sets: rec.sets ?? 5, + prize: rec.prize ?? 0, + note: rec.note ?? '', + }); + setPurchaseFormError(''); + setPurchaseFormOpen(true); + }; + + const handlePurchaseFormSubmit = async (e) => { + e.preventDefault(); + setPurchaseFormSaving(true); setPurchaseFormError(''); + const payload = { + draw_no: Number(purchaseForm.draw_no), + amount: Number(purchaseForm.amount), + sets: Number(purchaseForm.sets), + prize: Number(purchaseForm.prize), + note: purchaseForm.note.trim(), + }; try { - await navigator.clipboard.writeText(text); - alert(`복사 완료: ${text}`); - } catch { - prompt('복사해서 사용하세요:', text); + if (purchaseEditId != null) { + const updated = await updatePurchase(purchaseEditId, payload); + setPurchases((prev) => + prev.map((r) => r.id === purchaseEditId ? (updated ?? { ...payload, id: purchaseEditId }) : r) + ); + } else { + const saved = await addPurchase(payload); + setPurchases((prev) => [saved ?? { ...payload, id: Date.now() }, ...prev]); + } + // 통계 재로드 + try { setPurchaseStats(await getPurchaseStats()); } catch {} + handlePurchaseFormClose(); + } catch (err) { + setPurchaseFormError(err?.message ?? String(err)); + } finally { + setPurchaseFormSaving(false); } }; + const handlePurchaseDelete = async (id) => { + if (!confirm('이 구매 기록을 삭제할까요?')) return; + setPurchases((prev) => prev.filter((r) => r.id !== id)); + try { + await deletePurchase(id); + try { setPurchaseStats(await getPurchaseStats()); } catch {} + } catch { refreshPurchases(); } + }; + // ── 초기 로드 ────────────────────────────────────────────────────────────── useEffect(() => { refreshLatest(); @@ -445,15 +939,16 @@ export default function Functions() { refreshStats(); refreshBestPicks(); refreshAnalysis(); + refreshPerfStats(); + refreshReport(); + refreshPersonalAnalysis(); + refreshPurchases(); }, []); useEffect(() => { if (historyExpanded && !prevHistoryExpandedRef.current) { requestAnimationFrame(() => { - historyEndRef.current?.scrollIntoView({ - behavior: 'smooth', - block: 'end', - }); + historyEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }); }); } prevHistoryExpandedRef.current = historyExpanded; @@ -468,13 +963,14 @@ export default function Functions() {

오류

{error}

- +
) : null} - {/* ── 상단 2열 그리드: 최신 회차 + 시뮬레이션 추천 ── */} + {/* ── 신뢰도 배너 ── */} + + + {/* ── 최신 회차 + 시뮬레이션 추천 ── */}
{/* Latest Draw */}
@@ -482,22 +978,15 @@ export default function Functions() {

Latest Draw

최신 회차

-

- 최신 회차와 번호를 빠르게 확인할 수 있습니다. -

+

최신 회차와 번호를 빠르게 확인할 수 있습니다.

{loading.latest ? 로딩 중 : null} -
- {latest ? ( <>
@@ -505,23 +994,15 @@ export default function Functions() {

{latest.drawNo}회

{latest.date}

-
-

- 보너스 {latest.bonus} -

- {overallMetrics ? ( - - ) : null} +

보너스 {latest.bonus}

+ {overallMetrics && ( + + )} ) : (

최신 회차 데이터가 없습니다.

@@ -540,44 +1021,28 @@ export default function Functions() {
{loading.bestPicks ? 로딩 중 : null} - {simulating ? ( - 분석 중 - ) : null} - -
- {simResult ? ( + {simResult && (
-

- 완료: {simResult.total_generated?.toLocaleString()}개 후보 →{' '} - 상위 {simResult.best_n_saved}개 저장 -

-

- 최고 점수 {((simResult.best_score ?? 0) * 100).toFixed(1)}% /{' '} - 평균 {((simResult.avg_score ?? 0) * 100).toFixed(1)}% -

+

완료: {simResult.total_generated?.toLocaleString()}개 후보 → 상위 {simResult.best_n_saved}개 저장

+

최고 점수 {((simResult.best_score ?? 0) * 100).toFixed(1)}% / 평균 {((simResult.avg_score ?? 0) * 100).toFixed(1)}%

- ) : null} + )} {bestPicks.length === 0 ? (

- {loading.bestPicks - ? '불러오는 중...' - : "시뮬레이션 결과가 없습니다. '지금 실행'으로 시작하세요."} + {loading.bestPicks ? '불러오는 중...' : "시뮬레이션 결과가 없습니다. '지금 실행'으로 시작하세요."}

) : ( <> @@ -592,116 +1057,83 @@ export default function Functions() { {((pick.score_total ?? 0) * 100).toFixed(1)}%
- +
-
))}
- - {bestPicks.length > BEST_PICKS_DEFAULT_SHOW ? ( + {bestPicks.length > BEST_PICKS_DEFAULT_SHOW && ( - ) : null} - + )}

갱신: {fmtKST(bestPicks[0]?.created_at) || '-'} - {bestPicks[0]?.based_on_draw - ? ` · ${bestPicks[0].based_on_draw}회 기준` - : ''} + {bestPicks[0]?.based_on_draw ? ` · ${bestPicks[0].based_on_draw}회 기준` : ''}

)} + {/* ── 이번 주 공략 리포트 ── */} + + {/* ── 통계 분석 ── */}

Analysis

통계 분석

-

- 빈도, Z-score, 갭 분석으로 번호를 분류합니다. -

+

빈도, Z-score, 갭 분석으로 번호를 분류합니다.

{loading.analysis ? 로딩 중 : null} -
- {analysis ? (
-

- 🔥 핫 번호 - 출현 빈도 상위 10 -

+

🔥 핫 번호 출현 빈도 상위 10

- {(analysis.hot_numbers ?? []).map((n) => ( - - ))} + {(analysis.hot_numbers ?? []).map((n) => )}
-

- 🧊 콜드 번호 - 출현 빈도 하위 10 -

+

🧊 콜드 번호 출현 빈도 하위 10

- {(analysis.cold_numbers ?? []).map((n) => ( - - ))} + {(analysis.cold_numbers ?? []).map((n) => )}
-

- ⏰ 오버듀 번호 - 오래 안 나온 번호 (회차 수) -

+

⏰ 오버듀 번호 오래 안 나온 번호 (회차 수)

{(analysis.overdue_numbers ?? []).map((n) => { - const stat = (analysis.number_stats ?? []).find( - (s) => s.number === n - ); + const stat = (analysis.number_stats ?? []).find((s) => s.number === n); return (
- - {stat?.gap ?? '-'}회 - + {stat?.gap ?? '-'}회
); })} @@ -709,21 +1141,13 @@ export default function Functions() {
- - 역대 합계 평균 {analysis.mean_sum} - - - 표준편차 ±{analysis.std_sum} - - - 분석 회차 {analysis.total_draws?.toLocaleString()} - + 역대 합계 평균 {analysis.mean_sum} + 표준편차 ±{analysis.std_sum} + 분석 회차 {analysis.total_draws?.toLocaleString()} 홀수 3:짝수 3 확률{' '} - {analysis.odd_distribution?.['3'] - ? `${analysis.odd_distribution['3']}%` - : '-'} + {analysis.odd_distribution?.['3'] ? `${analysis.odd_distribution['3']}%` : '-'}
@@ -741,27 +1165,18 @@ export default function Functions() {

Distribution

전체 회차 번호 분포

-

- 1~45번 번호가 등장한 횟수를 기준으로 분포를 표시합니다. -

+

1~45번 번호가 등장한 횟수를 기준으로 분포를 표시합니다.

- {statsLoading ? ( - 로딩 중 - ) : null} + {statsLoading ? 로딩 중 : null} {stats?.total_draws ? ( {stats.total_draws}회차 ) : null} -
- {statsError ?

{statsError}

: null} {stats ? ( @@ -770,36 +1185,48 @@ export default function Functions() { )}
+ {/* ── 내 번호 패턴 ── */} + + + {/* ── 구매 기록 ── */} + + {/* ── 수동 추천 ── */}

Manual Recommendation

수동 추천

-

- 파라미터를 직접 조정해 번호를 추천받을 수 있습니다. -

+

파라미터를 직접 조정해 번호를 추천받을 수 있습니다.

- {loading.recommend ? ( - 계산 중 - ) : null} + {loading.recommend ? 계산 중 : null}
{presets.map((preset) => ( - ))} @@ -807,63 +1234,23 @@ export default function Functions() {
- -
- @@ -872,69 +1259,46 @@ export default function Functions() {

추천 ID #{result.id}

-

- 기준 회차 {result.based_on_latest_draw ?? '-'} -

+

기준 회차 {result.based_on_latest_draw ?? '-'}

-
- {result.numbers ? : null} - {historyMetrics ? ( + {result.numbers && } + {historyMetrics && (
- +
- ) : null} + )} {Array.isArray(result.items) && result.items.length ? (
추천 후보 보기
{result.items.map((item, idx) => ( -
+
-

- 후보 #{item.id ?? idx + 1} -

-

- 기준 회차 {item.based_on_draw ?? '-'} -

+

후보 #{item.id ?? idx + 1}

+

기준 회차 {item.based_on_draw ?? '-'}

-
- {item.metrics ? ( - - ) : null} + {item.metrics && }
))}
) : null} - {result.explain ? ( + {result.explain && (
설명 보기
{JSON.stringify(result.explain, null, 2)}
- ) : null} + )}
) : (

아직 추천 결과가 없습니다.

@@ -947,42 +1311,25 @@ export default function Functions() {

History

추천 히스토리

-

- 수동 추천 결과를 모아서 확인할 수 있습니다. -

+

수동 추천 결과를 모아서 확인할 수 있습니다.

{history.length}건 - {history.length > 5 ? ( - - ) : null} -
{loading.history ?

불러오는 중...

: null} - {history.length === 0 ? (

저장된 히스토리가 없습니다.

) : ( @@ -997,22 +1344,15 @@ export default function Functions() {

- window={item.params?.recent_window}, weight= - {item.params?.recent_weight}, avoid_k= - {item.params?.avoid_recent_k} + window={item.params?.recent_window}, weight={item.params?.recent_weight}, + avoid_k={item.params?.avoid_recent_k}

- -
@@ -1024,10 +1364,8 @@ export default function Functions() {
); diff --git a/src/pages/lotto/Lotto.css b/src/pages/lotto/Lotto.css index 20aee89..084f13c 100644 --- a/src/pages/lotto/Lotto.css +++ b/src/pages/lotto/Lotto.css @@ -734,6 +734,344 @@ font-weight: 600; } +/* ── 신뢰도 배너 ─────────────────────────────────────────────────────────── */ + +.lotto-perf-banner { + border: 1px solid var(--line); + border-radius: var(--radius-lg); + padding: 14px 20px; + background: rgba(151, 201, 170, 0.06); + border-color: rgba(151, 201, 170, 0.25); + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} + +.lotto-perf-banner__label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.18em; + color: rgba(151, 201, 170, 0.85); + flex-shrink: 0; +} + +.lotto-perf-banner__items { + display: flex; + align-items: center; + gap: 0; + flex-wrap: wrap; + flex: 1; +} + +.lotto-perf-banner__item { + display: flex; + flex-direction: column; + gap: 2px; + padding: 0 16px; +} + +.lotto-perf-banner__divider { + width: 1px; + height: 32px; + background: var(--line); + flex-shrink: 0; +} + +.lotto-perf-banner__val { + font-size: 18px; + font-weight: 700; + line-height: 1; +} + +.lotto-perf-banner__val.is-pos { color: #97c9aa; } +.lotto-perf-banner__val.is-neg { color: #f7a8a5; } +.lotto-perf-banner__val.is-prize { color: #fdd4b1; } + +.lotto-perf-banner__lbl { + font-size: 11px; + color: var(--muted); + letter-spacing: 0.04em; +} + +/* ── 공략 리포트 ─────────────────────────────────────────────────────────── */ + +.lotto-report-history { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 10px 0; + border-bottom: 1px solid var(--line); +} + +.lotto-report-top { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 20px; +} + +.lotto-report-confidence { + display: flex; + align-items: flex-start; + gap: 16px; + border: 1px solid var(--line); + border-radius: 16px; + padding: 16px; + background: rgba(255, 255, 255, 0.02); +} + +.lotto-confidence-ring { + flex-shrink: 0; +} + +.lotto-report-confidence__title { + margin: 0 0 10px; + font-size: 13px; + font-weight: 600; +} + +.lotto-report-confidence__factors { + display: grid; + gap: 7px; +} + +.lotto-report-confidence__factor { + display: grid; + grid-template-columns: 90px minmax(0, 1fr) 28px; + align-items: center; + gap: 8px; + font-size: 11px; +} + +.lotto-report-confidence__factor-lbl { + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.lotto-report-confidence__factor-val { + text-align: right; + color: var(--muted); + font-weight: 600; +} + +.lotto-report-pattern { + border: 1px solid var(--line); + border-radius: 16px; + padding: 16px; + background: rgba(255, 255, 255, 0.02); +} + +.lotto-report-pattern__title { + margin: 0 0 12px; + font-size: 13px; + font-weight: 600; +} + +.lotto-report-pattern__stats { + display: grid; + gap: 10px; +} + +.lotto-report-pattern__stat { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + color: var(--muted); +} + +.lotto-report-pattern__stat strong { + color: var(--text); + font-size: 15px; + font-weight: 700; +} + +/* 전략 카드 */ +.lotto-strategy-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 14px; +} + +.lotto-strategy-card { + border: 1px solid var(--line); + border-radius: 16px; + padding: 16px; + background: rgba(133, 165, 216, 0.05); + display: grid; + gap: 10px; +} + +.lotto-strategy-card__name { + margin: 0; + font-size: 13px; + font-weight: 600; + color: rgba(133, 165, 216, 0.9); +} + +.lotto-strategy-card__desc { + margin: 0; + font-size: 11px; + color: var(--muted); + line-height: 1.5; +} + +/* ── 개인 패턴 분석 ──────────────────────────────────────────────────────── */ + +.lotto-personal-tendency { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.lotto-personal-tendency__badge { + font-size: 11px; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid rgba(253, 212, 177, 0.4); + background: rgba(253, 212, 177, 0.1); + color: #fdd4b1; + letter-spacing: 0.04em; +} + +/* ── 구매 기록 ───────────────────────────────────────────────────────────── */ + +.lotto-purchase-stats { + display: flex; + flex-wrap: wrap; + gap: 0; + border: 1px solid var(--line); + border-radius: 16px; + overflow: hidden; +} + +.lotto-purchase-stat { + display: flex; + flex-direction: column; + gap: 3px; + padding: 14px 18px; + border-right: 1px solid var(--line); + flex: 1; + min-width: 100px; +} + +.lotto-purchase-stat:last-child { + border-right: none; +} + +.lotto-purchase-stat__val { + font-size: 16px; + font-weight: 700; + line-height: 1; +} + +.lotto-purchase-stat__val.is-pos { color: #97c9aa; } +.lotto-purchase-stat__val.is-neg { color: #f7a8a5; } +.lotto-purchase-stat__val.is-prize { color: #fdd4b1; } + +.lotto-purchase-stat__lbl { + font-size: 11px; + color: var(--muted); +} + +/* 구매 폼 */ +.lotto-purchase-form { + border: 1px solid var(--line); + border-radius: 16px; + padding: 18px; + background: rgba(255, 255, 255, 0.02); + display: grid; + gap: 14px; +} + +.lotto-purchase-form__title { + margin: 0; + font-size: 13px; + font-weight: 600; +} + +.lotto-purchase-form__grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 10px; +} + +.lotto-purchase-form__note { + grid-column: span 2; +} + +.lotto-purchase-form__actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +/* 구매 목록 */ +.lotto-purchase-list { + display: grid; + gap: 0; + border: 1px solid var(--line); + border-radius: 16px; + overflow: hidden; +} + +.lotto-purchase-list__head { + display: grid; + grid-template-columns: 60px 100px 100px 100px minmax(0, 1fr) 120px; + gap: 8px; + padding: 10px 14px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--muted); + background: rgba(0, 0, 0, 0.2); + border-bottom: 1px solid var(--line); +} + +.lotto-purchase-row { + display: grid; + grid-template-columns: 60px 100px 100px 100px minmax(0, 1fr) 120px; + gap: 8px; + align-items: center; + padding: 12px 14px; + font-size: 13px; + border-bottom: 1px solid var(--line); + transition: background 0.15s ease; +} + +.lotto-purchase-row:last-child { + border-bottom: none; +} + +.lotto-purchase-row:hover { + background: rgba(255, 255, 255, 0.03); +} + +.lotto-purchase-row__drw { + font-weight: 600; +} + +.lotto-purchase-row__note { + color: var(--muted); + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lotto-purchase-row__actions { + display: flex; + gap: 6px; + justify-content: flex-end; +} + +.is-pos { color: #97c9aa; } +.is-neg { color: #f7a8a5; } +.is-prize { color: #fdd4b1; } + /* ── 반응형 ─────────────────────────────────────────────────────────────── */ @media (max-width: 900px) { @@ -754,6 +1092,68 @@ grid-template-columns: 24px minmax(0, 1fr) auto; gap: 8px; } + + .lotto-report-top { + grid-template-columns: 1fr; + } + + .lotto-purchase-list__head, + .lotto-purchase-row { + grid-template-columns: 56px 90px 90px minmax(0, 1fr) 100px; + } + + .lotto-purchase-list__head span:nth-child(4), + .lotto-purchase-row span:nth-child(4) { + display: none; + } +} + +@media (max-width: 640px) { + .lotto-purchase-stats { + flex-direction: column; + } + + .lotto-purchase-stat { + border-right: none; + border-bottom: 1px solid var(--line); + flex-direction: row; + justify-content: space-between; + align-items: center; + padding: 10px 14px; + } + + .lotto-purchase-stat:last-child { + border-bottom: none; + } + + .lotto-purchase-list__head, + .lotto-purchase-row { + grid-template-columns: 56px minmax(0, 1fr) auto; + gap: 8px; + } + + .lotto-purchase-list__head span:nth-child(n+3):nth-child(-n+5), + .lotto-purchase-row span:nth-child(n+3):nth-child(-n+5) { + display: none; + } + + .lotto-purchase-form__note { + grid-column: span 1; + } + + .lotto-perf-banner { + flex-direction: column; + align-items: flex-start; + gap: 10px; + } + + .lotto-perf-banner__items { + width: 100%; + } + + .lotto-perf-banner__item { + padding: 0 10px; + } } @media (max-width: 768px) { diff --git a/src/pages/travel/Travel.css b/src/pages/travel/Travel.css index cd25772..18c8bd4 100644 --- a/src/pages/travel/Travel.css +++ b/src/pages/travel/Travel.css @@ -1,290 +1,961 @@ +/* ═══════════════════════════════════════════════════ + Travel — "Dark Room" Editorial Photo Archive + Fonts: Cormorant Garamond (display) · Space Mono (mono) +═══════════════════════════════════════════════════ */ + +@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;1,300;1,400&family=Space+Mono:ital@0;1&display=swap'); + +/* ── CSS tokens ──────────────────────────────────────── */ .travel { + --tv-bg: #0f0c09; + --tv-surface: #1a1510; + --tv-surface-2: #221c14; + --tv-line: rgba(232, 221, 208, 0.1); + --tv-line-bright: rgba(232, 221, 208, 0.22); + --tv-text: #e8ddd0; + --tv-muted: rgba(232, 221, 208, 0.45); + --tv-dim: rgba(232, 221, 208, 0.25); + --tv-accent: var(--region-accent, #c8905e); + --tv-serif: 'Cormorant Garamond', Georgia, serif; + --tv-mono: 'Space Mono', 'Courier New', monospace; + --tv-r-sm: 10px; + --tv-r-md: 16px; + --tv-r-lg: 22px; + + display: grid; + gap: 40px; + color: var(--tv-text); + font-family: var(--tv-serif); +} + +/* ═══════════════════════════════════════════════════ + HEADER — editorial masthead +═══════════════════════════════════════════════════ */ +.tv-header { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); + gap: 32px; + align-items: end; + padding-bottom: 28px; + border-bottom: 1px solid var(--tv-line-bright); +} + +.tv-header__meta { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 14px; +} + +.tv-header__issue, +.tv-header__tagline { + font-family: var(--tv-mono); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.22em; + color: var(--tv-accent); +} + +.tv-header__divider { + color: var(--tv-line-bright); +} + +.tv-header__title { + font-family: var(--tv-serif); + font-weight: 300; + line-height: 0.9; + margin: 0 0 18px; + font-size: clamp(52px, 8vw, 88px); + letter-spacing: -0.02em; + display: flex; + flex-direction: column; +} + +.tv-header__title-main { + color: var(--tv-text); +} + +.tv-header__title-italic { + font-style: italic; + font-weight: 300; + color: var(--tv-accent); + margin-left: 0.12em; +} + +.tv-header__desc { + margin: 0; + color: var(--tv-muted); + font-size: 14px; + line-height: 1.75; + font-family: var(--tv-serif); + font-style: italic; + max-width: 420px; +} + +/* Active region info */ +.tv-header__active-region { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 18px 20px; + border: 1px solid rgba(var(--tv-accent-rgb, 200, 144, 94), 0.28); + border-radius: var(--tv-r-md); + background: rgba(255, 255, 255, 0.03); +} + +.tv-header__region-indicator { + width: 3px; + height: 52px; + border-radius: 999px; + background: var(--accent, var(--tv-accent)); + flex-shrink: 0; + margin-top: 2px; +} + +.tv-header__region-label { + font-family: var(--tv-mono); + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.2em; + color: var(--tv-dim); + margin: 0 0 4px; +} + +.tv-header__region-name { + font-family: var(--tv-serif); + font-size: 28px; + font-weight: 600; + color: var(--tv-text); + margin: 0 0 4px; + letter-spacing: -0.01em; +} + +.tv-header__region-count { + font-family: var(--tv-mono); + font-size: 10px; + color: var(--tv-muted); + letter-spacing: 0.14em; + margin: 0; +} + +/* Hint when no region selected */ +.tv-header__hint { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + justify-content: center; + padding: 24px; + text-align: center; + border: 1px dashed var(--tv-line-bright); + border-radius: var(--tv-r-md); +} + +.tv-header__hint-icon { + color: var(--tv-dim); + opacity: 0.6; +} + +.tv-header__hint-text { + font-family: var(--tv-mono); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.18em; + color: var(--tv-muted); + margin: 0; +} + +/* ═══════════════════════════════════════════════════ + MAP SECTION +═══════════════════════════════════════════════════ */ +.tv-map-section { display: grid; gap: 28px; + transition: opacity 0.35s ease; } -.travel-header { - display: grid; - grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr); - gap: 24px; - align-items: center; -} - -.travel-kicker { - text-transform: uppercase; - letter-spacing: 0.3em; - font-size: 12px; - color: var(--accent-travel); - margin: 0 0 10px; -} - -.travel-header h1 { - font-family: var(--font-display); - margin: 0 0 12px; - font-size: clamp(30px, 4vw, 40px); -} - -@media (max-width: 768px) { - .travel-header h1 { - font-size: clamp(24px, 6vw, 32px); - } -} - -.travel-sub { - margin: 0; - color: var(--muted); -} - -.travel-note { - border: 1px solid var(--line); - border-radius: 20px; - padding: 20px; - background: var(--surface); -} - -.travel-note__title { - margin: 0 0 8px; - font-weight: 600; -} - -.travel-note__desc { - margin: 0; - color: var(--muted); -} - -.travel-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 18px; -} - -@media (max-width: 768px) { - .travel-grid { - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 12px; - } -} - -.travel-albums { - display: grid; - gap: 24px; -} - -.travel-albums.is-blurred { - opacity: 0.5; - transform: scale(0.995); - transition: opacity 0.2s ease, transform 0.2s ease; -} - -.travel-albums.is-blurred * { +.tv-map-section.is-dimmed { + opacity: 0.3; pointer-events: none; } -.travel-map { - display: grid; - gap: 18px; +.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); } -.travel-map__canvas { +.tv-map { width: 100%; - min-height: 520px; - border-radius: var(--radius-lg); - overflow: hidden; - border: 1px solid rgba(255, 255, 255, 0.12); - background: rgba(10, 12, 20, 0.6); - box-shadow: var(--shadow-md); + height: 480px; } @media (max-width: 768px) { - .travel-map__canvas { - min-height: 300px; + .tv-map { + height: 300px; } } -.travel-map__info { - width: 100%; - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 18px; - padding: 14px 16px; - background: rgba(10, 12, 20, 0.75); - backdrop-filter: blur(6px); - display: grid; +/* 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); +} + +/* ── Loading / Error states ──────────────────────── */ +.tv-state { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + color: var(--tv-muted); + font-family: var(--tv-mono); + font-size: 11px; + letter-spacing: 0.1em; +} + +.tv-state__loader { + display: flex; gap: 8px; - align-content: center; +} + +.tv-state__loader span { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--tv-accent); + animation: tv-pulse 1.2s ease-in-out infinite; +} + +.tv-state__loader span:nth-child(2) { animation-delay: 0.2s; } +.tv-state__loader span:nth-child(3) { animation-delay: 0.4s; } + +@keyframes tv-pulse { + 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } + 40% { transform: scale(1); opacity: 1; } +} + +.tv-state--empty { + font-family: var(--tv-mono); + font-size: 11px; + letter-spacing: 0.16em; text-align: center; } -.travel-map__title { - margin: 0; - font-size: 14px; - letter-spacing: 0.2em; - text-transform: uppercase; - color: var(--accent-travel); +.tv-error { + font-family: var(--tv-mono); + font-size: 11px; + color: #f2a09a; + border: 1px solid rgba(242, 160, 154, 0.3); + border-radius: var(--tv-r-sm); + padding: 12px 16px; + background: rgba(242, 160, 154, 0.06); + letter-spacing: 0.08em; } -.travel-map__desc { - margin: 0; - color: var(--muted); - font-size: 14px; -} - -.travel-album { - border: 1px solid var(--line); - border-radius: 24px; - padding: 20px; - background: rgba(9, 10, 16, 0.5); - display: grid; - gap: 18px; -} - -.travel-album__footer { - display: flex; - justify-content: center; -} - -.travel-load-more { - border: 1px solid rgba(255, 255, 255, 0.2); - background: rgba(10, 12, 20, 0.6); - color: #f8f4f0; - border-radius: 999px; - padding: 10px 18px; - font-size: 13px; - letter-spacing: 0.02em; - cursor: pointer; - transition: transform 0.2s ease, border-color 0.2s ease; -} - -.travel-load-more:hover { - transform: translateY(-1px); - border-color: rgba(255, 255, 255, 0.5); -} - -.travel-album__head { +/* ═══════════════════════════════════════════════════ + ALBUM HEADER +═══════════════════════════════════════════════════ */ +.tv-album-header { display: flex; justify-content: space-between; - align-items: center; + 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; } -.travel-album__eyebrow { - margin: 0 0 6px; - font-size: 12px; +.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; - letter-spacing: 0.22em; - color: var(--accent); } -.travel-album__meta { - margin: 6px 0 0; - color: var(--muted); - font-size: 13px; +.tv-album-header__count { + font-family: var(--tv-mono); + font-size: 11px; + color: var(--tv-dim); + letter-spacing: 0.12em; + flex-shrink: 0; } -.travel-album__cover { - width: 120px; - height: 120px; - border-radius: 16px; - object-fit: cover; - border: 1px solid rgba(255, 255, 255, 0.12); +/* ═══════════════════════════════════════════════════ + 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; } -.travel-state { - color: var(--muted); -} - -.travel-error { - color: #f9b6b1; - border: 1px solid rgba(249, 182, 177, 0.4); - border-radius: 14px; - padding: 12px; - background: rgba(249, 182, 177, 0.1); -} - -.travel-card { - position: relative; - border-radius: 20px; - overflow: hidden; - border: 1px solid rgba(255, 255, 255, 0.12); - min-height: 220px; - cursor: pointer; - opacity: 0; - transform: translateY(22px) scale(0.98); - transition: opacity 0.45s ease, transform 0.45s ease; - transition-delay: var(--reveal-delay, 0ms); - will-change: opacity, transform; -} - -@media (max-width: 768px) { - .travel-card { - min-height: 150px; +@media (max-width: 1024px) { + .photo-mosaic { + grid-template-columns: repeat(3, 1fr); + grid-auto-rows: 200px; } } -.travel-card.is-wide { +@media (max-width: 640px) { + .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; } -.travel-card[data-revealed='true'] { - opacity: 1; - transform: translateY(0) scale(1); -} - -.travel-card img { +/* Image */ +.photo-card img { width: 100%; height: 100%; object-fit: cover; display: block; - filter: saturate(1.05); + transition: transform 0.6s cubic-bezier(0.25, 0, 0, 1), filter 0.4s ease; + filter: saturate(0.85) brightness(0.92); } -.travel-card__overlay { +.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(180deg, rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.7)); - color: #f8f4f0; + 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; - gap: 6px; - padding: 18px; + padding: 14px; } -.travel-card__title { +.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; - font-weight: 600; - font-size: 18px; + letter-spacing: 0.06em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; } -.travel-modal { +/* 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(6, 8, 12, 0.55); - backdrop-filter: blur(var(--modal-blur, 6px)); + 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; - align-items: start; - justify-items: center; - padding: 28px 24px 24px; - z-index: 2000; + place-items: center; } -.travel-modal__content { +.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; - max-width: min(1200px, 94vw); - max-height: 90vh; - background: rgba(10, 12, 20, 0.92); - border: 1px solid rgba(255, 255, 255, 0.14); - border-radius: 18px; - padding: 20px; - display: grid; - gap: 14px; - margin-top: 24px; + 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(18px); - transition: opacity 0.5s ease, transform 0.5s ease; + transform: translateY(20px); + transition: opacity 0.6s ease, transform 0.6s ease; } [data-reveal][data-revealed='true'] { @@ -292,345 +963,84 @@ transform: translateY(0); } -.travel-modal__summary { - display: grid; - gap: 6px; - padding-bottom: 6px; - border-bottom: 1px solid rgba(255, 255, 255, 0.08); -} - -.travel-modal__summary-title { - margin: 0; - font-size: 15px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--accent-travel); -} - -.travel-modal__summary-meta { - margin: 0; - color: var(--muted); - font-size: 12px; - line-height: 1.5; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -} - -.travel-modal__stage { - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - align-items: center; - gap: 14px; -} - -.travel-modal__controls { - display: grid; - gap: 8px; - font-size: 11px; - color: var(--muted); - text-transform: uppercase; - letter-spacing: 0.12em; -} - -.travel-modal__control { - display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; -} - -.travel-blur-slider { - display: flex; - align-items: center; - gap: 10px; -} - -.travel-blur-slider input[type='range'] { - appearance: none; - width: 140px; - height: 4px; - background: rgba(255, 255, 255, 0.2); - border-radius: 999px; - outline: none; -} - -.travel-blur-slider input[type='range']::-webkit-slider-thumb { - appearance: none; - width: 14px; - height: 14px; - border-radius: 50%; - background: #f8f4f0; - border: 1px solid rgba(255, 255, 255, 0.5); - cursor: pointer; -} - -.travel-blur-slider input[type='range']::-moz-range-thumb { - width: 14px; - height: 14px; - border-radius: 50%; - background: #f8f4f0; - border: 1px solid rgba(255, 255, 255, 0.5); - cursor: pointer; -} - -.travel-blur-value { - font-size: 11px; - color: var(--muted); -} - -.travel-modal__frame { - width: 100%; - height: 68vh; - max-height: 68vh; - display: grid; - place-items: center; - overflow: hidden; - background: rgba(8, 10, 16, 0.3); - border-radius: 16px; -} - -.travel-modal__image { - width: min(100%, 980px); - max-height: 68vh; - object-fit: contain; - border-radius: 14px; -} - -.travel-modal__image.is-next { - animation: travel-slide-next 280ms ease; -} - -.travel-modal__image.is-prev { - animation: travel-slide-prev 280ms ease; -} - -.travel-modal__strip-wrap { - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - align-items: center; - gap: 8px; -} - -.travel-modal__strip { - display: flex; - gap: 8px; - overflow-x: auto; - padding-bottom: 6px; - scroll-behavior: smooth; - scrollbar-width: thin; -} - -.travel-modal__thumb { - width: 64px; - height: 48px; - border-radius: 10px; - border: 1px solid rgba(255, 255, 255, 0.2); - background: rgba(10, 12, 20, 0.6); - padding: 0; - cursor: pointer; - opacity: 0.7; - flex: 0 0 auto; -} - -.travel-modal__thumb img { - width: 100%; - height: 100%; - object-fit: cover; - border-radius: 10px; - display: block; -} - -.travel-modal__thumb.is-active { - opacity: 1; - border-color: rgba(255, 255, 255, 0.6); -} - -.travel-modal__strip-arrow { - width: 32px; - height: 32px; - border-radius: 999px; - border: 1px solid rgba(255, 255, 255, 0.2); - background: rgba(10, 12, 20, 0.7); - color: #f8f4f0; - font-size: 18px; - cursor: pointer; - transition: transform 0.2s ease, border-color 0.2s ease; -} - -.travel-modal__strip-arrow:hover { - transform: translateY(-1px); - border-color: rgba(255, 255, 255, 0.5); -} - -.travel-modal__meta { - margin: 0; - color: var(--muted); - font-size: 12px; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.travel-modal__close { - position: absolute; - top: 10px; - right: 10px; - width: 32px; - height: 32px; - border-radius: 50%; - border: 1px solid rgba(255, 255, 255, 0.2); - background: rgba(10, 12, 20, 0.8); - color: #f8f4f0; - font-size: 18px; - cursor: pointer; -} - -.travel-modal__arrow { - pointer-events: auto; - width: 56px; - height: 56px; - border-radius: 16px; - border: 1px solid rgba(255, 255, 255, 0.2); - background: rgba(10, 12, 20, 0.85); - color: #f8f4f0; - font-size: 26px; - cursor: pointer; - transition: transform 0.2s ease, border-color 0.2s ease; -} - -.travel-modal__arrow.is-loading { - position: relative; -} - -.travel-modal__arrow-icon { - display: block; -} - -.travel-modal__spinner { - position: absolute; - inset: 0; - margin: auto; - width: 18px; - height: 18px; - border-radius: 50%; - border: 2px solid rgba(255, 255, 255, 0.35); - border-top-color: rgba(255, 255, 255, 0.9); - animation: travel-spin 0.8s linear infinite; -} - -.travel-modal__arrow:hover { - transform: translateY(-1px) scale(1.02); - border-color: rgba(255, 255, 255, 0.5); -} - -.travel-modal__arrow:disabled { - cursor: not-allowed; - opacity: 0.4; - transform: none; - border-color: rgba(255, 255, 255, 0.15); -} - -@keyframes travel-slide-next { - from { - transform: translateX(20px) scale(0.98); - } - to { - transform: translateX(0) scale(1); - } -} - -@keyframes travel-slide-prev { - from { - transform: translateX(-20px) scale(0.98); - } - to { - transform: translateX(0) scale(1); - } -} - -@keyframes travel-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -.travel-modal__toast { - position: absolute; - left: 24px; - bottom: 20px; - padding: 8px 12px; - border-radius: 999px; - background: rgba(10, 12, 20, 0.85); - border: 1px solid rgba(255, 255, 255, 0.2); - color: #f8f4f0; - font-size: 12px; - letter-spacing: 0.04em; - box-shadow: 0 8px 18px rgba(0, 0, 0, 0.3); -} - -@media (max-width: 768px) { - .travel-modal__content { - padding: 16px; - } - - .travel-modal__stage { - grid-template-columns: 1fr; - gap: 10px; - } - - .travel-modal__frame { - height: 56vh; - max-height: 56vh; - } - - .travel-modal__image { - max-height: 56vh; - } - - .travel-modal__arrow { - width: 52px; - height: 52px; - font-size: 24px; - justify-self: center; - } -} - -.travel-card__meta { - margin: 0; - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.16em; - color: rgba(248, 244, 240, 0.8); -} - +/* ═══════════════════════════════════════════════════ + RESPONSIVE +═══════════════════════════════════════════════════ */ @media (max-width: 900px) { - .travel-header { + .tv-header { grid-template-columns: 1fr; } - - .travel-map { - grid-template-columns: 1fr; - } - - .travel-card.is-wide { - grid-column: span 1; - } - - .travel-album__cover { - width: 100%; - height: 160px; - } } +@media (max-width: 640px) { + .travel { + gap: 28px; + } + + .tv-header { + gap: 20px; + padding-bottom: 20px; + } + + .tv-header__title { + 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; + } +} + +/* ═══════════════════════════════════════════════════ + REDUCED MOTION +═══════════════════════════════════════════════════ */ @media (prefers-reduced-motion: reduce) { - .travel-card, + .photo-card, [data-reveal] { opacity: 1 !important; transform: none !important; transition: none !important; } + + .lightbox__photo.slide-next, + .lightbox__photo.slide-prev { + animation: none !important; + } + + .photo-card img { + transition: none !important; + } } diff --git a/src/pages/travel/Travel.jsx b/src/pages/travel/Travel.jsx index f5c348a..0703369 100644 --- a/src/pages/travel/Travel.jsx +++ b/src/pages/travel/Travel.jsx @@ -3,9 +3,56 @@ import { GeoJSON, MapContainer, TileLayer, useMap } from 'react-leaflet'; import 'leaflet/dist/leaflet.css'; import './Travel.css'; +/* ───────────────────────────────────────────── + 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) => { @@ -47,9 +94,67 @@ const getStripRange = (length, center) => { return [start, end]; }; -const TravelPhotoGrid = ({ +/* ───────────────────────────────────────────── + 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, @@ -59,23 +164,21 @@ const TravelPhotoGrid = ({ const gridRef = useRef(null); const revealObserverRef = useRef(null); + /* Infinite scroll sentinel */ useEffect(() => { const sentinel = sentinelRef.current; - if (!sentinel || !onLoadMore) return undefined; - - const observer = new IntersectionObserver( - (entries) => { - if (!entries[0]?.isIntersecting) return; - if (isLoadingMore || !hasNext) return; - onLoadMore(); + if (!sentinel || !onLoadMore) return; + const io = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting && !isLoadingMore && hasNext) onLoadMore(); }, - { rootMargin: '240px' } + { rootMargin: '300px' } ); - - observer.observe(sentinel); - return () => observer.disconnect(); + io.observe(sentinel); + return () => io.disconnect(); }, [hasNext, isLoadingMore, onLoadMore]); + /* Scroll-reveal observer */ useEffect(() => { revealObserverRef.current = new IntersectionObserver( (entries) => { @@ -85,9 +188,8 @@ const TravelPhotoGrid = ({ revealObserverRef.current?.unobserve(entry.target); }); }, - { rootMargin: '120px', threshold: 0.15 } + { rootMargin: '120px', threshold: 0.05 } ); - return () => revealObserverRef.current?.disconnect(); }, []); @@ -95,107 +197,79 @@ const TravelPhotoGrid = ({ const observer = revealObserverRef.current; const grid = gridRef.current; if (!observer || !grid) return; - const cards = grid.querySelectorAll( - '.travel-card:not([data-revealed="true"])' - ); - cards.forEach((card) => observer.observe(card)); - const fallback = window.setTimeout(() => { - const stillHidden = grid.querySelectorAll( - '.travel-card:not([data-revealed="true"])' - ); - if (stillHidden.length) { - stillHidden.forEach( - (card) => (card.dataset.revealed = 'true') - ); - } - }, 500); + 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 () => { - window.clearTimeout(fallback); - cards.forEach((card) => observer.unobserve(card)); + clearTimeout(fallback); + cards.forEach((c) => observer.unobserve(c)); }; }, [photos.length]); return ( <> -
- {photos.map((photo, index) => { - const label = getPhotoLabel(photo); - return ( -
onSelectPhoto(index, event)} - role="button" - tabIndex={0} - onKeyDown={(event) => { - if (event.key === 'Enter') { - onSelectPhoto(index, event); - } - }} - > - {label} { - const img = event.currentTarget; - if (photo.original && img.src !== photo.original) { - img.src = photo.original; - } - }} - /> -
-

{label}

-
-
- ); - })} +
+ {photos.map((photo, index) => ( + + ))}
-
- {hasNext ? ( - - ) : photos.length ? ( -

모든 사진을 불러왔습니다.

- ) : null} + +
+ {isLoadingMore && ( +
+ + + +
+ )} + {!hasNext && photos.length > 0 && ( +

+ +  {photos.length} frames developed  + +

+ )}
); }; -const RegionsLayer = ({ geojson, onSelectRegion }) => { +/* ───────────────────────────────────────────── + MapLayer — GeoJSON region polygons +───────────────────────────────────────────── */ +const MapLayer = ({ geojson, selectedRegionId, onSelectRegion }) => { const map = useMap(); - if (!geojson) return null; return ( ({ - color: '#7c7c7c', - weight: 1, - fillOpacity: 0.2, - })} + style={(feature) => { + 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, - }); + map.fitBounds(layer.getBounds(), { padding: [40, 40], animate: true }); onSelectRegion({ id: feature.properties.id, name: feature.properties.name || feature.properties.id, @@ -206,6 +280,256 @@ const RegionsLayer = ({ geojson, onSelectRegion }) => { ); }; +/* ───────────────────────────────────────────── + 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); @@ -215,221 +539,145 @@ const Travel = () => { const [selectedRegion, setSelectedRegion] = useState(null); const [regionsGeojson, setRegionsGeojson] = useState(null); const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(null); - const [modalOffset, setModalOffset] = useState(24); - const touchStartXRef = useRef(null); const [slideDirection, setSlideDirection] = useState('next'); const [slideToken, setSlideToken] = useState(0); - const pendingAdvanceRef = useRef(null); const [backdropBlur, setBackdropBlur] = useState(6); const [thumbScrollDuration, setThumbScrollDuration] = useState(360); const [toastMessage, setToastMessage] = useState(''); - const toastTimerRef = useRef(null); - const thumbStripRef = useRef(null); const [page, setPage] = useState(1); const [hasNext, setHasNext] = useState(true); + + 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); + const regionAccent = getRegionAccent(selectedRegion?.id || ''); + + /* ── Scroll-reveal for page sections ─── */ useEffect(() => { const root = travelRef.current; - if (!root) return undefined; - const observer = new IntersectionObserver( + if (!root) return; + const io = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (!entry.isIntersecting) return; entry.target.dataset.revealed = 'true'; - observer.unobserve(entry.target); + io.unobserve(entry.target); }); }, { rootMargin: '140px' } ); - const targets = root.querySelectorAll('[data-reveal]'); - targets.forEach((node) => observer.observe(node)); - return () => observer.disconnect(); + root.querySelectorAll('[data-reveal]').forEach((n) => io.observe(n)); + return () => io.disconnect(); }, []); + /* ── Load GeoJSON regions ──────────────── */ useEffect(() => { const controller = new AbortController(); - - const loadRegions = async () => { + (async () => { try { - const regionRes = await fetch('/api/travel/regions', { - signal: controller.signal, - }); - if (!regionRes.ok) { - throw new Error( - `지역 정보 로딩 실패 (${regionRes.status})` - ); - } - const regionJson = await regionRes.json(); - setRegionsGeojson(regionJson); + 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') return; - setError(err?.message ?? String(err)); + if (err?.name !== 'AbortError') setError(err?.message ?? String(err)); } - }; - - loadRegions(); + })(); return () => controller.abort(); }, []); + /* ── Load photos for selected region ──── */ useEffect(() => { if (!selectedRegion) { - setPhotos([]); - setPhotoSummary(null); - setSelectedPhotoIndex(null); - setPage(1); - setHasNext(true); - return undefined; + setPhotos([]); setPhotoSummary(null); + setSelectedPhotoIndex(null); setPage(1); setHasNext(true); + return; } const controller = new AbortController(); - - const loadRegionPhotos = async () => { + (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(''); - if (cached.items.length > 0) { - setModalOffset(24); - setSelectedPhotoIndex(0); - } else { - setSelectedPhotoIndex(null); - } + 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); + setLoading(true); setLoadingMore(false); setError(''); + setPhotos([]); setPhotoSummary(null); setSelectedPhotoIndex(null); + setPage(1); setHasNext(true); try { - const photoRes = await fetch( - `/api/travel/photos?region=${encodeURIComponent( - selectedRegion.id - )}&page=1&size=${PAGE_SIZE}`, + const res = await fetch( + `/api/travel/photos?region=${encodeURIComponent(selectedRegion.id)}&page=1&size=${PAGE_SIZE}`, { signal: controller.signal } ); - if (!photoRes.ok) { - throw new Error( - `지역 사진 로딩 실패 (${photoRes.status})` - ); - } - const photoJson = await photoRes.json(); - const items = Array.isArray(photoJson) - ? photoJson - : photoJson.items ?? []; - const summarySource = Array.isArray(photoJson) - ? {} - : photoJson ?? {}; + 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 summarySource.has_next === 'boolean' - ? summarySource.has_next - : typeof summarySource.hasNext === 'boolean' - ? summarySource.hasNext - : normalized.length >= PAGE_SIZE; - const summaryPayload = hasSummaryInfo(summarySource) - ? { - total: summarySource.total, - albums: summarySource.matched_albums ?? [], - } + 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(summaryPayload); + setPhotoSummary(summary); setPhotos(normalized); setHasNext(nextHasNext); setPage(2); cacheRef.current.set(selectedRegion.id, { timestamp: Date.now(), - items: normalized, - page: 2, - hasNext: nextHasNext, - summary: summaryPayload, + items: normalized, page: 2, hasNext: nextHasNext, summary, }); - if (normalized.length > 0) { - setModalOffset(24); - setSelectedPhotoIndex(0); - } else { - setSelectedPhotoIndex(null); - } + setSelectedPhotoIndex(normalized.length > 0 ? 0 : null); } catch (err) { if (err?.name === 'AbortError') return; setError(err?.message ?? String(err)); - setPhotos([]); - setPhotoSummary(null); + setPhotos([]); setPhotoSummary(null); } finally { setLoading(false); } - }; - - loadRegionPhotos(); + })(); return () => controller.abort(); }, [selectedRegion]); + /* ── Load more photos ──────────────────── */ const loadMorePhotos = useCallback(async () => { if (!selectedRegion || loading || loadingMore || !hasNext) return; - setLoadingMore(true); - setError(''); - + setLoadingMore(true); setError(''); try { - const photoRes = await fetch( - `/api/travel/photos?region=${encodeURIComponent( - selectedRegion.id - )}&page=${page}&size=${PAGE_SIZE}` + const res = await fetch( + `/api/travel/photos?region=${encodeURIComponent(selectedRegion.id)}&page=${page}&size=${PAGE_SIZE}` ); - if (!photoRes.ok) { - throw new Error( - `지역 사진 로딩 실패 (${photoRes.status})` - ); - } - const photoJson = await photoRes.json(); - const items = Array.isArray(photoJson) - ? photoJson - : photoJson.items ?? []; - const summarySource = Array.isArray(photoJson) - ? {} - : photoJson ?? {}; + 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 summarySource.has_next === 'boolean' - ? summarySource.has_next - : typeof summarySource.hasNext === 'boolean' - ? summarySource.hasNext - : normalized.length >= PAGE_SIZE; - const summaryPayload = hasSummaryInfo(summarySource) - ? { - total: summarySource.total ?? photoSummary?.total, - albums: - summarySource.matched_albums ?? - photoSummary?.albums ?? - [], - } + 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 ?? summaryPayload, + items: merged, page: page + 1, hasNext: nextHasNext, + summary: photoSummary ?? summary, }); return merged; }); - if (!photoSummary && summaryPayload) { - setPhotoSummary(summaryPayload); - } + if (!photoSummary && summary) setPhotoSummary(summary); setHasNext(nextHasNext); - setPage((prev) => prev + 1); + setPage((p) => p + 1); } catch (err) { setError(err?.message ?? String(err)); } finally { @@ -437,40 +685,32 @@ const Travel = () => { } }, [hasNext, loading, loadingMore, page, photoSummary, selectedRegion]); - const bumpSlide = (direction) => { - setSlideDirection(direction); - setSlideToken((prev) => prev + 1); + /* ── Slide helpers ─────────────────────── */ + const bumpSlide = (dir) => { + setSlideDirection(dir); + setSlideToken((t) => t + 1); }; - const showToast = useCallback((message) => { - setToastMessage(message); - if (toastTimerRef.current) { - window.clearTimeout(toastTimerRef.current); - } - toastTimerRef.current = window.setTimeout(() => { - setToastMessage(''); - }, 1600); + const showToast = useCallback((msg) => { + setToastMessage(msg); + if (toastTimerRef.current) clearTimeout(toastTimerRef.current); + toastTimerRef.current = setTimeout(() => setToastMessage(''), 1600); }, []); - useEffect(() => { - return () => { - if (toastTimerRef.current) { - window.clearTimeout(toastTimerRef.current); - } - }; - }, []); + useEffect(() => () => { if (toastTimerRef.current) clearTimeout(toastTimerRef.current); }, []); + /* ── Navigation ────────────────────────── */ const goPrev = useCallback(() => { - if (selectedPhotoIndex === null || selectedPhotoIndex <= 0) return; + if (selectedPhotoIndex == null || selectedPhotoIndex <= 0) return; bumpSlide('prev'); - setSelectedPhotoIndex(selectedPhotoIndex - 1); + setSelectedPhotoIndex((i) => i - 1); }, [selectedPhotoIndex]); const goNext = useCallback(() => { - if (selectedPhotoIndex === null) return; + if (selectedPhotoIndex == null) return; if (selectedPhotoIndex < photos.length - 1) { bumpSlide('next'); - setSelectedPhotoIndex(selectedPhotoIndex + 1); + setSelectedPhotoIndex((i) => i + 1); return; } if (hasNext && !loadingMore) { @@ -478,488 +718,274 @@ const Travel = () => { loadMorePhotos(); return; } - if (!hasNext) { - showToast('다음 사진 없음'); - } - }, [ - hasNext, - loadMorePhotos, - loadingMore, - photos.length, - selectedPhotoIndex, - showToast, - ]); + 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 == null) { pendingAdvanceRef.current = null; return; } if (selectedPhotoIndex < photos.length - 1) { bumpSlide('next'); - setSelectedPhotoIndex((prev) => - prev === null ? prev : prev + 1 - ); - pendingAdvanceRef.current = null; - } - if (!hasNext && selectedPhotoIndex >= photos.length - 1) { + 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 undefined; - - const handleKeyDown = (event) => { - if (event.key === 'Escape') { - setSelectedPhotoIndex(null); - return; - } - if (event.key === 'ArrowLeft') { - goPrev(); - } - if (event.key === 'ArrowRight') { - goNext(); - } + if (selectedPhotoIndex == null) return; + const onKey = (e) => { + if (e.key === 'Escape') setSelectedPhotoIndex(null); + if (e.key === 'ArrowLeft') goPrev(); + if (e.key === 'ArrowRight') goNext(); }; - - const handleTouchStart = (event) => { - if (selectedPhotoIndex === null) return; - const touch = event.touches[0]; - touchStartXRef.current = touch.clientX; - }; - - const handleTouchEnd = (event) => { - if (selectedPhotoIndex === null || touchStartXRef.current === null) - return; - const touch = event.changedTouches[0]; - const deltaX = touch.clientX - touchStartXRef.current; - if (Math.abs(deltaX) > 50) { // 스와이프 거리 임계값 - if (deltaX > 0) { - // 왼쪽으로 스와이프: 이전 사진 - goPrev(); - } else { - // 오른쪽으로 스와이프: 다음 사진 - 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', handleKeyDown); - window.addEventListener('touchstart', handleTouchStart); - window.addEventListener('touchend', handleTouchEnd); + window.addEventListener('keydown', onKey); + window.addEventListener('touchstart', onTouchStart); + window.addEventListener('touchend', onTouchEnd); return () => { - window.removeEventListener('keydown', handleKeyDown); - window.removeEventListener('touchstart', handleTouchStart); - window.removeEventListener('touchend', handleTouchEnd); + window.removeEventListener('keydown', onKey); + window.removeEventListener('touchstart', onTouchStart); + window.removeEventListener('touchend', onTouchEnd); }; }, [goNext, goPrev, selectedPhotoIndex]); + /* ── Body scroll lock ──────────────────── */ useEffect(() => { - if (selectedPhotoIndex === null) return undefined; - const { overflow } = document.body.style; + if (selectedPhotoIndex == null) return; + const prev = document.body.style.overflow; document.body.style.overflow = 'hidden'; - return () => { - document.body.style.overflow = overflow; - }; + return () => { document.body.style.overflow = prev; }; }, [selectedPhotoIndex]); - const [stripStart, stripEnd] = - selectedPhotoIndex === null - ? [0, 0] - : getStripRange(photos.length, selectedPhotoIndex); + /* ── Thumbnail strip range ─────────────── */ + const [stripStart, stripEnd] = selectedPhotoIndex == null + ? [0, 0] + : getStripRange(photos.length, selectedPhotoIndex); - const scrollToX = (element, target, duration) => { - if (!element) return; - if ( - window.matchMedia('(prefers-reduced-motion: reduce)').matches || - duration <= 0 - ) { - element.scrollLeft = target; - return; + /* ── 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 = element.scrollLeft; + const start = el.scrollLeft; const diff = target - start; if (!diff) return; - let startTime = null; + let t0 = null; const ease = (t) => 0.5 - Math.cos(Math.PI * t) / 2; - const step = (timestamp) => { - if (!startTime) startTime = timestamp; - const elapsed = timestamp - startTime; - const t = Math.min(elapsed / duration, 1); - element.scrollLeft = start + diff * ease(t); - if (t < 1) { - window.requestAnimationFrame(step); - } + 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); }; - window.requestAnimationFrame(step); + requestAnimationFrame(step); }; - const scrollThumbs = (direction) => { + const scrollThumbs = (dir) => { const strip = thumbStripRef.current; if (!strip) return; - const offset = strip.clientWidth * 0.6; - const target = - strip.scrollLeft + (direction === 'next' ? offset : -offset); - scrollToX(strip, target, thumbScrollDuration); + scrollToX(strip, strip.scrollLeft + (dir === 'next' ? 1 : -1) * strip.clientWidth * 0.6, thumbScrollDuration); }; + /* Auto-center active thumb */ useEffect(() => { - if (selectedPhotoIndex === null) return; + if (selectedPhotoIndex == null) return; const strip = thumbStripRef.current; if (!strip) return; - const target = strip.querySelector( - `[data-thumb-index="${selectedPhotoIndex}"]` - ); - if (!target) return; - const stripRect = strip.getBoundingClientRect(); - const targetRect = target.getBoundingClientRect(); - const currentScroll = strip.scrollLeft; - const targetCenter = - targetRect.left - - stripRect.left + - currentScroll + - targetRect.width / 2; - const nextScroll = targetCenter - stripRect.width / 2; - scrollToX(strip, nextScroll, thumbScrollDuration); + 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]); - const handleSelectPhoto = (index, event) => { - if (selectedPhotoIndex === null) { - bumpSlide('next'); - } else if (index !== selectedPhotoIndex) { - bumpSlide(index > selectedPhotoIndex ? 'next' : 'prev'); - } - if (event) { - const pointY = - typeof event.clientY === 'number' - ? event.clientY - : event?.currentTarget?.getBoundingClientRect - ? event.currentTarget.getBoundingClientRect().top - : null; - if (pointY !== null) { - const nextOffset = Math.min( - Math.max(pointY - 120, 16), - window.innerHeight - 200 - ); - setModalOffset(nextOffset); - } else { - setModalOffset(24); - } - } else { - setModalOffset(24); - } + /* ── Photo selection ───────────────────── */ + const handleSelectPhoto = (index) => { + if (selectedPhotoIndex == null) bumpSlide('next'); + else if (index !== selectedPhotoIndex) bumpSlide(index > selectedPhotoIndex ? 'next' : 'prev'); setSelectedPhotoIndex(index); }; + /* ───────────────────────────────────────────── + Render + ───────────────────────────────────────────── */ return ( -
-
-
-

Visual Diary

-

Travel Archive

-

- 여행에서 느낀 감성과 분위기를 모아 전시하는 페이지입니다. -

-
-
-

폴더별 큐레이션

-

- 지역마다 그리드와 기록의 흐름을 다르게 배치해 리듬감을 만들었습니다. +

+ {/* ═══════════════════════════════════════ + HEADER — editorial masthead + ═══════════════════════════════════════ */} +
+
+
+ Visual Diary + · + 여행 포토 아카이브 +
+

+ Travel + Archive +

+

+ 여행에서 포착한 색, 빛, 장면들을 필름처럼 현상합니다. + 지도에서 지역을 선택하면 해당 앨범이 펼쳐집니다.

+ + {selectedRegion ? ( +
+
+
+

Currently viewing

+

{selectedRegion.name}

+ {photoSummary && ( +

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

+ )} +
+
+ ) : ( +
+
+ + + + +
+

지도에서 지역을 선택하세요

+
+ )}
+ {/* ═══════════════════════════════════════ + MAP — world map with region selection + ═══════════════════════════════════════ */}
-
-
-

Select a region

-

- {selectedRegion - ? `${selectedRegion.name} 사진을 불러옵니다.` - : '지도에서 지역을 클릭하면 해당 사진만 로딩됩니다.'} -

-
+
- + + {/* Map overlay hint */} + {!selectedRegion && ( +
+ CLICK A REGION +
+ )}
- {loading ? ( -

사진을 불러오는 중...

- ) : null} - {error ?

{error}

: null} - {!loading && !error && selectedRegion && photos.length === 0 ? ( -

- 선택한 지역에 사진이 없습니다. -

- ) : null} - - {!loading && !error && selectedRegion ? ( - - ) : null} -
- {selectedPhotoIndex !== null ? ( -
setSelectedPhotoIndex(null)} - style={{ - '--modal-offset': `${modalOffset}px`, - '--modal-blur': `${backdropBlur}px`, - }} - > -
event.stopPropagation()} - style={{ marginTop: `${modalOffset}px` }} - > -
-

- {selectedRegion?.name || 'Region'} ·{' '} - {photoSummary?.total ?? photos.length} photos -

- {photoSummary?.albums?.length ? ( -

- {photoSummary.albums - .map((album) => album.album) - .join(', ')} -

- ) : null} -
-
- Blur -
- - setBackdropBlur( - Number(event.target.value) - ) - } - aria-label="Background blur" - /> - - {backdropBlur}px - -
-
-
- Thumb -
- - setThumbScrollDuration( - Number(event.target.value) - ) - } - aria-label="Thumbnail scroll speed" - /> - - {thumbScrollDuration}ms - -
-
-
+ {/* State messages */} + {loading && ( +
+
+
- -
- -
- {getPhotoLabel(photos[selectedPhotoIndex])} { - const img = event.currentTarget; - const original = - photos[selectedPhotoIndex]?.original; - if (original && img.src !== original) { - img.src = original; - } - }} - /> -
- -
-
- -
- {photos - .slice(stripStart, stripEnd) - .map((photo, idx) => { - const realIndex = stripStart + idx; - return ( - - ); - })} -
- -
- {photos[selectedPhotoIndex]?.album || - photos[selectedPhotoIndex]?.file ? ( -

- {photos[selectedPhotoIndex]?.album}{' '} - {photos[selectedPhotoIndex]?.file - ? `- ${photos[selectedPhotoIndex]?.file}` - : ''} -

- ) : null} - {toastMessage ? ( -
- {toastMessage} -
- ) : null} +

Developing film…

-
- ) : null} + )} + {error &&

{error}

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

+ 이 지역에는 아직 사진이 없습니다. +

+ )} + + {/* ── Photo Mosaic ─────────────────── */} + {!loading && !error && selectedRegion && photos.length > 0 && ( + <> +
+
+ + {selectedRegion.name} + + {photoSummary?.albums?.length > 0 && ( + + {photoSummary.albums.map((a) => a.album).join(' · ')} + + )} +
+ + {photos.length} + {hasNext && '+'} + +
+ + + + )} + + + {/* ═══════════════════════════════════════ + LIGHTBOX — cinematic fullscreen viewer + ═══════════════════════════════════════ */} + {selectedPhotoIndex != null && ( + setSelectedPhotoIndex(null)} + onPrev={goPrev} + onNext={goNext} + onSelectThumb={setSelectedPhotoIndex} + onScrollThumbPrev={() => scrollThumbs('prev')} + onScrollThumbNext={() => scrollThumbs('next')} + /> + )}
); }; export default Travel; - diff --git a/vite.config.js b/vite.config.js index eaf1c98..8b6256f 100644 --- a/vite.config.js +++ b/vite.config.js @@ -14,6 +14,12 @@ export default defineConfig({ changeOrigin: true, secure: true, }, + // 여행 사진 미디어 파일 (/media/travel/...) + '/media': { + target: 'https://gahusb.synology.me', + changeOrigin: true, + secure: true, + }, // Fear & Greed Index (CNN 공개 API) // 프로덕션 nginx에서는 아래 proxy_pass 추가 필요: // location /ext/feargreed {