dashboard 형태의 UI 수정 및 고도화
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Navbar from './components/Navbar';
|
||||
import PageHeader from './components/PageHeader';
|
||||
import Loading from './components/Loading';
|
||||
import './App.css';
|
||||
|
||||
@@ -10,6 +11,7 @@ function App() {
|
||||
<Navbar />
|
||||
<div className="app-content">
|
||||
<main className="site-main">
|
||||
<PageHeader />
|
||||
<React.Suspense fallback={<div className="suspend-loading"><Loading /></div>}>
|
||||
<Outlet />
|
||||
</React.Suspense>
|
||||
|
||||
32
src/api.js
32
src/api.js
@@ -155,3 +155,35 @@ export async function getFearAndGreed() {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
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');
|
||||
}
|
||||
|
||||
106
src/components/FearGreedGauge.jsx
Normal file
106
src/components/FearGreedGauge.jsx
Normal 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;
|
||||
@@ -59,3 +59,15 @@ export const IconLab = () =>
|
||||
<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" />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -62,12 +62,14 @@
|
||||
|
||||
.sidebar__brand-sub {
|
||||
margin: 0;
|
||||
font-size: 10px;
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--neon-cyan);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── 구분선 ──────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -46,7 +46,7 @@ const Navbar = () => {
|
||||
<img src={mainLogo} alt="Logo" className="sidebar__logo" />
|
||||
<div className="sidebar__brand-text">
|
||||
<p className="sidebar__brand-name">Jaeoh</p>
|
||||
<p className="sidebar__brand-sub">Dashboard</p>
|
||||
<p className="sidebar__brand-sub">MANAGEMENT ROOM</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
67
src/components/PageHeader.css
Normal file
67
src/components/PageHeader.css
Normal 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);
|
||||
}
|
||||
}
|
||||
31
src/components/PageHeader.jsx
Normal file
31
src/components/PageHeader.jsx
Normal 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;
|
||||
@@ -367,6 +367,94 @@
|
||||
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 ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.home-profile {
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { navLinks } from '../../routes.jsx';
|
||||
import { getBlogPosts } from '../../data/blog';
|
||||
import { getTodos } from '../../api';
|
||||
import myPhoto from '../../assets/myPhoto.jpg';
|
||||
import './Home.css';
|
||||
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const Home = () => {
|
||||
const posts = getBlogPosts().slice(0, 3);
|
||||
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 (
|
||||
<div className="home">
|
||||
@@ -97,6 +114,36 @@ const Home = () => {
|
||||
</div>
|
||||
</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">
|
||||
<div className="home-section__header">
|
||||
<h2>Profile</h2>
|
||||
|
||||
@@ -240,11 +240,11 @@
|
||||
}
|
||||
|
||||
.stock-snapshot__change.is-up {
|
||||
color: #f3a7a7;
|
||||
color: #f04452;
|
||||
}
|
||||
|
||||
.stock-snapshot__change.is-down {
|
||||
color: #9fc5ff;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.stock-schedule {
|
||||
@@ -291,7 +291,6 @@
|
||||
.stock-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stock-tab {
|
||||
@@ -452,11 +451,11 @@
|
||||
}
|
||||
|
||||
.stock-profit.is-up {
|
||||
color: #f3a7a7;
|
||||
color: #f04452;
|
||||
}
|
||||
|
||||
.stock-profit.is-down {
|
||||
color: #9fc5ff;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.stock-profit.is-flat {
|
||||
@@ -1122,6 +1121,115 @@
|
||||
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
|
||||
══════════════════════════════════════════════════════════════════ */
|
||||
@@ -1450,3 +1558,125 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getStockIndices, getStockNews } from '../../api';
|
||||
import { getStockIndices, getStockNews, getFearAndGreed, getVix } from '../../api';
|
||||
import Loading from '../../components/Loading';
|
||||
import FearGreedGauge from '../../components/FearGreedGauge';
|
||||
import './Stock.css';
|
||||
|
||||
const formatDate = (value) => {
|
||||
@@ -11,21 +12,6 @@ const formatDate = (value) => {
|
||||
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) => {
|
||||
if (!data) return [];
|
||||
|
||||
@@ -65,23 +51,40 @@ const normalizeIndices = (data) => {
|
||||
};
|
||||
|
||||
const getDirection = (change, percent, direction) => {
|
||||
if (direction === 'red') return 'up';
|
||||
if (direction === 'blue') return 'down';
|
||||
const pick = (value) =>
|
||||
value === undefined || value === null || value === '' ? null : value;
|
||||
const raw = pick(change) ?? pick(percent);
|
||||
if (!raw) return '';
|
||||
const str = String(raw).trim();
|
||||
// 숫자 부호로 방향 추출 (percent → change 순서로 시도)
|
||||
const fromStr = (s) => {
|
||||
if (s === undefined || s === null || s === '') return null;
|
||||
const str = String(s).trim();
|
||||
if (str.startsWith('-')) return 'down';
|
||||
if (str.startsWith('+')) return 'up';
|
||||
const numeric = Number(str.replace(/[^0-9.-]/g, ''));
|
||||
if (Number.isFinite(numeric)) {
|
||||
if (numeric > 0) return 'up';
|
||||
if (numeric < 0) return 'down';
|
||||
if (Number.isFinite(numeric) && numeric !== 0) {
|
||||
return numeric > 0 ? 'up' : '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 '';
|
||||
};
|
||||
|
||||
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 [newsDomestic, setNewsDomestic] = useState([]);
|
||||
const [newsOverseas, setNewsOverseas] = useState([]);
|
||||
@@ -94,14 +97,13 @@ const Stock = () => {
|
||||
const [indicesLoading, setIndicesLoading] = useState(false);
|
||||
const [autoRefreshMs] = useState(180000);
|
||||
|
||||
const [fgData, setFgData] = useState(null);
|
||||
const [vixData, setVixData] = useState(null);
|
||||
|
||||
const combinedNews = useMemo(
|
||||
() => [...newsDomestic, ...newsOverseas],
|
||||
[newsDomestic, newsOverseas]
|
||||
);
|
||||
const latestPublished = useMemo(
|
||||
() => getLatestBy(combinedNews, 'published_at'),
|
||||
[combinedNews]
|
||||
);
|
||||
|
||||
const loadNews = async () => {
|
||||
setLoading(true);
|
||||
@@ -143,6 +145,19 @@ const Stock = () => {
|
||||
return () => window.clearInterval(timer);
|
||||
}, [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 = [
|
||||
'KOSPI',
|
||||
'KOSDAQ',
|
||||
@@ -262,62 +277,53 @@ const Stock = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 시장 심리 지표 행 */}
|
||||
<section className="stock-filter-row">
|
||||
<div className="stock-panel stock-panel--compact">
|
||||
<div className="stock-panel__head">
|
||||
<div>
|
||||
<p className="stock-panel__eyebrow">필터</p>
|
||||
<h3>뉴스 필터</h3>
|
||||
<p className="stock-panel__sub">
|
||||
표시할 뉴스 개수를 조정합니다.
|
||||
</p>
|
||||
<p className="stock-panel__eyebrow">심리 지표</p>
|
||||
<h3>Fear & Greed</h3>
|
||||
<p className="stock-panel__sub">시장 탐욕·공포 지수 (0–100)</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>
|
||||
{fgData ? (
|
||||
<FearGreedGauge
|
||||
score={Math.round(fgData.score)}
|
||||
date={fgData.timestamp ? new Date(fgData.timestamp).toLocaleDateString('ko-KR') : undefined}
|
||||
showLevels
|
||||
/>
|
||||
) : (
|
||||
<p className="stock-empty" style={{ fontSize: 13 }}>데이터 없음</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="stock-panel stock-panel--compact">
|
||||
<div className="stock-panel__head">
|
||||
<div>
|
||||
<p className="stock-panel__eyebrow">요약</p>
|
||||
<h3>뉴스 요약</h3>
|
||||
<p className="stock-panel__sub">
|
||||
최신 발행 시각과 기사 수를 확인합니다.
|
||||
<p className="stock-panel__eyebrow">변동성 지수</p>
|
||||
<h3>VIX</h3>
|
||||
<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>
|
||||
<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 className="stock-status">
|
||||
<div>
|
||||
<span>최신 발행</span>
|
||||
<strong>{formatDate(latestPublished)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>국내</span>
|
||||
<strong>{newsDomestic.length}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>해외</span>
|
||||
<strong>{newsOverseas.length}</strong>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="stock-empty" style={{ fontSize: 13 }}>데이터 없음</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -347,46 +353,46 @@ const Stock = () => {
|
||||
<p className="stock-empty">뉴스가 없습니다.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="stock-news-toolbar">
|
||||
<div className="stock-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`stock-tab ${newsCategory === 'domestic'
|
||||
? 'is-active'
|
||||
: ''
|
||||
}`}
|
||||
className={`stock-tab ${newsCategory === 'domestic' ? 'is-active' : ''}`}
|
||||
onClick={() => setNewsCategory('domestic')}
|
||||
>
|
||||
국내
|
||||
국내 <span className="stock-tab-count">{newsDomestic.length}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`stock-tab ${newsCategory === 'overseas'
|
||||
? 'is-active'
|
||||
: ''
|
||||
}`}
|
||||
className={`stock-tab ${newsCategory === 'overseas' ? 'is-active' : ''}`}
|
||||
onClick={() => setNewsCategory('overseas')}
|
||||
>
|
||||
해외
|
||||
해외 <span className="stock-tab-count">{newsOverseas.length}</span>
|
||||
</button>
|
||||
</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 ? (
|
||||
<p className="stock-empty">
|
||||
해당 카테고리 뉴스가 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
<div className="stock-news">
|
||||
<div className="stock-news-grid">
|
||||
{activeNews.map((item) => (
|
||||
<article
|
||||
key={item.id ?? item.link}
|
||||
className="stock-news__item"
|
||||
className="stock-news-card"
|
||||
>
|
||||
<div>
|
||||
<p className="stock-news__title">
|
||||
{item.title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="stock-news__meta">
|
||||
<span>
|
||||
<div className="stock-news-card__head">
|
||||
<span className="stock-news-card__date">
|
||||
{formatDate(item.published_at)}
|
||||
</span>
|
||||
{item.sentiment ? (
|
||||
@@ -394,14 +400,25 @@ const Stock = () => {
|
||||
{item.sentiment}
|
||||
</span>
|
||||
) : 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
|
||||
href={item.link}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="stock-news-card__link"
|
||||
>
|
||||
원문 보기
|
||||
원문 보기 →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
deletePortfolio,
|
||||
upsertCash,
|
||||
deleteCash,
|
||||
getFearAndGreed,
|
||||
} from '../../api';
|
||||
import Loading from '../../components/Loading';
|
||||
import './Stock.css';
|
||||
@@ -81,24 +80,6 @@ const toNumeric = (value) => {
|
||||
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 ──────────────────────────────────────────────── */
|
||||
|
||||
const CHART_COLORS = ['#818cf8', '#fbbf24', '#34d399', '#f472b6', '#fb923c', '#a78bfa', '#38bdf8', '#4ade80'];
|
||||
@@ -130,7 +111,7 @@ const TAB_REPORT = 'report';
|
||||
|
||||
const StockTrade = () => {
|
||||
/* Active tab */
|
||||
const [activeTab, setActiveTab] = useState(TAB_PORTFOLIO);
|
||||
const [activeTab, setActiveTab] = useState(TAB_REPORT);
|
||||
|
||||
/* ────────────────────────────────────────────────────────────── */
|
||||
/* 쟁승토리 계좌 (Portfolio) state */
|
||||
@@ -166,12 +147,6 @@ const StockTrade = () => {
|
||||
const [reportSortField, setReportSortField] = useState('profit_rate');
|
||||
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 */
|
||||
const [aiApiKey, setAiApiKey] = useState('');
|
||||
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 () => {
|
||||
setBalanceLoading(true);
|
||||
setBalanceError('');
|
||||
@@ -257,13 +215,6 @@ const StockTrade = () => {
|
||||
}
|
||||
}, [activeTab, portfolioLoaded, balanceLoaded, loadPortfolio, loadBalance]);
|
||||
|
||||
/* Fear & Greed: 리포트 탭 첫 진입 시 자동 로드 */
|
||||
useEffect(() => {
|
||||
if (activeTab === TAB_REPORT && !fgLoaded) {
|
||||
loadFearAndGreed();
|
||||
}
|
||||
}, [activeTab, fgLoaded, loadFearAndGreed]);
|
||||
|
||||
/* AI Coach: 마운트 시 localStorage에서 API Key + 오늘 캐시 복원 */
|
||||
useEffect(() => {
|
||||
const savedKey = localStorage.getItem('ai_coach_key') ?? '';
|
||||
@@ -1454,63 +1405,6 @@ ${holdingsText}
|
||||
)}
|
||||
{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 & 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 && (
|
||||
<section className="stock-panel stock-panel--wide">
|
||||
|
||||
271
src/pages/todo/Todo.css
Normal file
271
src/pages/todo/Todo.css
Normal 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
240
src/pages/todo/Todo.jsx
Normal 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;
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
IconStock,
|
||||
IconTravel,
|
||||
IconLab,
|
||||
IconTodo,
|
||||
} from './components/Icons';
|
||||
|
||||
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 StockTrade = lazy(() => import('./pages/stock/StockTrade'));
|
||||
const EffectLab = lazy(() => import('./pages/effect-lab/EffectLab'));
|
||||
const Todo = lazy(() => import('./pages/todo/Todo'));
|
||||
|
||||
export const navLinks = [
|
||||
{
|
||||
id: 'home',
|
||||
label: 'Home',
|
||||
path: '/',
|
||||
subtitle: 'PERSONAL ARCHIVE',
|
||||
description: '첫 인상과 최신 업데이트를 모아둔 허브',
|
||||
icon: <IconHome />,
|
||||
accent: '#f7a8a5',
|
||||
@@ -29,6 +32,7 @@ export const navLinks = [
|
||||
id: 'blog',
|
||||
label: 'Blog',
|
||||
path: '/blog',
|
||||
subtitle: 'JOURNAL',
|
||||
description: '생각과 기록, 코드 스니펫을 모으는 공간',
|
||||
icon: <IconBlog />,
|
||||
accent: '#c084fc',
|
||||
@@ -37,6 +41,7 @@ export const navLinks = [
|
||||
id: 'lotto',
|
||||
label: 'Lotto',
|
||||
path: '/lotto',
|
||||
subtitle: 'PLAYGROUND',
|
||||
description: '숫자를 뽑고 통계를 확인하는 실험실',
|
||||
icon: <IconLotto />,
|
||||
accent: '#34d399',
|
||||
@@ -45,6 +50,7 @@ export const navLinks = [
|
||||
id: 'stock',
|
||||
label: 'Stock',
|
||||
path: '/stock',
|
||||
subtitle: '마켓 랩',
|
||||
description: '아침 시장 흐름을 확인하는 주식 연구실',
|
||||
icon: <IconStock />,
|
||||
accent: '#60a5fa',
|
||||
@@ -53,6 +59,7 @@ export const navLinks = [
|
||||
id: 'travel',
|
||||
label: 'Travel',
|
||||
path: '/travel',
|
||||
subtitle: 'VISUAL DIARY',
|
||||
description: '여행에서 담은 색과 장면을 전시하는 갤러리',
|
||||
icon: <IconTravel />,
|
||||
accent: '#fb923c',
|
||||
@@ -61,10 +68,20 @@ export const navLinks = [
|
||||
id: 'lab',
|
||||
label: 'Lab',
|
||||
path: '/lab',
|
||||
subtitle: 'STREAM',
|
||||
description: '실험적인 UI/UX 효과를 테스트하는 공간',
|
||||
icon: <IconLab />,
|
||||
accent: '#fbbf24',
|
||||
},
|
||||
{
|
||||
id: 'todo',
|
||||
label: 'Todo',
|
||||
path: '/todo',
|
||||
subtitle: 'TASK BOARD',
|
||||
description: '할 일을 관리하는 태스크 보드',
|
||||
icon: <IconTodo />,
|
||||
accent: '#f472b6',
|
||||
},
|
||||
];
|
||||
|
||||
export const appRoutes = [
|
||||
@@ -96,4 +113,8 @@ export const appRoutes = [
|
||||
path: 'lab',
|
||||
element: <EffectLab />,
|
||||
},
|
||||
{
|
||||
path: 'todo',
|
||||
element: <Todo />,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -26,6 +26,18 @@ export default defineConfig({
|
||||
secure: true,
|
||||
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',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user