feat(frontend): add new layout components
- Collapsible Sidebar with navigation - Header with page titles and logout - DashboardLayout with responsive design - Updated dashboard page Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
0200ebc7ad
commit
3e733ec1b8
@ -1,49 +1,112 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ThemeToggle } from "@/components/ui/theme-toggle";
|
||||
import { Home, Settings, User } from "lucide-react";
|
||||
'use client';
|
||||
|
||||
export default function TestPage() {
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Wallet, TrendingUp, Briefcase, RefreshCw } from 'lucide-react';
|
||||
|
||||
const summaryCards = [
|
||||
{
|
||||
title: '총 자산',
|
||||
value: '---',
|
||||
description: '전체 포트폴리오 가치',
|
||||
icon: Wallet,
|
||||
},
|
||||
{
|
||||
title: '총 수익률',
|
||||
value: '---%',
|
||||
description: '전체 수익률',
|
||||
icon: TrendingUp,
|
||||
},
|
||||
{
|
||||
title: '포트폴리오',
|
||||
value: '-',
|
||||
description: '활성 포트폴리오 수',
|
||||
icon: Briefcase,
|
||||
},
|
||||
{
|
||||
title: '리밸런싱',
|
||||
value: '-',
|
||||
description: '예정된 리밸런싱',
|
||||
icon: RefreshCw,
|
||||
},
|
||||
];
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div className="min-h-screen p-8 bg-background text-foreground">
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-3xl font-bold">Phase 1 테스트</h1>
|
||||
<ThemeToggle />
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Summary Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{summaryCards.map((card) => {
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
<Card key={card.title}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{card.title}
|
||||
</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{card.value}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{card.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>shadcn/ui 컴포넌트 테스트</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Button>Primary</Button>
|
||||
<Button variant="secondary">Secondary</Button>
|
||||
<Button variant="destructive">Destructive</Button>
|
||||
<Button variant="outline">Outline</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
</div>
|
||||
<div className="flex gap-4 items-center">
|
||||
<Home className="h-6 w-6" />
|
||||
<Settings className="h-6 w-6" />
|
||||
<User className="h-6 w-6" />
|
||||
<span>Lucide Icons</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Chart Placeholders */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>포트폴리오 성과</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center h-64 bg-muted/50 rounded-lg">
|
||||
<p className="text-muted-foreground">차트 영역</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>테마 테스트</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
위의 테마 토글 버튼으로 라이트/다크 모드를 전환해보세요.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>자산 배분</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center h-64 bg-muted/50 rounded-lg">
|
||||
<p className="text-muted-foreground">차트 영역</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>최근 거래</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center h-48 bg-muted/50 rounded-lg">
|
||||
<p className="text-muted-foreground">거래 내역 영역</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>알림</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center h-48 bg-muted/50 rounded-lg">
|
||||
<p className="text-muted-foreground">알림 영역</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
83
frontend/src/components/layout/dashboard-layout.tsx
Normal file
83
frontend/src/components/layout/dashboard-layout.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { api } from '@/lib/api';
|
||||
import { NewSidebar } from './new-sidebar';
|
||||
import { NewHeader } from './new-header';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
|
||||
interface User {
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const userData = await api.getCurrentUser() as User;
|
||||
setUser(userData);
|
||||
} catch {
|
||||
router.push('/login');
|
||||
return;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, [router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
<p className="text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
{/* Desktop Sidebar */}
|
||||
<div className="hidden md:block">
|
||||
<NewSidebar />
|
||||
</div>
|
||||
|
||||
{/* Mobile Sidebar (Sheet) */}
|
||||
<Sheet open={isMobileMenuOpen} onOpenChange={setIsMobileMenuOpen}>
|
||||
<SheetContent side="left" className="p-0 w-64">
|
||||
<SheetTitle className="sr-only">내비게이션 메뉴</SheetTitle>
|
||||
<NewSidebar />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex flex-col flex-1 overflow-hidden">
|
||||
<NewHeader
|
||||
username={user?.username}
|
||||
showMenuButton
|
||||
onMenuClick={() => setIsMobileMenuOpen(true)}
|
||||
/>
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
frontend/src/components/layout/new-header.tsx
Normal file
84
frontend/src/components/layout/new-header.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { Menu, LogOut, User } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const pageTitles: Record<string, string> = {
|
||||
'/': '대시보드',
|
||||
'/portfolio': '포트폴리오',
|
||||
'/strategy': '전략',
|
||||
'/backtest': '백테스트',
|
||||
'/admin/data': '데이터 관리',
|
||||
};
|
||||
|
||||
function getPageTitle(pathname: string): string {
|
||||
// Check exact match first
|
||||
if (pageTitles[pathname]) {
|
||||
return pageTitles[pathname];
|
||||
}
|
||||
|
||||
// Check for partial matches (for nested routes)
|
||||
for (const [path, title] of Object.entries(pageTitles)) {
|
||||
if (path !== '/' && pathname.startsWith(path)) {
|
||||
return title;
|
||||
}
|
||||
}
|
||||
|
||||
return '대시보드';
|
||||
}
|
||||
|
||||
interface NewHeaderProps {
|
||||
username?: string;
|
||||
onMenuClick?: () => void;
|
||||
showMenuButton?: boolean;
|
||||
}
|
||||
|
||||
export function NewHeader({ username, onMenuClick, showMenuButton = false }: NewHeaderProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const pageTitle = getPageTitle(pathname);
|
||||
|
||||
const handleLogout = () => {
|
||||
api.logout();
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex items-center justify-between h-16 px-4 border-b bg-background">
|
||||
<div className="flex items-center gap-4">
|
||||
{showMenuButton && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onMenuClick}
|
||||
className="md:hidden"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
<span className="sr-only">메뉴 열기</span>
|
||||
</Button>
|
||||
)}
|
||||
<h1 className="text-xl font-semibold">{pageTitle}</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{username && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<User className="h-4 w-4" />
|
||||
<span>{username}</span>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleLogout}
|
||||
className="gap-2"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">로그아웃</span>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
134
frontend/src/components/layout/new-sidebar.tsx
Normal file
134
frontend/src/components/layout/new-sidebar.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Briefcase,
|
||||
TrendingUp,
|
||||
FlaskConical,
|
||||
Database,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
|
||||
const navItems = [
|
||||
{ href: '/', label: '대시보드', icon: LayoutDashboard },
|
||||
{ href: '/portfolio', label: '포트폴리오', icon: Briefcase },
|
||||
{ href: '/strategy', label: '전략', icon: TrendingUp },
|
||||
{ href: '/backtest', label: '백테스트', icon: FlaskConical },
|
||||
{ href: '/admin/data', label: '데이터 관리', icon: Database },
|
||||
];
|
||||
|
||||
interface NewSidebarProps {
|
||||
collapsed?: boolean;
|
||||
onCollapsedChange?: (collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
export function NewSidebar({ collapsed = false, onCollapsedChange }: NewSidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const [isCollapsed, setIsCollapsed] = useState(collapsed);
|
||||
|
||||
const handleCollapse = () => {
|
||||
const newCollapsed = !isCollapsed;
|
||||
setIsCollapsed(newCollapsed);
|
||||
onCollapsedChange?.(newCollapsed);
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<aside
|
||||
className={cn(
|
||||
'flex flex-col h-full bg-card border-r transition-all duration-300',
|
||||
isCollapsed ? 'w-16' : 'w-64'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className={cn(
|
||||
'flex items-center h-16 border-b px-4',
|
||||
isCollapsed ? 'justify-center' : 'justify-between'
|
||||
)}>
|
||||
{!isCollapsed && (
|
||||
<div>
|
||||
<h1 className="text-lg font-bold">Galaxy-PO</h1>
|
||||
<p className="text-xs text-muted-foreground">Quant Portfolio</p>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleCollapse}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 py-4">
|
||||
<ul className="space-y-1 px-2">
|
||||
{navItems.map((item) => {
|
||||
const isActive =
|
||||
pathname === item.href ||
|
||||
(item.href !== '/' && pathname.startsWith(item.href));
|
||||
const Icon = item.icon;
|
||||
|
||||
const linkContent = (
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 transition-colors',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
isCollapsed && 'justify-center px-2'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5 shrink-0" />
|
||||
{!isCollapsed && <span>{item.label}</span>}
|
||||
</Link>
|
||||
);
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{linkContent}</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>{item.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return <li key={item.href}>{linkContent}</li>;
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className={cn(
|
||||
'border-t p-4',
|
||||
isCollapsed ? 'flex justify-center' : ''
|
||||
)}>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</aside>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user