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 }) => {
@@ -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 (
+
+ );
+};
+
+/* 공략 리포트 패널 */
+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 && (
+
+ )}
+
+ {/* 기록 목록 */}
+ {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}
-
copyNumbers(latest.numbers)}
- >
+ copyNumbers(latest.numbers)}>
번호 복사
-
- 보너스 {latest.bonus}
-
- {overallMetrics ? (
-
- ) : null}
+
보너스 {latest.bonus}
+ {overallMetrics && (
+
+ )}
>
) : (
최신 회차 데이터가 없습니다.
@@ -540,44 +1021,28 @@ export default function Functions() {
{loading.bestPicks ? 로딩 중 : null}
- {simulating ? (
- 분석 중
- ) : null}
-
+ {simulating ? 분석 중 : null}
+
새로고침
-
+
{simulating ? '실행 중...' : '지금 실행'}
- {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)}%
-
+
- copyNumbers(pick.numbers)}
- >
+ copyNumbers(pick.numbers)}>
복사
))}
-
- {bestPicks.length > BEST_PICKS_DEFAULT_SHOW ? (
+ {bestPicks.length > BEST_PICKS_DEFAULT_SHOW && (
setBestPicksExpanded((prev) => !prev)}
+ onClick={() => setBestPicksExpanded((p) => !p)}
aria-expanded={bestPicksExpanded}
>
- {bestPicksExpanded
- ? '접기'
- : `모두 보기 (${bestPicks.length}개)`}
-
- ▼
-
+ {bestPicksExpanded ? '접기' : `모두 보기 (${bestPicks.length}개)`}
+ ▼
- ) : 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) => (
-
- setParams({
- recent_window: preset.recent_window,
- recent_weight: preset.recent_weight,
- avoid_recent_k: preset.avoid_recent_k,
- })
- }
- >
+ setParams({
+ recent_window: preset.recent_window,
+ recent_weight: preset.recent_weight,
+ avoid_recent_k: preset.avoid_recent_k,
+ })}>
{preset.name}
))}
@@ -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 ?? '-'}
-
copyNumbers(result.numbers)}
- >
+ copyNumbers(result.numbers)}>
번호 복사
- {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 ?? '-'}
-
copyNumbers(item.numbers)}
- >
+ copyNumbers(item.numbers)}>
복사
- {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 ? (
- setHistoryExpanded((prev) => !prev)}
- aria-expanded={historyExpanded}
- aria-label={historyExpanded ? '히스토리 접기' : '히스토리 더보기'}
- >
+ {history.length > 5 && (
+ setHistoryExpanded((p) => !p)}
+ aria-expanded={historyExpanded}>
{historyExpanded ? '접기' : '더보기'}
-
- ▼
-
+ ▼
- ) : 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}
- copyNumbers(item.numbers)}
- >
+ copyNumbers(item.numbers)}>
복사
- onDelete(item.id)}
- >
+ onDelete(item.id)}>
삭제
@@ -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 */}
+
{
+ 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);
- }
- }}
- >
-
{
- const img = event.currentTarget;
- if (photo.original && img.src !== photo.original) {
- img.src = photo.original;
- }
- }}
- />
-
-
- );
- })}
+
+ {photos.map((photo, index) => (
+
+ ))}
-
- {hasNext ? (
-
- {isLoadingMore
- ? 'Loading more...'
- : `Load more (${photos.length})`}
-
- ) : 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 (
+
onSelect(realIndex)}
+ aria-label={getPhotoLabel(photo)}
+ role="listitem"
+ >
+
{
+ if (photo.original && e.currentTarget.src !== photo.original)
+ e.currentTarget.src = photo.original;
+ }}
+ />
+
+ {String(realIndex + 1).padStart(2, '0')}
+
+
+ );
+ })}
+
+
+
+
+ ›
+
+
+);
+
+/* ─────────────────────────────────────────────
+ 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 ──────────────────────── */}
+
+
+
+
+
+
+

{
+ if (photo?.original && e.currentTarget.src !== photo.original)
+ e.currentTarget.src = photo.original;
+ }}
+ />
+ {/* Photo frame decoration */}
+
+
+
+
+ {loadingMore && hasNext && selectedIndex === photos.length - 1 ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* ── 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 (
-