Compare commits

...

2 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
12 changed files with 471 additions and 57 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,11 +26,18 @@
} }
} }
.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);

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">
<React.Suspense fallback={<div className="suspend-loading"><Loading /></div>}>
<Outlet /> <Outlet />
</React.Suspense>
</main> </main>
</div> </div>
); );

View File

@@ -4,20 +4,19 @@ const API_BASE = import.meta.env.VITE_API_BASE || "";
const toApiUrl = (path) => { const toApiUrl = (path) => {
if (!API_BASE) return path; if (!API_BASE) return path;
const baseClean = API_BASE.replace(/\/+$/, ""); try {
const baseForJoin = `${baseClean}/`; const base = new URL(API_BASE, window.location.origin);
const normalizedPath = path.startsWith("/") ? path.slice(1) : path; // Ensure base pathname ends with '/' if it's not the root or if likely intended as a directory
let pathForJoin = normalizedPath; if (!base.pathname.endsWith('/')) {
base.pathname += '/';
if (baseClean.endsWith("/api") && normalizedPath.startsWith("api/")) {
pathForJoin = normalizedPath.slice(4);
} }
try { // Remove leading slash from path to avoid double slashes when joining
const baseUrl = new URL(baseForJoin, window.location.origin); const cleanPath = path.startsWith('/') ? path.slice(1) : path;
return new URL(pathForJoin, baseUrl).toString();
return new URL(cleanPath, base).toString();
} catch (error) { } catch (error) {
console.warn("Invalid VITE_API_BASE, falling back to relative URL.", error); console.error("Invalid VITE_API_BASE configuration:", error);
return path; return path;
} }
}; };

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

@@ -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;

View File

@@ -1,6 +1,8 @@
.stock { .stock {
display: grid; display: grid;
gap: 28px; gap: 28px;
/* Prevent overflow on small screens */
width: 100%;
} }
.stock-header { .stock-header {
@@ -536,8 +538,32 @@
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.stock {
gap: 20px;
}
.stock-panel { .stock-panel {
padding: 16px; 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 { .stock-card {
@@ -568,13 +594,19 @@
} }
.stock-holdings__metric { .stock-holdings__metric {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-columns: repeat(2, 1fr);
align-items: center; align-items: center;
justify-items: start; 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 { .stock-holdings__metric span {
font-size: 12px; font-size: 11px;
} }
.stock-holdings__metric strong { .stock-holdings__metric strong {

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { getStockIndices, getStockNews } from '../../api'; import { getStockIndices, getStockNews } from '../../api';
import Loading from '../../components/Loading';
import './Stock.css'; import './Stock.css';
const formatDate = (value) => { const formatDate = (value) => {
@@ -206,7 +207,7 @@ const Stock = () => {
</div> </div>
<div className="stock-panel__actions"> <div className="stock-panel__actions">
{indicesLoading ? ( {indicesLoading ? (
<span className="stock-chip">불러오는 </span> <Loading type="spinner" message="" />
) : null} ) : null}
<button <button
className="button ghost small" className="button ghost small"
@@ -237,8 +238,7 @@ const Stock = () => {
return ( return (
<div <div
key={item.name} key={item.name}
className={`stock-snapshot__card ${ className={`stock-snapshot__card ${highlighted.has(item.name)
highlighted.has(item.name)
? 'is-highlight' ? 'is-highlight'
: '' : ''
}`} }`}
@@ -246,8 +246,7 @@ const Stock = () => {
<p>{item.name}</p> <p>{item.name}</p>
<strong>{item.value ?? '--'}</strong> <strong>{item.value ?? '--'}</strong>
<span <span
className={`stock-snapshot__change ${ className={`stock-snapshot__change ${direction === 'up'
direction === 'up'
? 'is-up' ? 'is-up'
: direction === 'down' : direction === 'down'
? 'is-down' ? 'is-down'
@@ -332,9 +331,7 @@ const Stock = () => {
</p> </p>
</div> </div>
<div className="stock-panel__actions"> <div className="stock-panel__actions">
{loading ? ( {loading && <span className="stock-chip">Updating...</span>}
<span className="stock-chip">불러오는 </span>
) : null}
<span className="stock-chip"> <span className="stock-chip">
국내 {newsDomestic.length} / 해외{' '} 국내 {newsDomestic.length} / 해외{' '}
{newsOverseas.length} {newsOverseas.length}
@@ -342,8 +339,8 @@ const Stock = () => {
</div> </div>
</div> </div>
{loading ? ( {loading && combinedNews.length === 0 ? (
<p className="stock-empty">뉴스를 불러오는 ...</p> <Loading type="skeleton" />
) : newsError ? ( ) : newsError ? (
<p className="stock-empty">{newsError}</p> <p className="stock-empty">{newsError}</p>
) : combinedNews.length === 0 ? ( ) : combinedNews.length === 0 ? (
@@ -353,8 +350,7 @@ const Stock = () => {
<div className="stock-tabs"> <div className="stock-tabs">
<button <button
type="button" type="button"
className={`stock-tab ${ className={`stock-tab ${newsCategory === 'domestic'
newsCategory === 'domestic'
? 'is-active' ? 'is-active'
: '' : ''
}`} }`}
@@ -364,8 +360,7 @@ const Stock = () => {
</button> </button>
<button <button
type="button" type="button"
className={`stock-tab ${ className={`stock-tab ${newsCategory === 'overseas'
newsCategory === 'overseas'
? 'is-active' ? 'is-active'
: '' : ''
}`} }`}

View File

@@ -1,10 +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'));
import Stock from './pages/stock/Stock'; const Travel = lazy(() => import('./pages/travel/Travel'));
import StockTrade from './pages/stock/StockTrade'; 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 = [
{ {
@@ -37,6 +39,12 @@ export const navLinks = [
path: '/travel', path: '/travel',
description: '여행에서 담은 색과 장면을 전시하는 갤러리', description: '여행에서 담은 색과 장면을 전시하는 갤러리',
}, },
{
id: 'lab',
label: 'Lab',
path: '/lab',
description: '실험적인 UI/UX 효과를 테스트하는 공간',
},
]; ];
export const appRoutes = [ export const appRoutes = [
@@ -64,4 +72,8 @@ export const appRoutes = [
path: 'travel', path: 'travel',
element: <Travel />, element: <Travel />,
}, },
{
path: 'lab',
element: <EffectLab />,
},
]; ];