dashboard 형태의 UI 수정 및 고도화
This commit is contained in:
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;
|
||||
Reference in New Issue
Block a user