Compare commits
2 Commits
d7e7ccdb16
...
1d78b2c430
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d78b2c430 | |||
| bdb055cb32 |
9
package-lock.json
generated
9
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
21
src/api.js
21
src/api.js
@@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
58
src/components/Loading.css
Normal file
58
src/components/Loading.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/components/Loading.jsx
Normal file
23
src/components/Loading.jsx
Normal 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;
|
||||||
59
src/pages/effect-lab/EffectLab.css
Normal file
59
src/pages/effect-lab/EffectLab.css
Normal 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;
|
||||||
|
}
|
||||||
218
src/pages/effect-lab/EffectLab.jsx
Normal file
218
src/pages/effect-lab/EffectLab.jsx
Normal 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 |
|
||||||
|
<strong>Click & Hold</strong> to Orbit & Charge
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EffectLab;
|
||||||
@@ -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 {
|
||||||
@@ -66,7 +68,7 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stock-status > div {
|
.stock-status>div {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -536,15 +538,39 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@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 {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stock-status > div {
|
.stock-status>div {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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'
|
||||||
: ''
|
: ''
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -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 />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user