UI 디자인 대대적으로 대시보드 형태의 전문적인 느낌으로 재구성
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3em;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
color: var(--accent-blog);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
@@ -98,29 +98,34 @@
|
||||
}
|
||||
|
||||
.blog-category-chip.is-active {
|
||||
border-color: rgba(247, 168, 165, 0.6);
|
||||
background: rgba(247, 168, 165, 0.2);
|
||||
border-color: rgba(192, 132, 252, 0.55);
|
||||
background: rgba(192, 132, 252, 0.15);
|
||||
color: var(--accent-blog);
|
||||
}
|
||||
|
||||
.blog-list__item {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
border-radius: var(--radius-md);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
transition: border-color 0.2s ease;
|
||||
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
|
||||
box-shadow: var(--shadow-inset);
|
||||
}
|
||||
|
||||
.blog-list__item:hover {
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
border-color: var(--line-strong);
|
||||
background: var(--surface-raised);
|
||||
box-shadow: var(--shadow-sm), var(--shadow-inset);
|
||||
}
|
||||
|
||||
.blog-list__item.is-active {
|
||||
border-color: rgba(247, 168, 165, 0.6);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
|
||||
border-color: rgba(192, 132, 252, 0.5);
|
||||
box-shadow: 0 4px 20px rgba(192, 132, 252, 0.12), var(--shadow-inset);
|
||||
background: rgba(192, 132, 252, 0.05);
|
||||
}
|
||||
|
||||
.blog-pagination {
|
||||
@@ -168,14 +173,15 @@
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--accent);
|
||||
color: var(--accent-blog);
|
||||
}
|
||||
|
||||
.blog-article {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 24px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(9, 10, 16, 0.65);
|
||||
padding: 24px;
|
||||
padding: 28px;
|
||||
box-shadow: var(--shadow-md), var(--shadow-inset);
|
||||
}
|
||||
|
||||
.blog-article__meta {
|
||||
@@ -277,8 +283,9 @@
|
||||
.md-quote {
|
||||
margin: 0 0 14px;
|
||||
padding: 12px 16px;
|
||||
border-left: 3px solid rgba(247, 168, 165, 0.6);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-left: 3px solid rgba(192, 132, 252, 0.5);
|
||||
background: rgba(192, 132, 252, 0.05);
|
||||
border-radius: 0 8px 8px 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,77 +1,113 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
Home Page — Dashboard Style
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.home {
|
||||
display: grid;
|
||||
gap: 60px;
|
||||
gap: 32px;
|
||||
animation: fadeIn 0.4s var(--ease-out) both;
|
||||
}
|
||||
|
||||
.home > section {
|
||||
animation: fadeUp 0.7s ease both;
|
||||
}
|
||||
|
||||
.home > section:nth-child(1) {
|
||||
animation-delay: 0.05s;
|
||||
}
|
||||
|
||||
.home > section:nth-child(2) {
|
||||
animation-delay: 0.12s;
|
||||
}
|
||||
|
||||
.home > section:nth-child(3) {
|
||||
animation-delay: 0.18s;
|
||||
}
|
||||
/* ── Hero ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.home-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr);
|
||||
gap: 32px;
|
||||
align-items: center;
|
||||
grid-template-columns: minmax(0, 1.3fr) minmax(0, 0.7fr);
|
||||
gap: 24px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.home-hero__kicker {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.28em;
|
||||
color: var(--accent);
|
||||
margin: 0 0 12px;
|
||||
letter-spacing: 0.3em;
|
||||
color: var(--neon-cyan);
|
||||
margin: 0 0 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.home-hero__kicker::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 1.5px;
|
||||
background: var(--neon-cyan);
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 6px var(--neon-cyan);
|
||||
}
|
||||
|
||||
.home-hero h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(32px, 4vw, 46px);
|
||||
font-size: clamp(28px, 3.5vw, 44px);
|
||||
margin: 0 0 16px;
|
||||
line-height: 1.2;
|
||||
color: var(--text-bright);
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.home-hero__lead {
|
||||
color: var(--muted);
|
||||
line-height: 1.7;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.75;
|
||||
margin: 0 0 24px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.home-hero__actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── Hero Card ───────────────────────────────────────────────────────── */
|
||||
|
||||
.home-hero__card {
|
||||
background: var(--surface);
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 24px;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.25);
|
||||
box-shadow: var(--shadow-card);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.home-hero__card-title {
|
||||
.home-hero__card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: var(--grad-accent);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.home-hero__card-eyebrow {
|
||||
margin: 0 0 12px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.home-hero__card-body h2 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 24px;
|
||||
margin: 0 0 12px;
|
||||
font-size: 20px;
|
||||
margin: 0 0 8px;
|
||||
color: var(--text-bright);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.home-hero__card-body p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.home-hero__stats {
|
||||
@@ -85,81 +121,184 @@
|
||||
|
||||
.stat-label {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
margin: 6px 0 0;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
margin: 5px 0 0;
|
||||
font-weight: 700;
|
||||
font-size: 20px;
|
||||
color: var(--text-bright);
|
||||
line-height: 1;
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.stat-unit {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-dim);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.stat-value--sm {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ── Section Header ──────────────────────────────────────────────────── */
|
||||
|
||||
.home-section__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 18px;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.home-section__header h2 {
|
||||
margin: 0;
|
||||
font-size: 26px;
|
||||
font-size: clamp(17px, 2vw, 22px);
|
||||
font-family: var(--font-display);
|
||||
color: var(--text-bright);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.home-section__header p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Navigation Cards Grid ───────────────────────────────────────────── */
|
||||
|
||||
.home-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.home-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 16px;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
padding: 18px;
|
||||
border-radius: 18px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--line);
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.01));
|
||||
transition: transform 0.2s ease, border-color 0.2s ease;
|
||||
background: var(--surface-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
transition:
|
||||
transform 0.22s var(--ease-out),
|
||||
border-color 0.22s ease,
|
||||
box-shadow 0.22s ease,
|
||||
background 0.22s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.home-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: var(--grad-accent);
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.home-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(
|
||||
circle at 30% 0%,
|
||||
rgba(var(--card-accent-rgb, 0, 212, 255), 0.08),
|
||||
transparent 60%
|
||||
);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.home-card:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: rgba(255, 255, 255, 0.22);
|
||||
border-color: rgba(0, 212, 255, 0.2);
|
||||
box-shadow:
|
||||
var(--shadow-md),
|
||||
0 0 0 1px rgba(0, 212, 255, 0.08);
|
||||
}
|
||||
|
||||
.home-card:hover::before {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.home-card:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.home-card__icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 212, 255, 0.08);
|
||||
border: 1px solid rgba(0, 212, 255, 0.15);
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.22s var(--ease-spring);
|
||||
color: var(--neon-cyan);
|
||||
}
|
||||
|
||||
.home-card:hover .home-card__icon {
|
||||
transform: scale(1.1) rotate(-4deg);
|
||||
}
|
||||
|
||||
.home-card__body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.home-card__title {
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
margin: 0 0 8px;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
margin: 0 0 5px;
|
||||
color: var(--text-bright);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.home-card__desc {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.home-card__cta {
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--accent);
|
||||
.home-card__arrow {
|
||||
font-size: 16px;
|
||||
color: var(--neon-cyan);
|
||||
opacity: 0;
|
||||
transform: translateX(-4px);
|
||||
transition: opacity 0.22s ease, transform 0.22s ease;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.home-card:hover .home-card__arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* ── Blog Posts ──────────────────────────────────────────────────────── */
|
||||
|
||||
.home-posts {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.home-post {
|
||||
@@ -167,46 +306,96 @@
|
||||
color: inherit;
|
||||
border: 1px solid var(--line);
|
||||
padding: 16px 18px;
|
||||
border-radius: 16px;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-card);
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
transition: border-color 0.2s ease;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: start;
|
||||
gap: 14px;
|
||||
transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.home-post:hover {
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
border-color: rgba(192, 132, 252, 0.25);
|
||||
background: var(--surface-raised);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.home-post__dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--neon-purple);
|
||||
box-shadow: 0 0 6px var(--neon-purple);
|
||||
margin-top: 7px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.home-post:hover .home-post__dot {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.home-post__content {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.home-post__title {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
font-size: 15px;
|
||||
color: var(--text-bright);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.home-post__excerpt {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.home-post__meta {
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
color: var(--neon-purple-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
letter-spacing: 0.12em;
|
||||
white-space: nowrap;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
/* ── Profile ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.home-profile {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.home-profile__card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
padding: 22px;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
background: var(--surface-card);
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
gap: 18px;
|
||||
box-shadow: var(--shadow-card);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.home-profile__card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: var(--grad-accent);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.home-profile__identity {
|
||||
@@ -216,31 +405,39 @@
|
||||
}
|
||||
|
||||
.home-profile__avatar {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.3);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 212, 255, 0.2),
|
||||
0 0 12px rgba(0, 212, 255, 0.1),
|
||||
0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.home-profile__role {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.22em;
|
||||
color: var(--neon-cyan);
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.home-profile__name {
|
||||
margin: 6px 0 0;
|
||||
font-weight: 600;
|
||||
margin: 4px 0 0;
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
color: var(--text-bright);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.home-profile__bio {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.75;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.home-profile__timeline {
|
||||
@@ -250,10 +447,11 @@
|
||||
|
||||
.home-profile__section-title {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.22em;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.24em;
|
||||
color: var(--neon-cyan);
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.home-profile__timeline ul {
|
||||
@@ -261,87 +459,124 @@
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.home-profile__timeline li {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
gap: 2px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.home-profile__timeline span {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
.home-profile__timeline li:hover {
|
||||
border-color: rgba(0, 212, 255, 0.15);
|
||||
background: rgba(0, 212, 255, 0.03);
|
||||
}
|
||||
|
||||
.timeline-period {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.home-profile__timeline strong {
|
||||
font-size: 15px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
.home-profile__timeline span:not(.timeline-period) {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.home-profile__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.home-profile__tags span {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
transition: border-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.home-profile__tags span:hover {
|
||||
border-color: rgba(0, 212, 255, 0.2);
|
||||
color: var(--neon-cyan);
|
||||
}
|
||||
|
||||
.home-profile__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
/* ── Responsive ──────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.home-hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.home-hero__card {
|
||||
max-width: 480px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.home {
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.home-hero h1 {
|
||||
font-size: clamp(24px, 6vw, 36px);
|
||||
font-size: clamp(22px, 6vw, 32px);
|
||||
}
|
||||
|
||||
.home-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.home-card {
|
||||
padding: 14px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.home-card__title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.home-card__desc {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.home-posts {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.home-card__icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.home-card__title {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.home-card__desc {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.home-post {
|
||||
padding: 14px 16px;
|
||||
padding: 12px 14px;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.home-post__meta {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.home-post__title {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.home-profile__card {
|
||||
@@ -351,8 +586,15 @@
|
||||
.home-profile__name {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.home-profile__bio {
|
||||
font-size: 14px;
|
||||
@media (max-width: 480px) {
|
||||
.home-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.home-hero__stats {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ const Home = () => {
|
||||
<section className="home-hero">
|
||||
<div className="home-hero__text">
|
||||
<p className="home-hero__kicker">Personal Archive</p>
|
||||
<h1>기록을 모으고, 이야기를 이어붙이는 작은 집.</h1>
|
||||
<h1>기록을 모으고,<br />이야기를 이어붙이는 작은 집.</h1>
|
||||
<p className="home-hero__lead">
|
||||
개발, 여행 스냅, 그리고 생각을 모아두는 공간입니다.
|
||||
</p>
|
||||
@@ -28,7 +28,7 @@ const Home = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="home-hero__card">
|
||||
<p className="home-hero__card-title">이번 달 집중 테마</p>
|
||||
<p className="home-hero__card-eyebrow">이번 달 집중 테마</p>
|
||||
<div className="home-hero__card-body">
|
||||
<h2>느린 기록, 깊은 회고</h2>
|
||||
<p>
|
||||
@@ -37,13 +37,13 @@ const Home = () => {
|
||||
</p>
|
||||
</div>
|
||||
<div className="home-hero__stats">
|
||||
<div>
|
||||
<div className="home-hero__stat">
|
||||
<p className="stat-label">게시 글</p>
|
||||
<p className="stat-value">{posts.length}편</p>
|
||||
<p className="stat-value">{posts.length}<span className="stat-unit">편</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="home-hero__stat">
|
||||
<p className="stat-label">다음 업데이트</p>
|
||||
<p className="stat-value">이번 주말</p>
|
||||
<p className="stat-value stat-value--sm">이번 주말</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,12 +56,23 @@ const Home = () => {
|
||||
</div>
|
||||
<div className="home-grid">
|
||||
{highlights.map((item) => (
|
||||
<Link key={item.id} to={item.path} className="home-card">
|
||||
<div>
|
||||
<Link
|
||||
key={item.id}
|
||||
to={item.path}
|
||||
className="home-card"
|
||||
style={{ '--card-accent': item.accent }}
|
||||
>
|
||||
<div
|
||||
className="home-card__icon"
|
||||
style={{ color: item.accent }}
|
||||
>
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className="home-card__body">
|
||||
<p className="home-card__title">{item.label}</p>
|
||||
<p className="home-card__desc">{item.description}</p>
|
||||
</div>
|
||||
<span className="home-card__cta">열기</span>
|
||||
<span className="home-card__arrow">→</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
@@ -75,8 +86,11 @@ const Home = () => {
|
||||
<div className="home-posts">
|
||||
{posts.map((post) => (
|
||||
<Link key={post.slug} to="/blog" className="home-post">
|
||||
<p className="home-post__title">{post.title}</p>
|
||||
<p className="home-post__excerpt">{post.excerpt}</p>
|
||||
<div className="home-post__dot" />
|
||||
<div className="home-post__content">
|
||||
<p className="home-post__title">{post.title}</p>
|
||||
<p className="home-post__excerpt">{post.excerpt}</p>
|
||||
</div>
|
||||
<span className="home-post__meta">{post.date || '작성일 미정'}</span>
|
||||
</Link>
|
||||
))}
|
||||
@@ -110,31 +124,26 @@ const Home = () => {
|
||||
<p className="home-profile__section-title">연혁</p>
|
||||
<ul>
|
||||
<li>
|
||||
<span>2023.02 - 현재</span>
|
||||
<span className="timeline-period">2023.02 - 현재</span>
|
||||
<strong>Server Developer</strong>
|
||||
<span>내비 TIS 교통 서버/현대오토에버</span>
|
||||
<span>내비 TIS 교통 서버 / 현대오토에버</span>
|
||||
</li>
|
||||
<li>
|
||||
<span>2020.01 - 2023.02</span>
|
||||
<span className="timeline-period">2020.01 - 2023.02</span>
|
||||
<strong>Embedded Device SW Developer</strong>
|
||||
<span>캐시비 단말기 개발/롯데정보통신</span>
|
||||
<span>캐시비 단말기 개발 / 롯데정보통신</span>
|
||||
</li>
|
||||
<li>
|
||||
<span>2019.07 - 2019.12</span>
|
||||
<span className="timeline-period">2019.07 - 2019.12</span>
|
||||
<strong>SSAFY - 삼성 SW Academy</strong>
|
||||
<span>SSAFY</span>
|
||||
<span>SSAFY 1기 수료</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="home-profile__tags">
|
||||
<span>C++</span>
|
||||
<span>Git</span>
|
||||
<span>AWS</span>
|
||||
<span>Jira</span>
|
||||
<span>MySQL</span>
|
||||
<span>Docker</span>
|
||||
<span>Kubernetes</span>
|
||||
<span>Linux</span>
|
||||
{['C++', 'Git', 'AWS', 'Jira', 'MySQL', 'Docker', 'Kubernetes', 'Linux'].map((tag) => (
|
||||
<span key={tag}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="home-profile__actions">
|
||||
<button className="button ghost">프로필 수정</button>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3em;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
color: var(--accent-lotto);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
@@ -63,10 +63,11 @@
|
||||
.lotto-panel {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
border-radius: 24px;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
box-shadow: var(--shadow-sm), var(--shadow-inset);
|
||||
}
|
||||
|
||||
.lotto-panel--wide .lotto-chart {
|
||||
@@ -94,7 +95,7 @@
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.22em;
|
||||
color: var(--accent);
|
||||
color: var(--accent-lotto);
|
||||
}
|
||||
|
||||
.lotto-panel__sub {
|
||||
@@ -213,7 +214,8 @@
|
||||
}
|
||||
|
||||
.lotto-field input:focus {
|
||||
border-color: rgba(247, 168, 165, 0.6);
|
||||
border-color: rgba(52, 211, 153, 0.6);
|
||||
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
|
||||
}
|
||||
|
||||
.lotto-result {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3em;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
color: var(--accent-stock);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
@@ -134,10 +134,11 @@
|
||||
.stock-panel {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
border-radius: 24px;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
box-shadow: var(--shadow-sm), var(--shadow-inset);
|
||||
}
|
||||
|
||||
.stock-panel--wide {
|
||||
@@ -169,7 +170,7 @@
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.22em;
|
||||
color: var(--accent);
|
||||
color: var(--accent-stock);
|
||||
}
|
||||
|
||||
.stock-panel__sub {
|
||||
@@ -211,9 +212,9 @@
|
||||
}
|
||||
|
||||
.stock-snapshot__card.is-highlight {
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(96, 165, 250, 0.4);
|
||||
background: rgba(96, 165, 250, 0.06);
|
||||
box-shadow: 0 0 0 1px rgba(96, 165, 250, 0.1), var(--shadow-inset);
|
||||
}
|
||||
|
||||
.stock-snapshot__card p {
|
||||
@@ -305,8 +306,9 @@
|
||||
}
|
||||
|
||||
.stock-tab.is-active {
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
border-color: rgba(96, 165, 250, 0.5);
|
||||
color: var(--text);
|
||||
background: rgba(96, 165, 250, 0.1);
|
||||
}
|
||||
|
||||
.stock-news__item {
|
||||
@@ -340,7 +342,14 @@
|
||||
}
|
||||
|
||||
.stock-news__meta a {
|
||||
color: var(--accent);
|
||||
color: var(--accent-stock);
|
||||
text-decoration: none;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.stock-news__meta a:hover {
|
||||
opacity: 0.8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.stock-empty {
|
||||
@@ -918,4 +927,526 @@
|
||||
.pf-total-summary__card strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Cash Panel (예수금) ─────────────────────────────────────────── */
|
||||
|
||||
.pf-cash-table {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pf-cash-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pf-cash-broker {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.pf-cash-amount {
|
||||
font-size: 15px;
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.pf-cash-date {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
min-width: 80px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.pf-cash-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
padding: 14px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 16px;
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.pf-cash-form label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pf-cash-form input {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.pf-cash-form input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.pf-cash-badge {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 8px;
|
||||
background: rgba(147, 197, 253, 0.15);
|
||||
border: 1px solid rgba(147, 197, 253, 0.3);
|
||||
color: #93c5fd;
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pf-total-summary__card.is-cash {
|
||||
border-color: rgba(147, 197, 253, 0.4);
|
||||
}
|
||||
|
||||
.pf-total-summary__card.is-cash strong {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.pf-total-summary__card.is-assets {
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.pf-total-summary__card.is-assets strong {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pf-cash-form {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.pf-cash-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.pf-cash-row {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pf-cash-date {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════
|
||||
Fear & Greed Index Panel
|
||||
══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.fg-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
padding: 16px 0 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fg-gauge {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.fg-gauge__track {
|
||||
position: relative;
|
||||
height: 14px;
|
||||
border-radius: 7px;
|
||||
background: linear-gradient(to right, #ef4444 0%, #f97316 25%, #eab308 50%, #84cc16 75%, #22c55e 100%);
|
||||
margin-bottom: 10px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.fg-gauge__needle {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
transform: translateX(-50%);
|
||||
width: 5px;
|
||||
height: 24px;
|
||||
background: #fff;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.6);
|
||||
transition: left 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.fg-gauge__labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.fg-score-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 90px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fg-score-number {
|
||||
font-size: 48px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
transition: color 0.4s ease;
|
||||
}
|
||||
|
||||
.fg-score-label {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
transition: color 0.4s ease;
|
||||
}
|
||||
|
||||
.fg-score-date {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════
|
||||
Report Charts Row
|
||||
══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.report-charts-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.report-chart-box {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.report-chart-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.report-charts-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════
|
||||
Report Table (Sortable)
|
||||
══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.report-table-wrapper {
|
||||
overflow-x: auto;
|
||||
margin-top: 8px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.report-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.report-table thead {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.report-table th {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
border-bottom: 1px solid var(--line);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.report-table th:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.report-sort-icon {
|
||||
font-size: 10px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.report-table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
color: var(--text);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.report-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.report-table tbody tr:hover td {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.report-td-muted {
|
||||
color: var(--muted) !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.report-table-name {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.report-table-code {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.report-rate-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.report-rate-bar {
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.report-rate-bar__fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.report-rate-bar__fill.is-up {
|
||||
background: #34d399;
|
||||
}
|
||||
|
||||
.report-rate-bar__fill.is-down {
|
||||
background: #f87171;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════
|
||||
AI 투자 코치 패널
|
||||
══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.ai-coach-settings {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 12px 16px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
margin-bottom: 16px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.ai-coach-settings label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.ai-coach-settings input,
|
||||
.ai-coach-settings select {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 9px 12px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.ai-coach-settings input:focus,
|
||||
.ai-coach-settings select:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.ai-coach-key-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ai-coach-key-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai-coach-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.ai-coach-note {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Result */
|
||||
.ai-coach-result {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 20px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ai-coach-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ai-grade-badge {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
flex-shrink: 0;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.ai-grade-badge.grade-s { background: linear-gradient(135deg, #7c3aed, #4f46e5); color: #fff; }
|
||||
.ai-grade-badge.grade-a { background: linear-gradient(135deg, #059669, #10b981); color: #fff; }
|
||||
.ai-grade-badge.grade-b { background: linear-gradient(135deg, #0284c7, #38bdf8); color: #fff; }
|
||||
.ai-grade-badge.grade-c { background: linear-gradient(135deg, #d97706, #fbbf24); color: #fff; }
|
||||
.ai-grade-badge.grade-d { background: linear-gradient(135deg, #dc2626, #f87171); color: #fff; }
|
||||
|
||||
.ai-score-wrap {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.ai-score-num {
|
||||
font-size: 36px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ai-score-unit {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.ai-summary-text {
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.ai-evaluation-text {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
line-height: 1.7;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.ai-advice-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ai-advice-card {
|
||||
background: rgba(129, 140, 248, 0.07);
|
||||
border: 1px solid rgba(129, 140, 248, 0.2);
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.ai-advice-card:hover {
|
||||
background: rgba(129, 140, 248, 0.12);
|
||||
}
|
||||
|
||||
.ai-advice-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #a5b4fc;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.ai-advice-body {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.ai-coach-settings {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ai-advice-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ai-grade-badge {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.ai-score-num {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.fg-gauge__labels span:nth-child(2),
|
||||
.fg-gauge__labels span:nth-child(4) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,17 @@ import {
|
||||
addPortfolio,
|
||||
updatePortfolio,
|
||||
deletePortfolio,
|
||||
upsertCash,
|
||||
deleteCash,
|
||||
getFearAndGreed,
|
||||
} from '../../api';
|
||||
import Loading from '../../components/Loading';
|
||||
import './Stock.css';
|
||||
import {
|
||||
PieChart, Pie, Cell,
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid,
|
||||
Tooltip as ChartTooltip, Legend, ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
/* ── helpers ─────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -73,6 +81,28 @@ 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'];
|
||||
|
||||
const profitColorClass = (numericValue) => {
|
||||
if (numericValue > 0) return 'is-up';
|
||||
if (numericValue < 0) return 'is-down';
|
||||
@@ -94,6 +124,7 @@ const emptyPortfolioForm = {
|
||||
|
||||
const TAB_PORTFOLIO = 'portfolio';
|
||||
const TAB_AI = 'ai';
|
||||
const TAB_REPORT = 'report';
|
||||
|
||||
/* ── component ───────────────────────────────────────────────────── */
|
||||
|
||||
@@ -124,6 +155,30 @@ const StockTrade = () => {
|
||||
/* Portfolio delete */
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
|
||||
|
||||
/* Cash (예수금) form */
|
||||
const [cashForm, setCashForm] = useState({ broker: '', cash: '' });
|
||||
const [cashSaving, setCashSaving] = useState(false);
|
||||
const [cashError, setCashError] = useState('');
|
||||
|
||||
/* ────────────────────────────────────────────────────────────── */
|
||||
/* 리포트 탭 state */
|
||||
/* ────────────────────────────────────────────────────────────── */
|
||||
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');
|
||||
const [aiResult, setAiResult] = useState(null);
|
||||
const [aiLoading, setAiLoading] = useState(false);
|
||||
const [aiError, setAiError] = useState('');
|
||||
|
||||
/* ────────────────────────────────────────────────────────────── */
|
||||
/* AI 투자 (Balance) state */
|
||||
/* ────────────────────────────────────────────────────────────── */
|
||||
@@ -160,6 +215,23 @@ 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('');
|
||||
@@ -180,9 +252,31 @@ const StockTrade = () => {
|
||||
loadPortfolio();
|
||||
} else if (activeTab === TAB_AI && !balanceLoaded) {
|
||||
loadBalance();
|
||||
} else if (activeTab === TAB_REPORT && !portfolioLoaded) {
|
||||
loadPortfolio();
|
||||
}
|
||||
}, [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') ?? '';
|
||||
const savedModel = localStorage.getItem('ai_coach_model') ?? 'claude-haiku-4-5-20251001';
|
||||
setAiApiKey(savedKey);
|
||||
setAiModel(savedModel);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const cached = localStorage.getItem(`ai_coach_${today}`);
|
||||
if (cached) {
|
||||
try { setAiResult({ ...JSON.parse(cached), cached: true }); } catch { /* ignore */ }
|
||||
}
|
||||
}, []);
|
||||
|
||||
/* Auto-refresh portfolio every 3 min (포트폴리오 탭 활성 시) */
|
||||
useEffect(() => {
|
||||
if (activeTab !== TAB_PORTFOLIO) return;
|
||||
@@ -277,6 +371,126 @@ const StockTrade = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/* ── cash (예수금) actions ──────────────────────────────────── */
|
||||
|
||||
const handleCashSave = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!cashForm.broker.trim() || cashForm.cash === '') return;
|
||||
setCashSaving(true);
|
||||
setCashError('');
|
||||
try {
|
||||
await upsertCash(cashForm.broker.trim(), Number(cashForm.cash));
|
||||
setCashForm({ broker: '', cash: '' });
|
||||
await loadPortfolio();
|
||||
} catch (err) {
|
||||
setCashError(err?.message ?? String(err));
|
||||
} finally {
|
||||
setCashSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCashDelete = async (broker) => {
|
||||
try {
|
||||
await deleteCash(broker);
|
||||
await loadPortfolio();
|
||||
} catch (err) {
|
||||
alert('예수금 삭제 실패: ' + (err?.message ?? String(err)));
|
||||
}
|
||||
};
|
||||
|
||||
/* ── report sort ─────────────────────────────────────────────── */
|
||||
|
||||
const handleReportSort = (field) => {
|
||||
if (reportSortField === field) {
|
||||
setReportSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setReportSortField(field);
|
||||
setReportSortDir('desc');
|
||||
}
|
||||
};
|
||||
|
||||
/* ── AI coach ────────────────────────────────────────────────── */
|
||||
|
||||
const handleAiCoach = async () => {
|
||||
if (!aiApiKey.trim() || portfolioHoldings.length === 0) return;
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const cacheKey = `ai_coach_${today}`;
|
||||
const cached = localStorage.getItem(cacheKey);
|
||||
if (cached) {
|
||||
try { setAiResult({ ...JSON.parse(cached), cached: true }); return; } catch { /* invalid cache */ }
|
||||
}
|
||||
|
||||
setAiLoading(true);
|
||||
setAiError('');
|
||||
|
||||
const holdingsText = portfolioHoldings
|
||||
.map((item) =>
|
||||
`- ${item.name ?? item.ticker}(${item.ticker ?? ''}): ${item.quantity}주, 매입가 ${formatNumber(item.avg_price)}원, 현재가 ${item.current_price != null ? formatNumber(item.current_price) + '원' : '미조회'}, 수익률 ${item.profit_rate != null ? formatPercent(item.profit_rate) : '미조회'}`
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
const prompt = `당신은 한국 주식 전문 투자 코치입니다. 아래 포트폴리오를 분석하여 JSON으로만 답하세요.
|
||||
|
||||
분석 일자: ${today}
|
||||
총 매입금액: ${formatNumber(portfolioSummary.total_buy)}원
|
||||
총 평가금액: ${formatNumber(portfolioSummary.total_eval)}원
|
||||
총 손익: ${formatNumber(portfolioSummary.total_profit)}원 (수익률: ${formatPercent(portfolioSummary.total_profit_rate)})
|
||||
예수금 합계: ${totalCash != null ? formatNumber(totalCash) + '원' : '미입력'}
|
||||
총 자산: ${totalAssets != null ? formatNumber(totalAssets) + '원' : '미집계'}
|
||||
보유 종목 수: ${portfolioHoldings.length}개
|
||||
보유 종목:
|
||||
${holdingsText}
|
||||
|
||||
반드시 아래 JSON 형식으로만 응답하세요 (코드블록 없이, 모든 텍스트는 한국어로):
|
||||
{
|
||||
"score": 85,
|
||||
"grade": "A",
|
||||
"summary": "30자 이내 한줄 평가",
|
||||
"evaluation": "200자 이내 상세 평가",
|
||||
"advice": [
|
||||
{ "title": "조언 제목", "body": "50자 이내 조언 내용" },
|
||||
{ "title": "조언 제목", "body": "50자 이내 조언 내용" },
|
||||
{ "title": "조언 제목", "body": "50자 이내 조언 내용" }
|
||||
]
|
||||
}`;
|
||||
|
||||
try {
|
||||
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': aiApiKey.trim(),
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-dangerous-direct-browser-access': 'true',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: aiModel,
|
||||
max_tokens: 1024,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`Claude API 오류 (${res.status}): ${text.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const text = data.content?.[0]?.text ?? '';
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) throw new Error('AI 응답에서 JSON을 파싱할 수 없습니다.');
|
||||
const result = JSON.parse(jsonMatch[0]);
|
||||
const final = { ...result, generated_at: new Date().toISOString(), cached: false };
|
||||
localStorage.setItem(cacheKey, JSON.stringify(final));
|
||||
setAiResult(final);
|
||||
} catch (err) {
|
||||
setAiError(err?.message ?? String(err));
|
||||
} finally {
|
||||
setAiLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ── manual order ────────────────────────────────────────────── */
|
||||
|
||||
const submitManualOrder = async (event) => {
|
||||
@@ -327,6 +541,9 @@ const StockTrade = () => {
|
||||
|
||||
const portfolioHoldings = portfolio?.holdings ?? [];
|
||||
const portfolioSummary = portfolio?.summary ?? {};
|
||||
const cashList = portfolio?.cash ?? [];
|
||||
const totalCash = portfolioSummary.total_cash ?? null;
|
||||
const totalAssets = portfolioSummary.total_assets ?? null;
|
||||
const brokerGroups = useMemo(() => {
|
||||
const map = {};
|
||||
for (const item of portfolioHoldings) {
|
||||
@@ -370,6 +587,62 @@ const StockTrade = () => {
|
||||
return map;
|
||||
}, [brokerGroups]);
|
||||
|
||||
/* ── derived: Report ──────────────────────────────────────────── */
|
||||
|
||||
const brokerPieData = useMemo(() =>
|
||||
brokerGroups
|
||||
.map(([broker, items]) => ({ name: broker, value: getBrokerSummary(items).totalEval }))
|
||||
.filter((d) => d.value > 0),
|
||||
[brokerGroups]
|
||||
);
|
||||
|
||||
const profitBarData = useMemo(() =>
|
||||
portfolioHoldings
|
||||
.filter((item) => item.profit_rate != null)
|
||||
.map((item) => ({
|
||||
name: item.ticker ?? (item.name ?? 'N/A').slice(0, 5),
|
||||
fullName: item.name ?? item.ticker ?? 'N/A',
|
||||
rate: toNumeric(item.profit_rate) ?? 0,
|
||||
}))
|
||||
.sort((a, b) => b.rate - a.rate),
|
||||
[portfolioHoldings]
|
||||
);
|
||||
|
||||
const maxAbsRate = useMemo(() =>
|
||||
Math.max(1, ...portfolioHoldings.map((h) => Math.abs(toNumeric(h.profit_rate) ?? 0))),
|
||||
[portfolioHoldings]
|
||||
);
|
||||
|
||||
const sortedHoldings = useMemo(() => {
|
||||
const getVal = (item) => {
|
||||
switch (reportSortField) {
|
||||
case 'profit_rate': return toNumeric(item.profit_rate) ?? -Infinity;
|
||||
case 'profit_amount': return toNumeric(item.profit_amount) ?? -Infinity;
|
||||
case 'eval_amount': {
|
||||
const ea = toNumeric(item.eval_amount);
|
||||
if (ea != null) return ea;
|
||||
const cp = toNumeric(item.current_price);
|
||||
const qty = toNumeric(item.quantity);
|
||||
return cp != null && qty != null ? cp * qty : -Infinity;
|
||||
}
|
||||
default: return 0;
|
||||
}
|
||||
};
|
||||
return [...portfolioHoldings].sort((a, b) => {
|
||||
if (reportSortField === 'name')
|
||||
return reportSortDir === 'asc'
|
||||
? (a.name ?? '').localeCompare(b.name ?? '')
|
||||
: (b.name ?? '').localeCompare(a.name ?? '');
|
||||
if (reportSortField === 'broker')
|
||||
return reportSortDir === 'asc'
|
||||
? (a.broker ?? '').localeCompare(b.broker ?? '')
|
||||
: (b.broker ?? '').localeCompare(a.broker ?? '');
|
||||
const av = getVal(a);
|
||||
const bv = getVal(b);
|
||||
return reportSortDir === 'asc' ? av - bv : bv - av;
|
||||
});
|
||||
}, [portfolioHoldings, reportSortField, reportSortDir]);
|
||||
|
||||
/* ── render ───────────────────────────────────────────────────── */
|
||||
|
||||
return (
|
||||
@@ -390,11 +663,9 @@ const StockTrade = () => {
|
||||
</div>
|
||||
<div className="stock-card">
|
||||
<p className="stock-card__title">
|
||||
{activeTab === TAB_PORTFOLIO
|
||||
? '쟁승토리 계좌 요약'
|
||||
: 'AI 투자 요약'}
|
||||
{activeTab === TAB_AI ? 'AI 투자 요약' : '쟁승토리 계좌 요약'}
|
||||
</p>
|
||||
{activeTab === TAB_PORTFOLIO ? (
|
||||
{activeTab === TAB_PORTFOLIO || activeTab === TAB_REPORT ? (
|
||||
/* Portfolio summary */
|
||||
<div className="stock-status">
|
||||
<div>
|
||||
@@ -424,6 +695,22 @@ const StockTrade = () => {
|
||||
<span>보유 종목</span>
|
||||
<strong>{portfolioHoldings.length}</strong>
|
||||
</div>
|
||||
{totalCash != null && (
|
||||
<div>
|
||||
<span>예수금 합계</span>
|
||||
<strong style={{ color: '#93c5fd' }}>
|
||||
{formatNumber(totalCash)}원
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
{totalAssets != null && (
|
||||
<div>
|
||||
<span>총 자산</span>
|
||||
<strong style={{ fontWeight: 700 }}>
|
||||
{formatNumber(totalAssets)}원
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* AI balance summary */
|
||||
@@ -472,6 +759,15 @@ const StockTrade = () => {
|
||||
<span className="stock-main-tab__label">AI 투자</span>
|
||||
<span className="stock-main-tab__sub">모의투자</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`stock-main-tab ${activeTab === TAB_REPORT ? 'is-active' : ''}`}
|
||||
onClick={() => setActiveTab(TAB_REPORT)}
|
||||
>
|
||||
<span className="stock-main-tab__icon">📊</span>
|
||||
<span className="stock-main-tab__label">리포트</span>
|
||||
<span className="stock-main-tab__sub">분석·AI코치</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ════════════════════════════════════════════════════════
|
||||
@@ -612,10 +908,102 @@ const StockTrade = () => {
|
||||
</strong>
|
||||
</div>
|
||||
))}
|
||||
{totalCash != null && (
|
||||
<div className="pf-total-summary__card is-cash">
|
||||
<span>예수금 합계</span>
|
||||
<strong>{formatNumber(totalCash)}원</strong>
|
||||
</div>
|
||||
)}
|
||||
{totalAssets != null && (
|
||||
<div className="pf-total-summary__card is-assets">
|
||||
<span>총 자산</span>
|
||||
<strong>{formatNumber(totalAssets)}원</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 예수금 패널 */}
|
||||
<section className="stock-panel stock-panel--wide">
|
||||
<div className="stock-panel__head">
|
||||
<div>
|
||||
<p className="stock-panel__eyebrow">예수금 관리</p>
|
||||
<h3>증권사별 예수금</h3>
|
||||
<p className="stock-panel__sub">
|
||||
증권사별 예수금을 입력하면 총 자산에 자동 반영됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cashList.length > 0 && (
|
||||
<div className="pf-cash-table">
|
||||
{cashList.map((item) => (
|
||||
<div key={item.id ?? item.broker} className="pf-cash-row">
|
||||
<span className="pf-cash-broker">{item.broker}</span>
|
||||
<strong className="pf-cash-amount">
|
||||
{formatNumber(item.cash)}원
|
||||
</strong>
|
||||
<span className="pf-cash-date">
|
||||
{item.updated_at
|
||||
? new Date(item.updated_at).toLocaleDateString('ko-KR')
|
||||
: ''}
|
||||
</span>
|
||||
<button
|
||||
className="button ghost small pf-btn-danger"
|
||||
onClick={() => handleCashDelete(item.broker)}
|
||||
title="삭제"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{cashList.length === 0 && (
|
||||
<p className="stock-empty" style={{ fontSize: 13 }}>
|
||||
등록된 예수금이 없습니다.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form className="pf-cash-form" onSubmit={handleCashSave}>
|
||||
<label>
|
||||
증권사명
|
||||
<input
|
||||
type="text"
|
||||
value={cashForm.broker}
|
||||
onChange={(e) =>
|
||||
setCashForm((p) => ({ ...p, broker: e.target.value }))
|
||||
}
|
||||
placeholder="KB증권"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
예수금 (원)
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={cashForm.cash}
|
||||
onChange={(e) =>
|
||||
setCashForm((p) => ({ ...p, cash: e.target.value }))
|
||||
}
|
||||
placeholder="1500000"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="button primary"
|
||||
type="submit"
|
||||
disabled={cashSaving}
|
||||
>
|
||||
{cashSaving ? '저장 중...' : '저장'}
|
||||
</button>
|
||||
{cashError && <p className="stock-error">{cashError}</p>}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* Broker cards stacked */}
|
||||
{brokerGroups.map(([broker, items]) => {
|
||||
const bSummary = getBrokerSummary(items);
|
||||
@@ -649,6 +1037,16 @@ const StockTrade = () => {
|
||||
{formatNumber(bSummary.totalProfit)} (
|
||||
{formatPercent(bSummary.totalProfitRate)})
|
||||
</span>
|
||||
{(() => {
|
||||
const bc = cashList.find(
|
||||
(c) => c.broker === broker
|
||||
);
|
||||
return bc ? (
|
||||
<span className="pf-cash-badge">
|
||||
예수금 {formatNumber(bc.cash)}원
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1044,6 +1442,344 @@ const StockTrade = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ════════════════════════════════════════════════════════
|
||||
TAB 3: 리포트 + AI 코치
|
||||
════════════════════════════════════════════════════════ */}
|
||||
{activeTab === TAB_REPORT && (
|
||||
<>
|
||||
{portfolioLoading && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
|
||||
<Loading type="spinner" message="포트폴리오 로딩 중..." />
|
||||
</div>
|
||||
)}
|
||||
{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">
|
||||
<div className="stock-panel__head">
|
||||
<div>
|
||||
<p className="stock-panel__eyebrow">포트폴리오 분석</p>
|
||||
<h3>자산 배분 현황</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="report-charts-row">
|
||||
<div className="report-chart-box">
|
||||
<p className="report-chart-title">증권사별 자산 배분</p>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={brokerPieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={52}
|
||||
outerRadius={84}
|
||||
dataKey="value"
|
||||
paddingAngle={2}
|
||||
>
|
||||
{brokerPieData.map((_, i) => (
|
||||
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<ChartTooltip
|
||||
formatter={(v) => [formatNumber(v) + '원', '평가금액']}
|
||||
contentStyle={{ background: '#1e293b', border: 'none', borderRadius: 8, fontSize: 12 }}
|
||||
/>
|
||||
<Legend
|
||||
iconType="circle"
|
||||
iconSize={8}
|
||||
formatter={(v) => <span style={{ color: '#9ca3af', fontSize: 12 }}>{v}</span>}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="report-chart-box">
|
||||
<p className="report-chart-title">종목별 수익률 (%)</p>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={profitBarData} margin={{ top: 0, right: 8, left: -16, bottom: 48 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fill: '#9ca3af', fontSize: 10 }}
|
||||
angle={-40}
|
||||
textAnchor="end"
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: '#9ca3af', fontSize: 10 }}
|
||||
tickFormatter={(v) => `${v}%`}
|
||||
/>
|
||||
<ChartTooltip
|
||||
formatter={(v, _n, props) => [`${v.toFixed(2)}%`, props.payload.fullName]}
|
||||
contentStyle={{ background: '#1e293b', border: 'none', borderRadius: 8, fontSize: 12 }}
|
||||
/>
|
||||
<Bar dataKey="rate" radius={[4, 4, 0, 0]}>
|
||||
{profitBarData.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.rate >= 0 ? '#34d399' : '#f87171'} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── 수익률 랭킹 테이블 ─────────────────────────── */}
|
||||
{portfolioHoldings.length > 0 && (
|
||||
<section className="stock-panel stock-panel--wide">
|
||||
<div className="stock-panel__head">
|
||||
<div>
|
||||
<p className="stock-panel__eyebrow">수익률 랭킹</p>
|
||||
<h3>종목별 상세 현황</h3>
|
||||
<p className="stock-panel__sub">헤더 클릭으로 정렬</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="report-table-wrapper">
|
||||
<table className="report-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{[
|
||||
{ key: 'name', label: '종목명' },
|
||||
{ key: 'broker', label: '증권사' },
|
||||
{ key: 'profit_rate', label: '수익률' },
|
||||
{ key: 'profit_amount', label: '평가손익' },
|
||||
{ key: 'eval_amount', label: '평가금액' },
|
||||
].map(({ key, label }) => (
|
||||
<th key={key} onClick={() => handleReportSort(key)}>
|
||||
{label}{' '}
|
||||
<span className="report-sort-icon">
|
||||
{reportSortField === key
|
||||
? reportSortDir === 'asc' ? '↑' : '↓'
|
||||
: '↕'}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedHoldings.map((item) => {
|
||||
const rateN = toNumeric(item.profit_rate);
|
||||
const pnlN = toNumeric(item.profit_amount);
|
||||
const evalAmt = item.eval_amount != null
|
||||
? item.eval_amount
|
||||
: item.current_price != null
|
||||
? item.current_price * item.quantity
|
||||
: null;
|
||||
return (
|
||||
<tr key={item.id}>
|
||||
<td>
|
||||
<p className="report-table-name">{item.name ?? item.ticker ?? 'N/A'}</p>
|
||||
<span className="report-table-code">{item.ticker ?? ''}</span>
|
||||
</td>
|
||||
<td className="report-td-muted">{item.broker ?? '-'}</td>
|
||||
<td className={`stock-profit ${profitColorClass(rateN)}`}>
|
||||
<div className="report-rate-cell">
|
||||
<span>{item.profit_rate != null ? formatPercent(item.profit_rate) : '-'}</span>
|
||||
{rateN != null && (
|
||||
<div className="report-rate-bar">
|
||||
<div
|
||||
className={`report-rate-bar__fill ${rateN >= 0 ? 'is-up' : 'is-down'}`}
|
||||
style={{ width: `${maxAbsRate > 0 ? Math.abs(rateN) / maxAbsRate * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className={`stock-profit ${profitColorClass(pnlN)}`}>
|
||||
{item.profit_amount != null ? formatNumber(item.profit_amount) : '-'}
|
||||
</td>
|
||||
<td className="report-td-muted">
|
||||
{evalAmt != null ? formatNumber(evalAmt) : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{portfolioLoaded && portfolioHoldings.length === 0 && !portfolioError && (
|
||||
<section className="stock-panel stock-panel--wide">
|
||||
<p className="stock-empty" style={{ textAlign: 'center', padding: 24 }}>
|
||||
등록된 종목이 없습니다. <strong>쟁승토리 계좌</strong> 탭에서 종목을 먼저 등록하세요.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── AI 투자 코치 ───────────────────────────────── */}
|
||||
<section className="stock-panel stock-panel--wide">
|
||||
<div className="stock-panel__head">
|
||||
<div>
|
||||
<p className="stock-panel__eyebrow">AI 투자 코치</p>
|
||||
<h3>오늘의 투자 평가</h3>
|
||||
<p className="stock-panel__sub">
|
||||
포트폴리오를 AI가 분석하여 성취도 등급과 내일을 위한 투자 조언을 드립니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Key 설정 */}
|
||||
<div className="ai-coach-settings">
|
||||
<label>
|
||||
Anthropic API Key
|
||||
<div className="ai-coach-key-row">
|
||||
<input
|
||||
type="password"
|
||||
className="ai-coach-key-input"
|
||||
value={aiApiKey}
|
||||
onChange={(e) => setAiApiKey(e.target.value)}
|
||||
placeholder="sk-ant-api03-..."
|
||||
/>
|
||||
<button
|
||||
className="button ghost small"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
localStorage.setItem('ai_coach_key', aiApiKey);
|
||||
localStorage.setItem('ai_coach_model', aiModel);
|
||||
}}
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
AI 모델
|
||||
<select
|
||||
value={aiModel}
|
||||
onChange={(e) => {
|
||||
setAiModel(e.target.value);
|
||||
localStorage.setItem('ai_coach_model', e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="claude-haiku-4-5-20251001">Claude Haiku (빠름·저렴)</option>
|
||||
<option value="claude-sonnet-4-6">Claude Sonnet (고성능)</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="ai-coach-actions">
|
||||
<button
|
||||
className="button primary"
|
||||
type="button"
|
||||
onClick={handleAiCoach}
|
||||
disabled={aiLoading || !aiApiKey.trim() || portfolioHoldings.length === 0}
|
||||
>
|
||||
{aiLoading ? 'AI 분석 중...' : '오늘 투자 평가 받기'}
|
||||
</button>
|
||||
{portfolioHoldings.length === 0 && (
|
||||
<span className="ai-coach-note">종목 등록 후 이용 가능합니다.</span>
|
||||
)}
|
||||
{aiResult?.generated_at && (
|
||||
<span className="ai-coach-note">
|
||||
{aiResult.cached ? '오늘 캐시 결과 · ' : ''}
|
||||
{new Date(aiResult.generated_at).toLocaleTimeString('ko-KR')} 생성
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{aiError && <p className="stock-error" style={{ marginTop: 8 }}>{aiError}</p>}
|
||||
|
||||
{aiResult && !aiLoading && (
|
||||
<div className="ai-coach-result">
|
||||
<div className="ai-coach-header">
|
||||
<div className={`ai-grade-badge grade-${(aiResult.grade ?? 'c').toLowerCase()}`}>
|
||||
{aiResult.grade ?? '?'}
|
||||
</div>
|
||||
<div className="ai-score-wrap">
|
||||
<span className="ai-score-num">{aiResult.score ?? 0}</span>
|
||||
<span className="ai-score-unit">/ 100</span>
|
||||
</div>
|
||||
<p className="ai-summary-text">{aiResult.summary}</p>
|
||||
</div>
|
||||
<p className="ai-evaluation-text">{aiResult.evaluation}</p>
|
||||
{aiResult.advice?.length > 0 && (
|
||||
<div className="ai-advice-list">
|
||||
{aiResult.advice.map((a, i) => (
|
||||
<div key={i} className="ai-advice-card">
|
||||
<p className="ai-advice-title">{a.title}</p>
|
||||
<p className="ai-advice-body">{a.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="button ghost small"
|
||||
type="button"
|
||||
style={{ marginTop: 16, fontSize: 11 }}
|
||||
onClick={() => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
localStorage.removeItem(`ai_coach_${today}`);
|
||||
setAiResult(null);
|
||||
}}
|
||||
>
|
||||
다시 평가받기 (캐시 삭제)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* KIS modal */}
|
||||
{kisModal ? (
|
||||
<div className="stock-modal" role="dialog" aria-modal="true">
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3em;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
color: var(--accent-travel);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
@@ -88,10 +88,11 @@
|
||||
.travel-map__canvas {
|
||||
width: 100%;
|
||||
min-height: 520px;
|
||||
border-radius: 22px;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(10, 12, 20, 0.6);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -118,7 +119,7 @@
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
color: var(--accent-travel);
|
||||
}
|
||||
|
||||
.travel-map__desc {
|
||||
@@ -303,7 +304,7 @@
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: #f1c07a;
|
||||
color: var(--accent-travel);
|
||||
}
|
||||
|
||||
.travel-modal__summary-meta {
|
||||
|
||||
Reference in New Issue
Block a user