d16456cb92
# 사용자별 격리 - JWT 토큰에 uid 추가 (auth.get_uid 헬퍼) - PostgreSQL — exchange_credentials/automation_config/trades/signal_events 에 user_id BIGINT - SQLite user_settings 테이블 신설 (글로벌 settings 는 옛 호환) - 모든 DB 함수 시그니처에 user_id 인자 추가 — 다른 사용자 데이터 절대 접근 불가 - alert_state — 모든 dict key 가 (user_id, ...) tuple 로 계층화 - core_logic alert_loop — 활성 사용자 순회 + 각자 settings/symbol/텔레그램 적용 - ensure_user_defaults() / ensure_user_automation() — 첫 사용 시 자동 시드 # 사용자 관리 (admin only) - users_db: delete_user / admin_reset_password / set_role - /api/users POST DELETE PUT password PUT role (본인 강등 / 마지막 admin 보호) - /admin/users 페이지 — 등록/삭제/role 토글/비번 reset 모달 - 사이드바 adminOnly 필터 — admin role 만 메뉴 노출 # 대시보드 개선 - 모바일 / 범례 토글 (모바일 60 캔들, 데스크톱 200) - 트레이드 이력: open 트레이드 실시간 PnL% (Binance ticker 호출 + 방향별 계산) - 메트릭 카드 분리 (실거래 vs 실시간 open) # 안정성 - api.ts: error.detail array/object 안전 처리 ([object Object] 방지) - Chart.tsx: Plotly yaxis title 객체 형태 + 모바일 height 동적 조정 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
199 lines
8.7 KiB
TypeScript
199 lines
8.7 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, ShieldCheck,
|
|
} from 'lucide-react';
|
|
import { cn } from '@/lib/cn';
|
|
|
|
const NAV: { href: string; label: string; icon: any; adminOnly?: boolean }[] = [
|
|
{ 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 },
|
|
{ href: '/admin/users', label: '사용자 관리', icon: ShieldCheck, adminOnly: true },
|
|
];
|
|
|
|
const Logo = ({ mini = false }: { mini?: boolean }) => {
|
|
if (mini) {
|
|
return (
|
|
<svg viewBox="0 0 64 64" width={40} height={40} xmlns="http://www.w3.org/2000/svg">
|
|
<defs>
|
|
<radialGradient id="mLogoMini" cx="35%" cy="30%">
|
|
<stop offset="0%" stopColor="#fef3c7"/>
|
|
<stop offset="45%" stopColor="#fbbf24"/>
|
|
<stop offset="100%" stopColor="#b45309"/>
|
|
</radialGradient>
|
|
</defs>
|
|
<circle cx="40" cy="38" r="20" fill="url(#mLogoMini)" opacity="0.35"/>
|
|
<circle cx="32" cy="32" r="24" fill="url(#mLogoMini)" stroke="#92400e" strokeWidth="2"/>
|
|
<circle cx="32" cy="32" r="18" fill="none" stroke="rgba(255,255,255,0.45)" strokeWidth="1.2"/>
|
|
<text x="32" y="42" textAnchor="middle" fontFamily="'Arial Black', Impact, sans-serif" fontWeight="900" fontSize="32" fill="#7c2d12">$</text>
|
|
</svg>
|
|
);
|
|
}
|
|
return (
|
|
<svg viewBox="0 0 280 64" width={220} height={50} xmlns="http://www.w3.org/2000/svg">
|
|
<defs>
|
|
<radialGradient id="coinFace" cx="35%" cy="30%">
|
|
<stop offset="0%" stopColor="#fef3c7"/>
|
|
<stop offset="45%" stopColor="#fbbf24"/>
|
|
<stop offset="100%" stopColor="#b45309"/>
|
|
</radialGradient>
|
|
<linearGradient id="coinEdge" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor="#fde047"/>
|
|
<stop offset="100%" stopColor="#92400e"/>
|
|
</linearGradient>
|
|
<linearGradient id="brandGold" x1="0" y1="0" x2="1" y2="0">
|
|
<stop offset="0%" stopColor="#fbbf24"/>
|
|
<stop offset="50%" stopColor="#fde68a"/>
|
|
<stop offset="100%" stopColor="#f59e0b"/>
|
|
</linearGradient>
|
|
<filter id="logoGlow" x="-20%" y="-20%" width="140%" height="140%">
|
|
<feGaussianBlur stdDeviation="1.2" result="b"/>
|
|
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
|
|
</filter>
|
|
</defs>
|
|
<g transform="translate(8, 14)">
|
|
<ellipse cx="30" cy="24" rx="14" ry="14" fill="url(#coinFace)" opacity="0.3"/>
|
|
<ellipse cx="23" cy="20" rx="14" ry="14" fill="url(#coinFace)" opacity="0.6" stroke="url(#coinEdge)" strokeWidth="0.5"/>
|
|
<circle cx="16" cy="16" r="15.5" fill="url(#coinFace)" stroke="url(#coinEdge)" strokeWidth="1.2"/>
|
|
<circle cx="16" cy="16" r="12" fill="none" stroke="rgba(255,255,255,0.4)" strokeWidth="0.7"/>
|
|
<text x="16" y="22.5" textAnchor="middle" fontFamily="'Arial Black', Impact, sans-serif" fontWeight="900" fontSize="20" fill="#7c2d12" filter="url(#logoGlow)">$</text>
|
|
<path d="M 38 6 L 48 -2 L 42 -2 M 48 -2 L 48 4" stroke="#10b981" strokeWidth="2.4" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
|
|
<g fill="#fde68a">
|
|
<circle cx="40" cy="16" r="0.9"/>
|
|
<circle cx="46" cy="11" r="1.1"/>
|
|
<circle cx="43" cy="22" r="0.7"/>
|
|
</g>
|
|
</g>
|
|
<text x="66" y="30" fontFamily="Pretendard, 'Arial Black', sans-serif" fontWeight="900" fontSize="22" fill="url(#brandGold)" letterSpacing="1.8" filter="url(#logoGlow)">GOLDMINT</text>
|
|
<text x="66" y="46" fontFamily="Pretendard, sans-serif" fontWeight="600" fontSize="10" fill="#cbd5e1" letterSpacing="1.5">돈복사 시스템 · AUTO</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-[72px]' : 'w-[280px]';
|
|
|
|
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.filter(n => !n.adminOnly || role === 'admin').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-9 h-9 rounded-full flex items-center justify-center font-extrabold text-sm shrink-0"
|
|
style={{ background: 'radial-gradient(circle at 30% 30%, #fef3c7 0%, #fbbf24 45%, #b45309 100%)', color: '#7c2d12', border: '1px solid #92400e' }}>
|
|
{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>
|
|
</>
|
|
);
|
|
}
|