Compare commits

...

12 Commits

Author SHA1 Message Date
1d78b2c430 feat: Add Effect Lab page with an interactive Three.js sword stream animation. 2026-02-09 00:23:11 +09:00
bdb055cb32 1. 라우팅 최적화
2. API 호출 병렬 처리
3. UI개선 - 로딩 경험 개선
4. 반응형 디자인
5. API 통신 특이사항 - URL 구성 로직의 잠재적 위험 해결
2026-02-09 00:13:40 +09:00
d7e7ccdb16 주식 트레이드 핸드폰 화면 UI 개선 2026-01-29 22:51:19 +09:00
8fc7c2cb70 주식 트레이딩 페이지 고도화 2026-01-28 02:13:56 +09:00
7d01c72e58 주식 매매 api 및 화면 오류 수정 2026-01-27 03:27:01 +09:00
9ab45b64b6 주식 매매 프로그램 연동 및 페이지 개발 구체화 2026-01-27 02:03:04 +09:00
22897c3eb6 주식 잔고, 주문 창 업그레이드
- 잔고, 주문 창 분리
 - full-width 섹션으로 쌓게 변경
2026-01-26 22:47:18 +09:00
5f4742085c 주식 즉시 스크래핑 api 오류 수정 2026-01-26 03:58:00 +09:00
5dab3d99c1 주식 즉시 스크래핑 추가 2026-01-26 03:31:55 +09:00
b559eeda58 주식 주요 지수 가져오기 추가 2026-01-26 03:14:16 +09:00
07b43c48c1 stock lab 기능 구현
- 주가지수 API 연결 (KOSPI/KOSDAQ/NASDAQ 등)
 - 뉴스 카드에 키워드 하이라이트/태그 자동 추출
 - 아침 8시 스크랩” 기준 타이머/카운트다운 표시
2026-01-26 03:05:50 +09:00
9d8af6b03b 여행 기록 UI/UX 오류 수정 2026-01-26 03:05:38 +09:00
16 changed files with 2433 additions and 68 deletions

9
package-lock.json generated
View File

@@ -12,7 +12,8 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-leaflet": "^4.2.1", "react-leaflet": "^4.2.1",
"react-router-dom": "^6.30.3" "react-router-dom": "^6.30.3",
"three": "^0.182.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
@@ -2921,6 +2922,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/three": {
"version": "0.182.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz",
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",

View File

@@ -17,7 +17,8 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-leaflet": "^4.2.1", "react-leaflet": "^4.2.1",
"react-router-dom": "^6.30.3" "react-router-dom": "^6.30.3",
"three": "^0.182.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",

View File

@@ -26,18 +26,25 @@
} }
} }
.suspend-loading {
display: grid;
place-items: center;
min-height: 50vh;
}
@keyframes fadeUp { @keyframes fadeUp {
from { from {
opacity: 0; opacity: 0;
transform: translateY(16px); transform: translateY(16px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
} }
} }
.site-main > * { .site-main>* {
animation: fadeUp 0.6s ease both; animation: fadeUp 0.6s ease both;
} }

View File

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

View File

@@ -1,6 +1,30 @@
// src/api.js // src/api.js
const API_BASE = import.meta.env.VITE_API_BASE || "";
const toApiUrl = (path) => {
if (!API_BASE) return path;
try {
const base = new URL(API_BASE, window.location.origin);
// Ensure base pathname ends with '/' if it's not the root or if likely intended as a directory
if (!base.pathname.endsWith('/')) {
base.pathname += '/';
}
// Remove leading slash from path to avoid double slashes when joining
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
return new URL(cleanPath, base).toString();
} catch (error) {
console.error("Invalid VITE_API_BASE configuration:", error);
return path;
}
};
export async function apiGet(path) { export async function apiGet(path) {
const res = await fetch(path, { headers: { "Accept": "application/json" } }); const res = await fetch(toApiUrl(path), {
headers: { "Accept": "application/json" },
});
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => ""); const text = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`); throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
@@ -9,7 +33,23 @@ export async function apiGet(path) {
} }
export async function apiDelete(path) { export async function apiDelete(path) {
const res = await fetch(path, { method: "DELETE" }); const res = await fetch(toApiUrl(path), { method: "DELETE" });
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
}
return res.json();
}
export async function apiPost(path, body) {
const res = await fetch(toApiUrl(path), {
method: "POST",
headers: {
"Accept": "application/json",
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => ""); const text = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`); throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
@@ -41,3 +81,23 @@ export function getHistory(limit = 30, offset = 0) {
export function deleteHistory(id) { export function deleteHistory(id) {
return apiDelete(`/api/history/${id}`); return apiDelete(`/api/history/${id}`);
} }
export function getStockNews(limit = 20, category) {
const qs = new URLSearchParams({ limit: String(limit) });
if (category) {
qs.set("category", category);
}
return apiGet(`/api/stock/news?${qs.toString()}`);
}
export function getStockIndices() {
return apiGet("/api/stock/indices");
}
export function getTradeBalance() {
return apiGet("/api/trade/balance");
}
export function createTradeOrder(payload) {
return apiPost("/api/trade/order", payload);
}

View File

@@ -0,0 +1,58 @@
.loading-spinner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
gap: 12px;
}
.loading-spinner__circle {
width: 32px;
height: 32px;
border: 3px solid rgba(255, 255, 255, 0.1);
border-radius: 50%;
border-top-color: var(--accent, #f7a8a5);
animation: spin 0.8s linear infinite;
}
.loading-spinner__text {
font-size: 13px;
color: var(--muted, #b6b1a9);
margin: 0;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-skeleton {
display: grid;
gap: 12px;
padding: 16px;
width: 100%;
}
.loading-skeleton__line {
height: 16px;
border-radius: 4px;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.05) 25%,
rgba(255, 255, 255, 0.1) 50%,
rgba(255, 255, 255, 0.05) 75%
);
background-size: 200% 100%;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}

View File

@@ -0,0 +1,23 @@
import React from 'react';
import './Loading.css';
const Loading = ({ type = 'spinner', message = '로딩 중...' }) => {
if (type === 'skeleton') {
return (
<div className="loading-skeleton">
<div className="loading-skeleton__line" style={{ width: '60%' }}></div>
<div className="loading-skeleton__line" style={{ width: '80%' }}></div>
<div className="loading-skeleton__line" style={{ width: '40%' }}></div>
</div>
);
}
return (
<div className="loading-spinner">
<div className="loading-spinner__circle"></div>
{message && <p className="loading-spinner__text">{message}</p>}
</div>
);
};
export default Loading;

View File

@@ -83,7 +83,8 @@ const inferDateFromSlug = (slug) => {
export const getBlogPosts = () => { export const getBlogPosts = () => {
const modules = import.meta.glob('/src/content/blog/**/*.md', { const modules = import.meta.glob('/src/content/blog/**/*.md', {
as: 'raw', query: '?raw',
import: 'default',
eager: true, eager: true,
}); });

View File

@@ -0,0 +1,59 @@
.effect-lab {
position: relative;
width: 100%;
height: 100%;
min-height: calc(100vh - 80px);
/* Adjust based on navbar height */
overflow: hidden;
background-color: #050505;
border-radius: 20px;
border: 1px solid var(--line);
display: flex;
flex-direction: column;
}
.effect-lab canvas {
display: block;
outline: none;
}
.effect-lab-overlay {
position: absolute;
top: 20px;
left: 20px;
color: rgba(255, 255, 255, 0.7);
pointer-events: none;
z-index: 10;
}
.effect-lab-overlay h2 {
margin: 0 0 8px;
font-family: var(--font-display);
font-size: 28px;
color: var(--text);
text-shadow: 0 0 20px rgba(68, 170, 221, 0.5);
}
.active-mode {
display: inline-block;
background: rgba(68, 170, 221, 0.1);
border: 1px solid rgba(68, 170, 221, 0.3);
padding: 6px 12px;
border-radius: 99px;
font-size: 12px;
color: #44aadd;
margin-bottom: 12px;
font-weight: 600;
}
.active-mode span {
color: #fff;
}
.effect-lab-overlay p {
margin: 0;
font-size: 14px;
color: var(--muted);
max-width: 400px;
line-height: 1.5;
}

View File

@@ -0,0 +1,218 @@
import React, { useEffect, useRef, useState } from 'react';
import * as THREE from 'three';
import './EffectLab.css';
const EffectLab = () => {
const containerRef = useRef(null);
const requestRef = useRef();
const [mode, setMode] = useState('HOVER'); // HOVER, ATTACK, ORBIT
useEffect(() => {
if (!containerRef.current) return;
// --- Configuration ---
const COUNT = 1500;
const SWORD_COLOR = 0x44aadd;
const SWORD_EMISSIVE = 0x112244;
// --- Helper: Random Range ---
const rand = (min, max) => Math.random() * (max - min) + min;
// --- Setup Scene ---
const width = containerRef.current.clientWidth;
const height = containerRef.current.clientHeight;
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x050505, 0.002);
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
camera.position.z = 80;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
// Tone mapping for better glow look
renderer.toneMapping = THREE.ACESFilmicToneMapping;
containerRef.current.appendChild(renderer.domElement);
// --- Lighting ---
const ambientLight = new THREE.AmbientLight(0x404040);
scene.add(ambientLight);
const pointLight = new THREE.PointLight(SWORD_COLOR, 2, 100);
scene.add(pointLight);
// --- Geometry & Material ---
// Sword shape: Cone stretched
const geometry = new THREE.CylinderGeometry(0.1, 0.4, 4, 8);
geometry.rotateX(Math.PI / 2); // Point towards Z
const material = new THREE.MeshPhongMaterial({
color: SWORD_COLOR,
emissive: SWORD_EMISSIVE,
shininess: 100,
flatShading: true,
});
const mesh = new THREE.InstancedMesh(geometry, material, COUNT);
scene.add(mesh);
// --- Particle Data ---
const dummy = new THREE.Object3D();
const particles = [];
for (let i = 0; i < COUNT; i++) {
particles.push({
pos: new THREE.Vector3(rand(-100, 100), rand(-100, 100), rand(-50, 50)),
vel: new THREE.Vector3(),
acc: new THREE.Vector3(),
// Orbit parameters
angle: rand(0, Math.PI * 2),
radius: rand(15, 30),
speed: rand(0.02, 0.05),
// Offset for natural movement
offset: new THREE.Vector3(rand(-5, 5), rand(-5, 5), rand(-5, 5))
});
}
// --- Mouse & Interaction State ---
const mouse = new THREE.Vector3();
const target = new THREE.Vector3();
const mousePlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
const raycaster = new THREE.Raycaster();
let isMouseDown = false;
let time = 0;
const handleMouseMove = (e) => {
const rect = renderer.domElement.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(new THREE.Vector2(x, y), camera);
raycaster.ray.intersectPlane(mousePlane, mouse);
// Allow light to follow mouse
pointLight.position.copy(mouse);
pointLight.position.z = 20;
};
const handleMouseDown = () => { isMouseDown = true; setMode('ORBIT'); };
const handleMouseUp = () => { isMouseDown = false; setMode('HOVER'); };
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mousedown', handleMouseDown);
window.addEventListener('mouseup', handleMouseUp);
// --- Animation Loop ---
const animate = () => {
requestRef.current = requestAnimationFrame(animate);
time += 0.01;
for (let i = 0; i < COUNT; i++) {
const p = particles[i];
// --- Behavior Logic ---
if (isMouseDown) {
// 1. ORBIT MODE: Rotate around mouse
p.angle += p.speed + 0.02; // Spin faster
const orbitX = mouse.x + Math.cos(p.angle + time) * p.radius;
const orbitY = mouse.y + Math.sin(p.angle + time) * p.radius;
// Spiraling Z for depth
const orbitZ = Math.sin(p.angle * 2 + time) * 10;
target.set(orbitX, orbitY, orbitZ);
// Strong pull to orbit positions
p.acc.subVectors(target, p.pos).multiplyScalar(0.08);
} else {
// 2. HOVER/FOLLOW MODE: Follow mouse with flocking feel
// Add noise/wandering
const noiseX = Math.sin(time + i * 0.1) * 5;
const noiseY = Math.cos(time + i * 0.1) * 5;
target.copy(mouse).add(p.offset).add(new THREE.Vector3(noiseX, noiseY, 0));
// Gentle pull
p.acc.subVectors(target, p.pos).multiplyScalar(0.008);
}
// Physics update
p.vel.add(p.acc);
p.vel.multiplyScalar(isMouseDown ? 0.90 : 0.94); // Drag
p.pos.add(p.vel);
// Update Matrix
dummy.position.copy(p.pos);
// Rotation: Look at velocity direction (dynamic) or mouse (focused)
// Blending lookAt target for smoother rotation
const lookPos = new THREE.Vector3().copy(p.pos).add(p.vel.clone().multiplyScalar(5));
// If moving very slowly, keep previous rotation to avoid jitter
if (p.vel.lengthSq() > 0.01) {
dummy.lookAt(lookPos);
}
// Scale effect based on speed (stretch when fast)
const speedScale = 1 + Math.min(p.vel.length(), 2) * 0.5;
dummy.scale.set(1, 1, speedScale);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
}
mesh.instanceMatrix.needsUpdate = true;
renderer.render(scene, camera);
};
animate();
// --- Resize ---
const handleResize = () => {
if (!containerRef.current) return;
const newWidth = containerRef.current.clientWidth;
const newHeight = containerRef.current.clientHeight;
camera.aspect = newWidth / newHeight;
camera.updateProjectionMatrix();
renderer.setSize(newWidth, newHeight);
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mousedown', handleMouseDown);
window.removeEventListener('mouseup', handleMouseUp);
window.removeEventListener('resize', handleResize);
cancelAnimationFrame(requestRef.current);
if (containerRef.current && renderer.domElement) {
containerRef.current.removeChild(renderer.domElement);
}
geometry.dispose();
material.dispose();
renderer.dispose();
};
}, []);
return (
<div className="effect-lab" ref={containerRef}>
<div className="effect-lab-overlay">
<h2>Sword Stream</h2>
<div className="active-mode">
MODE: <span>{mode}</span>
</div>
<p>
<strong>Move</strong> to Guide &nbsp;|&nbsp;
<strong>Click & Hold</strong> to Orbit & Charge
</p>
</div>
</div>
);
};
export default EffectLab;

626
src/pages/stock/Stock.css Normal file
View File

@@ -0,0 +1,626 @@
.stock {
display: grid;
gap: 28px;
/* Prevent overflow on small screens */
width: 100%;
}
.stock-header {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
gap: 24px;
align-items: center;
}
.stock-kicker {
text-transform: uppercase;
letter-spacing: 0.3em;
font-size: 12px;
color: var(--accent);
margin: 0 0 10px;
}
.stock-header h1 {
margin: 0 0 12px;
font-family: var(--font-display);
font-size: clamp(30px, 4vw, 40px);
}
.stock-sub {
margin: 0;
color: var(--muted);
}
.stock-actions {
display: flex;
gap: 12px;
margin-top: 18px;
flex-wrap: wrap;
}
.stock-card {
border: 1px solid var(--line);
border-radius: 20px;
padding: 20px;
background: var(--surface);
display: grid;
gap: 14px;
}
.stock-ideas {
margin: 0;
padding-left: 18px;
color: var(--muted);
font-size: 13px;
display: grid;
gap: 6px;
}
.stock-card__title {
margin: 0;
font-weight: 600;
}
.stock-status {
display: grid;
gap: 10px;
color: var(--muted);
font-size: 13px;
}
.stock-status>div {
display: flex;
justify-content: space-between;
gap: 12px;
}
.stock-status strong {
color: var(--text);
}
.stock-status__note {
margin: 0;
color: var(--muted);
font-size: 12px;
}
.stock-pill {
padding: 4px 10px;
border-radius: 999px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
border: 1px solid var(--line);
}
.stock-pill.is-ok {
border-color: rgba(106, 220, 187, 0.6);
color: #b5f0dd;
}
.stock-pill.is-warn {
border-color: rgba(245, 200, 115, 0.6);
color: #f5d28a;
}
.stock-pill.is-unknown {
color: var(--muted);
}
.stock-error {
margin: 0;
color: #f9b6b1;
border: 1px solid rgba(249, 182, 177, 0.4);
border-radius: 14px;
padding: 12px;
background: rgba(249, 182, 177, 0.1);
}
.stock-success {
margin: 0;
color: #b5f0dd;
border: 1px solid rgba(106, 220, 187, 0.4);
border-radius: 14px;
padding: 12px;
background: rgba(106, 220, 187, 0.12);
}
.stock-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 18px;
}
.stock-panel {
border: 1px solid var(--line);
background: var(--surface);
border-radius: 24px;
padding: 20px;
display: grid;
gap: 16px;
}
.stock-panel--wide {
grid-column: 1 / -1;
}
.stock-filter-row {
display: grid;
gap: 18px;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
align-items: stretch;
}
.stock-filter-row .stock-panel {
width: 100%;
max-width: none;
}
.stock-panel__head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.stock-panel__eyebrow {
margin: 0 0 6px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.22em;
color: var(--accent);
}
.stock-panel__sub {
margin: 6px 0 0;
color: var(--muted);
font-size: 13px;
}
.stock-panel__actions {
display: flex;
gap: 8px;
align-items: center;
}
.stock-chip {
font-size: 11px;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid var(--line);
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.12em;
}
.stock-snapshot {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.stock-snapshot__card {
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px;
display: grid;
gap: 6px;
background: rgba(0, 0, 0, 0.2);
min-height: 94px;
}
.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);
}
.stock-snapshot__card p {
margin: 0;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--muted);
}
.stock-snapshot__card strong {
font-size: 20px;
}
.stock-snapshot__card span {
color: var(--muted);
font-size: 12px;
}
.stock-snapshot__change {
font-size: 12px;
color: var(--muted);
}
.stock-snapshot__change.is-up {
color: #f3a7a7;
}
.stock-snapshot__change.is-down {
color: #9fc5ff;
}
.stock-schedule {
display: grid;
gap: 12px;
font-size: 13px;
color: var(--muted);
}
.stock-schedule strong {
color: var(--text);
}
.stock-filter {
display: grid;
gap: 12px;
color: var(--muted);
font-size: 13px;
}
.stock-filter label {
display: grid;
gap: 8px;
}
.stock-filter select {
border: 1px solid var(--line);
background: rgba(0, 0, 0, 0.25);
color: var(--text);
border-radius: 12px;
padding: 10px 12px;
}
.stock-filter__note {
margin: 0;
font-size: 12px;
}
.stock-news {
display: grid;
gap: 14px;
}
.stock-tabs {
display: flex;
gap: 8px;
margin-bottom: 10px;
}
.stock-tab {
border: 1px solid var(--line);
background: rgba(0, 0, 0, 0.2);
color: var(--muted);
border-radius: 999px;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
transition: border-color 0.2s ease, color 0.2s ease;
}
.stock-tab.is-active {
border-color: rgba(255, 255, 255, 0.5);
color: var(--text);
}
.stock-news__item {
border: 1px solid var(--line);
border-radius: 16px;
padding: 16px;
display: grid;
gap: 10px;
background: rgba(0, 0, 0, 0.2);
}
.stock-news__title {
margin: 0;
font-weight: 600;
font-size: 16px;
}
.stock-news__summary {
margin: 6px 0 0;
color: var(--muted);
font-size: 13px;
}
.stock-news__meta {
display: flex;
gap: 12px;
flex-wrap: wrap;
font-size: 12px;
color: var(--muted);
align-items: center;
}
.stock-news__meta a {
color: var(--accent);
}
.stock-empty {
margin: 0;
color: var(--muted);
}
.stock-trade {
display: grid;
gap: 16px;
}
.stock-balance {
display: grid;
gap: 12px;
}
.stock-balance__summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
}
.stock-balance__card {
border: 1px solid var(--line);
border-radius: 14px;
padding: 10px;
display: grid;
gap: 6px;
background: rgba(0, 0, 0, 0.2);
font-size: 12px;
color: var(--muted);
}
.stock-balance__card strong {
font-size: 16px;
color: var(--text);
}
.stock-holdings {
display: grid;
gap: 8px;
}
.stock-holdings__item {
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px;
display: grid;
grid-template-columns: minmax(0, 1.2fr) repeat(5, minmax(0, 0.6fr));
gap: 10px;
font-size: 13px;
color: var(--muted);
background: rgba(255, 255, 255, 0.02);
align-items: center;
}
.stock-holdings__name {
margin: 0;
font-weight: 600;
color: var(--text);
font-size: 14px;
}
.stock-holdings__code {
font-size: 12px;
}
.stock-holdings__metric {
display: grid;
gap: 4px;
justify-items: start;
}
.stock-holdings__metric span {
font-size: 11px;
color: var(--muted);
}
.stock-holdings__metric strong {
font-size: 14px;
color: var(--text);
}
.stock-profit {
color: var(--text);
}
.stock-profit.is-up {
color: #f3a7a7;
}
.stock-profit.is-down {
color: #9fc5ff;
}
.stock-profit.is-flat {
color: var(--muted);
}
.stock-result {
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px;
background: rgba(0, 0, 0, 0.2);
margin-top: 10px;
}
.stock-result__title {
margin: 0;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--muted);
}
.stock-result pre {
margin: 8px 0 0;
white-space: pre-wrap;
font-size: 12px;
color: var(--muted);
}
.stock-modal {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
}
.stock-modal__backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
}
.stock-modal__card {
position: relative;
width: min(520px, 90vw);
border: 1px solid var(--line);
border-radius: 16px;
background: var(--surface);
padding: 16px;
display: grid;
gap: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
}
.stock-modal__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.stock-modal__head h4 {
margin: 0;
font-size: 16px;
}
.stock-modal pre {
margin: 0;
white-space: pre-wrap;
font-size: 12px;
color: var(--muted);
}
.stock-order {
display: grid;
gap: 10px;
}
.stock-order label {
display: grid;
gap: 6px;
font-size: 12px;
color: var(--muted);
}
.stock-order input,
.stock-order select {
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px 12px;
background: rgba(0, 0, 0, 0.25);
color: var(--text);
outline: none;
}
@media (max-width: 900px) {
.stock-header {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.stock {
gap: 20px;
}
.stock-panel {
padding: 16px;
gap: 12px;
}
.stock-filter-row {
gap: 12px;
grid-template-columns: 1fr;
}
.stock-header h1 {
font-size: 28px;
}
.stock-actions {
width: 100%;
}
.stock-actions .button {
flex: 1;
text-align: center;
justify-content: center;
}
.stock-card {
padding: 16px;
}
.stock-status>div {
gap: 8px;
}
.stock-status strong {
font-size: 14px;
word-break: break-word;
}
.stock-balance__summary {
grid-template-columns: 1fr;
}
.stock-holdings__item {
grid-template-columns: minmax(0, 1fr);
gap: 12px;
align-items: start;
}
.stock-holdings__name {
font-size: 15px;
}
.stock-holdings__metric {
grid-template-columns: repeat(2, 1fr);
align-items: center;
justify-items: start;
gap: 8px 16px;
}
/* Make the last item span full width if it's odd */
.stock-holdings__metric>*:last-child:nth-child(odd) {
grid-column: 1 / -1;
}
.stock-holdings__metric span {
font-size: 11px;
}
.stock-holdings__metric strong {
font-size: 15px;
word-break: break-word;
}
}
@media (max-width: 520px) {
.stock-holdings__metric {
grid-template-columns: minmax(0, 1fr);
}
.stock-holdings__metric strong {
font-size: 14px;
}
}

416
src/pages/stock/Stock.jsx Normal file
View File

@@ -0,0 +1,416 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { getStockIndices, getStockNews } from '../../api';
import Loading from '../../components/Loading';
import './Stock.css';
const formatDate = (value) => {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return 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 [];
if (Array.isArray(data)) {
return data.map((item) => ({
name: item?.name ?? item?.key ?? '-',
value: item?.value ?? '-',
change: item?.change_value ?? item?.change ?? '',
percent: item?.change_percent ?? item?.percent ?? '',
direction: item?.direction ?? '',
}));
}
if (Array.isArray(data?.indices)) {
return data.indices.map((item) => ({
name: item?.name ?? item?.key ?? '-',
value: item?.value ?? '-',
change: item?.change_value ?? item?.change ?? '',
percent: item?.change_percent ?? item?.percent ?? '',
direction: item?.direction ?? '',
}));
}
if (typeof data === 'object') {
return Object.entries(data)
.filter(([, value]) => value && typeof value === 'object')
.map(([name, value]) => ({
name,
value: value?.value ?? '-',
change: value?.change ?? '',
percent: value?.percent ?? '',
direction: value?.direction ?? '',
}));
}
return [];
};
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();
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';
}
return '';
};
const Stock = () => {
const [newsDomestic, setNewsDomestic] = useState([]);
const [newsOverseas, setNewsOverseas] = useState([]);
const [newsCategory, setNewsCategory] = useState('domestic');
const [limit, setLimit] = useState(20);
const [loading, setLoading] = useState(false);
const [newsError, setNewsError] = useState('');
const [indicesError, setIndicesError] = useState('');
const [indices, setIndices] = useState([]);
const [indicesLoading, setIndicesLoading] = useState(false);
const [autoRefreshMs] = useState(180000);
const combinedNews = useMemo(
() => [...newsDomestic, ...newsOverseas],
[newsDomestic, newsOverseas]
);
const latestPublished = useMemo(
() => getLatestBy(combinedNews, 'published_at'),
[combinedNews]
);
const loadNews = async () => {
setLoading(true);
setNewsError('');
try {
const [domestic, overseas] = await Promise.all([
getStockNews(limit, 'domestic'),
getStockNews(limit, 'overseas'),
]);
setNewsDomestic(Array.isArray(domestic) ? domestic : []);
setNewsOverseas(Array.isArray(overseas) ? overseas : []);
} catch (err) {
setNewsError(err?.message ?? String(err));
} finally {
setLoading(false);
}
};
const loadIndices = async () => {
setIndicesLoading(true);
setIndicesError('');
try {
const data = await getStockIndices();
setIndices(normalizeIndices(data));
} catch (err) {
setIndicesError(err?.message ?? String(err));
} finally {
setIndicesLoading(false);
}
};
useEffect(() => {
loadNews();
}, [limit]);
useEffect(() => {
loadIndices();
const timer = window.setInterval(loadIndices, autoRefreshMs);
return () => window.clearInterval(timer);
}, [autoRefreshMs]);
const indexOrder = [
'KOSPI',
'KOSDAQ',
'KOSPI200',
'다우산업',
'나스닥',
'S&P500',
'원달러 환율',
];
const sortedIndices = [...indices].sort((a, b) => {
const aIndex = indexOrder.indexOf(a.name);
const bIndex = indexOrder.indexOf(b.name);
if (aIndex !== -1 || bIndex !== -1) {
return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex);
}
return a.name.localeCompare(b.name);
});
const highlighted = new Set(['KOSPI', 'KOSDAQ', '원달러 환율']);
const activeNews =
newsCategory === 'domestic' ? newsDomestic : newsOverseas;
return (
<div className="stock">
<header className="stock-header">
<div>
<p className="stock-kicker">마켓 </p>
<h1>주식 </h1>
<p className="stock-sub">
최신 시장 뉴스와 주요 지수 흐름을 한눈에 확인하세요.
</p>
<div className="stock-actions">
<button
className="button primary"
onClick={loadNews}
disabled={loading}
>
뉴스 새로고침
</button>
<Link className="button ghost" to="/stock/trade">
거래 데스크
</Link>
</div>
</div>
<div className="stock-card">
<p className="stock-card__title">다음 업데이트 아이디어</p>
<ul className="stock-ideas">
<li>관심 종목 실적 캘린더/일정 보기</li>
<li>뉴스 감성 요약 키워드 트렌드</li>
<li>보유 종목 알림(수익률/목표가)</li>
</ul>
</div>
</header>
<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 className="stock-panel__actions">
{indicesLoading ? (
<Loading type="spinner" message="" />
) : null}
<button
className="button ghost small"
onClick={loadIndices}
disabled={indicesLoading}
>
새로고침
</button>
</div>
</div>
<div className="stock-snapshot">
{indicesError ? (
<p className="stock-empty">{indicesError}</p>
) : sortedIndices.length === 0 ? (
<p className="stock-empty">
지수 데이터가 없습니다.
</p>
) : (
sortedIndices.map((item) => {
const direction = getDirection(
item.change,
item.percent,
item.direction
);
const changeText = [item.change, item.percent]
.filter(Boolean)
.join(' ');
return (
<div
key={item.name}
className={`stock-snapshot__card ${highlighted.has(item.name)
? 'is-highlight'
: ''
}`}
>
<p>{item.name}</p>
<strong>{item.value ?? '--'}</strong>
<span
className={`stock-snapshot__change ${direction === 'up'
? 'is-up'
: direction === 'down'
? 'is-down'
: ''
}`}
>
{changeText || '--'}
</span>
</div>
);
})
)}
</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>
</div>
</div>
<div className="stock-filter">
<label>
표시 개수
<select
value={limit}
onChange={(event) =>
setLimit(Number(event.target.value))
}
>
{[10, 20, 30, 40].map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
</label>
<p className="stock-filter__note">
최신 뉴스가 먼저 표시됩니다.
</p>
</div>
</div>
<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>
</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>
</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">
Stock Lab API에서 최신 뉴스를 불러옵니다.
</p>
</div>
<div className="stock-panel__actions">
{loading && <span className="stock-chip">Updating...</span>}
<span className="stock-chip">
국내 {newsDomestic.length} / 해외{' '}
{newsOverseas.length}
</span>
</div>
</div>
{loading && combinedNews.length === 0 ? (
<Loading type="skeleton" />
) : newsError ? (
<p className="stock-empty">{newsError}</p>
) : combinedNews.length === 0 ? (
<p className="stock-empty">뉴스가 없습니다.</p>
) : (
<>
<div className="stock-tabs">
<button
type="button"
className={`stock-tab ${newsCategory === 'domestic'
? 'is-active'
: ''
}`}
onClick={() => setNewsCategory('domestic')}
>
국내
</button>
<button
type="button"
className={`stock-tab ${newsCategory === 'overseas'
? 'is-active'
: ''
}`}
onClick={() => setNewsCategory('overseas')}
>
해외
</button>
</div>
{activeNews.length === 0 ? (
<p className="stock-empty">
해당 카테고리 뉴스가 없습니다.
</p>
) : (
<div className="stock-news">
{activeNews.map((item) => (
<article
key={item.id ?? item.link}
className="stock-news__item"
>
<div>
<p className="stock-news__title">
{item.title}
</p>
</div>
<div className="stock-news__meta">
<span>
{formatDate(item.published_at)}
</span>
{item.sentiment ? (
<span className="stock-chip">
{item.sentiment}
</span>
) : null}
<a
href={item.link}
target="_blank"
rel="noreferrer"
>
원문 보기
</a>
</div>
</article>
))}
</div>
)}
</>
)}
</section>
</div>
);
};
export default Stock;

View File

@@ -0,0 +1,424 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { createTradeOrder, getTradeBalance } from '../../api';
import './Stock.css';
const formatNumber = (value) => {
if (value === null || value === undefined || value === '') return '-';
const numeric = Number(value);
if (Number.isNaN(numeric)) return value;
return new Intl.NumberFormat('ko-KR').format(numeric);
};
const formatPercent = (value) => {
if (value === null || value === undefined || value === '') return '-';
if (typeof value === 'string' && value.includes('%')) return value;
const numeric = Number(value);
if (Number.isNaN(numeric)) return value;
return `${numeric.toFixed(2)}%`;
};
const pickFirst = (...values) =>
values.find((value) => value !== undefined && value !== null && value !== '');
const getQty = (item) =>
pickFirst(item?.qty, item?.quantity, item?.holding, item?.hold_qty);
const getBuyPrice = (item) =>
pickFirst(
item?.buy_price,
item?.avg_price,
item?.avg,
item?.purchase_price,
item?.buyPrice,
item?.price
);
const getCurrentPrice = (item) =>
pickFirst(
item?.current_price,
item?.current,
item?.cur_price,
item?.now_price,
item?.market_price
);
const getProfitRate = (item) =>
pickFirst(
item?.profit_rate,
item?.profitRate,
item?.profit_pct,
item?.profitPercent,
item?.pnl_rate,
item?.return_rate,
item?.yield
);
const getProfitLoss = (item) =>
pickFirst(item?.profit_loss, item?.pnl, item?.profitLoss);
const toNumeric = (value) => {
if (value === null || value === undefined || value === '') return null;
const numeric = Number(String(value).replace(/[^0-9.-]/g, ''));
return Number.isNaN(numeric) ? null : numeric;
};
const StockTrade = () => {
const [balance, setBalance] = useState(null);
const [balanceLoading, setBalanceLoading] = useState(false);
const [balanceError, setBalanceError] = useState('');
const [manualForm, setManualForm] = useState({
code: '',
qty: 1,
price: 0,
type: 'buy',
});
const [manualLoading, setManualLoading] = useState(false);
const [manualError, setManualError] = useState('');
const [manualResult, setManualResult] = useState(null);
const [kisModal, setKisModal] = useState('');
const loadBalance = async () => {
setBalanceLoading(true);
setBalanceError('');
try {
const data = await getTradeBalance();
setBalance(data);
} catch (err) {
setBalanceError(err?.message ?? String(err));
} finally {
setBalanceLoading(false);
}
};
const submitManualOrder = async (event) => {
event.preventDefault();
setManualLoading(true);
setManualError('');
setManualResult(null);
try {
const payload = {
ticker: manualForm.code.trim(),
action: manualForm.type === 'sell' ? 'SELL' : 'BUY',
quantity: Number(manualForm.qty),
price: Number(manualForm.price),
};
const result = await createTradeOrder(payload);
setManualResult(result ?? { ok: true });
if (result?.kis_result !== undefined) {
const message =
typeof result.kis_result === 'string'
? result.kis_result
: JSON.stringify(result.kis_result, null, 2);
setKisModal(message);
}
await loadBalance();
} catch (err) {
setManualError(err?.message ?? String(err));
} finally {
setManualLoading(false);
}
};
useEffect(() => {
loadBalance();
}, []);
const holdings = useMemo(() => {
if (!balance) return [];
if (Array.isArray(balance.holdings)) return balance.holdings;
if (Array.isArray(balance.positions)) return balance.positions;
if (Array.isArray(balance.items)) return balance.items;
return [];
}, [balance]);
const summary = balance?.summary ?? {};
const totalEval =
summary.total_eval ?? balance?.total_eval ?? balance?.total_value;
const deposit =
summary.deposit ?? balance?.deposit ?? balance?.available_cash;
return (
<div className="stock">
<header className="stock-header">
<div>
<p className="stock-kicker">거래 데스크</p>
<h1>주식 거래</h1>
<p className="stock-sub">
연결된 계좌 잔고를 확인하고 필요한 주문을 요청하세요.
</p>
<div className="stock-actions">
<Link className="button ghost" to="/stock">
주식 랩으로 돌아가기
</Link>
</div>
</div>
<div className="stock-card">
<p className="stock-card__title">계좌 요약</p>
<div className="stock-status">
<div>
<span> 평가금액</span>
<strong>{formatNumber(totalEval)}</strong>
</div>
<div>
<span>예수금</span>
<strong>{formatNumber(deposit)}</strong>
</div>
<div>
<span>보유 종목</span>
<strong>{holdings.length}</strong>
</div>
</div>
{summary.note ? (
<p className="stock-status__note">{summary.note}</p>
) : null}
</div>
</header>
{balanceError ? <p className="stock-error">{balanceError}</p> : null}
<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 className="stock-panel__actions">
{balanceLoading ? (
<span className="stock-chip">조회 </span>
) : null}
<button
className="button ghost small"
onClick={loadBalance}
disabled={balanceLoading}
>
새로고침
</button>
</div>
</div>
<div className="stock-balance">
<div className="stock-balance__summary">
{[
{
label: '총 평가',
value: totalEval,
},
{
label: '예수금',
value: deposit,
},
].map((item) => (
<div
key={item.label}
className="stock-balance__card"
>
<span>{item.label}</span>
<strong>{formatNumber(item.value)}</strong>
</div>
))}
</div>
{holdings.length ? (
<div className="stock-holdings">
{holdings.map((item, idx) => {
const profitLoss = getProfitLoss(item);
const profitLossNumeric = toNumeric(profitLoss);
const profitClass =
profitLossNumeric > 0
? 'is-up'
: profitLossNumeric < 0
? 'is-down'
: profitLossNumeric === 0
? 'is-flat'
: '';
const profitRate = getProfitRate(item);
const profitRateNumeric = toNumeric(profitRate);
const profitRateClass =
profitRateNumeric > 0
? 'is-up'
: profitRateNumeric < 0
? 'is-down'
: profitRateNumeric === 0
? 'is-flat'
: '';
return (
<div
key={item.code ?? `${item.name}-${idx}`}
className="stock-holdings__item"
>
<div>
<p className="stock-holdings__name">
{item.name ?? item.code ?? 'N/A'}
</p>
<span className="stock-holdings__code">
{item.code ?? ''}
</span>
</div>
<div className="stock-holdings__metric">
<span>수량</span>
<strong>
{formatNumber(getQty(item))}
</strong>
</div>
<div className="stock-holdings__metric">
<span>매입가</span>
<strong>
{formatNumber(getBuyPrice(item))}
</strong>
</div>
<div className="stock-holdings__metric">
<span>현재가</span>
<strong>
{formatNumber(
getCurrentPrice(item)
)}
</strong>
</div>
<div className="stock-holdings__metric">
<span>수익률</span>
<strong
className={`stock-profit ${profitRateClass}`}
>
{formatPercent(profitRate)}
</strong>
</div>
<div className="stock-holdings__metric">
<span>평가손익</span>
<strong
className={`stock-profit ${profitClass}`}
>
{formatNumber(profitLoss)}
</strong>
</div>
</div>
);
})}
</div>
) : (
<p className="stock-empty">보유 종목이 없습니다.</p>
)}
</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>
<form className="stock-order" onSubmit={submitManualOrder}>
<label>
종목명/코드
<input
type="text"
value={manualForm.code}
onChange={(event) =>
setManualForm((prev) => ({
...prev,
code: event.target.value,
}))
}
placeholder="005930 또는 삼성전자"
required
/>
</label>
<label>
매수/매도
<select
value={manualForm.type}
onChange={(event) =>
setManualForm((prev) => ({
...prev,
type: event.target.value,
}))
}
>
<option value="buy">매수</option>
<option value="sell">매도</option>
</select>
</label>
<label>
수량
<input
type="number"
min={1}
step={1}
value={manualForm.qty}
onChange={(event) =>
setManualForm((prev) => ({
...prev,
qty: Number(event.target.value),
}))
}
required
/>
</label>
<label>
금액()
<input
type="number"
min={0}
step={1}
value={manualForm.price}
onChange={(event) =>
setManualForm((prev) => ({
...prev,
price: Number(event.target.value),
}))
}
/>
</label>
<button
className="button primary"
type="submit"
disabled={manualLoading}
>
{manualLoading ? '요청 중...' : '주문 요청'}
</button>
{manualError ? (
<p className="stock-error">{manualError}</p>
) : null}
{manualResult ? (
<div className="stock-result">
<p className="stock-result__title">요청 결과</p>
<pre>
{typeof manualResult === 'string'
? manualResult
: JSON.stringify(manualResult, null, 2)}
</pre>
</div>
) : null}
</form>
</section>
{kisModal ? (
<div className="stock-modal" role="dialog" aria-modal="true">
<div
className="stock-modal__backdrop"
onClick={() => setKisModal('')}
/>
<div className="stock-modal__card">
<div className="stock-modal__head">
<h4>주문 결과</h4>
<button
type="button"
className="button ghost small"
onClick={() => setKisModal('')}
>
닫기
</button>
</div>
<pre>{kisModal}</pre>
</div>
</div>
) : null}
</div>
);
};
export default StockTrade;

View File

@@ -71,8 +71,9 @@
} }
.travel-albums.is-blurred { .travel-albums.is-blurred {
filter: blur(3px); opacity: 0.5;
transition: filter 0.2s ease; transform: scale(0.995);
transition: opacity 0.2s ease, transform 0.2s ease;
} }
.travel-albums.is-blurred * { .travel-albums.is-blurred * {
@@ -206,6 +207,11 @@
border: 1px solid rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.12);
min-height: 220px; min-height: 220px;
cursor: pointer; cursor: pointer;
opacity: 0;
transform: translateY(22px) scale(0.98);
transition: opacity 0.45s ease, transform 0.45s ease;
transition-delay: var(--reveal-delay, 0ms);
will-change: opacity, transform;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
@@ -218,6 +224,11 @@
grid-column: span 2; grid-column: span 2;
} }
.travel-card[data-revealed='true'] {
opacity: 1;
transform: translateY(0) scale(1);
}
.travel-card img { .travel-card img {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -247,7 +258,8 @@
.travel-modal { .travel-modal {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(6, 8, 12, 0.75); background: rgba(6, 8, 12, 0.55);
backdrop-filter: blur(var(--modal-blur, 6px));
display: grid; display: grid;
align-items: start; align-items: start;
justify-items: center; justify-items: center;
@@ -268,6 +280,17 @@
margin-top: 24px; margin-top: 24px;
} }
[data-reveal] {
opacity: 0;
transform: translateY(18px);
transition: opacity 0.5s ease, transform 0.5s ease;
}
[data-reveal][data-revealed='true'] {
opacity: 1;
transform: translateY(0);
}
.travel-modal__summary { .travel-modal__summary {
display: grid; display: grid;
gap: 6px; gap: 6px;
@@ -301,6 +324,61 @@
gap: 14px; gap: 14px;
} }
.travel-modal__controls {
display: grid;
gap: 8px;
font-size: 11px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.12em;
}
.travel-modal__control {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.travel-blur-slider {
display: flex;
align-items: center;
gap: 10px;
}
.travel-blur-slider input[type='range'] {
appearance: none;
width: 140px;
height: 4px;
background: rgba(255, 255, 255, 0.2);
border-radius: 999px;
outline: none;
}
.travel-blur-slider input[type='range']::-webkit-slider-thumb {
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: #f8f4f0;
border: 1px solid rgba(255, 255, 255, 0.5);
cursor: pointer;
}
.travel-blur-slider input[type='range']::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: #f8f4f0;
border: 1px solid rgba(255, 255, 255, 0.5);
cursor: pointer;
}
.travel-blur-value {
font-size: 11px;
color: var(--muted);
}
.travel-modal__frame { .travel-modal__frame {
width: 100%; width: 100%;
height: 68vh; height: 68vh;
@@ -327,11 +405,20 @@
animation: travel-slide-prev 280ms ease; animation: travel-slide-prev 280ms ease;
} }
.travel-modal__strip-wrap {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
}
.travel-modal__strip { .travel-modal__strip {
display: flex; display: flex;
gap: 8px; gap: 8px;
overflow-x: auto; overflow-x: auto;
padding-bottom: 6px; padding-bottom: 6px;
scroll-behavior: smooth;
scrollbar-width: thin;
} }
.travel-modal__thumb { .travel-modal__thumb {
@@ -343,6 +430,7 @@
padding: 0; padding: 0;
cursor: pointer; cursor: pointer;
opacity: 0.7; opacity: 0.7;
flex: 0 0 auto;
} }
.travel-modal__thumb img { .travel-modal__thumb img {
@@ -358,6 +446,23 @@
border-color: rgba(255, 255, 255, 0.6); border-color: rgba(255, 255, 255, 0.6);
} }
.travel-modal__strip-arrow {
width: 32px;
height: 32px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(10, 12, 20, 0.7);
color: #f8f4f0;
font-size: 18px;
cursor: pointer;
transition: transform 0.2s ease, border-color 0.2s ease;
}
.travel-modal__strip-arrow:hover {
transform: translateY(-1px);
border-color: rgba(255, 255, 255, 0.5);
}
.travel-modal__meta { .travel-modal__meta {
margin: 0; margin: 0;
color: var(--muted); color: var(--muted);
@@ -393,6 +498,26 @@
transition: transform 0.2s ease, border-color 0.2s ease; transition: transform 0.2s ease, border-color 0.2s ease;
} }
.travel-modal__arrow.is-loading {
position: relative;
}
.travel-modal__arrow-icon {
display: block;
}
.travel-modal__spinner {
position: absolute;
inset: 0;
margin: auto;
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.35);
border-top-color: rgba(255, 255, 255, 0.9);
animation: travel-spin 0.8s linear infinite;
}
.travel-modal__arrow:hover { .travel-modal__arrow:hover {
transform: translateY(-1px) scale(1.02); transform: translateY(-1px) scale(1.02);
border-color: rgba(255, 255, 255, 0.5); border-color: rgba(255, 255, 255, 0.5);
@@ -423,6 +548,29 @@
} }
} }
@keyframes travel-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.travel-modal__toast {
position: absolute;
left: 24px;
bottom: 20px;
padding: 8px 12px;
border-radius: 999px;
background: rgba(10, 12, 20, 0.85);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #f8f4f0;
font-size: 12px;
letter-spacing: 0.04em;
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.3);
}
@media (max-width: 768px) { @media (max-width: 768px) {
.travel-modal__content { .travel-modal__content {
padding: 16px; padding: 16px;
@@ -476,3 +624,12 @@
height: 160px; height: 160px;
} }
} }
@media (prefers-reduced-motion: reduce) {
.travel-card,
[data-reveal] {
opacity: 1 !important;
transform: none !important;
transition: none !important;
}
}

View File

@@ -56,6 +56,8 @@ const TravelPhotoGrid = ({
isLoadingMore, isLoadingMore,
}) => { }) => {
const sentinelRef = useRef(null); const sentinelRef = useRef(null);
const gridRef = useRef(null);
const revealObserverRef = useRef(null);
useEffect(() => { useEffect(() => {
const sentinel = sentinelRef.current; const sentinel = sentinelRef.current;
@@ -74,9 +76,48 @@ const TravelPhotoGrid = ({
return () => observer.disconnect(); return () => observer.disconnect();
}, [hasNext, isLoadingMore, onLoadMore]); }, [hasNext, isLoadingMore, onLoadMore]);
useEffect(() => {
revealObserverRef.current = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
entry.target.dataset.revealed = 'true';
revealObserverRef.current?.unobserve(entry.target);
});
},
{ rootMargin: '120px', threshold: 0.15 }
);
return () => revealObserverRef.current?.disconnect();
}, []);
useEffect(() => {
const observer = revealObserverRef.current;
const grid = gridRef.current;
if (!observer || !grid) return;
const cards = grid.querySelectorAll(
'.travel-card:not([data-revealed="true"])'
);
cards.forEach((card) => observer.observe(card));
const fallback = window.setTimeout(() => {
const stillHidden = grid.querySelectorAll(
'.travel-card:not([data-revealed="true"])'
);
if (stillHidden.length) {
stillHidden.forEach(
(card) => (card.dataset.revealed = 'true')
);
}
}, 500);
return () => {
window.clearTimeout(fallback);
cards.forEach((card) => observer.unobserve(card));
};
}, [photos.length]);
return ( return (
<> <>
<div className="travel-grid"> <div className="travel-grid" ref={gridRef}>
{photos.map((photo, index) => { {photos.map((photo, index) => {
const label = getPhotoLabel(photo); const label = getPhotoLabel(photo);
return ( return (
@@ -85,6 +126,9 @@ const TravelPhotoGrid = ({
className={`travel-card ${ className={`travel-card ${
index % 6 === 0 ? 'is-wide' : '' index % 6 === 0 ? 'is-wide' : ''
}`} }`}
style={{
'--reveal-delay': `${Math.min(index, 16) * 40}ms`,
}}
onClick={(event) => onSelectPhoto(index, event)} onClick={(event) => onSelectPhoto(index, event)}
role="button" role="button"
tabIndex={0} tabIndex={0}
@@ -175,10 +219,35 @@ const Travel = () => {
const touchStartXRef = useRef(null); const touchStartXRef = useRef(null);
const [slideDirection, setSlideDirection] = useState('next'); const [slideDirection, setSlideDirection] = useState('next');
const [slideToken, setSlideToken] = useState(0); const [slideToken, setSlideToken] = useState(0);
const pendingAdvanceRef = useRef(null);
const [backdropBlur, setBackdropBlur] = useState(6);
const [thumbScrollDuration, setThumbScrollDuration] = useState(360);
const [toastMessage, setToastMessage] = useState('');
const toastTimerRef = useRef(null);
const thumbStripRef = useRef(null);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [hasNext, setHasNext] = useState(true); const [hasNext, setHasNext] = useState(true);
const cacheRef = useRef(new Map()); const cacheRef = useRef(new Map());
const cacheTtlMs = 10 * 60 * 1000; const cacheTtlMs = 10 * 60 * 1000;
const travelRef = useRef(null);
useEffect(() => {
const root = travelRef.current;
if (!root) return undefined;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
entry.target.dataset.revealed = 'true';
observer.unobserve(entry.target);
});
},
{ rootMargin: '140px' }
);
const targets = root.querySelectorAll('[data-reveal]');
targets.forEach((node) => observer.observe(node));
return () => observer.disconnect();
}, []);
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
@@ -373,6 +442,24 @@ const Travel = () => {
setSlideToken((prev) => prev + 1); setSlideToken((prev) => prev + 1);
}; };
const showToast = useCallback((message) => {
setToastMessage(message);
if (toastTimerRef.current) {
window.clearTimeout(toastTimerRef.current);
}
toastTimerRef.current = window.setTimeout(() => {
setToastMessage('');
}, 1600);
}, []);
useEffect(() => {
return () => {
if (toastTimerRef.current) {
window.clearTimeout(toastTimerRef.current);
}
};
}, []);
const goPrev = useCallback(() => { const goPrev = useCallback(() => {
if (selectedPhotoIndex === null || selectedPhotoIndex <= 0) return; if (selectedPhotoIndex === null || selectedPhotoIndex <= 0) return;
bumpSlide('prev'); bumpSlide('prev');
@@ -380,15 +467,46 @@ const Travel = () => {
}, [selectedPhotoIndex]); }, [selectedPhotoIndex]);
const goNext = useCallback(() => { const goNext = useCallback(() => {
if ( if (selectedPhotoIndex === null) return;
selectedPhotoIndex === null || if (selectedPhotoIndex < photos.length - 1) {
selectedPhotoIndex >= photos.length - 1 bumpSlide('next');
) { setSelectedPhotoIndex(selectedPhotoIndex + 1);
return; return;
} }
bumpSlide('next'); if (hasNext && !loadingMore) {
setSelectedPhotoIndex(selectedPhotoIndex + 1); pendingAdvanceRef.current = 'next';
}, [photos.length, selectedPhotoIndex]); loadMorePhotos();
return;
}
if (!hasNext) {
showToast('다음 사진 없음');
}
}, [
hasNext,
loadMorePhotos,
loadingMore,
photos.length,
selectedPhotoIndex,
showToast,
]);
useEffect(() => {
if (pendingAdvanceRef.current !== 'next') return;
if (selectedPhotoIndex === null) {
pendingAdvanceRef.current = null;
return;
}
if (selectedPhotoIndex < photos.length - 1) {
bumpSlide('next');
setSelectedPhotoIndex((prev) =>
prev === null ? prev : prev + 1
);
pendingAdvanceRef.current = null;
}
if (!hasNext && selectedPhotoIndex >= photos.length - 1) {
pendingAdvanceRef.current = null;
}
}, [hasNext, photos.length, selectedPhotoIndex]);
useEffect(() => { useEffect(() => {
if (selectedPhotoIndex === null) return undefined; if (selectedPhotoIndex === null) return undefined;
@@ -453,6 +571,61 @@ const Travel = () => {
? [0, 0] ? [0, 0]
: getStripRange(photos.length, selectedPhotoIndex); : getStripRange(photos.length, selectedPhotoIndex);
const scrollToX = (element, target, duration) => {
if (!element) return;
if (
window.matchMedia('(prefers-reduced-motion: reduce)').matches ||
duration <= 0
) {
element.scrollLeft = target;
return;
}
const start = element.scrollLeft;
const diff = target - start;
if (!diff) return;
let startTime = null;
const ease = (t) => 0.5 - Math.cos(Math.PI * t) / 2;
const step = (timestamp) => {
if (!startTime) startTime = timestamp;
const elapsed = timestamp - startTime;
const t = Math.min(elapsed / duration, 1);
element.scrollLeft = start + diff * ease(t);
if (t < 1) {
window.requestAnimationFrame(step);
}
};
window.requestAnimationFrame(step);
};
const scrollThumbs = (direction) => {
const strip = thumbStripRef.current;
if (!strip) return;
const offset = strip.clientWidth * 0.6;
const target =
strip.scrollLeft + (direction === 'next' ? offset : -offset);
scrollToX(strip, target, thumbScrollDuration);
};
useEffect(() => {
if (selectedPhotoIndex === null) return;
const strip = thumbStripRef.current;
if (!strip) return;
const target = strip.querySelector(
`[data-thumb-index="${selectedPhotoIndex}"]`
);
if (!target) return;
const stripRect = strip.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const currentScroll = strip.scrollLeft;
const targetCenter =
targetRect.left -
stripRect.left +
currentScroll +
targetRect.width / 2;
const nextScroll = targetCenter - stripRect.width / 2;
scrollToX(strip, nextScroll, thumbScrollDuration);
}, [selectedPhotoIndex, stripStart, stripEnd, thumbScrollDuration]);
const handleSelectPhoto = (index, event) => { const handleSelectPhoto = (index, event) => {
if (selectedPhotoIndex === null) { if (selectedPhotoIndex === null) {
bumpSlide('next'); bumpSlide('next');
@@ -482,8 +655,8 @@ const Travel = () => {
}; };
return ( return (
<div className="travel"> <div className="travel" ref={travelRef}>
<header className="travel-header"> <header className="travel-header" data-reveal>
<div> <div>
<p className="travel-kicker">Visual Diary</p> <p className="travel-kicker">Visual Diary</p>
<h1>Travel Archive</h1> <h1>Travel Archive</h1>
@@ -503,9 +676,10 @@ const Travel = () => {
className={`travel-albums ${ className={`travel-albums ${
selectedPhotoIndex !== null ? 'is-blurred' : '' selectedPhotoIndex !== null ? 'is-blurred' : ''
}`} }`}
data-reveal
> >
<div className="travel-map"> <div className="travel-map">
<div className="travel-map__info"> <div className="travel-map__info" data-reveal>
<p className="travel-map__title">Select a region</p> <p className="travel-map__title">Select a region</p>
<p className="travel-map__desc"> <p className="travel-map__desc">
{selectedRegion {selectedRegion
@@ -557,7 +731,10 @@ const Travel = () => {
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
onClick={() => setSelectedPhotoIndex(null)} onClick={() => setSelectedPhotoIndex(null)}
style={{ '--modal-offset': `${modalOffset}px` }} style={{
'--modal-offset': `${modalOffset}px`,
'--modal-blur': `${backdropBlur}px`,
}}
> >
<div <div
className="travel-modal__content" className="travel-modal__content"
@@ -576,6 +753,50 @@ const Travel = () => {
.join(', ')} .join(', ')}
</p> </p>
) : null} ) : null}
<div className="travel-modal__controls">
<div className="travel-modal__control">
<span>Blur</span>
<div className="travel-blur-slider">
<input
type="range"
min={0}
max={16}
step={2}
value={backdropBlur}
onChange={(event) =>
setBackdropBlur(
Number(event.target.value)
)
}
aria-label="Background blur"
/>
<span className="travel-blur-value">
{backdropBlur}px
</span>
</div>
</div>
<div className="travel-modal__control">
<span>Thumb</span>
<div className="travel-blur-slider">
<input
type="range"
min={150}
max={1000}
step={50}
value={thumbScrollDuration}
onChange={(event) =>
setThumbScrollDuration(
Number(event.target.value)
)
}
aria-label="Thumbnail scroll speed"
/>
<span className="travel-blur-value">
{thumbScrollDuration}ms
</span>
</div>
</div>
</div>
</div> </div>
<button <button
type="button" type="button"
@@ -620,54 +841,105 @@ const Travel = () => {
</div> </div>
<button <button
type="button" type="button"
className="travel-modal__arrow is-next" className={`travel-modal__arrow is-next ${
loadingMore &&
hasNext &&
selectedPhotoIndex === photos.length - 1
? 'is-loading'
: ''
}`}
onClick={goNext} onClick={goNext}
disabled={ disabled={
selectedPhotoIndex === photos.length - 1 selectedPhotoIndex === photos.length - 1 &&
!hasNext
} }
aria-label="Next" aria-label="Next"
aria-busy={
loadingMore &&
hasNext &&
selectedPhotoIndex === photos.length - 1
}
>
<span className="travel-modal__arrow-icon">
{'>'}
</span>
{loadingMore &&
hasNext &&
selectedPhotoIndex === photos.length - 1 ? (
<span
className="travel-modal__spinner"
aria-hidden
/>
) : null}
</button>
</div>
<div className="travel-modal__strip-wrap">
<button
type="button"
className="travel-modal__strip-arrow is-prev"
onClick={() => scrollThumbs('prev')}
aria-label="이전 썸네일"
>
{'<'}
</button>
<div
className="travel-modal__strip"
role="list"
ref={thumbStripRef}
>
{photos
.slice(stripStart, stripEnd)
.map((photo, idx) => {
const realIndex = stripStart + idx;
return (
<button
key={`${photo.src}-${realIndex}`}
type="button"
className={`travel-modal__thumb ${
realIndex ===
selectedPhotoIndex
? 'is-active'
: ''
}`}
data-thumb-index={realIndex}
onClick={() =>
setSelectedPhotoIndex(
realIndex
)
}
aria-label={getPhotoLabel(photo)}
role="listitem"
>
<img
src={photo.src}
alt={getPhotoLabel(photo)}
loading="lazy"
onError={(event) => {
const img =
event.currentTarget;
if (
photo.original &&
img.src !==
photo.original
) {
img.src =
photo.original;
}
}}
/>
</button>
);
})}
</div>
<button
type="button"
className="travel-modal__strip-arrow is-next"
onClick={() => scrollThumbs('next')}
aria-label="다음 썸네일"
> >
{'>'} {'>'}
</button> </button>
</div> </div>
<div className="travel-modal__strip" role="list">
{photos
.slice(stripStart, stripEnd)
.map((photo, idx) => {
const realIndex = stripStart + idx;
return (
<button
key={`${photo.src}-${realIndex}`}
type="button"
className={`travel-modal__thumb ${
realIndex === selectedPhotoIndex
? 'is-active'
: ''
}`}
onClick={() =>
setSelectedPhotoIndex(realIndex)
}
aria-label={getPhotoLabel(photo)}
role="listitem"
>
<img
src={photo.src}
alt={getPhotoLabel(photo)}
loading="lazy"
onError={(event) => {
const img = event.currentTarget;
if (
photo.original &&
img.src !== photo.original
) {
img.src = photo.original;
}
}}
/>
</button>
);
})}
</div>
{photos[selectedPhotoIndex]?.album || {photos[selectedPhotoIndex]?.album ||
photos[selectedPhotoIndex]?.file ? ( photos[selectedPhotoIndex]?.file ? (
<p className="travel-modal__meta"> <p className="travel-modal__meta">
@@ -677,6 +949,11 @@ const Travel = () => {
: ''} : ''}
</p> </p>
) : null} ) : null}
{toastMessage ? (
<div className="travel-modal__toast">
{toastMessage}
</div>
) : null}
</div> </div>
</div> </div>
) : null} ) : null}

View File

@@ -1,8 +1,12 @@
import React from 'react'; import React, { lazy } from 'react';
import Home from './pages/home/Home';
import Blog from './pages/blog/Blog'; const Home = lazy(() => import('./pages/home/Home'));
import Lotto from './pages/lotto/Lotto'; const Blog = lazy(() => import('./pages/blog/Blog'));
import Travel from './pages/travel/Travel'; const Lotto = lazy(() => import('./pages/lotto/Lotto'));
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'));
export const navLinks = [ export const navLinks = [
{ {
@@ -23,12 +27,24 @@ export const navLinks = [
path: '/lotto', path: '/lotto',
description: '숫자를 뽑고 통계를 확인하는 실험실', description: '숫자를 뽑고 통계를 확인하는 실험실',
}, },
{
id: 'stock',
label: 'Stock',
path: '/stock',
description: '아침 시장 흐름을 확인하는 주식 연구실',
},
{ {
id: 'travel', id: 'travel',
label: 'Travel', label: 'Travel',
path: '/travel', path: '/travel',
description: '여행에서 담은 색과 장면을 전시하는 갤러리', description: '여행에서 담은 색과 장면을 전시하는 갤러리',
}, },
{
id: 'lab',
label: 'Lab',
path: '/lab',
description: '실험적인 UI/UX 효과를 테스트하는 공간',
},
]; ];
export const appRoutes = [ export const appRoutes = [
@@ -44,8 +60,20 @@ export const appRoutes = [
path: 'lotto', path: 'lotto',
element: <Lotto />, element: <Lotto />,
}, },
{
path: 'stock',
element: <Stock />,
},
{
path: 'stock/trade',
element: <StockTrade />,
},
{ {
path: 'travel', path: 'travel',
element: <Travel />, element: <Travel />,
}, },
{
path: 'lab',
element: <EffectLab />,
},
]; ];