Phase 1: Foundation — backend auth, frontend shell, Docker setup

Backend (FastAPI):
- App factory with async SQLAlchemy 2.0 + PostgreSQL
- Alembic migration for users and sessions tables
- JWT auth (access + refresh tokens, bcrypt passwords)
- Auth endpoints: register, login, refresh, logout, me
- Admin seed script, role-based access deps

Frontend (React + TypeScript):
- Vite + Tailwind CSS + shadcn/ui theme (health-oriented palette)
- i18n with English and Russian translations
- Zustand auth/UI stores with localStorage persistence
- Axios client with automatic token refresh on 401
- Login/register pages, protected routing
- App layout: collapsible sidebar, header with theme/language toggles
- Dashboard with placeholder stats

Infrastructure:
- Docker Compose (postgres, backend, frontend, nginx)
- Nginx reverse proxy with WebSocket support
- Dev override with hot reload

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 12:25:02 +03:00
parent 5bdc296172
commit 7c752cae6b
75 changed files with 7706 additions and 2 deletions

13
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM node:20-alpine AS dev
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
FROM dev AS build
RUN npm run build
FROM nginx:1.25-alpine AS production
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

16
frontend/components.json Normal file
View File

@@ -0,0 +1,16 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Assistant</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

11
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,11 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
}

4961
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
frontend/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "ai-assistant-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"axios": "^1.7.0",
"zustand": "^5.0.0",
"@tanstack/react-query": "^5.60.0",
"i18next": "^24.0.0",
"react-i18next": "^15.1.0",
"i18next-http-backend": "^3.0.0",
"i18next-browser-languagedetector": "^8.0.0",
"lucide-react": "^0.460.0",
"clsx": "^2.1.0",
"tailwind-merge": "^2.6.0",
"class-variance-authority": "^0.7.1",
"sonner": "^1.7.0"
},
"devDependencies": {
"vite": "^6.0.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.6.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"tailwindcss": "^3.4.0",
"postcss": "^8.4.0",
"autoprefixer": "^10.4.0"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,57 @@
{
"auth": {
"login": "Log In",
"register": "Sign Up",
"email": "Email",
"password": "Password",
"confirmPassword": "Confirm Password",
"username": "Username",
"fullName": "Full Name",
"rememberMe": "Remember me",
"submit": "Submit",
"noAccount": "Don't have an account?",
"hasAccount": "Already have an account?",
"loginTitle": "Welcome Back",
"loginSubtitle": "Sign in to your account",
"registerTitle": "Create Account",
"registerSubtitle": "Get started with your personal AI assistant",
"errors": {
"invalidCredentials": "Invalid email or password",
"emailExists": "User with this email or username already exists",
"passwordMismatch": "Passwords do not match",
"passwordMinLength": "Password must be at least 8 characters",
"usernameFormat": "Username must be 3-50 characters (letters, numbers, _ or -)",
"required": "This field is required"
}
},
"layout": {
"dashboard": "Dashboard",
"chats": "Chats",
"documents": "Documents",
"memory": "Memory",
"notifications": "Notifications",
"profile": "Profile",
"logout": "Log Out",
"settings": "Settings",
"admin": "Admin",
"users": "Users",
"context": "Context",
"skills": "Skills"
},
"dashboard": {
"welcome": "Welcome, {{name}}",
"subtitle": "Your personal AI health assistant"
},
"common": {
"loading": "Loading...",
"error": "An error occurred",
"notFound": "Page not found",
"goHome": "Go to Dashboard",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"create": "Create",
"search": "Search"
}
}

View File

@@ -0,0 +1,57 @@
{
"auth": {
"login": "Войти",
"register": "Регистрация",
"email": "Email",
"password": "Пароль",
"confirmPassword": "Подтвердите пароль",
"username": "Имя пользователя",
"fullName": "Полное имя",
"rememberMe": "Запомнить меня",
"submit": "Отправить",
"noAccount": "Нет аккаунта?",
"hasAccount": "Уже есть аккаунт?",
"loginTitle": "С возвращением",
"loginSubtitle": "Войдите в свой аккаунт",
"registerTitle": "Создать аккаунт",
"registerSubtitle": "Начните работу с персональным ИИ-ассистентом",
"errors": {
"invalidCredentials": "Неверный email или пароль",
"emailExists": "Пользователь с таким email или именем уже существует",
"passwordMismatch": "Пароли не совпадают",
"passwordMinLength": "Пароль должен содержать минимум 8 символов",
"usernameFormat": "Имя пользователя: 3-50 символов (буквы, цифры, _ или -)",
"required": "Это поле обязательно"
}
},
"layout": {
"dashboard": "Главная",
"chats": "Чаты",
"documents": "Документы",
"memory": "Память",
"notifications": "Уведомления",
"profile": "Профиль",
"logout": "Выйти",
"settings": "Настройки",
"admin": "Администрирование",
"users": "Пользователи",
"context": "Контекст",
"skills": "Навыки"
},
"dashboard": {
"welcome": "Добро пожаловать, {{name}}",
"subtitle": "Ваш персональный ИИ-ассистент по здоровью"
},
"common": {
"loading": "Загрузка...",
"error": "Произошла ошибка",
"notFound": "Страница не найдена",
"goHome": "На главную",
"save": "Сохранить",
"cancel": "Отмена",
"delete": "Удалить",
"edit": "Редактировать",
"create": "Создать",
"search": "Поиск"
}
}

19
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { Suspense } from "react";
import { RouterProvider } from "react-router-dom";
import { QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "@/components/shared/theme-provider";
import { queryClient } from "@/lib/query-client";
import { router } from "@/routes";
import "@/i18n";
export function App() {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<Suspense fallback={<div className="flex h-screen items-center justify-center">Loading...</div>}>
<RouterProvider router={router} />
</Suspense>
</ThemeProvider>
</QueryClientProvider>
);
}

68
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,68 @@
import api from "./client";
export interface UserResponse {
id: string;
email: string;
username: string;
full_name: string | null;
role: string;
is_active: boolean;
created_at: string;
}
export interface AuthResponse {
user: UserResponse;
access_token: string;
refresh_token: string;
token_type: string;
}
export interface TokenResponse {
access_token: string;
refresh_token: string;
token_type: string;
}
export async function login(
email: string,
password: string,
remember_me: boolean
): Promise<AuthResponse> {
const { data } = await api.post<AuthResponse>("/auth/login", {
email,
password,
remember_me,
});
return data;
}
export async function register(
email: string,
username: string,
password: string,
full_name?: string
): Promise<AuthResponse> {
const { data } = await api.post<AuthResponse>("/auth/register", {
email,
username,
password,
full_name: full_name || undefined,
});
return data;
}
export async function refresh(refreshToken: string): Promise<TokenResponse> {
const { data } = await api.post<TokenResponse>("/auth/refresh", {
refresh_token: refreshToken,
});
return data;
}
export async function logout(refreshToken: string): Promise<void> {
await api.post("/auth/logout", { refresh_token: refreshToken });
}
export async function getMe(): Promise<UserResponse> {
const { data } = await api.get<UserResponse>("/auth/me");
return data;
}

View File

@@ -0,0 +1,83 @@
import axios from "axios";
import { useAuthStore } from "@/stores/auth-store";
const api = axios.create({
baseURL: "/api/v1",
headers: { "Content-Type": "application/json" },
});
// Attach access token
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().accessToken;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Auto-refresh on 401
let isRefreshing = false;
let failedQueue: Array<{
resolve: (token: string) => void;
reject: (error: unknown) => void;
}> = [];
function processQueue(error: unknown, token: string | null) {
failedQueue.forEach(({ resolve, reject }) => {
if (error) reject(error);
else resolve(token!);
});
failedQueue = [];
}
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status !== 401 || originalRequest._retry) {
return Promise.reject(error);
}
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({
resolve: (token: string) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
resolve(api(originalRequest));
},
reject,
});
});
}
originalRequest._retry = true;
isRefreshing = true;
const refreshToken = useAuthStore.getState().refreshToken;
if (!refreshToken) {
useAuthStore.getState().clearAuth();
window.location.href = "/login";
return Promise.reject(error);
}
try {
const { data } = await axios.post("/api/v1/auth/refresh", {
refresh_token: refreshToken,
});
useAuthStore.getState().setTokens(data.access_token, data.refresh_token);
processQueue(null, data.access_token);
originalRequest.headers.Authorization = `Bearer ${data.access_token}`;
return api(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
useAuthStore.getState().clearAuth();
window.location.href = "/login";
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
);
export default api;

View File

@@ -0,0 +1,103 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { useAuthStore } from "@/stores/auth-store";
import { login } from "@/api/auth";
export function LoginForm() {
const { t } = useTranslation();
const navigate = useNavigate();
const setAuth = useAuthStore((s) => s.setAuth);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const data = await login(email, password, rememberMe);
setAuth(data.user, data.access_token, data.refresh_token);
navigate("/");
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ||
t("auth.errors.invalidCredentials");
setError(msg);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium">
{t("auth.email")}
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
placeholder="name@example.com"
/>
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium">
{t("auth.password")}
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="flex items-center gap-2">
<input
id="remember"
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
<label htmlFor="remember" className="text-sm text-muted-foreground">
{t("auth.rememberMe")}
</label>
</div>
<button
type="submit"
disabled={loading}
className="inline-flex h-10 w-full items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{loading ? t("common.loading") : t("auth.login")}
</button>
<p className="text-center text-sm text-muted-foreground">
{t("auth.noAccount")}{" "}
<Link to="/register" className="text-primary hover:underline">
{t("auth.register")}
</Link>
</p>
</form>
);
}

View File

@@ -0,0 +1,150 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { useAuthStore } from "@/stores/auth-store";
import { register } from "@/api/auth";
export function RegisterForm() {
const { t } = useTranslation();
const navigate = useNavigate();
const setAuth = useAuthStore((s) => s.setAuth);
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [fullName, setFullName] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (password.length < 8) {
setError(t("auth.errors.passwordMinLength"));
return;
}
if (password !== confirmPassword) {
setError(t("auth.errors.passwordMismatch"));
return;
}
if (!/^[a-zA-Z0-9_-]{3,50}$/.test(username)) {
setError(t("auth.errors.usernameFormat"));
return;
}
setLoading(true);
try {
const data = await register(email, username, password, fullName || undefined);
setAuth(data.user, data.access_token, data.refresh_token);
navigate("/");
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ||
t("auth.errors.emailExists");
setError(msg);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium">
{t("auth.email")}
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
placeholder="name@example.com"
/>
</div>
<div className="space-y-2">
<label htmlFor="username" className="text-sm font-medium">
{t("auth.username")}
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="space-y-2">
<label htmlFor="fullName" className="text-sm font-medium">
{t("auth.fullName")}
</label>
<input
id="fullName"
type="text"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium">
{t("auth.password")}
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="space-y-2">
<label htmlFor="confirmPassword" className="text-sm font-medium">
{t("auth.confirmPassword")}
</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<button
type="submit"
disabled={loading}
className="inline-flex h-10 w-full items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{loading ? t("common.loading") : t("auth.register")}
</button>
<p className="text-center text-sm text-muted-foreground">
{t("auth.hasAccount")}{" "}
<Link to="/login" className="text-primary hover:underline">
{t("auth.login")}
</Link>
</p>
</form>
);
}

View File

@@ -0,0 +1,17 @@
import { Outlet } from "react-router-dom";
import { Sidebar } from "./sidebar";
import { Header } from "./header";
export function AppLayout() {
return (
<div className="flex h-screen overflow-hidden">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto p-6">
<Outlet />
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Menu, Sun, Moon, LogOut, User } from "lucide-react";
import { useAuthStore } from "@/stores/auth-store";
import { useUIStore } from "@/stores/ui-store";
import { LanguageToggle } from "@/components/shared/language-toggle";
import { logout as logoutApi } from "@/api/auth";
export function Header() {
const { t } = useTranslation();
const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
const { refreshToken, clearAuth } = useAuthStore();
const { theme, setTheme, toggleSidebar } = useUIStore();
const handleLogout = async () => {
try {
if (refreshToken) await logoutApi(refreshToken);
} finally {
clearAuth();
navigate("/login");
}
};
const toggleTheme = () => {
if (theme === "light") setTheme("dark");
else if (theme === "dark") setTheme("system");
else setTheme("light");
};
return (
<header className="flex h-14 items-center gap-4 border-b bg-card px-4">
<button
onClick={toggleSidebar}
className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<Menu className="h-5 w-5" />
</button>
<div className="flex-1" />
<LanguageToggle />
<button
onClick={toggleTheme}
className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
title={`Theme: ${theme}`}
>
{theme === "dark" ? <Moon className="h-5 w-5" /> : <Sun className="h-5 w-5" />}
</button>
<div className="flex items-center gap-2 border-l pl-4">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary">
<User className="h-4 w-4" />
</div>
<span className="hidden text-sm font-medium sm:block">
{user?.full_name || user?.username}
</span>
<button
onClick={handleLogout}
className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-destructive transition-colors"
title={t("layout.logout")}
>
<LogOut className="h-4 w-4" />
</button>
</div>
</header>
);
}

View File

@@ -0,0 +1,99 @@
import { useTranslation } from "react-i18next";
import { NavLink } from "react-router-dom";
import {
LayoutDashboard,
MessageSquare,
FileText,
Brain,
Bell,
Shield,
} from "lucide-react";
import { useAuthStore } from "@/stores/auth-store";
import { useUIStore } from "@/stores/ui-store";
import { cn } from "@/lib/utils";
const navItems = [
{ key: "dashboard", to: "/", icon: LayoutDashboard, enabled: true },
{ key: "chats", to: "/chat", icon: MessageSquare, enabled: false },
{ key: "documents", to: "/documents", icon: FileText, enabled: false },
{ key: "memory", to: "/memory", icon: Brain, enabled: false },
{ key: "notifications", to: "/notifications", icon: Bell, enabled: false },
];
export function Sidebar() {
const { t } = useTranslation();
const user = useAuthStore((s) => s.user);
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
return (
<aside
className={cn(
"flex h-full flex-col border-r bg-card transition-all duration-300",
sidebarOpen ? "w-60" : "w-16"
)}
>
<div className="flex h-14 items-center border-b px-4">
{sidebarOpen && (
<span className="text-lg font-semibold text-primary">AI Assistant</span>
)}
{!sidebarOpen && (
<span className="text-lg font-semibold text-primary mx-auto">AI</span>
)}
</div>
<nav className="flex-1 space-y-1 p-2">
{navItems.map((item) => {
const Icon = item.icon;
if (!item.enabled) {
return (
<div
key={item.key}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-muted-foreground/50 cursor-not-allowed",
!sidebarOpen && "justify-center px-2"
)}
>
<Icon className="h-5 w-5 shrink-0" />
{sidebarOpen && <span>{t(`layout.${item.key}`)}</span>}
</div>
);
}
return (
<NavLink
key={item.key}
to={item.to}
end
className={({ isActive }) =>
cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-accent hover:text-foreground",
!sidebarOpen && "justify-center px-2"
)
}
>
<Icon className="h-5 w-5 shrink-0" />
{sidebarOpen && <span>{t(`layout.${item.key}`)}</span>}
</NavLink>
);
})}
</nav>
{user?.role === "admin" && (
<div className="border-t p-2">
<div
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-muted-foreground/50 cursor-not-allowed",
!sidebarOpen && "justify-center px-2"
)}
>
<Shield className="h-5 w-5 shrink-0" />
{sidebarOpen && <span>{t("layout.admin")}</span>}
</div>
</div>
)}
</aside>
);
}

View File

@@ -0,0 +1,22 @@
import { useTranslation } from "react-i18next";
import { Languages } from "lucide-react";
export function LanguageToggle() {
const { i18n } = useTranslation();
const toggle = () => {
const next = i18n.language === "ru" ? "en" : "ru";
i18n.changeLanguage(next);
};
return (
<button
onClick={toggle}
className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
title={i18n.language === "ru" ? "Switch to English" : "Переключить на русский"}
>
<Languages className="h-4 w-4" />
<span className="uppercase">{i18n.language === "ru" ? "en" : "ru"}</span>
</button>
);
}

View File

@@ -0,0 +1,12 @@
import { Navigate, Outlet } from "react-router-dom";
import { useAuthStore } from "@/stores/auth-store";
export function ProtectedRoute() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <Outlet />;
}

View File

@@ -0,0 +1,20 @@
import { useEffect } from "react";
import { useUIStore } from "@/stores/ui-store";
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const theme = useUIStore((s) => s.theme);
useEffect(() => {
const root = document.documentElement;
root.classList.remove("light", "dark");
if (theme === "system") {
const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
root.classList.add(systemDark ? "dark" : "light");
} else {
root.classList.add(theme);
}
}, [theme]);
return <>{children}</>;
}

25
frontend/src/i18n.ts Normal file
View File

@@ -0,0 +1,25 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import HttpBackend from "i18next-http-backend";
import LanguageDetector from "i18next-browser-languagedetector";
i18n
.use(HttpBackend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: "en",
supportedLngs: ["en", "ru"],
interpolation: {
escapeValue: false,
},
backend: {
loadPath: "/locales/{{lng}}/translation.json",
},
detection: {
order: ["localStorage", "navigator"],
caches: ["localStorage"],
},
});
export default i18n;

59
frontend/src/index.css Normal file
View File

@@ -0,0 +1,59 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 210 40% 98%;
--foreground: 222 47% 11%;
--card: 0 0% 100%;
--card-foreground: 222 47% 11%;
--popover: 0 0% 100%;
--popover-foreground: 222 47% 11%;
--primary: 198 70% 40%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96%;
--secondary-foreground: 222 47% 11%;
--muted: 210 40% 96%;
--muted-foreground: 215 16% 47%;
--accent: 210 40% 96%;
--accent-foreground: 222 47% 11%;
--destructive: 0 84% 60%;
--destructive-foreground: 210 40% 98%;
--border: 214 32% 91%;
--input: 214 32% 91%;
--ring: 198 70% 40%;
--radius: 0.75rem;
}
.dark {
--background: 222 47% 11%;
--foreground: 210 40% 98%;
--card: 222 47% 13%;
--card-foreground: 210 40% 98%;
--popover: 222 47% 13%;
--popover-foreground: 210 40% 98%;
--primary: 198 70% 50%;
--primary-foreground: 222 47% 11%;
--secondary: 217 33% 17%;
--secondary-foreground: 210 40% 98%;
--muted: 217 33% 17%;
--muted-foreground: 215 20% 65%;
--accent: 217 33% 17%;
--accent-foreground: 210 40% 98%;
--destructive: 0 63% 31%;
--destructive-foreground: 210 40% 98%;
--border: 217 33% 17%;
--input: 217 33% 17%;
--ring: 198 70% 50%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,11 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
},
},
});

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,33 @@
import { useTranslation } from "react-i18next";
import { useAuthStore } from "@/stores/auth-store";
export function DashboardPage() {
const { t } = useTranslation();
const user = useAuthStore((s) => s.user);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{t("dashboard.welcome", { name: user?.full_name || user?.username })}
</h1>
<p className="text-muted-foreground">{t("dashboard.subtitle")}</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div className="rounded-xl border bg-card p-6 shadow-sm">
<h3 className="text-sm font-medium text-muted-foreground">{t("layout.chats")}</h3>
<p className="mt-2 text-3xl font-bold">0</p>
</div>
<div className="rounded-xl border bg-card p-6 shadow-sm">
<h3 className="text-sm font-medium text-muted-foreground">{t("layout.documents")}</h3>
<p className="mt-2 text-3xl font-bold">0</p>
</div>
<div className="rounded-xl border bg-card p-6 shadow-sm">
<h3 className="text-sm font-medium text-muted-foreground">{t("layout.notifications")}</h3>
<p className="mt-2 text-3xl font-bold">0</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { Navigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useAuthStore } from "@/stores/auth-store";
import { LoginForm } from "@/components/auth/login-form";
import { LanguageToggle } from "@/components/shared/language-toggle";
export function LoginPage() {
const { t } = useTranslation();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
if (isAuthenticated) return <Navigate to="/" replace />;
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="absolute right-4 top-4">
<LanguageToggle />
</div>
<div className="w-full max-w-md space-y-6 rounded-xl border bg-card p-8 shadow-sm">
<div className="space-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">{t("auth.loginTitle")}</h1>
<p className="text-sm text-muted-foreground">{t("auth.loginSubtitle")}</p>
</div>
<LoginForm />
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
export function NotFoundPage() {
const { t } = useTranslation();
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-background">
<h1 className="text-6xl font-bold text-muted-foreground">404</h1>
<p className="mt-4 text-lg text-muted-foreground">{t("common.notFound")}</p>
<Link
to="/"
className="mt-6 inline-flex h-10 items-center justify-center rounded-md bg-primary px-6 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
{t("common.goHome")}
</Link>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { Navigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useAuthStore } from "@/stores/auth-store";
import { RegisterForm } from "@/components/auth/register-form";
import { LanguageToggle } from "@/components/shared/language-toggle";
export function RegisterPage() {
const { t } = useTranslation();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
if (isAuthenticated) return <Navigate to="/" replace />;
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="absolute right-4 top-4">
<LanguageToggle />
</div>
<div className="w-full max-w-md space-y-6 rounded-xl border bg-card p-8 shadow-sm">
<div className="space-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">{t("auth.registerTitle")}</h1>
<p className="text-sm text-muted-foreground">{t("auth.registerSubtitle")}</p>
</div>
<RegisterForm />
</div>
</div>
);
}

33
frontend/src/routes.tsx Normal file
View File

@@ -0,0 +1,33 @@
import { createBrowserRouter } from "react-router-dom";
import { ProtectedRoute } from "@/components/shared/protected-route";
import { AppLayout } from "@/components/layout/app-layout";
import { LoginPage } from "@/pages/login";
import { RegisterPage } from "@/pages/register";
import { DashboardPage } from "@/pages/dashboard";
import { NotFoundPage } from "@/pages/not-found";
export const router = createBrowserRouter([
{
path: "/login",
element: <LoginPage />,
},
{
path: "/register",
element: <RegisterPage />,
},
{
element: <ProtectedRoute />,
children: [
{
element: <AppLayout />,
children: [
{ index: true, element: <DashboardPage /> },
],
},
],
},
{
path: "*",
element: <NotFoundPage />,
},
]);

View File

@@ -0,0 +1,41 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { UserResponse } from "@/api/auth";
interface AuthState {
user: UserResponse | null;
accessToken: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
setAuth: (user: UserResponse, accessToken: string, refreshToken: string) => void;
setTokens: (accessToken: string, refreshToken: string) => void;
setUser: (user: UserResponse) => void;
clearAuth: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
setAuth: (user, accessToken, refreshToken) =>
set({ user, accessToken, refreshToken, isAuthenticated: true }),
setTokens: (accessToken, refreshToken) =>
set({ accessToken, refreshToken }),
setUser: (user) => set({ user }),
clearAuth: () =>
set({ user: null, accessToken: null, refreshToken: null, isAuthenticated: false }),
}),
{
name: "auth-storage",
partialize: (state) => ({
user: state.user,
accessToken: state.accessToken,
refreshToken: state.refreshToken,
isAuthenticated: state.isAuthenticated,
}),
}
)
);

View File

@@ -0,0 +1,28 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
type Theme = "light" | "dark" | "system";
interface UIState {
sidebarOpen: boolean;
theme: Theme;
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
setTheme: (theme: Theme) => void;
}
export const useUIStore = create<UIState>()(
persist(
(set) => ({
sidebarOpen: true,
theme: "system",
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
setSidebarOpen: (open) => set({ sidebarOpen: open }),
setTheme: (theme) => set({ theme }),
}),
{
name: "ui-storage",
partialize: (state) => ({ theme: state.theme }),
}
)
);

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,51 @@
import type { Config } from "tailwindcss";
export default {
darkMode: "class",
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [],
} satisfies Config;

24
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src", "vite.config.ts"]
}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"composite": true,
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

24
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,24 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
proxy: {
"/api": {
target: "http://localhost:8000",
changeOrigin: true,
},
"/ws": {
target: "http://localhost:8000",
ws: true,
},
},
},
});