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 {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.site-main > * {
|
||||
.site-main>* {
|
||||
animation: fadeUp 0.6s ease both;
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
<React.Suspense fallback={<div className="suspend-loading"><Loading /></div>}>
|
||||
<Outlet />
|
||||
</React.Suspense>
|
||||
</main>
|
||||
</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) => {
|
||||
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 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 += '/';
|
||||
}
|
||||
|
||||
try {
|
||||
const baseUrl = new URL(baseForJoin, window.location.origin);
|
||||
return new URL(pathForJoin, baseUrl).toString();
|
||||
// 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;
|
||||
}
|
||||
};
|
||||
|
||||
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 {
|
||||
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 {
|
||||
|
||||
@@ -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,8 +238,7 @@ const Stock = () => {
|
||||
return (
|
||||
<div
|
||||
key={item.name}
|
||||
className={`stock-snapshot__card ${
|
||||
highlighted.has(item.name)
|
||||
className={`stock-snapshot__card ${highlighted.has(item.name)
|
||||
? 'is-highlight'
|
||||
: ''
|
||||
}`}
|
||||
@@ -246,8 +246,7 @@ const Stock = () => {
|
||||
<p>{item.name}</p>
|
||||
<strong>{item.value ?? '--'}</strong>
|
||||
<span
|
||||
className={`stock-snapshot__change ${
|
||||
direction === 'up'
|
||||
className={`stock-snapshot__change ${direction === 'up'
|
||||
? 'is-up'
|
||||
: direction === 'down'
|
||||
? 'is-down'
|
||||
@@ -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,8 +350,7 @@ const Stock = () => {
|
||||
<div className="stock-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`stock-tab ${
|
||||
newsCategory === 'domestic'
|
||||
className={`stock-tab ${newsCategory === 'domestic'
|
||||
? 'is-active'
|
||||
: ''
|
||||
}`}
|
||||
@@ -364,8 +360,7 @@ const Stock = () => {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`stock-tab ${
|
||||
newsCategory === 'overseas'
|
||||
className={`stock-tab ${newsCategory === 'overseas'
|
||||
? 'is-active'
|
||||
: ''
|
||||
}`}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
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'));
|
||||
|
||||
export const navLinks = [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user