Compare commits

..

2 Commits

Author SHA1 Message Date
07b43c48c1 stock lab 기능 구현
- 주가지수 API 연결 (KOSPI/KOSDAQ/NASDAQ 등)
 - 뉴스 카드에 키워드 하이라이트/태그 자동 추출
 - 아침 8시 스크랩” 기준 타이머/카운트다운 표시
2026-01-26 03:05:50 +09:00
9d8af6b03b 여행 기록 UI/UX 오류 수정 2026-01-26 03:05:38 +09:00
6 changed files with 1104 additions and 55 deletions

View File

@@ -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
View 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
View 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;

View File

@@ -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;
}
}

View File

@@ -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}

View File

@@ -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 />,