feat: frontend setup — React, TypeScript, Tailwind, auth, layout, routing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-05 16:19:55 +05:00
parent bfe52bb9f8
commit 106c82bdf4
19 changed files with 467 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Meeting Protocol Service</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
{
"name": "meeting-protocol-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.62.0",
"axios": "^1.7.9",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"typescript": "^5.7.2",
"vite": "^6.0.3"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+32
View File
@@ -0,0 +1,32 @@
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import Layout from './components/Layout'
import ProtectedRoute from './components/ProtectedRoute'
import LoginPage from './pages/LoginPage'
import RecordingsPage from './pages/RecordingsPage'
import ProtocolsPage from './pages/ProtocolsPage'
import ProtocolDetailPage from './pages/ProtocolDetailPage'
import TemplatesPage from './pages/TemplatesPage'
import SettingsPage from './pages/SettingsPage'
const queryClient = new QueryClient()
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<ProtectedRoute><Layout /></ProtectedRoute>}>
<Route path="/" element={<RecordingsPage />} />
<Route path="/recordings" element={<RecordingsPage />} />
<Route path="/protocols" element={<ProtocolsPage />} />
<Route path="/protocols/:id" element={<ProtocolDetailPage />} />
<Route path="/templates" element={<TemplatesPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
</Routes>
</BrowserRouter>
</QueryClientProvider>
)
}
+24
View File
@@ -0,0 +1,24 @@
import axios from 'axios'
const api = axios.create({ baseURL: '/api' })
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
}
return Promise.reject(error)
},
)
export default api
+37
View File
@@ -0,0 +1,37 @@
import { Link, Outlet } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { useAuth } from '../hooks/useAuth'
import api from '../api/client'
import type { StorageInfo } from '../types'
export default function Layout() {
const { user, logout } = useAuth()
const { data: storage } = useQuery<StorageInfo>({
queryKey: ['storage'],
queryFn: () => api.get('/settings/storage').then((r) => r.data),
})
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-gray-900 text-white px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-6">
<Link to="/" className="text-blue-400 font-bold text-lg">MeetingProto</Link>
<Link to="/recordings" className="hover:text-blue-300">Записи</Link>
<Link to="/protocols" className="hover:text-blue-300">Протоколы</Link>
<Link to="/templates" className="hover:text-blue-300">Шаблоны</Link>
<Link to="/settings" className="text-gray-400 hover:text-blue-300">Настройки</Link>
</div>
<div className="flex items-center gap-4 text-sm">
{storage && (
<span className="text-gray-400">Занято: {storage.total_gb} ГБ</span>
)}
<span>{user?.username}</span>
<button onClick={logout} className="text-gray-400 hover:text-white">Выйти</button>
</div>
</nav>
<main className="max-w-7xl mx-auto px-6 py-8">
<Outlet />
</main>
</div>
)
}
@@ -0,0 +1,10 @@
import { Navigate } from 'react-router-dom'
import { useAuth } from '../hooks/useAuth'
export default function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, isLoading } = useAuth()
if (isLoading) return <div className="p-8 text-center">Загрузка...</div>
if (!user) return <Navigate to="/login" replace />
return <>{children}</>
}
+31
View File
@@ -0,0 +1,31 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import api from '../api/client'
import type { User } from '../types'
export function useAuth() {
const queryClient = useQueryClient()
const { data: user, isLoading } = useQuery<User>({
queryKey: ['me'],
queryFn: () => api.get('/auth/me').then((r) => r.data),
retry: false,
enabled: !!localStorage.getItem('token'),
})
const loginMutation = useMutation({
mutationFn: (data: { username: string; password: string }) =>
api.post('/auth/login', data).then((r) => r.data),
onSuccess: (data) => {
localStorage.setItem('token', data.access_token)
queryClient.invalidateQueries({ queryKey: ['me'] })
},
})
const logout = () => {
localStorage.removeItem('token')
queryClient.clear()
window.location.href = '/login'
}
return { user, isLoading, login: loginMutation.mutateAsync, loginError: loginMutation.error, logout }
}
+34
View File
@@ -0,0 +1,34 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import api from '../api/client'
import type { Protocol } from '../types'
export function useProtocols() {
const queryClient = useQueryClient()
const { data, isLoading } = useQuery<{ items: Protocol[]; total: number }>({
queryKey: ['protocols'],
queryFn: () => api.get('/protocols').then((r) => r.data),
refetchInterval: 5000,
})
const regenerateMutation = useMutation({
mutationFn: ({ id, templateId }: { id: string; templateId?: string }) =>
api.post(`/protocols/${id}/regenerate`, { template_id: templateId }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['protocols'] }),
})
return {
protocols: data?.items ?? [],
total: data?.total ?? 0,
isLoading,
regenerate: regenerateMutation.mutateAsync,
}
}
export function useProtocol(id: string) {
return useQuery<Protocol>({
queryKey: ['protocol', id],
queryFn: () => api.get(`/protocols/${id}`).then((r) => r.data),
refetchInterval: 3000,
})
}
+37
View File
@@ -0,0 +1,37 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import api from '../api/client'
import type { Recording } from '../types'
export function useRecordings() {
const queryClient = useQueryClient()
const { data, isLoading } = useQuery<{ items: Recording[]; total: number }>({
queryKey: ['recordings'],
queryFn: () => api.get('/recordings').then((r) => r.data),
refetchInterval: 5000,
})
const uploadMutation = useMutation({
mutationFn: ({ file, title }: { file: File; title: string }) => {
const form = new FormData()
form.append('file', file)
form.append('title', title)
return api.post('/recordings/upload', form)
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['recordings'] }),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/recordings/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['recordings'] }),
})
return {
recordings: data?.items ?? [],
total: data?.total ?? 0,
isLoading,
upload: uploadMutation.mutateAsync,
isUploading: uploadMutation.isPending,
deleteRecording: deleteMutation.mutateAsync,
}
}
+36
View File
@@ -0,0 +1,36 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import api from '../api/client'
import type { PromptTemplate } from '../types'
export function useTemplates() {
const queryClient = useQueryClient()
const { data, isLoading } = useQuery<PromptTemplate[]>({
queryKey: ['templates'],
queryFn: () => api.get('/templates').then((r) => r.data),
})
const createMutation = useMutation({
mutationFn: (data: Partial<PromptTemplate>) => api.post('/templates', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['templates'] }),
})
const updateMutation = useMutation({
mutationFn: ({ id, ...data }: Partial<PromptTemplate> & { id: string }) =>
api.put(`/templates/${id}`, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['templates'] }),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/templates/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['templates'] }),
})
return {
templates: data ?? [],
isLoading,
createTemplate: createMutation.mutateAsync,
updateTemplate: updateMutation.mutateAsync,
deleteTemplate: deleteMutation.mutateAsync,
}
}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+10
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>,
)
+50
View File
@@ -0,0 +1,50 @@
import { FormEvent, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../hooks/useAuth'
export default function LoginPage() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const { login } = useAuth()
const navigate = useNavigate()
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
setError('')
try {
await login({ username, password })
navigate('/')
} catch {
setError('Неверный логин или пароль')
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<form onSubmit={handleSubmit} className="bg-white p-8 rounded-lg shadow-md w-96">
<h1 className="text-2xl font-bold mb-6 text-center">Meeting Protocol Service</h1>
{error && <p className="text-red-500 mb-4 text-sm">{error}</p>}
<input
type="text"
placeholder="Логин"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full border rounded px-3 py-2 mb-4"
required
/>
<input
type="password"
placeholder="Пароль"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full border rounded px-3 py-2 mb-4"
required
/>
<button type="submit" className="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700">
Войти
</button>
</form>
</div>
)
}
+70
View File
@@ -0,0 +1,70 @@
export interface User {
id: string
username: string
email: string
role: 'admin' | 'user'
is_active: boolean
created_at: string
}
export interface Recording {
id: string
user_id: string
title: string
file_size: number
duration: number | null
format: string
source: 'web' | 'telegram' | 'lensa'
status: 'uploaded' | 'processing' | 'done' | 'error'
created_at: string
}
export interface Transcription {
id: string
recording_id: string
text: string
segments: { start: number; end: number; text: string }[]
language: string | null
whisper_model: string
processing_time: number | null
created_at: string
}
export interface Protocol {
id: string
transcription_id: string
template_id: string
content: Record<string, unknown>
raw_text: string
llm_model: string
status: 'generating' | 'done' | 'error'
created_at: string
updated_at: string
}
export interface PromptTemplate {
id: string
name: string
description: string
type: 'client_survey' | 'client_intro' | 'internal' | 'custom'
system_prompt: string
user_prompt: string
output_schema: Record<string, unknown>
is_default: boolean
created_at: string
}
export interface ExportFile {
id: string
protocol_id: string
format: 'docx' | 'pdf' | 'md'
file_size: number
created_at: string
}
export interface StorageInfo {
uploads_bytes: number
exports_bytes: number
total_bytes: number
total_gb: number
}
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: { extend: {} },
plugins: [],
}
+21
View File
@@ -0,0 +1,21 @@
{
"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,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:8000',
},
},
})