dashboard 형태의 UI 수정 및 고도화

This commit is contained in:
2026-03-04 08:29:39 +09:00
parent 618d5f8e6f
commit ccc9f7c634
17 changed files with 1296 additions and 224 deletions

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import Navbar from './components/Navbar'; import Navbar from './components/Navbar';
import PageHeader from './components/PageHeader';
import Loading from './components/Loading'; import Loading from './components/Loading';
import './App.css'; import './App.css';
@@ -10,6 +11,7 @@ function App() {
<Navbar /> <Navbar />
<div className="app-content"> <div className="app-content">
<main className="site-main"> <main className="site-main">
<PageHeader />
<React.Suspense fallback={<div className="suspend-loading"><Loading /></div>}> <React.Suspense fallback={<div className="suspend-loading"><Loading /></div>}>
<Outlet /> <Outlet />
</React.Suspense> </React.Suspense>

View File

@@ -155,3 +155,35 @@ export async function getFearAndGreed() {
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json(); return res.json();
} }
// VIX 지수 (Yahoo Finance 공개 API)
export async function getVix() {
const res = await fetch('/ext/vix', { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const price = data?.chart?.result?.[0]?.meta?.regularMarketPrice;
if (price === undefined || price === null) throw new Error('VIX 데이터 없음');
return { value: Math.round(price * 100) / 100 };
}
// ── TODO API ─────────────────────────────────────────────────────────────────
export function getTodos() {
return apiGet('/api/todos');
}
export function addTodo(data) {
return apiPost('/api/todos', data);
}
export function updateTodo(id, data) {
return apiPut(`/api/todos/${id}`, data);
}
export function deleteTodo(id) {
return apiDelete(`/api/todos/${id}`);
}
export function clearTodos() {
return apiDelete('/api/todos/done');
}

View File

@@ -0,0 +1,106 @@
import React from 'react';
export const getFgColor = (score) => {
if (score <= 25) return '#ef4444';
if (score <= 45) return '#f97316';
if (score <= 55) return '#eab308';
if (score <= 75) return '#84cc16';
return '#22c55e';
};
export const getFgLabel = (score) => {
if (score <= 25) return '극단적 공포';
if (score <= 45) return '공포';
if (score <= 55) return '중립';
if (score <= 75) return '탐욕';
return '극단적 탐욕';
};
const FG_LEVELS = [
{
range: '0 25',
label: '극단적 공포',
color: '#ef4444',
desc: '투자자들이 극도로 불안해하는 상태. 역사적으로 매수 기회가 되기도 하나, 하락세가 이어질 수 있습니다.',
},
{
range: '26 45',
label: '공포',
color: '#f97316',
desc: '시장 심리가 위축된 상태. 불확실성이 높고, 매도 압력이 강합니다.',
},
{
range: '46 55',
label: '중립',
color: '#eab308',
desc: '공포와 탐욕이 균형을 이루는 상태. 뚜렷한 방향성 없이 관망세가 지속됩니다.',
},
{
range: '56 75',
label: '탐욕',
color: '#84cc16',
desc: '투자자들이 낙관적이고 시장에 적극 참여하는 상태. 과열 신호를 주의해야 합니다.',
},
{
range: '76 100',
label: '극단적 탐욕',
color: '#22c55e',
desc: '시장이 과열된 상태. 조정 가능성이 높아지므로 리스크 관리가 필요합니다.',
},
];
/**
* Fear & Greed 게이지 컴포넌트
* @param {{ score: number, date?: string, showLevels?: boolean }} props
*/
const FearGreedGauge = ({ score, date, showLevels = false }) => {
const color = getFgColor(score);
const label = getFgLabel(score);
return (
<div className="fg-wrap">
<div className="fg-panel">
<div className="fg-score-display">
<span className="fg-score-number" style={{ color }}>{score}</span>
<span className="fg-score-label" style={{ color }}>{label}</span>
{date && <span className="fg-score-date">{date}</span>}
</div>
<div className="fg-gauge">
<div className="fg-gauge__track">
<div
className="fg-gauge__needle"
style={{ left: `${Math.min(100, Math.max(0, score))}%` }}
/>
</div>
<div className="fg-gauge__labels">
<span>극단적 공포</span>
<span>공포</span>
<span>중립</span>
<span>탐욕</span>
<span>극단적 탐욕</span>
</div>
</div>
</div>
{showLevels && (
<div className="fg-levels">
{FG_LEVELS.map((lv) => (
<div
key={lv.label}
className={`fg-level${getFgLabel(score) === lv.label ? ' is-current' : ''}`}
>
<div className="fg-level__head">
<span className="fg-level__dot" style={{ background: lv.color }} />
<span className="fg-level__label" style={{ color: lv.color }}>{lv.label}</span>
<span className="fg-level__range">{lv.range}</span>
</div>
<p className="fg-level__desc">{lv.desc}</p>
</div>
))}
</div>
)}
</div>
);
};
export default FearGreedGauge;

View File

@@ -59,3 +59,15 @@ export const IconLab = () =>
<line x1="6.5" y1="15" x2="17.5" y2="15" /> <line x1="6.5" y1="15" x2="17.5" y2="15" />
</> </>
); );
export const IconTodo = () =>
svg(
<>
<rect x="3" y="5" width="6" height="6" rx="1" />
<polyline points="9,8 11,10 15,6" />
<rect x="3" y="13" width="6" height="6" rx="1" />
<line x1="13" y1="16" x2="21" y2="16" />
<line x1="13" y1="8" x2="21" y2="8" />
<line x1="17" y1="12" x2="21" y2="12" />
</>
);

View File

@@ -62,12 +62,14 @@
.sidebar__brand-sub { .sidebar__brand-sub {
margin: 0; margin: 0;
font-size: 10px; font-size: 9px;
font-weight: 500; font-weight: 500;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.18em; letter-spacing: 0.12em;
color: var(--neon-cyan); color: var(--neon-cyan);
white-space: nowrap; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
/* ── 구분선 ──────────────────────────────────────────────────────────── */ /* ── 구분선 ──────────────────────────────────────────────────────────── */

View File

@@ -46,7 +46,7 @@ const Navbar = () => {
<img src={mainLogo} alt="Logo" className="sidebar__logo" /> <img src={mainLogo} alt="Logo" className="sidebar__logo" />
<div className="sidebar__brand-text"> <div className="sidebar__brand-text">
<p className="sidebar__brand-name">Jaeoh</p> <p className="sidebar__brand-name">Jaeoh</p>
<p className="sidebar__brand-sub">Dashboard</p> <p className="sidebar__brand-sub">MANAGEMENT ROOM</p>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,67 @@
/* ── PageHeader ──────────────────────────────────────────────────────── */
.page-header {
padding: 0 0 20px;
margin-bottom: 4px;
}
.page-header__inner {
display: flex;
flex-direction: column;
gap: 4px;
}
.page-header__subtitle {
margin: 0;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.28em;
color: var(--page-accent, var(--neon-cyan));
font-family: var(--font-display, 'Space Grotesk', sans-serif);
display: flex;
align-items: center;
gap: 10px;
}
.page-header__subtitle::before {
content: '';
display: block;
width: 20px;
height: 1.5px;
background: var(--page-accent, var(--neon-cyan));
border-radius: 2px;
box-shadow: 0 0 6px var(--page-accent, var(--neon-cyan));
flex-shrink: 0;
}
.page-header__title {
margin: 0;
font-size: clamp(22px, 3vw, 32px);
font-weight: 800;
font-family: var(--font-display, 'Space Grotesk', sans-serif);
color: var(--text-bright, #fff);
letter-spacing: -0.03em;
line-height: 1.1;
}
.page-header__line {
height: 1px;
background: linear-gradient(
90deg,
var(--page-accent, var(--neon-cyan)) 0%,
transparent 60%
);
margin-top: 14px;
opacity: 0.3;
}
@media (max-width: 768px) {
.page-header {
padding: 0 0 16px;
}
.page-header__title {
font-size: clamp(18px, 5vw, 24px);
}
}

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { useLocation } from 'react-router-dom';
import { navLinks } from '../routes.jsx';
import './PageHeader.css';
const PageHeader = () => {
const { pathname } = useLocation();
// Home 페이지에서는 Hero 섹션이 있으므로 숨김
if (pathname === '/') return null;
// stock/trade 같은 하위 경로도 stock로 매칭
const current = navLinks.find((link) => {
if (link.path === '/') return false;
return pathname === link.path || pathname.startsWith(link.path + '/');
});
if (!current) return null;
return (
<header className="page-header" style={{ '--page-accent': current.accent }}>
<div className="page-header__inner">
<p className="page-header__subtitle">{current.subtitle}</p>
<h1 className="page-header__title">{current.label}</h1>
</div>
<div className="page-header__line" />
</header>
);
};
export default PageHeader;

View File

@@ -367,6 +367,94 @@
padding-top: 4px; padding-top: 4px;
} }
/* ── Dev Log ─────────────────────────────────────────────────────────── */
.home-dev-log {
display: grid;
gap: 8px;
}
.home-dev-log__empty {
margin: 0;
color: var(--text-muted);
font-size: 13px;
padding: 16px 0;
}
.home-dev-log__item {
border: 1px solid var(--line);
padding: 14px 18px;
border-radius: var(--radius-md);
background: var(--surface-card);
display: grid;
grid-template-columns: auto 1fr auto;
align-items: start;
gap: 14px;
box-shadow: var(--shadow-card);
transition: border-color 0.2s ease, background 0.2s ease;
}
.home-dev-log__item:hover {
border-color: rgba(52, 211, 153, 0.25);
background: var(--surface-raised);
}
.home-dev-log__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #34d399;
box-shadow: 0 0 6px rgba(52, 211, 153, 0.8);
margin-top: 7px;
flex-shrink: 0;
}
.home-dev-log__content {
display: grid;
gap: 4px;
}
.home-dev-log__title {
margin: 0;
font-weight: 600;
font-size: 15px;
color: var(--text-bright);
letter-spacing: -0.01em;
}
.home-dev-log__desc {
margin: 0;
color: var(--text-dim);
font-size: 12px;
line-height: 1.6;
}
.home-dev-log__date {
font-size: 11px;
color: rgba(52, 211, 153, 0.7);
text-transform: uppercase;
letter-spacing: 0.08em;
white-space: nowrap;
padding-top: 4px;
}
.home-dev-log__link {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 4px;
font-size: 13px;
color: #34d399;
text-decoration: none;
padding: 8px 0;
transition: opacity 0.2s ease;
font-weight: 500;
}
.home-dev-log__link:hover {
opacity: 0.75;
}
/* ── Profile ─────────────────────────────────────────────────────────── */ /* ── Profile ─────────────────────────────────────────────────────────── */
.home-profile { .home-profile {

View File

@@ -1,13 +1,30 @@
import React from 'react'; import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { navLinks } from '../../routes.jsx'; import { navLinks } from '../../routes.jsx';
import { getBlogPosts } from '../../data/blog'; import { getBlogPosts } from '../../data/blog';
import { getTodos } from '../../api';
import myPhoto from '../../assets/myPhoto.jpg'; import myPhoto from '../../assets/myPhoto.jpg';
import './Home.css'; import './Home.css';
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
const Home = () => { const Home = () => {
const posts = getBlogPosts().slice(0, 3); const posts = getBlogPosts().slice(0, 3);
const highlights = navLinks.filter((link) => link.id !== 'home'); const highlights = navLinks.filter((link) => link.id !== 'home');
const [recentDev, setRecentDev] = useState([]);
useEffect(() => {
getTodos()
.then((todos) => {
if (!Array.isArray(todos)) return;
const now = Date.now();
const filtered = todos
.filter((t) => t.status === 'done' && t.updated_at && (now - new Date(t.updated_at).getTime()) <= SEVEN_DAYS_MS)
.slice(0, 5);
setRecentDev(filtered);
})
.catch(() => { /* 조용히 실패 */ });
}, []);
return ( return (
<div className="home"> <div className="home">
@@ -97,6 +114,36 @@ const Home = () => {
</div> </div>
</section> </section>
<section className="home-section">
<div className="home-section__header">
<h2>최근 개발</h2>
<p>최근 7 완료된 태스크를 보여줍니다.</p>
</div>
<div className="home-dev-log">
{recentDev.length === 0 ? (
<p className="home-dev-log__empty">완료된 태스크가 없습니다.</p>
) : (
recentDev.map((todo) => (
<div key={todo.id} className="home-dev-log__item">
<span className="home-dev-log__dot" />
<div className="home-dev-log__content">
<p className="home-dev-log__title">{todo.title}</p>
{todo.description && (
<p className="home-dev-log__desc">{todo.description}</p>
)}
</div>
<span className="home-dev-log__date">
{new Date(todo.updated_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })}
</span>
</div>
))
)}
<Link to="/todo" className="home-dev-log__link">
Todo 보드 열기
</Link>
</div>
</section>
<section className="home-section"> <section className="home-section">
<div className="home-section__header"> <div className="home-section__header">
<h2>Profile</h2> <h2>Profile</h2>

View File

@@ -240,11 +240,11 @@
} }
.stock-snapshot__change.is-up { .stock-snapshot__change.is-up {
color: #f3a7a7; color: #f04452;
} }
.stock-snapshot__change.is-down { .stock-snapshot__change.is-down {
color: #9fc5ff; color: #3b82f6;
} }
.stock-schedule { .stock-schedule {
@@ -291,7 +291,6 @@
.stock-tabs { .stock-tabs {
display: flex; display: flex;
gap: 8px; gap: 8px;
margin-bottom: 10px;
} }
.stock-tab { .stock-tab {
@@ -452,11 +451,11 @@
} }
.stock-profit.is-up { .stock-profit.is-up {
color: #f3a7a7; color: #f04452;
} }
.stock-profit.is-down { .stock-profit.is-down {
color: #9fc5ff; color: #3b82f6;
} }
.stock-profit.is-flat { .stock-profit.is-flat {
@@ -1122,6 +1121,115 @@
margin-top: 2px; margin-top: 2px;
} }
/* ── F&G Level 설명 ─────────────────────────────────────────────── */
.fg-wrap {
display: flex;
flex-direction: column;
gap: 0;
}
.fg-levels {
display: grid;
gap: 6px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--line);
}
.fg-level {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.02);
transition: background 0.2s ease, border-color 0.2s ease;
}
.fg-level.is-current {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
.fg-level__head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.fg-level__dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.fg-level__label {
font-size: 12px;
font-weight: 700;
}
.fg-level__range {
font-size: 11px;
color: var(--muted);
margin-left: auto;
}
.fg-level__desc {
margin: 0;
font-size: 12px;
color: var(--muted);
line-height: 1.6;
padding-left: 16px;
}
/* ── 뉴스 툴바 (탭 + 인라인 필터) ─────────────────────────────── */
.stock-news-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.stock-tab-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: rgba(96, 165, 250, 0.15);
font-size: 10px;
font-weight: 700;
margin-left: 4px;
vertical-align: middle;
}
.stock-tab.is-active .stock-tab-count {
background: rgba(96, 165, 250, 0.3);
}
.stock-news-limit {
border: 1px solid var(--line);
background: rgba(0, 0, 0, 0.25);
color: var(--text);
border-radius: 10px;
padding: 6px 10px;
font-size: 12px;
cursor: pointer;
outline: none;
transition: border-color 0.2s ease;
}
.stock-news-limit:hover,
.stock-news-limit:focus {
border-color: rgba(96, 165, 250, 0.4);
}
/* ══════════════════════════════════════════════════════════════════ /* ══════════════════════════════════════════════════════════════════
Report Charts Row Report Charts Row
══════════════════════════════════════════════════════════════════ */ ══════════════════════════════════════════════════════════════════ */
@@ -1450,3 +1558,125 @@
display: none; display: none;
} }
} }
/* ══════════════════════════════════════════════════════════════════
VIX Panel
══════════════════════════════════════════════════════════════════ */
.stock-vix {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 12px 0 8px;
text-align: center;
}
.stock-vix__score {
font-size: 52px;
font-weight: 800;
line-height: 1;
transition: color 0.4s ease;
}
.stock-vix__label {
margin: 0;
font-size: 14px;
font-weight: 700;
transition: color 0.4s ease;
}
.stock-vix__legend {
display: flex;
flex-wrap: wrap;
gap: 6px 12px;
justify-content: center;
margin-top: 10px;
font-size: 11px;
}
.stock-vix__legend span {
font-weight: 500;
}
/* ══════════════════════════════════════════════════════════════════
News Card Grid
══════════════════════════════════════════════════════════════════ */
.stock-news-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 14px;
}
.stock-news-card {
border: 1px solid var(--line);
border-radius: 16px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
background: rgba(0, 0, 0, 0.2);
transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease;
}
.stock-news-card:hover {
border-color: rgba(96, 165, 250, 0.3);
background: rgba(96, 165, 250, 0.04);
transform: translateY(-2px);
}
.stock-news-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.stock-news-card__date {
font-size: 11px;
color: var(--muted);
}
.stock-news-card__title {
margin: 0;
font-weight: 600;
font-size: 15px;
color: var(--text);
line-height: 1.4;
}
.stock-news-card__summary {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.stock-news-card__link {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--accent-stock);
text-decoration: none;
margin-top: auto;
transition: opacity 0.15s;
font-weight: 500;
}
.stock-news-card__link:hover {
opacity: 0.75;
text-decoration: underline;
}
@media (max-width: 640px) {
.stock-news-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -1,7 +1,8 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { getStockIndices, getStockNews } from '../../api'; import { getStockIndices, getStockNews, getFearAndGreed, getVix } from '../../api';
import Loading from '../../components/Loading'; import Loading from '../../components/Loading';
import FearGreedGauge from '../../components/FearGreedGauge';
import './Stock.css'; import './Stock.css';
const formatDate = (value) => { const formatDate = (value) => {
@@ -11,21 +12,6 @@ const formatDate = (value) => {
return date.toLocaleString('sv-SE'); return date.toLocaleString('sv-SE');
}; };
const toDateValue = (value) => {
if (!value) return null;
const date = new Date(value);
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 normalizeIndices = (data) => { const normalizeIndices = (data) => {
if (!data) return []; if (!data) return [];
@@ -65,23 +51,40 @@ const normalizeIndices = (data) => {
}; };
const getDirection = (change, percent, direction) => { const getDirection = (change, percent, direction) => {
if (direction === 'red') return 'up'; // 숫자 부호로 방향 추출 (percent → change 순서로 시도)
if (direction === 'blue') return 'down'; const fromStr = (s) => {
const pick = (value) => if (s === undefined || s === null || s === '') return null;
value === undefined || value === null || value === '' ? null : value; const str = String(s).trim();
const raw = pick(change) ?? pick(percent);
if (!raw) return '';
const str = String(raw).trim();
if (str.startsWith('-')) return 'down'; if (str.startsWith('-')) return 'down';
if (str.startsWith('+')) return 'up'; if (str.startsWith('+')) return 'up';
const numeric = Number(str.replace(/[^0-9.-]/g, '')); const numeric = Number(str.replace(/[^0-9.-]/g, ''));
if (Number.isFinite(numeric)) { if (Number.isFinite(numeric) && numeric !== 0) {
if (numeric > 0) return 'up'; return numeric > 0 ? 'up' : 'down';
if (numeric < 0) return 'down'; }
return null;
};
// percent 필드가 부호를 가장 신뢰성 있게 포함하는 경우가 많음
const byPercent = fromStr(percent);
if (byPercent) return byPercent;
const byChange = fromStr(change);
if (byChange) return byChange;
// 숫자로 판별 불가 시 direction 필드 fallback
if (direction) {
const d = String(direction).toLowerCase();
if (d === 'red' || d === 'up' || d === 'rise' || d === 'positive') return 'up';
if (d === 'blue' || d === 'down' || d === 'fall' || d === 'negative') return 'down';
} }
return ''; return '';
}; };
const getVixLevel = (score) => {
if (score < 12) return { label: '극히 낮음', color: '#22c55e' };
if (score < 20) return { label: '정상', color: '#84cc16' };
if (score < 30) return { label: '보통', color: '#eab308' };
if (score < 40) return { label: '높음', color: '#f97316' };
return { label: '극단', color: '#ef4444' };
};
const Stock = () => { const Stock = () => {
const [newsDomestic, setNewsDomestic] = useState([]); const [newsDomestic, setNewsDomestic] = useState([]);
const [newsOverseas, setNewsOverseas] = useState([]); const [newsOverseas, setNewsOverseas] = useState([]);
@@ -94,14 +97,13 @@ const Stock = () => {
const [indicesLoading, setIndicesLoading] = useState(false); const [indicesLoading, setIndicesLoading] = useState(false);
const [autoRefreshMs] = useState(180000); const [autoRefreshMs] = useState(180000);
const [fgData, setFgData] = useState(null);
const [vixData, setVixData] = useState(null);
const combinedNews = useMemo( const combinedNews = useMemo(
() => [...newsDomestic, ...newsOverseas], () => [...newsDomestic, ...newsOverseas],
[newsDomestic, newsOverseas] [newsDomestic, newsOverseas]
); );
const latestPublished = useMemo(
() => getLatestBy(combinedNews, 'published_at'),
[combinedNews]
);
const loadNews = async () => { const loadNews = async () => {
setLoading(true); setLoading(true);
@@ -143,6 +145,19 @@ const Stock = () => {
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [autoRefreshMs]); }, [autoRefreshMs]);
useEffect(() => {
getFearAndGreed()
.then((data) => {
const fg = data?.fear_and_greed ?? data;
const score = typeof fg?.score === 'number' ? fg.score : parseFloat(fg?.score);
if (!isNaN(score)) {
setFgData({ score, timestamp: fg?.timestamp ?? null });
}
})
.catch(() => { });
getVix().then(setVixData).catch(() => { });
}, []);
const indexOrder = [ const indexOrder = [
'KOSPI', 'KOSPI',
'KOSDAQ', 'KOSDAQ',
@@ -262,62 +277,53 @@ const Stock = () => {
</div> </div>
</section> </section>
{/* 시장 심리 지표 행 */}
<section className="stock-filter-row"> <section className="stock-filter-row">
<div className="stock-panel stock-panel--compact"> <div className="stock-panel stock-panel--compact">
<div className="stock-panel__head"> <div className="stock-panel__head">
<div> <div>
<p className="stock-panel__eyebrow">필터</p> <p className="stock-panel__eyebrow">심리 지표</p>
<h3>뉴스 필터</h3> <h3>Fear & Greed</h3>
<p className="stock-panel__sub"> <p className="stock-panel__sub">시장 탐욕·공포 지수 (0100)</p>
표시할 뉴스 개수를 조정합니다.
</p>
</div> </div>
</div> </div>
<div className="stock-filter"> {fgData ? (
<label> <FearGreedGauge
표시 개수 score={Math.round(fgData.score)}
<select date={fgData.timestamp ? new Date(fgData.timestamp).toLocaleDateString('ko-KR') : undefined}
value={limit} showLevels
onChange={(event) => />
setLimit(Number(event.target.value)) ) : (
} <p className="stock-empty" style={{ fontSize: 13 }}>데이터 없음</p>
> )}
{[10, 20, 30, 40].map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
</label>
<p className="stock-filter__note">
최신 뉴스가 먼저 표시됩니다.
</p>
</div>
</div> </div>
<div className="stock-panel stock-panel--compact"> <div className="stock-panel stock-panel--compact">
<div className="stock-panel__head"> <div className="stock-panel__head">
<div> <div>
<p className="stock-panel__eyebrow">요약</p> <p className="stock-panel__eyebrow">변동성 지수</p>
<h3>뉴스 요약</h3> <h3>VIX</h3>
<p className="stock-panel__sub"> <p className="stock-panel__sub">CBOE 공포 지수</p>
최신 발행 시각과 기사 수를 확인합니다. </div>
</div>
{vixData ? (
<div className="stock-vix">
<div className="stock-vix__score" style={{ color: getVixLevel(vixData.value ?? vixData.vix ?? 0).color }}>
{vixData.value ?? vixData.vix ?? '--'}
</div>
<p className="stock-vix__label" style={{ color: getVixLevel(vixData.value ?? vixData.vix ?? 0).color }}>
{getVixLevel(vixData.value ?? vixData.vix ?? 0).label}
</p> </p>
<div className="stock-vix__legend">
<span style={{ color: '#22c55e' }}>{'<12'} 극히낮음</span>
<span style={{ color: '#84cc16' }}>12-20 정상</span>
<span style={{ color: '#eab308' }}>20-30 보통</span>
<span style={{ color: '#f97316' }}>30-40 높음</span>
<span style={{ color: '#ef4444' }}>{'40+'} 극단</span>
</div> </div>
</div> </div>
<div className="stock-status"> ) : (
<div> <p className="stock-empty" style={{ fontSize: 13 }}>데이터 없음</p>
<span>최신 발행</span> )}
<strong>{formatDate(latestPublished)}</strong>
</div>
<div>
<span>국내</span>
<strong>{newsDomestic.length}</strong>
</div>
<div>
<span>해외</span>
<strong>{newsOverseas.length}</strong>
</div>
</div>
</div> </div>
</section> </section>
@@ -347,46 +353,46 @@ const Stock = () => {
<p className="stock-empty">뉴스가 없습니다.</p> <p className="stock-empty">뉴스가 없습니다.</p>
) : ( ) : (
<> <>
<div className="stock-news-toolbar">
<div className="stock-tabs"> <div className="stock-tabs">
<button <button
type="button" type="button"
className={`stock-tab ${newsCategory === 'domestic' className={`stock-tab ${newsCategory === 'domestic' ? 'is-active' : ''}`}
? 'is-active'
: ''
}`}
onClick={() => setNewsCategory('domestic')} onClick={() => setNewsCategory('domestic')}
> >
국내 국내 <span className="stock-tab-count">{newsDomestic.length}</span>
</button> </button>
<button <button
type="button" type="button"
className={`stock-tab ${newsCategory === 'overseas' className={`stock-tab ${newsCategory === 'overseas' ? 'is-active' : ''}`}
? 'is-active'
: ''
}`}
onClick={() => setNewsCategory('overseas')} onClick={() => setNewsCategory('overseas')}
> >
해외 해외 <span className="stock-tab-count">{newsOverseas.length}</span>
</button> </button>
</div> </div>
<select
className="stock-news-limit"
value={limit}
onChange={(e) => setLimit(Number(e.target.value))}
>
{[10, 20, 30, 40].map((v) => (
<option key={v} value={v}>{v}</option>
))}
</select>
</div>
{activeNews.length === 0 ? ( {activeNews.length === 0 ? (
<p className="stock-empty"> <p className="stock-empty">
해당 카테고리 뉴스가 없습니다. 해당 카테고리 뉴스가 없습니다.
</p> </p>
) : ( ) : (
<div className="stock-news"> <div className="stock-news-grid">
{activeNews.map((item) => ( {activeNews.map((item) => (
<article <article
key={item.id ?? item.link} key={item.id ?? item.link}
className="stock-news__item" className="stock-news-card"
> >
<div> <div className="stock-news-card__head">
<p className="stock-news__title"> <span className="stock-news-card__date">
{item.title}
</p>
</div>
<div className="stock-news__meta">
<span>
{formatDate(item.published_at)} {formatDate(item.published_at)}
</span> </span>
{item.sentiment ? ( {item.sentiment ? (
@@ -394,14 +400,25 @@ const Stock = () => {
{item.sentiment} {item.sentiment}
</span> </span>
) : null} ) : null}
</div>
<p className="stock-news-card__title">
{item.title}
</p>
{item.summary && (
<p className="stock-news-card__summary">
{item.summary}
</p>
)}
{item.link && (
<a <a
href={item.link} href={item.link}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="stock-news-card__link"
> >
원문 보기 원문 보기
</a> </a>
</div> )}
</article> </article>
))} ))}
</div> </div>

View File

@@ -9,7 +9,6 @@ import {
deletePortfolio, deletePortfolio,
upsertCash, upsertCash,
deleteCash, deleteCash,
getFearAndGreed,
} from '../../api'; } from '../../api';
import Loading from '../../components/Loading'; import Loading from '../../components/Loading';
import './Stock.css'; import './Stock.css';
@@ -81,24 +80,6 @@ const toNumeric = (value) => {
return Number.isNaN(numeric) ? null : numeric; return Number.isNaN(numeric) ? null : numeric;
}; };
/* ── Fear & Greed helpers ──────────────────────────────────────── */
const getFgColor = (score) => {
if (score <= 25) return '#ef4444';
if (score <= 45) return '#f97316';
if (score <= 55) return '#eab308';
if (score <= 75) return '#84cc16';
return '#22c55e';
};
const getFgLabel = (score) => {
if (score <= 25) return '극단적 공포';
if (score <= 45) return '공포';
if (score <= 55) return '중립';
if (score <= 75) return '탐욕';
return '극단적 탐욕';
};
/* ── Chart colors ──────────────────────────────────────────────── */ /* ── Chart colors ──────────────────────────────────────────────── */
const CHART_COLORS = ['#818cf8', '#fbbf24', '#34d399', '#f472b6', '#fb923c', '#a78bfa', '#38bdf8', '#4ade80']; const CHART_COLORS = ['#818cf8', '#fbbf24', '#34d399', '#f472b6', '#fb923c', '#a78bfa', '#38bdf8', '#4ade80'];
@@ -130,7 +111,7 @@ const TAB_REPORT = 'report';
const StockTrade = () => { const StockTrade = () => {
/* Active tab */ /* Active tab */
const [activeTab, setActiveTab] = useState(TAB_PORTFOLIO); const [activeTab, setActiveTab] = useState(TAB_REPORT);
/* ────────────────────────────────────────────────────────────── */ /* ────────────────────────────────────────────────────────────── */
/* 쟁승토리 계좌 (Portfolio) state */ /* 쟁승토리 계좌 (Portfolio) state */
@@ -166,12 +147,6 @@ const StockTrade = () => {
const [reportSortField, setReportSortField] = useState('profit_rate'); const [reportSortField, setReportSortField] = useState('profit_rate');
const [reportSortDir, setReportSortDir] = useState('desc'); const [reportSortDir, setReportSortDir] = useState('desc');
/* Fear & Greed */
const [fgData, setFgData] = useState(null);
const [fgLoading, setFgLoading] = useState(false);
const [fgError, setFgError] = useState('');
const [fgLoaded, setFgLoaded] = useState(false);
/* AI Coach */ /* AI Coach */
const [aiApiKey, setAiApiKey] = useState(''); const [aiApiKey, setAiApiKey] = useState('');
const [aiModel, setAiModel] = useState('claude-haiku-4-5-20251001'); const [aiModel, setAiModel] = useState('claude-haiku-4-5-20251001');
@@ -215,23 +190,6 @@ const StockTrade = () => {
} }
}, []); }, []);
const loadFearAndGreed = useCallback(async () => {
setFgLoading(true);
setFgError('');
try {
const data = await getFearAndGreed();
const fg = data?.fear_and_greed ?? data;
const score = typeof fg?.score === 'number' ? fg.score : parseFloat(fg?.score);
if (isNaN(score)) throw new Error('지수 데이터 형식 오류');
setFgData({ score, rating: fg.rating ?? '', timestamp: fg.timestamp ?? null });
setFgLoaded(true);
} catch (err) {
setFgError('F&G 지수 조회 실패: ' + (err?.message ?? String(err)));
} finally {
setFgLoading(false);
}
}, []);
const loadBalance = useCallback(async () => { const loadBalance = useCallback(async () => {
setBalanceLoading(true); setBalanceLoading(true);
setBalanceError(''); setBalanceError('');
@@ -257,13 +215,6 @@ const StockTrade = () => {
} }
}, [activeTab, portfolioLoaded, balanceLoaded, loadPortfolio, loadBalance]); }, [activeTab, portfolioLoaded, balanceLoaded, loadPortfolio, loadBalance]);
/* Fear & Greed: 리포트 탭 첫 진입 시 자동 로드 */
useEffect(() => {
if (activeTab === TAB_REPORT && !fgLoaded) {
loadFearAndGreed();
}
}, [activeTab, fgLoaded, loadFearAndGreed]);
/* AI Coach: 마운트 시 localStorage에서 API Key + 오늘 캐시 복원 */ /* AI Coach: 마운트 시 localStorage에서 API Key + 오늘 캐시 복원 */
useEffect(() => { useEffect(() => {
const savedKey = localStorage.getItem('ai_coach_key') ?? ''; const savedKey = localStorage.getItem('ai_coach_key') ?? '';
@@ -1454,63 +1405,6 @@ ${holdingsText}
)} )}
{portfolioError && <p className="stock-error">{portfolioError}</p>} {portfolioError && <p className="stock-error">{portfolioError}</p>}
{/* ── Fear & Greed Index ─────────────────────────── */}
<section className="stock-panel stock-panel--wide">
<div className="stock-panel__head">
<div>
<p className="stock-panel__eyebrow">시장 심리 지표</p>
<h3>Fear & Greed Index</h3>
<p className="stock-panel__sub">
CNN Fear &amp; Greed 지수로 현재 시장 심리를 파악합니다.
</p>
</div>
<div className="stock-panel__actions">
<button
className="button ghost small"
onClick={loadFearAndGreed}
disabled={fgLoading}
>
{fgLoading ? '조회 중...' : '새로고침'}
</button>
</div>
</div>
{fgError && <p className="stock-error">{fgError}</p>}
{fgData ? (
<div className="fg-panel">
<div className="fg-gauge">
<div className="fg-gauge__track">
<div
className="fg-gauge__needle"
style={{ left: `${Math.min(100, Math.max(0, fgData.score))}%` }}
/>
</div>
<div className="fg-gauge__labels">
<span>극단적 공포</span>
<span>공포</span>
<span>중립</span>
<span>탐욕</span>
<span>극단적 탐욕</span>
</div>
</div>
<div className="fg-score-display">
<span className="fg-score-number" style={{ color: getFgColor(fgData.score) }}>
{Math.round(fgData.score)}
</span>
<span className="fg-score-label" style={{ color: getFgColor(fgData.score) }}>
{getFgLabel(fgData.score)}
</span>
{fgData.timestamp && (
<span className="fg-score-date">
{new Date(fgData.timestamp).toLocaleDateString('ko-KR')}
</span>
)}
</div>
</div>
) : !fgError ? (
<p className="stock-empty">지수 데이터를 불러오는 ...</p>
) : null}
</section>
{/* ── 자산 배분 + 수익률 차트 ────────────────────── */} {/* ── 자산 배분 + 수익률 차트 ────────────────────── */}
{portfolioHoldings.length > 0 && ( {portfolioHoldings.length > 0 && (
<section className="stock-panel stock-panel--wide"> <section className="stock-panel stock-panel--wide">

271
src/pages/todo/Todo.css Normal file
View File

@@ -0,0 +1,271 @@
/* ═══════════════════════════════════════════════════════════════════════
Todo Page — Cyberpunk Kanban Board
═══════════════════════════════════════════════════════════════════════ */
.todo-page {
display: grid;
gap: 20px;
}
/* ── Toolbar ─────────────────────────────────────────────────────────── */
.todo-toolbar {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
/* ── Add Form ─────────────────────────────────────────────────────────── */
.todo-form {
background: var(--surface-card);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 20px;
display: grid;
gap: 14px;
animation: fadeIn 0.2s ease both;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.todo-form__field {
display: grid;
gap: 6px;
font-size: 12px;
color: var(--text-muted);
}
.todo-form__field input,
.todo-form__field textarea {
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px 12px;
background: rgba(0, 0, 0, 0.25);
color: var(--text-bright);
outline: none;
font-family: inherit;
font-size: 14px;
resize: vertical;
transition: border-color 0.2s ease;
}
.todo-form__field input:focus,
.todo-form__field textarea:focus {
border-color: rgba(244, 114, 182, 0.5);
box-shadow: 0 0 0 2px rgba(244, 114, 182, 0.1);
}
.todo-form__actions {
display: flex;
justify-content: flex-end;
}
/* ── Error / Loading ─────────────────────────────────────────────────── */
.todo-error {
margin: 0;
color: #f9b6b1;
border: 1px solid rgba(249, 182, 177, 0.4);
border-radius: 12px;
padding: 12px;
background: rgba(249, 182, 177, 0.08);
font-size: 13px;
}
.todo-loading {
margin: 0;
color: var(--text-muted);
font-size: 13px;
}
/* ── Board ───────────────────────────────────────────────────────────── */
.todo-board {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
align-items: start;
}
/* ── Column ──────────────────────────────────────────────────────────── */
.todo-col {
background: rgba(10, 18, 45, 0.6);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
gap: 0;
min-height: 200px;
transition: border-color 0.2s ease, background 0.2s ease;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.todo-col.is-drag-over {
border-color: rgba(244, 114, 182, 0.4);
background: rgba(244, 114, 182, 0.04);
}
.todo-col__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px 12px;
border-bottom: 1px solid var(--line);
}
.todo-col__title {
font-size: 13px;
font-weight: 700;
color: var(--text-bright);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.todo-col__count {
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
background: rgba(244, 114, 182, 0.12);
border: 1px solid rgba(244, 114, 182, 0.25);
color: #f472b6;
font-weight: 600;
}
.todo-col__body {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
flex: 1;
}
.todo-col__empty {
margin: 0;
color: var(--text-muted);
font-size: 12px;
text-align: center;
padding: 20px 0;
opacity: 0.5;
}
/* ── Card ────────────────────────────────────────────────────────────── */
.todo-card {
background: var(--surface-card);
border: 1px solid var(--line);
border-radius: var(--radius-md);
padding: 14px;
display: grid;
gap: 8px;
cursor: grab;
transition:
transform 0.2s ease,
border-color 0.2s ease,
opacity 0.2s ease,
box-shadow 0.2s ease;
box-shadow: var(--shadow-card);
}
.todo-card:hover {
border-color: rgba(244, 114, 182, 0.25);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.todo-card.is-dragging {
opacity: 0.4;
cursor: grabbing;
}
.todo-card__title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--text-bright);
line-height: 1.4;
}
.todo-card__desc {
margin: 0;
font-size: 12px;
color: var(--text-dim);
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.todo-card__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 2px;
}
.todo-card__date {
font-size: 11px;
color: var(--text-muted);
letter-spacing: 0.04em;
}
.todo-card__actions {
display: flex;
gap: 4px;
}
.todo-card__btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 8px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.04);
color: var(--text-dim);
font-size: 12px;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
padding: 0;
line-height: 1;
}
.todo-card__btn:hover {
background: rgba(244, 114, 182, 0.15);
border-color: rgba(244, 114, 182, 0.4);
color: #f472b6;
}
.todo-card__btn--danger:hover {
background: rgba(249, 182, 177, 0.15);
border-color: rgba(249, 182, 177, 0.4);
color: #f9b6b1;
}
/* ── Responsive ──────────────────────────────────────────────────────── */
@media (max-width: 768px) {
.todo-board {
grid-template-columns: 1fr;
}
.todo-col {
min-height: 120px;
}
}
@media (max-width: 480px) {
.todo-toolbar {
flex-direction: column;
}
.todo-toolbar .button {
width: 100%;
justify-content: center;
}
}

240
src/pages/todo/Todo.jsx Normal file
View File

@@ -0,0 +1,240 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { getTodos, addTodo, updateTodo, deleteTodo, clearTodos } from '../../api';
import './Todo.css';
const COLUMNS = [
{ id: 'todo', label: '할 일' },
{ id: 'in_progress', label: '진행 중' },
{ id: 'done', label: '완료' },
];
const emptyForm = { title: '', description: '' };
const Todo = () => {
const [todos, setTodos] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [form, setForm] = useState(emptyForm);
const [formOpen, setFormOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [dragging, setDragging] = useState(null);
const [dragOver, setDragOver] = useState(null);
const dragItem = useRef(null);
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await getTodos();
setTodos(Array.isArray(data) ? data : []);
} catch (err) {
setError(err?.message ?? String(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const handleAdd = async (e) => {
e.preventDefault();
if (!form.title.trim()) return;
setSaving(true);
try {
const created = await addTodo({ ...form, status: 'todo' });
setTodos((prev) => [created, ...prev]);
setForm(emptyForm);
setFormOpen(false);
} catch (err) {
setError(err?.message ?? String(err));
} finally {
setSaving(false);
}
};
const handleMove = async (id, newStatus) => {
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, status: newStatus } : t))
);
try {
await updateTodo(id, { status: newStatus, updated_at: new Date().toISOString() });
} catch {
load();
}
};
const handleDelete = async (id) => {
setTodos((prev) => prev.filter((t) => t.id !== id));
try {
await deleteTodo(id);
} catch {
load();
}
};
const handleClear = async () => {
try {
await clearTodos();
setTodos((prev) => prev.filter((t) => t.status !== 'done'));
} catch (err) {
setError(err?.message ?? String(err));
}
};
/* ── Drag & Drop ─────────────────────────────────────────────── */
const onDragStart = (e, todo) => {
dragItem.current = todo;
setDragging(todo.id);
e.dataTransfer.effectAllowed = 'move';
};
const onDragEnd = () => {
setDragging(null);
setDragOver(null);
dragItem.current = null;
};
const onDragOver = (e, colId) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOver(colId);
};
const onDrop = (e, colId) => {
e.preventDefault();
if (dragItem.current && dragItem.current.status !== colId) {
handleMove(dragItem.current.id, colId);
}
setDragOver(null);
};
const byStatus = (status) => todos.filter((t) => t.status === status);
return (
<div className="todo-page">
{/* 추가 버튼 & 완료 비우기 */}
<div className="todo-toolbar">
<button
type="button"
className="button primary"
onClick={() => setFormOpen((v) => !v)}
>
{formOpen ? '취소' : '+ 태스크 추가'}
</button>
<button
type="button"
className="button ghost"
onClick={handleClear}
>
완료 비우기
</button>
</div>
{/* 추가 폼 */}
{formOpen && (
<form className="todo-form" onSubmit={handleAdd}>
<label className="todo-form__field">
<span>제목 *</span>
<input
type="text"
placeholder="태스크 제목을 입력하세요"
value={form.title}
onChange={(e) => setForm((v) => ({ ...v, title: e.target.value }))}
required
/>
</label>
<label className="todo-form__field">
<span>설명</span>
<textarea
placeholder="설명 (선택)"
value={form.description}
rows={3}
onChange={(e) => setForm((v) => ({ ...v, description: e.target.value }))}
/>
</label>
<div className="todo-form__actions">
<button
type="submit"
className="button primary"
disabled={saving || !form.title.trim()}
>
{saving ? '저장 중...' : '추가'}
</button>
</div>
</form>
)}
{error && <p className="todo-error">{error}</p>}
{loading && todos.length === 0 && <p className="todo-loading">불러오는 ...</p>}
{/* 보드 */}
<div className="todo-board">
{COLUMNS.map((col) => {
const items = byStatus(col.id);
return (
<div
key={col.id}
className={`todo-col${dragOver === col.id ? ' is-drag-over' : ''}`}
onDragOver={(e) => onDragOver(e, col.id)}
onDrop={(e) => onDrop(e, col.id)}
>
<div className="todo-col__head">
<span className="todo-col__title">{col.label}</span>
<span className="todo-col__count">{items.length}</span>
</div>
<div className="todo-col__body">
{items.length === 0 && (
<p className="todo-col__empty">드래그하여 이동</p>
)}
{items.map((todo) => (
<div
key={todo.id}
className={`todo-card${dragging === todo.id ? ' is-dragging' : ''}`}
draggable
onDragStart={(e) => onDragStart(e, todo)}
onDragEnd={onDragEnd}
>
<p className="todo-card__title">{todo.title}</p>
{todo.description && (
<p className="todo-card__desc">{todo.description}</p>
)}
<div className="todo-card__footer">
<span className="todo-card__date">
{todo.created_at
? new Date(todo.created_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })
: ''}
</span>
<div className="todo-card__actions">
{COLUMNS.filter((c) => c.id !== col.id).map((c) => (
<button
key={c.id}
type="button"
className="todo-card__btn"
title={`${c.label}으로 이동`}
onClick={() => handleMove(todo.id, c.id)}
>
{c.id === 'todo' ? '↩' : c.id === 'in_progress' ? '▶' : '✓'}
</button>
))}
<button
type="button"
className="todo-card__btn todo-card__btn--danger"
title="삭제"
onClick={() => handleDelete(todo.id)}
>
</button>
</div>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
</div>
);
};
export default Todo;

View File

@@ -6,6 +6,7 @@ import {
IconStock, IconStock,
IconTravel, IconTravel,
IconLab, IconLab,
IconTodo,
} from './components/Icons'; } from './components/Icons';
const Home = lazy(() => import('./pages/home/Home')); const Home = lazy(() => import('./pages/home/Home'));
@@ -15,12 +16,14 @@ const Travel = lazy(() => import('./pages/travel/Travel'));
const Stock = lazy(() => import('./pages/stock/Stock')); const Stock = lazy(() => import('./pages/stock/Stock'));
const StockTrade = lazy(() => import('./pages/stock/StockTrade')); const StockTrade = lazy(() => import('./pages/stock/StockTrade'));
const EffectLab = lazy(() => import('./pages/effect-lab/EffectLab')); const EffectLab = lazy(() => import('./pages/effect-lab/EffectLab'));
const Todo = lazy(() => import('./pages/todo/Todo'));
export const navLinks = [ export const navLinks = [
{ {
id: 'home', id: 'home',
label: 'Home', label: 'Home',
path: '/', path: '/',
subtitle: 'PERSONAL ARCHIVE',
description: '첫 인상과 최신 업데이트를 모아둔 허브', description: '첫 인상과 최신 업데이트를 모아둔 허브',
icon: <IconHome />, icon: <IconHome />,
accent: '#f7a8a5', accent: '#f7a8a5',
@@ -29,6 +32,7 @@ export const navLinks = [
id: 'blog', id: 'blog',
label: 'Blog', label: 'Blog',
path: '/blog', path: '/blog',
subtitle: 'JOURNAL',
description: '생각과 기록, 코드 스니펫을 모으는 공간', description: '생각과 기록, 코드 스니펫을 모으는 공간',
icon: <IconBlog />, icon: <IconBlog />,
accent: '#c084fc', accent: '#c084fc',
@@ -37,6 +41,7 @@ export const navLinks = [
id: 'lotto', id: 'lotto',
label: 'Lotto', label: 'Lotto',
path: '/lotto', path: '/lotto',
subtitle: 'PLAYGROUND',
description: '숫자를 뽑고 통계를 확인하는 실험실', description: '숫자를 뽑고 통계를 확인하는 실험실',
icon: <IconLotto />, icon: <IconLotto />,
accent: '#34d399', accent: '#34d399',
@@ -45,6 +50,7 @@ export const navLinks = [
id: 'stock', id: 'stock',
label: 'Stock', label: 'Stock',
path: '/stock', path: '/stock',
subtitle: '마켓 랩',
description: '아침 시장 흐름을 확인하는 주식 연구실', description: '아침 시장 흐름을 확인하는 주식 연구실',
icon: <IconStock />, icon: <IconStock />,
accent: '#60a5fa', accent: '#60a5fa',
@@ -53,6 +59,7 @@ export const navLinks = [
id: 'travel', id: 'travel',
label: 'Travel', label: 'Travel',
path: '/travel', path: '/travel',
subtitle: 'VISUAL DIARY',
description: '여행에서 담은 색과 장면을 전시하는 갤러리', description: '여행에서 담은 색과 장면을 전시하는 갤러리',
icon: <IconTravel />, icon: <IconTravel />,
accent: '#fb923c', accent: '#fb923c',
@@ -61,10 +68,20 @@ export const navLinks = [
id: 'lab', id: 'lab',
label: 'Lab', label: 'Lab',
path: '/lab', path: '/lab',
subtitle: 'STREAM',
description: '실험적인 UI/UX 효과를 테스트하는 공간', description: '실험적인 UI/UX 효과를 테스트하는 공간',
icon: <IconLab />, icon: <IconLab />,
accent: '#fbbf24', accent: '#fbbf24',
}, },
{
id: 'todo',
label: 'Todo',
path: '/todo',
subtitle: 'TASK BOARD',
description: '할 일을 관리하는 태스크 보드',
icon: <IconTodo />,
accent: '#f472b6',
},
]; ];
export const appRoutes = [ export const appRoutes = [
@@ -96,4 +113,8 @@ export const appRoutes = [
path: 'lab', path: 'lab',
element: <EffectLab />, element: <EffectLab />,
}, },
{
path: 'todo',
element: <Todo />,
},
]; ];

View File

@@ -26,6 +26,18 @@ export default defineConfig({
secure: true, secure: true,
rewrite: () => '/index/fearandgreed/graphdata', rewrite: () => '/index/fearandgreed/graphdata',
}, },
// VIX (CBOE 변동성 지수) — Yahoo Finance 공개 API
// 프로덕션 nginx에서는 아래 proxy_pass 추가 필요:
// location /ext/vix {
// proxy_pass https://query1.finance.yahoo.com/v8/finance/chart/%5EVIX?interval=1d&range=1d;
// proxy_set_header Host query1.finance.yahoo.com;
// }
'/ext/vix': {
target: 'https://query1.finance.yahoo.com',
changeOrigin: true,
secure: true,
rewrite: () => '/v8/finance/chart/%5EVIX?interval=1d&range=1d',
},
}, },
}, },
}) })