1. 라우팅 최적화
2. API 호출 병렬 처리 3. UI개선 - 로딩 경험 개선 4. 반응형 디자인 5. API 통신 특이사항 - URL 구성 로직의 잠재적 위험 해결
This commit is contained in:
@@ -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">
|
||||||
<Outlet />
|
<React.Suspense fallback={<div className="suspend-loading"><Loading /></div>}>
|
||||||
|
<Outlet />
|
||||||
|
</React.Suspense>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
23
src/api.js
23
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(/\/+$/, "");
|
|
||||||
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 {
|
try {
|
||||||
const baseUrl = new URL(baseForJoin, window.location.origin);
|
const base = new URL(API_BASE, window.location.origin);
|
||||||
return new URL(pathForJoin, baseUrl).toString();
|
// 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) {
|
} 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;
|
||||||
@@ -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,22 +238,20 @@ 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'
|
: ''
|
||||||
: ''
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<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'
|
: ''
|
||||||
: ''
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{changeText || '--'}
|
{changeText || '--'}
|
||||||
</span>
|
</span>
|
||||||
@@ -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,22 +350,20 @@ 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'
|
: ''
|
||||||
: ''
|
}`}
|
||||||
}`}
|
|
||||||
onClick={() => setNewsCategory('domestic')}
|
onClick={() => setNewsCategory('domestic')}
|
||||||
>
|
>
|
||||||
국내
|
국내
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`stock-tab ${
|
className={`stock-tab ${newsCategory === 'overseas'
|
||||||
newsCategory === 'overseas'
|
? 'is-active'
|
||||||
? 'is-active'
|
: ''
|
||||||
: ''
|
}`}
|
||||||
}`}
|
|
||||||
onClick={() => setNewsCategory('overseas')}
|
onClick={() => setNewsCategory('overseas')}
|
||||||
>
|
>
|
||||||
해외
|
해외
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
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'));
|
||||||
|
|
||||||
export const navLinks = [
|
export const navLinks = [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user