feat: frontend setup — React, TypeScript, Tailwind, auth, layout, routing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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}</>
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: { extend: {} },
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user