From 106c82bdf40560e5201b9a48f35fc3a22b8e0ef8 Mon Sep 17 00:00:00 2001 From: nekenny Date: Sun, 5 Apr 2026 16:19:55 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20frontend=20setup=20=E2=80=94=20React,?= =?UTF-8?q?=20TypeScript,=20Tailwind,=20auth,=20layout,=20routing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- frontend/Dockerfile | 9 +++ frontend/index.html | 12 ++++ frontend/package.json | 28 +++++++++ frontend/postcss.config.js | 6 ++ frontend/src/App.tsx | 32 ++++++++++ frontend/src/api/client.ts | 24 ++++++++ frontend/src/components/Layout.tsx | 37 ++++++++++++ frontend/src/components/ProtectedRoute.tsx | 10 ++++ frontend/src/hooks/useAuth.ts | 31 ++++++++++ frontend/src/hooks/useProtocols.ts | 34 +++++++++++ frontend/src/hooks/useRecordings.ts | 37 ++++++++++++ frontend/src/hooks/useTemplates.ts | 36 +++++++++++ frontend/src/index.css | 3 + frontend/src/main.tsx | 10 ++++ frontend/src/pages/LoginPage.tsx | 50 ++++++++++++++++ frontend/src/types/index.ts | 70 ++++++++++++++++++++++ frontend/tailwind.config.js | 6 ++ frontend/tsconfig.json | 21 +++++++ frontend/vite.config.ts | 11 ++++ 19 files changed, 467 insertions(+) create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/components/Layout.tsx create mode 100644 frontend/src/components/ProtectedRoute.tsx create mode 100644 frontend/src/hooks/useAuth.ts create mode 100644 frontend/src/hooks/useProtocols.ts create mode 100644 frontend/src/hooks/useRecordings.ts create mode 100644 frontend/src/hooks/useTemplates.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/LoginPage.tsx create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..000d01a --- /dev/null +++ b/frontend/Dockerfile @@ -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 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..bb9f5b3 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Meeting Protocol Service + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..21fff0e --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..04df0e6 --- /dev/null +++ b/frontend/src/App.tsx @@ -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 ( + + + + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ) +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..43d89e6 --- /dev/null +++ b/frontend/src/api/client.ts @@ -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 diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..ccbf810 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -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({ + queryKey: ['storage'], + queryFn: () => api.get('/settings/storage').then((r) => r.data), + }) + + return ( +
+ +
+ +
+
+ ) +} diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..704a630 --- /dev/null +++ b/frontend/src/components/ProtectedRoute.tsx @@ -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
Загрузка...
+ if (!user) return + return <>{children} +} diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts new file mode 100644 index 0000000..0b43e06 --- /dev/null +++ b/frontend/src/hooks/useAuth.ts @@ -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({ + 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 } +} diff --git a/frontend/src/hooks/useProtocols.ts b/frontend/src/hooks/useProtocols.ts new file mode 100644 index 0000000..90afe34 --- /dev/null +++ b/frontend/src/hooks/useProtocols.ts @@ -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({ + queryKey: ['protocol', id], + queryFn: () => api.get(`/protocols/${id}`).then((r) => r.data), + refetchInterval: 3000, + }) +} diff --git a/frontend/src/hooks/useRecordings.ts b/frontend/src/hooks/useRecordings.ts new file mode 100644 index 0000000..0c14bd1 --- /dev/null +++ b/frontend/src/hooks/useRecordings.ts @@ -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, + } +} diff --git a/frontend/src/hooks/useTemplates.ts b/frontend/src/hooks/useTemplates.ts new file mode 100644 index 0000000..edc3054 --- /dev/null +++ b/frontend/src/hooks/useTemplates.ts @@ -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({ + queryKey: ['templates'], + queryFn: () => api.get('/templates').then((r) => r.data), + }) + + const createMutation = useMutation({ + mutationFn: (data: Partial) => api.post('/templates', data), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['templates'] }), + }) + + const updateMutation = useMutation({ + mutationFn: ({ id, ...data }: Partial & { 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, + } +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..964aeb4 --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + , +) diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..8fa7212 --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -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 ( +
+
+

Meeting Protocol Service

+ {error &&

{error}

} + setUsername(e.target.value)} + className="w-full border rounded px-3 py-2 mb-4" + required + /> + setPassword(e.target.value)} + className="w-full border rounded px-3 py-2 mb-4" + required + /> + +
+
+ ) +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..17d8569 --- /dev/null +++ b/frontend/src/types/index.ts @@ -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 + 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 + 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 +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..a72b2f1 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,6 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], + theme: { extend: {} }, + plugins: [], +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..20fb0a0 --- /dev/null +++ b/frontend/tsconfig.json @@ -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"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..69163ad --- /dev/null +++ b/frontend/vite.config.ts @@ -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', + }, + }, +})