diff --git a/.gitignore b/.gitignore index 65ebea1..8175330 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,9 @@ dist/ downloads/ eggs/ .eggs/ -lib/ lib64/ +# Note: Python lib/ excluded but frontend/src/lib/ allowed +!frontend/src/lib/ parts/ sdist/ var/ diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index a2dc41e..5ed6924 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1,26 +1,13 @@ -@import "tailwindcss"; +@tailwind base; +@tailwind components; +@tailwind utilities; :root { - --background: #ffffff; - --foreground: #171717; -} - -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); -} - -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } + --foreground-rgb: 0, 0, 0; + --background-rgb: 249, 250, 251; } body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + color: rgb(var(--foreground-rgb)); + background: rgb(var(--background-rgb)); } diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index f7fa87e..c21e45e 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,20 +1,12 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import "./globals.css"; +import type { Metadata } from 'next'; +import { Inter } from 'next/font/google'; +import './globals.css'; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); +const inter = Inter({ subsets: ['latin'] }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: 'Galaxy-PO', + description: 'Quant Portfolio Management Application', }; export default function RootLayout({ @@ -23,12 +15,8 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - - {children} - + + {children} ); } diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx new file mode 100644 index 0000000..2825a2a --- /dev/null +++ b/frontend/src/app/login/page.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { api } from '@/lib/api'; + +export default function LoginPage() { + const router = useRouter(); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + await api.login(username, password); + router.push('/'); + } catch (err) { + setError(err instanceof Error ? err.message : '로그인에 실패했습니다.'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

+ Galaxy-PO +

+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setUsername(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + required + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + required + /> +
+ + +
+
+
+ ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 295f8fd..ba26282 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,65 +1,78 @@ -import Image from "next/image"; +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Sidebar from '@/components/layout/Sidebar'; +import Header from '@/components/layout/Header'; +import { api } from '@/lib/api'; + +interface User { + id: number; + username: string; + email: string; +} + +export default function Dashboard() { + const router = useRouter(); + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const checkAuth = async () => { + try { + const userData = await api.getCurrentUser() as User; + setUser(userData); + } catch { + router.push('/login'); + } finally { + setLoading(false); + } + }; + + checkAuth(); + }, [router]); + + if (loading) { + return ( +
+
Loading...
+
+ ); + } -export default function Home() { return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
-
- - Vercel logomark - Deploy Now - - - Documentation - -
-
+
+ +
+
+
+

대시보드

+ +
+
+

총 자산

+

₩0

+
+
+

총 수익률

+

+0.00%

+
+
+

포트폴리오

+

0개

+
+
+

리밸런싱 필요

+

0건

+
+
+ +
+

최근 활동

+

아직 활동 내역이 없습니다.

+
+
+
); } diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx new file mode 100644 index 0000000..ac16ccf --- /dev/null +++ b/frontend/src/components/layout/Header.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { api } from '@/lib/api'; + +interface HeaderProps { + username?: string; +} + +export default function Header({ username }: HeaderProps) { + const router = useRouter(); + + const handleLogout = () => { + api.logout(); + router.push('/login'); + }; + + return ( +
+
+
+

+ 환영합니다{username ? `, ${username}` : ''} +

+
+
+ +
+
+
+ ); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..c2b59eb --- /dev/null +++ b/frontend/src/components/layout/Sidebar.tsx @@ -0,0 +1,51 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; + +const menuItems = [ + { href: '/', label: '대시보드', icon: '📊' }, + { href: '/portfolio', label: '포트폴리오', icon: '💼' }, + { href: '/strategy', label: '퀀트 전략', icon: '📈' }, + { href: '/backtest', label: '백테스트', icon: '🔬' }, + { href: '/market', label: '시세 조회', icon: '💹' }, + { href: '/admin/data', label: '데이터 관리', icon: '⚙️' }, +]; + +export default function Sidebar() { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..32a290f --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,108 @@ +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; + +class ApiClient { + private baseUrl: string; + private token: string | null = null; + + constructor(baseUrl: string) { + this.baseUrl = baseUrl; + if (typeof window !== 'undefined') { + this.token = localStorage.getItem('token'); + } + } + + setToken(token: string) { + this.token = token; + if (typeof window !== 'undefined') { + localStorage.setItem('token', token); + } + } + + clearToken() { + this.token = null; + if (typeof window !== 'undefined') { + localStorage.removeItem('token'); + } + } + + private async request( + endpoint: string, + options: RequestInit = {} + ): Promise { + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...options.headers, + }; + + if (this.token) { + (headers as Record)['Authorization'] = `Bearer ${this.token}`; + } + + const response = await fetch(`${this.baseUrl}${endpoint}`, { + ...options, + headers, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.detail || 'API request failed'); + } + + return response.json(); + } + + async get(endpoint: string): Promise { + return this.request(endpoint, { method: 'GET' }); + } + + async post(endpoint: string, data?: unknown): Promise { + return this.request(endpoint, { + method: 'POST', + body: data ? JSON.stringify(data) : undefined, + }); + } + + async put(endpoint: string, data?: unknown): Promise { + return this.request(endpoint, { + method: 'PUT', + body: data ? JSON.stringify(data) : undefined, + }); + } + + async delete(endpoint: string): Promise { + return this.request(endpoint, { method: 'DELETE' }); + } + + async login(username: string, password: string) { + const formData = new URLSearchParams(); + formData.append('username', username); + formData.append('password', password); + + const response = await fetch(`${this.baseUrl}/api/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: formData, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.detail || 'Login failed'); + } + + const data = await response.json(); + this.setToken(data.access_token); + return data; + } + + logout() { + this.clearToken(); + } + + async getCurrentUser() { + return this.get('/api/auth/me'); + } +} + +export const api = new ApiClient(API_URL);