Compare commits
2 Commits
d53f581c58
...
07b43c48c1
| Author | SHA1 | Date | |
|---|---|---|---|
| 07b43c48c1 | |||
| 9d8af6b03b |
28
src/api.js
28
src/api.js
@@ -17,6 +17,22 @@ export async function apiDelete(path) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function apiPost(path, body) {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json",
|
||||||
|
...(body ? { "Content-Type": "application/json" } : {}),
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
export function getLatest() {
|
export function getLatest() {
|
||||||
return apiGet("/api/lotto/latest");
|
return apiGet("/api/lotto/latest");
|
||||||
}
|
}
|
||||||
@@ -41,3 +57,15 @@ export function getHistory(limit = 30, offset = 0) {
|
|||||||
export function deleteHistory(id) {
|
export function deleteHistory(id) {
|
||||||
return apiDelete(`/api/history/${id}`);
|
return apiDelete(`/api/history/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getStockNews(limit = 20) {
|
||||||
|
return apiGet(`/api/stock/news?limit=${limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function triggerStockScrap() {
|
||||||
|
return apiPost("/api/admin/stock/scrap");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStockHealth() {
|
||||||
|
return apiGet("/api/stock/health");
|
||||||
|
}
|
||||||
|
|||||||
286
src/pages/stock/Stock.css
Normal file
286
src/pages/stock/Stock.css
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
.stock {
|
||||||
|
display: grid;
|
||||||
|
gap: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
|
||||||
|
gap: 24px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-kicker {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3em;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--accent);
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-header h1 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(30px, 4vw, 40px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-sub {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 18px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--surface);
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-card__title {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-status {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-status > div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-status strong {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-status__note {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-pill {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-pill.is-ok {
|
||||||
|
border-color: rgba(106, 220, 187, 0.6);
|
||||||
|
color: #b5f0dd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-pill.is-warn {
|
||||||
|
border-color: rgba(245, 200, 115, 0.6);
|
||||||
|
color: #f5d28a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-pill.is-unknown {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-error {
|
||||||
|
margin: 0;
|
||||||
|
color: #f9b6b1;
|
||||||
|
border: 1px solid rgba(249, 182, 177, 0.4);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(249, 182, 177, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-panel {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 20px;
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-panel--wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-panel__head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-panel__eyebrow {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-panel__sub {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-panel__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-chip {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-snapshot {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-snapshot__card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-snapshot__card p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-snapshot__card strong {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-snapshot__card span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-schedule {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-schedule strong {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-filter {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-filter label {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-filter select {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-filter__note {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-news {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-news__item {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-news__title {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-news__summary {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-news__meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-news__meta a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-empty {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.stock-header {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.stock-panel {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
290
src/pages/stock/Stock.jsx
Normal file
290
src/pages/stock/Stock.jsx
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
getStockHealth,
|
||||||
|
getStockNews,
|
||||||
|
triggerStockScrap,
|
||||||
|
} from '../../api';
|
||||||
|
import './Stock.css';
|
||||||
|
|
||||||
|
const formatDate = (value) => value ?? '-';
|
||||||
|
|
||||||
|
const toDateValue = (value) => {
|
||||||
|
if (!value) return null;
|
||||||
|
const normalized = value.replace(' ', 'T').replace(/\./g, '-');
|
||||||
|
const date = new Date(normalized);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLatestBy = (items, key) => {
|
||||||
|
const filtered = items
|
||||||
|
.map((item) => ({ ...item, __date: toDateValue(item?.[key]) }))
|
||||||
|
.filter((item) => item.__date);
|
||||||
|
if (!filtered.length) return null;
|
||||||
|
filtered.sort((a, b) => b.__date - a.__date);
|
||||||
|
return filtered[0]?.[key] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Stock = () => {
|
||||||
|
const [news, setNews] = useState([]);
|
||||||
|
const [limit, setLimit] = useState(20);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [scraping, setScraping] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [health, setHealth] = useState({
|
||||||
|
status: 'unknown',
|
||||||
|
message: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const latestCrawled = useMemo(
|
||||||
|
() => getLatestBy(news, 'crawled_at'),
|
||||||
|
[news]
|
||||||
|
);
|
||||||
|
const latestPublished = useMemo(
|
||||||
|
() => getLatestBy(news, 'pub_date'),
|
||||||
|
[news]
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadNews = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const data = await getStockNews(limit);
|
||||||
|
setNews(Array.isArray(data) ? data : []);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadHealth = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getStockHealth();
|
||||||
|
setHealth({
|
||||||
|
status: data?.ok ? 'ok' : 'warn',
|
||||||
|
message: data?.message ?? '',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setHealth({
|
||||||
|
status: 'unknown',
|
||||||
|
message: err?.message ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onScrap = async () => {
|
||||||
|
setScraping(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await triggerStockScrap();
|
||||||
|
if (!result?.ok) {
|
||||||
|
throw new Error('스크랩 요청이 실패했습니다.');
|
||||||
|
}
|
||||||
|
await loadNews();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
setScraping(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadNews();
|
||||||
|
}, [limit]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadHealth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stock">
|
||||||
|
<header className="stock-header">
|
||||||
|
<div>
|
||||||
|
<p className="stock-kicker">Market Lab</p>
|
||||||
|
<h1>Stock Lab</h1>
|
||||||
|
<p className="stock-sub">
|
||||||
|
매일 오전 8시에 주식 트렌드 기사를 스크랩하고 요약해, 거래 전
|
||||||
|
빠르게 흐름을 파악할 수 있게 구성했습니다.
|
||||||
|
</p>
|
||||||
|
<div className="stock-actions">
|
||||||
|
<button
|
||||||
|
className="button primary"
|
||||||
|
onClick={loadNews}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
뉴스 새로고침
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button ghost"
|
||||||
|
onClick={onScrap}
|
||||||
|
disabled={scraping}
|
||||||
|
>
|
||||||
|
{scraping ? '스크랩 중...' : '스크랩 즉시 실행'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stock-card">
|
||||||
|
<p className="stock-card__title">오늘의 상태</p>
|
||||||
|
<div className="stock-status">
|
||||||
|
<div>
|
||||||
|
<span>Health</span>
|
||||||
|
<span className={`stock-pill is-${health.status}`}>
|
||||||
|
{health.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>최근 스크랩</span>
|
||||||
|
<strong>{formatDate(latestCrawled)}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>최근 발행</span>
|
||||||
|
<strong>{formatDate(latestPublished)}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>기사 수</span>
|
||||||
|
<strong>{news.length}건</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{health.message ? (
|
||||||
|
<p className="stock-status__note">{health.message}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{error ? <p className="stock-error">{error}</p> : null}
|
||||||
|
|
||||||
|
<section className="stock-grid">
|
||||||
|
<div className="stock-panel">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">Snapshot</p>
|
||||||
|
<h3>시장 스냅샷</h3>
|
||||||
|
<p className="stock-panel__sub">
|
||||||
|
지수/가격 API 연동을 위한 준비 구간입니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stock-snapshot">
|
||||||
|
{['KOSPI', 'KOSDAQ', 'NASDAQ'].map((label) => (
|
||||||
|
<div key={label} className="stock-snapshot__card">
|
||||||
|
<p>{label}</p>
|
||||||
|
<strong>--</strong>
|
||||||
|
<span>연동 예정</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stock-panel">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">Schedule</p>
|
||||||
|
<h3>스크랩 일정</h3>
|
||||||
|
<p className="stock-panel__sub">
|
||||||
|
매일 오전 8시에 자동 스크랩이 실행됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stock-schedule">
|
||||||
|
<div>
|
||||||
|
<span>자동 실행</span>
|
||||||
|
<strong>08:00 KST</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>수동 실행</span>
|
||||||
|
<strong>관리자 전용</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>최근 스크랩</span>
|
||||||
|
<strong>{formatDate(latestCrawled)}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stock-panel">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">Filter</p>
|
||||||
|
<h3>뉴스 필터</h3>
|
||||||
|
<p className="stock-panel__sub">
|
||||||
|
표시 개수를 조정해 빠르게 훑어볼 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stock-filter">
|
||||||
|
<label>
|
||||||
|
표시 개수
|
||||||
|
<select
|
||||||
|
value={limit}
|
||||||
|
onChange={(event) =>
|
||||||
|
setLimit(Number(event.target.value))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{[10, 20, 30, 40].map((value) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{value}개
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p className="stock-filter__note">
|
||||||
|
최신 기사부터 정렬됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="stock-panel stock-panel--wide">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">Headlines</p>
|
||||||
|
<h3>트렌드 기사</h3>
|
||||||
|
<p className="stock-panel__sub">
|
||||||
|
스크랩된 뉴스 요약을 바로 확인할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="stock-panel__actions">
|
||||||
|
{loading ? (
|
||||||
|
<span className="stock-chip">불러오는 중</span>
|
||||||
|
) : null}
|
||||||
|
<span className="stock-chip">{news.length}건</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="stock-empty">뉴스를 불러오는 중...</p>
|
||||||
|
) : news.length === 0 ? (
|
||||||
|
<p className="stock-empty">표시할 뉴스가 없습니다.</p>
|
||||||
|
) : (
|
||||||
|
<div className="stock-news">
|
||||||
|
{news.map((item) => (
|
||||||
|
<article key={item.id} className="stock-news__item">
|
||||||
|
<div>
|
||||||
|
<p className="stock-news__title">
|
||||||
|
{item.title}
|
||||||
|
</p>
|
||||||
|
<p className="stock-news__summary">
|
||||||
|
{item.summary}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="stock-news__meta">
|
||||||
|
<span>{item.press}</span>
|
||||||
|
<span>{item.pub_date}</span>
|
||||||
|
<a
|
||||||
|
href={item.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
원문 보기
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Stock;
|
||||||
@@ -71,8 +71,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.travel-albums.is-blurred {
|
.travel-albums.is-blurred {
|
||||||
filter: blur(3px);
|
opacity: 0.5;
|
||||||
transition: filter 0.2s ease;
|
transform: scale(0.995);
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.travel-albums.is-blurred * {
|
.travel-albums.is-blurred * {
|
||||||
@@ -206,6 +207,11 @@
|
|||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
min-height: 220px;
|
min-height: 220px;
|
||||||
cursor: pointer;
|
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) {
|
@media (max-width: 768px) {
|
||||||
@@ -218,6 +224,11 @@
|
|||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.travel-card[data-revealed='true'] {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
.travel-card img {
|
.travel-card img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -247,7 +258,8 @@
|
|||||||
.travel-modal {
|
.travel-modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(6, 8, 12, 0.75);
|
background: rgba(6, 8, 12, 0.55);
|
||||||
|
backdrop-filter: blur(var(--modal-blur, 6px));
|
||||||
display: grid;
|
display: grid;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
@@ -268,6 +280,17 @@
|
|||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-reveal] {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(18px);
|
||||||
|
transition: opacity 0.5s ease, transform 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-reveal][data-revealed='true'] {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
.travel-modal__summary {
|
.travel-modal__summary {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -301,6 +324,61 @@
|
|||||||
gap: 14px;
|
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 {
|
.travel-modal__frame {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 68vh;
|
height: 68vh;
|
||||||
@@ -327,11 +405,20 @@
|
|||||||
animation: travel-slide-prev 280ms ease;
|
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 {
|
.travel-modal__strip {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding-bottom: 6px;
|
padding-bottom: 6px;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
.travel-modal__thumb {
|
.travel-modal__thumb {
|
||||||
@@ -343,6 +430,7 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.travel-modal__thumb img {
|
.travel-modal__thumb img {
|
||||||
@@ -358,6 +446,23 @@
|
|||||||
border-color: rgba(255, 255, 255, 0.6);
|
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 {
|
.travel-modal__meta {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -393,6 +498,26 @@
|
|||||||
transition: transform 0.2s ease, border-color 0.2s ease;
|
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 {
|
.travel-modal__arrow:hover {
|
||||||
transform: translateY(-1px) scale(1.02);
|
transform: translateY(-1px) scale(1.02);
|
||||||
border-color: rgba(255, 255, 255, 0.5);
|
border-color: rgba(255, 255, 255, 0.5);
|
||||||
@@ -423,6 +548,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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) {
|
@media (max-width: 768px) {
|
||||||
.travel-modal__content {
|
.travel-modal__content {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
@@ -476,3 +624,12 @@
|
|||||||
height: 160px;
|
height: 160px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.travel-card,
|
||||||
|
[data-reveal] {
|
||||||
|
opacity: 1 !important;
|
||||||
|
transform: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ const TravelPhotoGrid = ({
|
|||||||
isLoadingMore,
|
isLoadingMore,
|
||||||
}) => {
|
}) => {
|
||||||
const sentinelRef = useRef(null);
|
const sentinelRef = useRef(null);
|
||||||
|
const gridRef = useRef(null);
|
||||||
|
const revealObserverRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sentinel = sentinelRef.current;
|
const sentinel = sentinelRef.current;
|
||||||
@@ -74,9 +76,48 @@ const TravelPhotoGrid = ({
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [hasNext, isLoadingMore, onLoadMore]);
|
}, [hasNext, isLoadingMore, onLoadMore]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
revealObserverRef.current = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (!entry.isIntersecting) return;
|
||||||
|
entry.target.dataset.revealed = 'true';
|
||||||
|
revealObserverRef.current?.unobserve(entry.target);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ rootMargin: '120px', threshold: 0.15 }
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => revealObserverRef.current?.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
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);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(fallback);
|
||||||
|
cards.forEach((card) => observer.unobserve(card));
|
||||||
|
};
|
||||||
|
}, [photos.length]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="travel-grid">
|
<div className="travel-grid" ref={gridRef}>
|
||||||
{photos.map((photo, index) => {
|
{photos.map((photo, index) => {
|
||||||
const label = getPhotoLabel(photo);
|
const label = getPhotoLabel(photo);
|
||||||
return (
|
return (
|
||||||
@@ -85,6 +126,9 @@ const TravelPhotoGrid = ({
|
|||||||
className={`travel-card ${
|
className={`travel-card ${
|
||||||
index % 6 === 0 ? 'is-wide' : ''
|
index % 6 === 0 ? 'is-wide' : ''
|
||||||
}`}
|
}`}
|
||||||
|
style={{
|
||||||
|
'--reveal-delay': `${Math.min(index, 16) * 40}ms`,
|
||||||
|
}}
|
||||||
onClick={(event) => onSelectPhoto(index, event)}
|
onClick={(event) => onSelectPhoto(index, event)}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
@@ -175,10 +219,35 @@ const Travel = () => {
|
|||||||
const touchStartXRef = useRef(null);
|
const touchStartXRef = useRef(null);
|
||||||
const [slideDirection, setSlideDirection] = useState('next');
|
const [slideDirection, setSlideDirection] = useState('next');
|
||||||
const [slideToken, setSlideToken] = useState(0);
|
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 [page, setPage] = useState(1);
|
||||||
const [hasNext, setHasNext] = useState(true);
|
const [hasNext, setHasNext] = useState(true);
|
||||||
const cacheRef = useRef(new Map());
|
const cacheRef = useRef(new Map());
|
||||||
const cacheTtlMs = 10 * 60 * 1000;
|
const cacheTtlMs = 10 * 60 * 1000;
|
||||||
|
const travelRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = travelRef.current;
|
||||||
|
if (!root) return undefined;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (!entry.isIntersecting) return;
|
||||||
|
entry.target.dataset.revealed = 'true';
|
||||||
|
observer.unobserve(entry.target);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ rootMargin: '140px' }
|
||||||
|
);
|
||||||
|
const targets = root.querySelectorAll('[data-reveal]');
|
||||||
|
targets.forEach((node) => observer.observe(node));
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -373,6 +442,24 @@ const Travel = () => {
|
|||||||
setSlideToken((prev) => prev + 1);
|
setSlideToken((prev) => prev + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showToast = useCallback((message) => {
|
||||||
|
setToastMessage(message);
|
||||||
|
if (toastTimerRef.current) {
|
||||||
|
window.clearTimeout(toastTimerRef.current);
|
||||||
|
}
|
||||||
|
toastTimerRef.current = window.setTimeout(() => {
|
||||||
|
setToastMessage('');
|
||||||
|
}, 1600);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (toastTimerRef.current) {
|
||||||
|
window.clearTimeout(toastTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const goPrev = useCallback(() => {
|
const goPrev = useCallback(() => {
|
||||||
if (selectedPhotoIndex === null || selectedPhotoIndex <= 0) return;
|
if (selectedPhotoIndex === null || selectedPhotoIndex <= 0) return;
|
||||||
bumpSlide('prev');
|
bumpSlide('prev');
|
||||||
@@ -380,15 +467,46 @@ const Travel = () => {
|
|||||||
}, [selectedPhotoIndex]);
|
}, [selectedPhotoIndex]);
|
||||||
|
|
||||||
const goNext = useCallback(() => {
|
const goNext = useCallback(() => {
|
||||||
if (
|
if (selectedPhotoIndex === null) return;
|
||||||
selectedPhotoIndex === null ||
|
if (selectedPhotoIndex < photos.length - 1) {
|
||||||
selectedPhotoIndex >= photos.length - 1
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
bumpSlide('next');
|
bumpSlide('next');
|
||||||
setSelectedPhotoIndex(selectedPhotoIndex + 1);
|
setSelectedPhotoIndex(selectedPhotoIndex + 1);
|
||||||
}, [photos.length, selectedPhotoIndex]);
|
return;
|
||||||
|
}
|
||||||
|
if (hasNext && !loadingMore) {
|
||||||
|
pendingAdvanceRef.current = 'next';
|
||||||
|
loadMorePhotos();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hasNext) {
|
||||||
|
showToast('다음 사진 없음');
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
hasNext,
|
||||||
|
loadMorePhotos,
|
||||||
|
loadingMore,
|
||||||
|
photos.length,
|
||||||
|
selectedPhotoIndex,
|
||||||
|
showToast,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingAdvanceRef.current !== 'next') return;
|
||||||
|
if (selectedPhotoIndex === null) {
|
||||||
|
pendingAdvanceRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedPhotoIndex < photos.length - 1) {
|
||||||
|
bumpSlide('next');
|
||||||
|
setSelectedPhotoIndex((prev) =>
|
||||||
|
prev === null ? prev : prev + 1
|
||||||
|
);
|
||||||
|
pendingAdvanceRef.current = null;
|
||||||
|
}
|
||||||
|
if (!hasNext && selectedPhotoIndex >= photos.length - 1) {
|
||||||
|
pendingAdvanceRef.current = null;
|
||||||
|
}
|
||||||
|
}, [hasNext, photos.length, selectedPhotoIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedPhotoIndex === null) return undefined;
|
if (selectedPhotoIndex === null) return undefined;
|
||||||
@@ -453,6 +571,61 @@ const Travel = () => {
|
|||||||
? [0, 0]
|
? [0, 0]
|
||||||
: getStripRange(photos.length, selectedPhotoIndex);
|
: 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;
|
||||||
|
}
|
||||||
|
const start = element.scrollLeft;
|
||||||
|
const diff = target - start;
|
||||||
|
if (!diff) return;
|
||||||
|
let startTime = 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.requestAnimationFrame(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollThumbs = (direction) => {
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
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);
|
||||||
|
}, [selectedPhotoIndex, stripStart, stripEnd, thumbScrollDuration]);
|
||||||
|
|
||||||
const handleSelectPhoto = (index, event) => {
|
const handleSelectPhoto = (index, event) => {
|
||||||
if (selectedPhotoIndex === null) {
|
if (selectedPhotoIndex === null) {
|
||||||
bumpSlide('next');
|
bumpSlide('next');
|
||||||
@@ -482,8 +655,8 @@ const Travel = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="travel">
|
<div className="travel" ref={travelRef}>
|
||||||
<header className="travel-header">
|
<header className="travel-header" data-reveal>
|
||||||
<div>
|
<div>
|
||||||
<p className="travel-kicker">Visual Diary</p>
|
<p className="travel-kicker">Visual Diary</p>
|
||||||
<h1>Travel Archive</h1>
|
<h1>Travel Archive</h1>
|
||||||
@@ -503,9 +676,10 @@ const Travel = () => {
|
|||||||
className={`travel-albums ${
|
className={`travel-albums ${
|
||||||
selectedPhotoIndex !== null ? 'is-blurred' : ''
|
selectedPhotoIndex !== null ? 'is-blurred' : ''
|
||||||
}`}
|
}`}
|
||||||
|
data-reveal
|
||||||
>
|
>
|
||||||
<div className="travel-map">
|
<div className="travel-map">
|
||||||
<div className="travel-map__info">
|
<div className="travel-map__info" data-reveal>
|
||||||
<p className="travel-map__title">Select a region</p>
|
<p className="travel-map__title">Select a region</p>
|
||||||
<p className="travel-map__desc">
|
<p className="travel-map__desc">
|
||||||
{selectedRegion
|
{selectedRegion
|
||||||
@@ -557,7 +731,10 @@ const Travel = () => {
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
onClick={() => setSelectedPhotoIndex(null)}
|
onClick={() => setSelectedPhotoIndex(null)}
|
||||||
style={{ '--modal-offset': `${modalOffset}px` }}
|
style={{
|
||||||
|
'--modal-offset': `${modalOffset}px`,
|
||||||
|
'--modal-blur': `${backdropBlur}px`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="travel-modal__content"
|
className="travel-modal__content"
|
||||||
@@ -576,6 +753,50 @@ const Travel = () => {
|
|||||||
.join(', ')}
|
.join(', ')}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
<div className="travel-modal__controls">
|
||||||
|
<div className="travel-modal__control">
|
||||||
|
<span>Blur</span>
|
||||||
|
<div className="travel-blur-slider">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={16}
|
||||||
|
step={2}
|
||||||
|
value={backdropBlur}
|
||||||
|
onChange={(event) =>
|
||||||
|
setBackdropBlur(
|
||||||
|
Number(event.target.value)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
aria-label="Background blur"
|
||||||
|
/>
|
||||||
|
<span className="travel-blur-value">
|
||||||
|
{backdropBlur}px
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="travel-modal__control">
|
||||||
|
<span>Thumb</span>
|
||||||
|
<div className="travel-blur-slider">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={150}
|
||||||
|
max={1000}
|
||||||
|
step={50}
|
||||||
|
value={thumbScrollDuration}
|
||||||
|
onChange={(event) =>
|
||||||
|
setThumbScrollDuration(
|
||||||
|
Number(event.target.value)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
aria-label="Thumbnail scroll speed"
|
||||||
|
/>
|
||||||
|
<span className="travel-blur-value">
|
||||||
|
{thumbScrollDuration}ms
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -620,17 +841,52 @@ const Travel = () => {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="travel-modal__arrow is-next"
|
className={`travel-modal__arrow is-next ${
|
||||||
|
loadingMore &&
|
||||||
|
hasNext &&
|
||||||
|
selectedPhotoIndex === photos.length - 1
|
||||||
|
? 'is-loading'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
onClick={goNext}
|
onClick={goNext}
|
||||||
disabled={
|
disabled={
|
||||||
selectedPhotoIndex === photos.length - 1
|
selectedPhotoIndex === photos.length - 1 &&
|
||||||
|
!hasNext
|
||||||
}
|
}
|
||||||
aria-label="Next"
|
aria-label="Next"
|
||||||
|
aria-busy={
|
||||||
|
loadingMore &&
|
||||||
|
hasNext &&
|
||||||
|
selectedPhotoIndex === photos.length - 1
|
||||||
|
}
|
||||||
>
|
>
|
||||||
|
<span className="travel-modal__arrow-icon">
|
||||||
{'>'}
|
{'>'}
|
||||||
|
</span>
|
||||||
|
{loadingMore &&
|
||||||
|
hasNext &&
|
||||||
|
selectedPhotoIndex === photos.length - 1 ? (
|
||||||
|
<span
|
||||||
|
className="travel-modal__spinner"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="travel-modal__strip" role="list">
|
<div className="travel-modal__strip-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="travel-modal__strip-arrow is-prev"
|
||||||
|
onClick={() => scrollThumbs('prev')}
|
||||||
|
aria-label="이전 썸네일"
|
||||||
|
>
|
||||||
|
{'<'}
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className="travel-modal__strip"
|
||||||
|
role="list"
|
||||||
|
ref={thumbStripRef}
|
||||||
|
>
|
||||||
{photos
|
{photos
|
||||||
.slice(stripStart, stripEnd)
|
.slice(stripStart, stripEnd)
|
||||||
.map((photo, idx) => {
|
.map((photo, idx) => {
|
||||||
@@ -640,12 +896,16 @@ const Travel = () => {
|
|||||||
key={`${photo.src}-${realIndex}`}
|
key={`${photo.src}-${realIndex}`}
|
||||||
type="button"
|
type="button"
|
||||||
className={`travel-modal__thumb ${
|
className={`travel-modal__thumb ${
|
||||||
realIndex === selectedPhotoIndex
|
realIndex ===
|
||||||
|
selectedPhotoIndex
|
||||||
? 'is-active'
|
? 'is-active'
|
||||||
: ''
|
: ''
|
||||||
}`}
|
}`}
|
||||||
|
data-thumb-index={realIndex}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setSelectedPhotoIndex(realIndex)
|
setSelectedPhotoIndex(
|
||||||
|
realIndex
|
||||||
|
)
|
||||||
}
|
}
|
||||||
aria-label={getPhotoLabel(photo)}
|
aria-label={getPhotoLabel(photo)}
|
||||||
role="listitem"
|
role="listitem"
|
||||||
@@ -655,12 +915,15 @@ const Travel = () => {
|
|||||||
alt={getPhotoLabel(photo)}
|
alt={getPhotoLabel(photo)}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
const img = event.currentTarget;
|
const img =
|
||||||
|
event.currentTarget;
|
||||||
if (
|
if (
|
||||||
photo.original &&
|
photo.original &&
|
||||||
img.src !== photo.original
|
img.src !==
|
||||||
|
photo.original
|
||||||
) {
|
) {
|
||||||
img.src = photo.original;
|
img.src =
|
||||||
|
photo.original;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -668,6 +931,15 @@ const Travel = () => {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="travel-modal__strip-arrow is-next"
|
||||||
|
onClick={() => scrollThumbs('next')}
|
||||||
|
aria-label="다음 썸네일"
|
||||||
|
>
|
||||||
|
{'>'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{photos[selectedPhotoIndex]?.album ||
|
{photos[selectedPhotoIndex]?.album ||
|
||||||
photos[selectedPhotoIndex]?.file ? (
|
photos[selectedPhotoIndex]?.file ? (
|
||||||
<p className="travel-modal__meta">
|
<p className="travel-modal__meta">
|
||||||
@@ -677,6 +949,11 @@ const Travel = () => {
|
|||||||
: ''}
|
: ''}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{toastMessage ? (
|
||||||
|
<div className="travel-modal__toast">
|
||||||
|
{toastMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Home from './pages/home/Home';
|
|||||||
import Blog from './pages/blog/Blog';
|
import Blog from './pages/blog/Blog';
|
||||||
import Lotto from './pages/lotto/Lotto';
|
import Lotto from './pages/lotto/Lotto';
|
||||||
import Travel from './pages/travel/Travel';
|
import Travel from './pages/travel/Travel';
|
||||||
|
import Stock from './pages/stock/Stock';
|
||||||
|
|
||||||
export const navLinks = [
|
export const navLinks = [
|
||||||
{
|
{
|
||||||
@@ -23,6 +24,12 @@ export const navLinks = [
|
|||||||
path: '/lotto',
|
path: '/lotto',
|
||||||
description: '숫자를 뽑고 통계를 확인하는 실험실',
|
description: '숫자를 뽑고 통계를 확인하는 실험실',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'stock',
|
||||||
|
label: 'Stock',
|
||||||
|
path: '/stock',
|
||||||
|
description: '아침 시장 흐름을 확인하는 주식 연구실',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'travel',
|
id: 'travel',
|
||||||
label: 'Travel',
|
label: 'Travel',
|
||||||
@@ -44,6 +51,10 @@ export const appRoutes = [
|
|||||||
path: 'lotto',
|
path: 'lotto',
|
||||||
element: <Lotto />,
|
element: <Lotto />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'stock',
|
||||||
|
element: <Stock />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'travel',
|
path: 'travel',
|
||||||
element: <Travel />,
|
element: <Travel />,
|
||||||
|
|||||||
Reference in New Issue
Block a user