Initial MVP skeleton with auth, chat persistence, UI and text LLM integration
This commit is contained in:
228
frontend/components/Layout.tsx
Normal file
228
frontend/components/Layout.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
|
||||
interface Chat {
|
||||
id: string;
|
||||
title: string;
|
||||
model_alias: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
login: string;
|
||||
}
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "http://127.0.0.1:18000";
|
||||
|
||||
// Global modal state since Sidebar and Topbar both might need logic, but let's keep it simple
|
||||
export default function Layout({ children, chats: initialChats, user }: { children: React.ReactNode, chats: Chat[], user: User }) {
|
||||
const [chats, setChats] = useState<Chat[]>(initialChats);
|
||||
const [models, setModels] = useState<{alias: string, name: string}[]>([]);
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [newChatTitle, setNewChatTitle] = useState("");
|
||||
const [newChatModel, setNewChatModel] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE_URL}/api/models`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setModels(data);
|
||||
if (data.length > 0) setNewChatModel(data[0].alias);
|
||||
})
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await fetch(`${API_BASE_URL}/api/auth/logout`, { method: "POST" });
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
const createChat = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newChatTitle.trim() || !newChatModel) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/chats`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ title: newChatTitle, model_alias: newChatModel }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const chat = await res.json();
|
||||
setChats([chat, ...chats]);
|
||||
setIsModalOpen(false);
|
||||
setNewChatTitle("");
|
||||
router.push(`/chat/${chat.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to create chat", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', height: '100vh', flexDirection: 'column', backgroundColor: 'var(--bg-color)' }}>
|
||||
{/* Top Bar */}
|
||||
<header style={{
|
||||
height: 56,
|
||||
backgroundColor: 'var(--panel-bg)',
|
||||
borderBottom: '1px solid var(--border-color)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 24px',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, fontSize: 18, fontWeight: 600, color: 'var(--primary)' }}>
|
||||
<Link href="/" style={{ color: 'inherit', textDecoration: 'none' }}>AI Chat MVP</Link>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<div style={{ fontSize: 14, color: 'var(--text-main)', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ width: 28, height: 28, borderRadius: '50%', backgroundColor: '#e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 600, color: 'var(--text-main)' }}>
|
||||
{user.login.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{user.login}
|
||||
</div>
|
||||
<button onClick={handleLogout} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 14, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
Выход
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Container */}
|
||||
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside style={{
|
||||
width: 'var(--sidebar-w)',
|
||||
backgroundColor: 'var(--panel-bg)',
|
||||
borderRight: '1px solid var(--border-color)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
zIndex: 10
|
||||
}}>
|
||||
<div style={{ padding: '16px 16px 8px 16px' }}>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 16px',
|
||||
backgroundColor: 'var(--bg-color)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '8px',
|
||||
color: 'var(--text-main)',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
transition: 'background 0.2s',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
onMouseOver={e => e.currentTarget.style.backgroundColor = '#e8eaed'}
|
||||
onMouseOut={e => e.currentTarget.style.backgroundColor = 'var(--bg-color)'}
|
||||
>
|
||||
<span style={{ fontSize: 18, color: 'var(--primary)', fontWeight: 300 }}>+</span> Новый чат
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '16px', fontSize: 13, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
Мои чаты
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{chats.map(chat => (
|
||||
<Link key={chat.id} href={`/chat/${chat.id}`} style={{
|
||||
display: 'block',
|
||||
padding: '12px 16px',
|
||||
fontSize: 14,
|
||||
textDecoration: 'none',
|
||||
color: 'var(--text-main)',
|
||||
backgroundColor: pathname === `/chat/${chat.id}` ? 'var(--bg-color)' : 'transparent',
|
||||
borderLeft: pathname === `/chat/${chat.id}` ? '3px solid var(--primary)' : '3px solid transparent',
|
||||
}}
|
||||
onMouseOver={e => { if (pathname !== `/chat/${chat.id}`) e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.02)' }}
|
||||
onMouseOut={e => { if (pathname !== `/chat/${chat.id}`) e.currentTarget.style.backgroundColor = 'transparent' }}
|
||||
>
|
||||
<div style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: pathname === `/chat/${chat.id}` ? 500 : 400 }}>
|
||||
{chat.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>
|
||||
{chat.model_alias}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', position: 'relative' }}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* New Chat Modal */}
|
||||
{isModalOpen && (
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.4)', zIndex: 100,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: '#fff', borderRadius: 16, width: '100%', maxWidth: 480,
|
||||
boxShadow: '0 10px 40px rgba(0,0,0,0.1)', padding: 32
|
||||
}}>
|
||||
<h2 style={{ fontSize: 20, margin: '0 0 24px 0', fontWeight: 600 }}>Новый чат</h2>
|
||||
<form onSubmit={createChat} style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 14, marginBottom: 8, color: 'var(--text-main)', fontWeight: 500 }}>Название чата</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newChatTitle}
|
||||
onChange={e => setNewChatTitle(e.target.value)}
|
||||
placeholder="Введите название..."
|
||||
required
|
||||
style={{ width: '100%', padding: '10px 14px', borderRadius: 8, border: '1px solid var(--border-color)', fontSize: 14, outline: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 14, marginBottom: 8, color: 'var(--text-main)', fontWeight: 500 }}>Модель</label>
|
||||
<select
|
||||
value={newChatModel}
|
||||
onChange={e => setNewChatModel(e.target.value)}
|
||||
style={{ width: '100%', padding: '10px 14px', borderRadius: 8, border: '1px solid var(--border-color)', fontSize: 14, outline: 'none', backgroundColor: '#fff' }}
|
||||
>
|
||||
{models.map(m => (
|
||||
<option key={m.alias} value={m.alias}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 12, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
style={{ padding: '10px 20px', backgroundColor: 'transparent', border: '1px solid var(--border-color)', borderRadius: 8, fontSize: 14, fontWeight: 500, color: 'var(--text-main)' }}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
style={{ padding: '10px 20px', backgroundColor: 'var(--primary)', border: 'none', borderRadius: 8, fontSize: 14, fontWeight: 500, color: '#fff' }}
|
||||
>
|
||||
Создать чат
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user