Files
tradeing/frontend/components/sidebar.tsx
T
chpark c4e6aab7b2 React + FastAPI 풀 마이그레이션 — Streamlit 제거
- backend/ — FastAPI + JWT + 모든 REST 엔드포인트
- frontend/ — Next.js 14 + Tailwind + 7페이지 (대시보드/트레이드/거래소/자동매매/설정/내정보/로그인)
- core_logic.py — 신호계산/알림 로직 분리 (기존 app_streamlit.py 에서 추출)
- users_db.py + bcrypt 인증, exchange_keys.py + Fernet 암호화
- trades_db.py — 진입/청산 lifecycle 추적, signal_events raw 로그
- settings_db.py — 모든 운영 파라미터 DB 영속 저장 (RSI/거래량/펀딩비 임계값 포함)
- docker-compose: frontend / backend / postgres + Traefik 라우팅
- assets/logo.svg — JUNGGOMOA 그라디언트 로고

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:27:11 +09:00

166 lines
6.6 KiB
TypeScript

'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useAuth } from '@/lib/auth';
import {
LayoutDashboard, TrendingUp, KeyRound, Bot, Settings, User, LogOut, Menu,
} from 'lucide-react';
import { cn } from '@/lib/cn';
const NAV = [
{ href: '/', label: '대시보드', icon: LayoutDashboard },
{ href: '/trades', label: '트레이드 이력', icon: TrendingUp },
{ href: '/exchange', label: '거래소 API', icon: KeyRound },
{ href: '/automation', label: '자동매매', icon: Bot },
{ href: '/settings', label: '시스템 설정', icon: Settings },
{ href: '/profile', label: '내 정보', icon: User },
];
const Logo = ({ mini = false }: { mini?: boolean }) => (
<svg viewBox="0 0 220 60" width={mini ? 36 : 200} height={mini ? 36 : 50} xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="brand" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="#3b82f6"/>
<stop offset="100%" stopColor="#60a5fa"/>
</linearGradient>
</defs>
<g transform="translate(6, 10)">
<line x1="4" y1="6" x2="4" y2="40" stroke="#26a69a" strokeWidth="1.4"/>
<rect x="1" y="20" width="6" height="14" fill="#26a69a" rx="1"/>
<line x1="14" y1="2" x2="14" y2="34" stroke="#ef5350" strokeWidth="1.4"/>
<rect x="11" y="8" width="6" height="18" fill="#ef5350" rx="1"/>
<line x1="24" y1="10" x2="24" y2="42" stroke="#26a69a" strokeWidth="1.4"/>
<rect x="21" y="14" width="6" height="22" fill="#26a69a" rx="1"/>
<polyline points="2,28 14,18 24,10" fill="none" stroke="url(#brand)" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
<circle cx="24" cy="10" r="2.2" fill="#60a5fa"/>
</g>
{!mini && (
<>
<text x="46" y="28" fontFamily="Pretendard, sans-serif" fontWeight="800" fontSize="18" fill="url(#brand)" letterSpacing="1.2">JUNGGOMOA</text>
<text x="46" y="44" fontFamily="Pretendard, sans-serif" fontWeight="500" fontSize="10" fill="#9ca3af" letterSpacing="0.5"> </text>
</>
)}
</svg>
);
export default function Sidebar() {
const pathname = usePathname();
const { user, logout } = useAuth();
const [mini, setMini] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
useEffect(() => { setMobileOpen(false); }, [pathname]);
const initial = (user?.username?.[0] || '?').toUpperCase();
const w = mini ? 'w-[68px]' : 'w-[260px]';
return (
<>
{/* 모바일 햄버거 (사이드바 밖) */}
<button
onClick={() => setMobileOpen(true)}
className="lg:hidden fixed top-3 left-3 z-40 bg-slate-800 text-white p-2 rounded-md shadow-md"
>
<Menu size={20} />
</button>
{/* 모바일 오버레이 */}
{mobileOpen && (
<div
className="lg:hidden fixed inset-0 z-40 bg-black/50"
onClick={() => setMobileOpen(false)}
/>
)}
{/* 사이드바 */}
<aside className={cn(
'bg-slate-900 text-slate-200 flex flex-col h-screen sticky top-0 transition-all duration-200 z-50',
'hidden lg:flex',
w,
)}>
<SidebarInner mini={mini} setMini={setMini} pathname={pathname} initial={initial} username={user?.username || ''} role={user?.role || ''} logout={logout} />
</aside>
{/* 모바일 슬라이드 사이드바 */}
<aside className={cn(
'lg:hidden fixed top-0 left-0 h-screen w-[260px] bg-slate-900 text-slate-200 flex flex-col z-50 transition-transform',
mobileOpen ? 'translate-x-0' : '-translate-x-full',
)}>
<SidebarInner mini={false} setMini={() => setMobileOpen(false)} pathname={pathname} initial={initial} username={user?.username || ''} role={user?.role || ''} logout={logout} />
</aside>
</>
);
}
function SidebarInner({ mini, setMini, pathname, initial, username, role, logout }: any) {
return (
<>
{/* 헤더: 로고(좌) + 햄버거(우) */}
<div className="flex items-center justify-between px-3 py-3 border-b border-slate-700">
{!mini && <Logo mini={false} />}
{mini && <Logo mini={true} />}
{!mini && (
<button onClick={() => setMini(true)} className="p-2 rounded hover:bg-slate-700">
<Menu size={18} />
</button>
)}
</div>
{mini && (
<button onClick={() => setMini(false)} className="mx-2 my-2 p-2 rounded hover:bg-slate-700 text-slate-300">
<Menu size={18} className="mx-auto" />
</button>
)}
{/* 메뉴 */}
<nav className="flex-1 overflow-y-auto scrollbar-thin py-2">
{NAV.map((n) => {
const active = pathname === n.href || (n.href !== '/' && pathname.startsWith(n.href));
return (
<Link
key={n.href}
href={n.href}
className={cn(
'flex items-center gap-3 mx-2 my-1 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors',
active
? 'bg-blue-600 text-white shadow-md'
: 'text-slate-300 hover:bg-slate-700/60 hover:text-white',
mini && 'justify-center px-2',
)}
title={n.label}
>
<n.icon size={18} className={cn(active ? 'text-white' : 'text-blue-400')} />
{!mini && <span>{n.label}</span>}
</Link>
);
})}
</nav>
{/* 푸터: 사용자 + 로그아웃 */}
<div className="border-t border-slate-700 p-3">
{!mini ? (
<>
<div className="flex items-center gap-3 mb-2">
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white font-bold text-sm shrink-0"
style={{ background: 'linear-gradient(135deg,#3b82f6,#60a5fa)' }}>
{initial}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-slate-100 truncate">{username || 'guest'}</div>
<div className="text-xs text-slate-400">{role}</div>
</div>
</div>
<button onClick={logout} className="w-full flex items-center justify-center gap-2 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm">
<LogOut size={14}/>
</button>
</>
) : (
<button onClick={logout} className="w-full p-2 rounded hover:bg-slate-700" title={`${username} 로그아웃`}>
<LogOut size={18} className="mx-auto text-slate-300"/>
</button>
)}
</div>
</>
);
}