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-dom": "^18.2.0",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.30.3"
"react-router-dom": "^6.30.3",
"three": "^0.182.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
@@ -2921,6 +2922,12 @@
"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": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",

View File

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

View File

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

View File

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

View File

@@ -4,20 +4,19 @@ const API_BASE = import.meta.env.VITE_API_BASE || "";
const toApiUrl = (path) => {
if (!API_BASE) return path;
const baseClean = API_BASE.replace(/\/+$/, "");
const baseForJoin = `${baseClean}/`;
const normalizedPath = path.startsWith("/") ? path.slice(1) : path;
let pathForJoin = normalizedPath;
if (baseClean.endsWith("/api") && normalizedPath.startsWith("api/")) {
pathForJoin = normalizedPath.slice(4);
}
try {
const baseUrl = new URL(baseForJoin, window.location.origin);
return new URL(pathForJoin, baseUrl).toString();
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.warn("Invalid VITE_API_BASE, falling back to relative URL.", error);
console.error("Invalid VITE_API_BASE configuration:", error);
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 {
display: grid;
gap: 28px;
/* Prevent overflow on small screens */
width: 100%;
}
.stock-header {
@@ -66,7 +68,7 @@
font-size: 13px;
}
.stock-status > div {
.stock-status>div {
display: flex;
justify-content: space-between;
gap: 12px;
@@ -536,15 +538,39 @@
}
@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 {
.stock-status>div {
gap: 8px;
}
@@ -568,13 +594,19 @@
}
.stock-holdings__metric {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
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: 12px;
font-size: 11px;
}
.stock-holdings__metric strong {
@@ -591,4 +623,4 @@
.stock-holdings__metric strong {
font-size: 14px;
}
}
}

View File

@@ -1,6 +1,7 @@
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) => {
@@ -206,7 +207,7 @@ const Stock = () => {
</div>
<div className="stock-panel__actions">
{indicesLoading ? (
<span className="stock-chip">불러오는 </span>
<Loading type="spinner" message="" />
) : null}
<button
className="button ghost small"
@@ -237,22 +238,20 @@ const Stock = () => {
return (
<div
key={item.name}
className={`stock-snapshot__card ${
highlighted.has(item.name)
? 'is-highlight'
: ''
}`}
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'
: ''
}`}
className={`stock-snapshot__change ${direction === 'up'
? 'is-up'
: direction === 'down'
? 'is-down'
: ''
}`}
>
{changeText || '--'}
</span>
@@ -332,9 +331,7 @@ const Stock = () => {
</p>
</div>
<div className="stock-panel__actions">
{loading ? (
<span className="stock-chip">불러오는 </span>
) : null}
{loading && <span className="stock-chip">Updating...</span>}
<span className="stock-chip">
국내 {newsDomestic.length} / 해외{' '}
{newsOverseas.length}
@@ -342,8 +339,8 @@ const Stock = () => {
</div>
</div>
{loading ? (
<p className="stock-empty">뉴스를 불러오는 ...</p>
{loading && combinedNews.length === 0 ? (
<Loading type="skeleton" />
) : newsError ? (
<p className="stock-empty">{newsError}</p>
) : combinedNews.length === 0 ? (
@@ -353,22 +350,20 @@ const Stock = () => {
<div className="stock-tabs">
<button
type="button"
className={`stock-tab ${
newsCategory === 'domestic'
? 'is-active'
: ''
}`}
className={`stock-tab ${newsCategory === 'domestic'
? 'is-active'
: ''
}`}
onClick={() => setNewsCategory('domestic')}
>
국내
</button>
<button
type="button"
className={`stock-tab ${
newsCategory === 'overseas'
? 'is-active'
: ''
}`}
className={`stock-tab ${newsCategory === 'overseas'
? 'is-active'
: ''
}`}
onClick={() => setNewsCategory('overseas')}
>
해외

View File

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