feat(web): React-фронтенд вместо Flet + fix restore rmtree на bind-mount

web-react/: SPA Vite+React+TS, nginx раздаёт статику и проксирует /api на api:8000 по локалке. docker-compose: web(Flet)->web-react на :80. transfer_crud.backup_restore: чистим содержимое data/radar вместо rmtree точки монтирования (EBUSY).
This commit is contained in:
jze9
2026-06-02 13:56:44 +00:00
parent 71887b084a
commit eac61923f4
38 changed files with 4480 additions and 10 deletions

View File

@@ -20,27 +20,23 @@ services:
- REDIS_PORT=6379
- REDIS_DB=0
- REDIS_PASSWORD=CNXpuhMdxXHo7ZK8bhtXDvgXVZcjRn
# JWT — замените на длинную случайную строку (python -c "import secrets; print(secrets.token_hex(32))")
- SECRET_KEY=e8fe23297b4664a78ba8e8ebe34bac158398ac4d7dc6b664fd8d52b0094ee67b
# Ключ для admin-операций
- ADMIN_KEY=b00ac081344a99bf50dffeb942e7f4f1
# Логин и пароль для доступа к /docs и /redoc
- DOCS_USERNAME=admin
- DOCS_PASSWORD=2cbae90b3d9f6710cf51354f
volumes:
- ./data/radar:/app/data/radar
restart: unless-stopped
web:
web-react:
build:
context: ./web
dockerfile: ../Dockerfile.web
context: ./web-react
dockerfile: Dockerfile
ports:
- "80:80"
environment:
- API_URL=http://api:8000
depends_on:
- api
restart: unless-stopped
stats:
build:
context: .
@@ -51,3 +47,4 @@ services:
- API_URL=http://api:8000
depends_on:
- api
restart: unless-stopped

View File

@@ -309,7 +309,11 @@ def backup_restore(file: UploadFile = File(...)):
# 3. SVG-диаграммы: полностью очищаем каталог и восстанавливаем
svg_names = [n for n in zf.namelist() if n.startswith("radar_svgs/") and n != "radar_svgs/"]
if svg_names and _RADAR_DIR.exists():
shutil.rmtree(_RADAR_DIR)
for child in _RADAR_DIR.iterdir():
if child.is_dir():
shutil.rmtree(child)
else:
child.unlink()
_RADAR_DIR.mkdir(parents=True, exist_ok=True)
svg_written = 0

6
web-react/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
.vite
*.local
Dockerfile
.dockerignore

4
web-react/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
*.local
.vite

14
web-react/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
# --- Стадия 1: сборка React-приложения ---
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
# --- Стадия 2: раздача статики через nginx + прокси /api ---
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

58
web-react/README.md Normal file
View File

@@ -0,0 +1,58 @@
# web-react — фронтенд на React (Vite + TypeScript)
Замена Flet-интерфейса (`../web`). Обычный SPA: грузится в браузер один раз и работает
локально, обращаясь к API по HTTP. **Нет WebSocket и серверного UI-стейта** — это убирает
зависания, которые были у Flet (он рендерил UI на сервере и держал WS на каждого юзера).
Flet-сервис `web` намеренно оставлен в `docker-compose.yml` как запасной. Этот новый
фронтенд публикуется на **порту 3000**.
## Архитектура
```
Браузер ──▶ [ nginx :80 (контейнер web-react) ]
├── / → статика React (SPA, fallback на index.html)
└── /api/* → proxy_pass http://api:8000/ (включая SVG-диаграммы)
```
API-контейнер доступен только внутри Docker-сети, поэтому браузер ходит к нему через
nginx-прокси на том же origin (`/api/...`). SVG-диаграмма тянется напрямую `<img>`/`<a download>`.
## Разработка (локально)
```bash
# 1. Поднять API + БД (из корня репозитория)
docker compose up -d api postgres redis # postgres/redis — из docker-compose_db.yml
# 2. Dev-сервер фронтенда
cd web-react
npm install
npm run dev # http://localhost:5173, /api проксируется на http://localhost:8000
```
Адрес API для dev-прокси переопределяется переменной `API_URL` (см. `vite.config.ts`).
## Сборка / прод
```bash
# из корня репозитория
docker compose up -d --build web-react # http://localhost:3000
```
Либо вручную: `npm run build` → статика в `dist/`, раздаётся nginx (см. `Dockerfile`, `nginx.conf`).
## Структура
| Путь | Назначение |
|---|---|
| `src/api/client.ts` | все запросы к API (порт `web/api_client.py`) |
| `src/auth/` | `AuthContext` — токен в localStorage + `/users/me` |
| `src/theme/` | 5 тем + слайдер масштаба через CSS-переменные |
| `src/components/` | переиспользуемые компоненты (Button, TextField, SearchableDropdown, Layout, Modal, RadarBlock…) |
| `src/pages/` | экраны: Login, Register, Home, TestList, Poll, MyTests, Response, Profile |
| `src/styles/global.css` | стили на CSS-переменных |
## Маршруты
`/login`, `/register`, `/` (домашняя), `/tests`, `/poll/:pollId`, `/my-tests`,
`/response/:responseId`, `/profile`. Защищённые маршруты требуют токен (`ProtectedRoute`).

13
web-react/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ЦОПП</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

36
web-react/nginx.conf Normal file
View File

@@ -0,0 +1,36 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Статика SPA: всё неизвестное отдаём index.html (клиентский роутинг)
location / {
try_files $uri $uri/ /index.html;
}
# Прокси на внутренний API-контейнер (браузер к api:8000 напрямую не ходит).
# Префикс /api срезается: /api/auth/login -> http://api:8000/auth/login
#
# Используем встроенный DNS Docker (127.0.0.11) и переменную в proxy_pass,
# чтобы nginx переразрешал имя `api` на каждый запрос. Иначе nginx кэширует
# IP при старте и ломается (502), если контейнер api перезапустился и сменил IP.
location /api/ {
resolver 127.0.0.11 valid=10s ipv6=off;
set $api_backend api;
rewrite ^/api/(.*)$ /$1 break;
proxy_pass http://$api_backend:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
# Кэш статических ассетов с хэшем в имени
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

1774
web-react/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
web-react/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "copp-web-react",
"private": true,
"version": "1.0.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.26.2"
},
"devDependencies": {
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.4",
"vite": "^5.4.6"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 851 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24">
<path d="M15.829,2c-.413-1.164-1.525-2-2.829-2h-2c-1.304,0-2.416,.836-2.829,2H3V21c0,1.654,1.346,3,3,3h12c1.654,0,3-1.346,3-3V2h-5.171Zm3.171,19c0,.551-.449,1-1,1H6c-.551,0-1-.449-1-1V4h5v-1c0-.551,.449-1,1-1h2c.551,0,1,.449,1,1v1h5V21Zm-8-4h2v2.013h-2v-2.013Zm1.917-9.911c1.19,.308,2.158,1.276,2.465,2.467,.429,1.66-.376,3.381-1.913,4.092-.281,.13-.47,.472-.47,.852v.5h-2v-.5c0-1.162,.64-2.208,1.629-2.666,.548-.254,1.038-.919,.816-1.778-.126-.49-.54-.904-1.03-1.03-.483-.127-.956-.034-1.333,.258-.371,.287-.583,.72-.583,1.188h-2c0-1.091,.495-2.1,1.358-2.769,.863-.67,1.978-.894,3.059-.614Z"/>
</svg>

After

Width:  |  Height:  |  Size: 737 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24">
<path d="m19,0H5C2.243,0,0,2.243,0,5v14c0,2.757,2.243,5,5,5h14c2.757,0,5-2.243,5-5V5c0-2.757-2.243-5-5-5Zm3,19c0,1.654-1.346,3-3,3H5c-1.654,0-3-1.346-3-3V5c0-1.654,1.346-3,3-3h14c1.654,0,3,1.346,3,3v14Zm-3-1c0,.552-.447,1-1,1H6c-.552,0-1-.448-1-1s.448-1,1-1h12c.553,0,1,.448,1,1Zm-8.665-7h.665v.725c0,1.333-1.222,1.275-1.222,1.275,0,0-.778.448-.778,1s.448,1,1,1h4c.553,0,1-.448,1-1s-.778-1-.778-1c0,0-1.222.058-1.222-1.275v-.725h.664c2.548,0,4.503-1.258,5.229-3.366.206-.599.107-1.267-.264-1.787-.378-.53-.991-.847-1.641-.847h-1.008c.067-.526-.35-1-.891-1-1.557,0-4.621,0-6.177,0-.541,0-.959.474-.891,1h-1.008c-.649,0-1.263.317-1.641.847-.371.52-.469,1.188-.263,1.787.726,2.108,2.68,3.366,5.228,3.366Zm6.667-4.018c-.441,1.282-1.658,2.018-3.338,2.018h-.262c.138-.188.297-.364.492-.506.566-.412.999-.939,1.335-1.502l1.773-.01Zm-8.226.018c.335.56.767,1.084,1.33,1.494.195.142.353.317.492.506h-.262c-1.679,0-2.896-.735-3.324-2h1.765Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

38
web-react/src/App.tsx Normal file
View File

@@ -0,0 +1,38 @@
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider } from "./auth/AuthContext";
import { ThemeProvider } from "./theme/ThemeContext";
import { ProtectedRoute } from "./components/ProtectedRoute";
import { LoginPage } from "./pages/LoginPage";
import { RegisterPage } from "./pages/RegisterPage";
import { HomePage } from "./pages/HomePage";
import { TestListPage } from "./pages/TestListPage";
import { PollPage } from "./pages/PollPage";
import { MyTestsPage } from "./pages/MyTestsPage";
import { ResponsePage } from "./pages/ResponsePage";
import { ProfilePage } from "./pages/ProfilePage";
function protect(el: React.ReactNode) {
return <ProtectedRoute>{el}</ProtectedRoute>;
}
export default function App() {
return (
<ThemeProvider>
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/" element={protect(<HomePage />)} />
<Route path="/tests" element={protect(<TestListPage />)} />
<Route path="/poll/:pollId" element={protect(<PollPage />)} />
<Route path="/my-tests" element={protect(<MyTestsPage />)} />
<Route path="/response/:responseId" element={protect(<ResponsePage />)} />
<Route path="/profile" element={protect(<ProfilePage />)} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</AuthProvider>
</ThemeProvider>
);
}

283
web-react/src/api/client.ts Normal file
View File

@@ -0,0 +1,283 @@
// Единое место для всех HTTP-запросов к бэкенду.
// Порт web/api_client.py. Базовый путь /api проксируется nginx (прод) или Vite (dev) на api:8000.
const BASE = "/api";
// ---------------------------------------------------------------------------
// Типы
// ---------------------------------------------------------------------------
export interface Me {
id: string;
first_name?: string;
last_name?: string;
group_id?: string | null;
organization_id?: string | null;
}
export interface Organization {
id: string;
name_organization: string;
group_id?: string | null;
}
export interface Group {
id: string;
name_group: string;
}
export interface Choice {
id: string;
text: string;
}
export interface Question {
id: string;
text: string;
choices: Choice[];
}
export interface Poll {
id: string;
title: string;
description?: string | null;
questions?: Question[];
}
export interface Answer {
question_id: string;
choice_id: string;
text?: string | null;
}
export interface ResponseDetail {
id: string;
poll_id: string;
submitted_at: string;
answers: Answer[];
}
export interface UserResponse {
id: string;
poll_id: string;
submitted_at: string;
}
export interface RadarResult {
image_url?: string | null;
[k: string]: unknown;
}
export type Result<T> = { data: T | null; error: string | null };
// ---------------------------------------------------------------------------
// Низкоуровневые помощники
// ---------------------------------------------------------------------------
function authHeaders(token?: string | null): Record<string, string> {
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function safeJson(resp: Response): Promise<any> {
try {
return await resp.json();
} catch {
return null;
}
}
// GET, возвращает распарсенный JSON или null при любой ошибке (как в api_client.py)
async function getJson<T>(path: string, token?: string | null): Promise<T | null> {
try {
const resp = await fetch(BASE + path, { headers: authHeaders(token) });
if (resp.ok) return (await resp.json()) as T;
} catch {
/* network error -> null */
}
return null;
}
// ---------------------------------------------------------------------------
// Auth
// ---------------------------------------------------------------------------
export async function login(username: string, password: string): Promise<Result<string>> {
try {
const body = new URLSearchParams({ username, password });
const resp = await fetch(BASE + "/auth/login", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (resp.ok) {
const json = await resp.json();
return { data: json.access_token, error: null };
}
if (resp.status === 401) return { data: null, error: "Неверный логин или пароль" };
return { data: null, error: `Ошибка сервера: ${resp.status}` };
} catch {
return { data: null, error: "Не удалось подключиться к серверу" };
}
}
export async function logout(token: string): Promise<void> {
try {
await fetch(BASE + "/auth/logout", { method: "POST", headers: authHeaders(token) });
} catch {
/* локальный logout произойдёт в любом случае */
}
}
export async function getMe(token: string): Promise<Me | null> {
return getJson<Me>("/users/me", token);
}
const FIELD_NAMES: Record<string, string> = { password: "Пароль", username: "Логин" };
const MSG_MAP: Record<string, string> = {
"Password must be at least 8 characters": "Пароль должен быть не менее 8 символов",
"Username must be at least 3 characters": "Логин должен быть не менее 3 символов",
};
export interface RegisterPayload {
username: string;
password: string;
first_name: string;
last_name: string;
group_id?: string | null;
organization_id?: string | null;
}
// Возвращает строку с ошибкой или null при успехе (как register в api_client.py)
export async function register(p: RegisterPayload): Promise<string | null> {
const payload: Record<string, unknown> = {
username: p.username,
password: p.password,
first_name: p.first_name,
last_name: p.last_name,
};
if (p.group_id) payload.group_id = p.group_id;
if (p.organization_id) payload.organization_id = p.organization_id;
try {
const resp = await fetch(BASE + "/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (resp.status === 201) return null;
const json = await safeJson(resp);
if (resp.status === 400) return json?.detail ?? "Ошибка регистрации";
if (resp.status === 422) {
const errors: any[] = json?.detail ?? [];
for (const err of errors) {
const raw = err?.ctx?.error || err?.msg || "";
if (raw in MSG_MAP) return MSG_MAP[raw];
const loc: any[] = err?.loc ?? [];
const field = FIELD_NAMES[loc[loc.length - 1]] ?? "";
const msg = (err?.msg ?? "Ошибка валидации").replace(/^Value error, /, "");
return `${field}: ${msg}`.replace(/^: |:?\s*$/g, "").trim() || "Проверьте правильность введённых данных";
}
return "Проверьте правильность введённых данных";
}
return `Ошибка сервера: ${resp.status}`;
} catch {
return "Не удалось подключиться к серверу";
}
}
// ---------------------------------------------------------------------------
// Organizations & Groups
// ---------------------------------------------------------------------------
export async function getOrganizations(): Promise<Organization[]> {
return (await getJson<Organization[]>("/organizations/")) ?? [];
}
export async function getGroups(): Promise<Group[]> {
return (await getJson<Group[]>("/groups/")) ?? [];
}
// ---------------------------------------------------------------------------
// Polls
// ---------------------------------------------------------------------------
export async function getPolls(): Promise<Poll[]> {
return (await getJson<Poll[]>("/polls/")) ?? [];
}
export async function getPoll(pollId: string): Promise<Poll | null> {
return getJson<Poll>(`/polls/${pollId}`);
}
export async function getResponse(responseId: string): Promise<ResponseDetail | null> {
return getJson<ResponseDetail>(`/polls/responses/${responseId}`);
}
export async function getRadarResult(responseId: string): Promise<RadarResult | null> {
return getJson<RadarResult>(`/responses/${responseId}/radar`);
}
// URL SVG-диаграммы — браузер тянет напрямую с того же origin (через nginx-прокси).
export function radarImageUrl(responseId: string): string {
return `${BASE}/responses/${responseId}/radar/image`;
}
export interface SubmitPayload {
user_id?: string | null;
group_id?: string | null;
organization_id?: string | null;
}
export async function submitResponse(
pollId: string,
answers: Answer[],
extra: SubmitPayload = {},
): Promise<Result<string>> {
const payload: Record<string, unknown> = { answers };
if (extra.user_id) payload.user_id = extra.user_id;
if (extra.group_id) payload.group_id = extra.group_id;
if (extra.organization_id) payload.organization_id = extra.organization_id;
try {
const resp = await fetch(BASE + `/polls/${pollId}/responses`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (resp.status === 201) {
const json = await resp.json();
return { data: json.id, error: null };
}
return { data: null, error: `Ошибка сервера: ${resp.status}` };
} catch {
return { data: null, error: "Не удалось подключиться к серверу" };
}
}
export async function getUserResponses(userId: string): Promise<UserResponse[]> {
return (await getJson<UserResponse[]>(`/polls/users/${userId}/responses`)) ?? [];
}
export interface UpdateUserPayload {
first_name?: string | null;
last_name?: string | null;
group_id?: string | null;
organization_id?: string | null;
}
export async function updateUser(
userId: string,
token: string,
payload: UpdateUserPayload,
): Promise<Result<Me>> {
const body: Record<string, unknown> = {};
if (payload.first_name !== undefined) body.first_name = payload.first_name;
if (payload.last_name !== undefined) body.last_name = payload.last_name;
if (payload.group_id !== undefined) body.group_id = payload.group_id;
if (payload.organization_id !== undefined) body.organization_id = payload.organization_id;
try {
const resp = await fetch(BASE + `/users/${userId}`, {
method: "PUT",
headers: { "Content-Type": "application/json", ...authHeaders(token) },
body: JSON.stringify(body),
});
if (resp.ok) return { data: await resp.json(), error: null };
return { data: null, error: `Ошибка сервера: ${resp.status}` };
} catch {
return { data: null, error: "Не удалось подключиться к серверу" };
}
}

View File

@@ -0,0 +1,95 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import * as api from "../api/client";
const TOKEN_KEY = "auth_token";
export interface AuthUser {
id: string;
groupId: string;
orgId: string;
firstName: string;
lastName: string;
}
interface AuthCtx {
token: string | null;
user: AuthUser | null;
ready: boolean; // завершена ли первичная проверка токена
login: (username: string, password: string) => Promise<string | null>;
logout: () => Promise<void>;
setUserFromMe: (me: api.Me) => void;
}
const Ctx = createContext<AuthCtx | null>(null);
function meToUser(me: api.Me): AuthUser {
return {
id: me.id,
groupId: me.group_id ?? "",
orgId: me.organization_id ?? "",
firstName: me.first_name ?? "",
lastName: me.last_name ?? "",
};
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
const [user, setUser] = useState<AuthUser | null>(null);
const [ready, setReady] = useState(false);
// При старте: если есть токен — проверяем его через /users/me
useEffect(() => {
let cancelled = false;
(async () => {
if (!token) {
setReady(true);
return;
}
const me = await api.getMe(token);
if (cancelled) return;
if (me) {
setUser(meToUser(me));
} else {
// токен невалиден/просрочен
localStorage.removeItem(TOKEN_KEY);
setToken(null);
}
setReady(true);
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const login = async (username: string, password: string): Promise<string | null> => {
const { data, error } = await api.login(username, password);
if (error || !data) return error ?? "Ошибка входа";
localStorage.setItem(TOKEN_KEY, data);
setToken(data);
const me = await api.getMe(data);
if (me) setUser(meToUser(me));
return null;
};
const logout = async () => {
if (token) await api.logout(token);
localStorage.removeItem(TOKEN_KEY);
setToken(null);
setUser(null);
};
const setUserFromMe = (me: api.Me) => setUser(meToUser(me));
return (
<Ctx.Provider value={{ token, user, ready, login, logout, setUserFromMe }}>
{children}
</Ctx.Provider>
);
}
export function useAuth(): AuthCtx {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}

View File

@@ -0,0 +1,193 @@
import { useState } from "react";
import { Button } from "./ui";
// Текст пользовательского соглашения (перенос из AlterDiaalogApproval в web/designer.py).
const AGREEMENT = `# ПОЛЬЗОВАТЕЛЬСКОЕ СОГЛАШЕНИЕ
## 1. ОБЩИЕ ПОЛОЖЕНИЯ
Настоящее Пользовательское соглашение (далее — «Соглашение») регулирует отношения между администрацией сайта https://copp74.ru (далее — «Сайт», «Мы», «Администрация») и пользователем (далее — «Пользователь», «Вы»).
Используя Сайт, Вы автоматически соглашаетесь со всеми условиями настоящего Соглашения. Если Вы не согласны с этими условиями, пожалуйста, не используйте Сайт.
## 2. ОПИСАНИЕ САЙТА
Сайт https://copp74.ru представляет собой онлайн-платформу для прохождения тестов и проверки знаний. Основная функция Сайта — предоставление Пользователям возможности проходить различные тесты и получать результаты.
## 3. РЕГИСТРАЦИЯ И ЛИЧНЫЕ ДАННЫЕ
### 3.1 Регистрация
Для использования Сайта Пользователь должен зарегистрироваться и указать следующую информацию:
- Фамилию
- Имя
- Учебное заведение (образовательную организацию)
### 3.2 Назначение личных данных
Указанные фамилия, имя и учебное заведение используются исключительно для:
- Идентификации Пользователя в системе
- Отображения результатов тестирования
- Ведения статистики прохождения тестов
- Организации пользователей по учебным заведениям для аналитики
- Внутреннего анализа и улучшения качества тестирования
### 3.3 Защита личных данных
**Мы гарантируем, что Ваши персональные данные (фамилия, имя и информация об учебном заведении):**
- Не будут переданы третьим лицам или организациям без Вашего согласия
- Не будут использованы в коммерческих целях
- Не будут распространяться без Вашего согласия
- Хранятся в защищенной базе данных
Данные будут удалены в соответствии с законодательством Российской Федерации по защите персональных данных при закрытии аккаунта или по запросу Пользователя.
## 4. ПРАВА И ОБЯЗАННОСТИ ПОЛЬЗОВАТЕЛЯ
### 4.1 Права Пользователя
Пользователь имеет право:
- Проходить тесты, размещенные на Сайте
- Получать результаты своего тестирования
- Запросить удаление своего аккаунта и данных
- Обратиться в поддержку по вопросам использования Сайта
- Получить информацию о том, как его данные обрабатываются
### 4.2 Обязанности Пользователя
Пользователь обязуется:
- Предоставлять достоверную информацию при регистрации
- Указывать корректное название учебного заведения
- Не использовать Сайт в противоправных целях
- Не пытаться получить несанкционированный доступ к системам Сайта
- Не воспроизводить, не копировать и не распространять содержимое Сайта без разрешения
- Соблюдать все применимые законы Российской Федерации
## 5. ОГРАНИЧЕНИЕ ОТВЕТСТВЕННОСТИ
### 5.1 Предоставление Сайта «Как есть»
Сайт предоставляется в режиме «как есть» без каких-либо явных или подразумеваемых гарантий. Администрация не гарантирует:
- Непрерывную работу Сайта
- Отсутствие ошибок в тестах или результатах
- Соответствие Сайта конкретным целям Пользователя
### 5.2 Ограничение ответственности
В максимальной степени, допускаемой законодательством Российской Федерации, Администрация не несет ответственность за:
- Убытки, возникшие в результате использования или невозможности использования Сайта
- Технические сбои, потерю данных
- Ущерб, причиненный прямо или косвенно в связи с использованием Сайта
## 6. ИНТЕЛЛЕКТУАЛЬНАЯ СОБСТВЕННОСТЬ
Все содержимое Сайта, включая тесты, вопросы, задания, дизайн, логотипы и тексты, защищено авторским правом и законами об интеллектуальной собственности Российской Федерации. Пользователь не имеет права использовать это содержимое в коммерческих целях без письменного разрешения Администрации.
## 7. ИЗМЕНЕНИЕ СОГЛАШЕНИЯ
Администрация оставляет за собой право изменять условия настоящего Соглашения в любое время. Изменения вступают в силе с момента их публикации на Сайте. Продолжение использования Сайта после публикации изменений означает согласие Пользователя с новыми условиями.
## 8. ПРЕКРАЩЕНИЕ ДОСТУПА
Администрация может заблокировать доступ Пользователя к Сайту без предварительного уведомления, если:
- Пользователь нарушает условия настоящего Соглашения
- Пользователь использует Сайт в противоправных целях
- Пользователь предоставил поддельную информацию при регистрации
## 9. КОНФИДЕНЦИАЛЬНОСТЬ
Пожалуйста, ознакомьтесь с нашей Политикой конфиденциальности, которая описывает, как мы обращаемся с Вашими личными данными и какие меры безопасности мы применяем.
## 10. ПРИМЕНИМОЕ ПРАВО
Настоящее Соглашение регулируется законодательством Российской Федерации. Все споры, возникающие в связи с Соглашением, подлежат разрешению в судах Российской Федерации.
## 11. КОНТАКТНАЯ ИНФОРМАЦИЯ
Если у Вас есть вопросы по данному Соглашению или функционированию Сайта https://copp74.ru, свяжитесь с нами через форму обратной связи на Сайте.
## 12. ЗАКЛЮЧИТЕЛЬНЫЕ ПОЛОЖЕНИЯ
Если какая-либо часть настоящего Соглашения будет признана недействительной, остальные положения остаются в полной силе.
Пользователь подтверждает, что прочитал, понял и согласен со всеми условиями настоящего Соглашения перед использованием Сайта.
**Дата последнего обновления:** 19.03.2026
---
*Соглашение соответствует законодательству Российской Федерации, включая Федеральный закон «О защите персональных данных» и Федеральный закон «О защите прав потребителей».*`;
// Рендер inline-жирного (**...**) внутри строки.
function inline(text: string): React.ReactNode {
const parts = text.split(/\*\*(.+?)\*\*/g);
return parts.map((p, i) => (i % 2 === 1 ? <strong key={i}>{p}</strong> : p));
}
// Мини-рендер markdown-подмножества (заголовки/списки/абзацы/hr).
function renderMarkdown(src: string) {
const lines = src.split("\n");
const out: React.ReactNode[] = [];
let list: string[] = [];
const flushList = (key: number) => {
if (list.length) {
out.push(
<ul key={`ul-${key}`} className="agr-ul">
{list.map((it, i) => (
<li key={i}>{inline(it)}</li>
))}
</ul>,
);
list = [];
}
};
lines.forEach((raw, i) => {
const line = raw.trim();
if (line.startsWith("- ")) {
list.push(line.slice(2));
return;
}
flushList(i);
if (!line) return;
if (line === "---") out.push(<hr key={i} className="divider" />);
else if (line.startsWith("### ")) out.push(<h3 key={i} className="agr-h3">{line.slice(4)}</h3>);
else if (line.startsWith("## ")) out.push(<h2 key={i} className="agr-h2">{line.slice(3)}</h2>);
else if (line.startsWith("# ")) out.push(<h1 key={i} className="agr-h1">{line.slice(2)}</h1>);
else if (line.startsWith("*") && line.endsWith("*") && !line.startsWith("**"))
out.push(<p key={i} className="agr-p muted" style={{ fontStyle: "italic" }}>{line.slice(1, -1)}</p>);
else out.push(<p key={i} className="agr-p">{inline(line)}</p>);
});
flushList(lines.length);
return out;
}
// Диалог пользовательского соглашения: текст + переключатель согласия + «Принять».
// «Принять» неактивна, пока не включён переключатель (как в оригинале).
export function AgreementDialog({
onAccept,
onClose,
}: {
onAccept: () => void;
onClose: () => void;
}) {
const [agreed, setAgreed] = useState(false);
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal agr-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-title" style={{ color: "var(--primary)" }}>
Пользовательское соглашение
</div>
<div className="agr-body">{renderMarkdown(AGREEMENT)}</div>
<label className="agr-switch-row">
<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />
<span>Я согласен с условиями пользовательского соглашения</span>
</label>
<div className="row" style={{ justifyContent: "center" }}>
<Button disabled={!agreed} onClick={onAccept}>
Принять
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import type { ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import { IconArrowBack, IconAccount } from "./icons";
// Оболочка экранов: заголовок + опциональная кнопка «назад» + кнопка профиля.
export function Layout({
title,
children,
back,
showProfile = true,
}: {
title: string;
children: ReactNode;
back?: string; // путь для кнопки «назад»
showProfile?: boolean;
}) {
const navigate = useNavigate();
return (
<div className="page">
<header className="app-header">
<div className="row row-gap" style={{ minWidth: 0 }}>
{back && (
<button className="icon-btn" onClick={() => navigate(back)} aria-label="Назад">
<IconArrowBack />
</button>
)}
<span className="app-title" style={{ overflow: "hidden", textOverflow: "ellipsis" }}>
{title}
</span>
</div>
{showProfile && (
<button className="icon-btn" onClick={() => navigate("/profile")} aria-label="Профиль">
<IconAccount />
</button>
)}
</header>
{children}
</div>
);
}

View File

@@ -0,0 +1,18 @@
import { Navigate } from "react-router-dom";
import type { ReactNode } from "react";
import { useAuth } from "../auth/AuthContext";
import { Spinner } from "./ui";
// Пускает дальше только при наличии токена. Пока идёт первичная проверка — спиннер.
export function ProtectedRoute({ children }: { children: ReactNode }) {
const { token, ready } = useAuth();
if (!ready) {
return (
<div className="spinner-center">
<Spinner />
</div>
);
}
if (!token) return <Navigate to="/login" replace />;
return <>{children}</>;
}

View File

@@ -0,0 +1,19 @@
import { radarImageUrl } from "../api/client";
import { Button } from "./ui";
import { IconDownload } from "./icons";
// Блок с паутинной диаграммой: картинка + кнопка скачивания.
// SVG тянется напрямую с того же origin через nginx-прокси (/api/.../radar/image).
export function RadarBlock({ responseId }: { responseId: string }) {
const url = radarImageUrl(responseId);
return (
<div className="center-col" style={{ width: "100%" }}>
<img className="radar-img" src={url} alt="Паутинная диаграмма результата" />
<a href={url} download="diagram.svg">
<Button variant="info" icon={<IconDownload />}>
Сохранить диаграмму
</Button>
</a>
</div>
);
}

View File

@@ -0,0 +1,83 @@
import { useMemo, useState } from "react";
import { IconCaretDown } from "./icons";
export interface Option {
key: string;
text: string;
}
// Выпадающий список с поиском — порт SearchableDropdown из web/designer.py.
// При клике открывается модальный диалог с полем поиска и списком (как в оригинале).
export function SearchableDropdown({
label,
hint,
options,
value,
onChange,
}: {
label?: string;
hint?: string;
options: Option[];
value: string | null;
onChange: (key: string | null) => void;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const selected = useMemo(() => options.find((o) => o.key === value) ?? null, [options, value]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return options;
return options.filter((o) => o.text.toLowerCase().includes(q));
}, [options, query]);
return (
<div className="field">
{label && <span className="field-label">{label}</span>}
<div
className="dropdown-control"
onClick={() => {
setQuery("");
setOpen(true);
}}
>
<span className={"dropdown-value" + (selected ? "" : " placeholder")}>
{selected ? selected.text : hint ?? "Выберите..."}
</span>
<IconCaretDown style={{ color: "var(--text-secondary)", fontSize: "1.2rem", flexShrink: 0 }} />
</div>
{open && (
<div className="modal-backdrop" onClick={() => setOpen(false)}>
<div className="modal sd-dialog" onClick={(e) => e.stopPropagation()}>
<div className="modal-title">{label}</div>
<input
className="dropdown-search"
placeholder="Введите для поиска..."
value={query}
autoFocus
onChange={(e) => setQuery(e.target.value)}
/>
<hr className="divider" />
<div className="sd-list">
{filtered.length === 0 && <div className="dropdown-empty">Ничего не найдено</div>}
{filtered.map((o) => (
<div
key={o.key}
className={"dropdown-option" + (o.key === value ? " active" : "")}
onClick={() => {
onChange(o.key);
setOpen(false);
}}
>
{o.text}
</div>
))}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,90 @@
// Небольшой набор inline-SVG иконок (замена Material Icons из Flet).
// Наследуют цвет через currentColor и размер через 1em.
import type { SVGProps } from "react";
type P = SVGProps<SVGSVGElement>;
const base = (p: P) => ({
width: "1em",
height: "1em",
viewBox: "0 0 24 24",
fill: "currentColor",
...p,
});
export const IconPerson = (p: P) => (
<svg {...base(p)}>
<path d="M12 12a5 5 0 1 0-5-5 5 5 0 0 0 5 5Zm0 2c-3.3 0-10 1.7-10 5v3h20v-3c0-3.3-6.7-5-10-5Z" />
</svg>
);
export const IconLock = (p: P) => (
<svg {...base(p)}>
<path d="M17 9V7a5 5 0 0 0-10 0v2H5v13h14V9h-2ZM9 7a3 3 0 0 1 6 0v2H9V7Zm3 11a2 2 0 1 1 2-2 2 2 0 0 1-2 2Z" />
</svg>
);
export const IconAt = (p: P) => (
<svg {...base(p)}>
<path d="M12 2a10 10 0 1 0 4 19.2l-.8-1.8A8 8 0 1 1 20 12v1a1.5 1.5 0 0 1-3 0V8h-2v.7A4 4 0 1 0 16 15a3.5 3.5 0 0 0 6-2v-1A10 10 0 0 0 12 2Zm0 12a2 2 0 1 1 2-2 2 2 0 0 1-2 2Z" />
</svg>
);
export const IconCheckCircle = (p: P) => (
<svg {...base(p)}>
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2Zm-2 15-5-5 1.4-1.4L10 14.2l7.6-7.6L19 8Z" />
</svg>
);
export const IconRadioUnchecked = (p: P) => (
<svg {...base(p)} fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="9" />
</svg>
);
export const IconDownload = (p: P) => (
<svg {...base(p)}>
<path d="M12 16 6 10l1.4-1.4L11 12.2V3h2v9.2l3.6-3.6L18 10Zm-8 3h16v2H4Z" />
</svg>
);
export const IconChevronRight = (p: P) => (
<svg {...base(p)}>
<path d="M9 6 7.6 7.4 12.2 12l-4.6 4.6L9 18l6-6Z" />
</svg>
);
export const IconArrowBack = (p: P) => (
<svg {...base(p)}>
<path d="M20 11H7.8l5.6-5.6L12 4l-8 8 8 8 1.4-1.4L7.8 13H20Z" />
</svg>
);
export const IconAccount = (p: P) => (
<svg {...base(p)}>
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2Zm0 4a3 3 0 1 1-3 3 3 3 0 0 1 3-3Zm0 14a8 8 0 0 1-6-2.7c0-2 4-3.1 6-3.1s6 1.1 6 3.1A8 8 0 0 1 12 20Z" />
</svg>
);
export const IconStop = (p: P) => (
<svg {...base(p)}>
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2ZM8 8h8v8H8Z" />
</svg>
);
export const IconError = (p: P) => (
<svg {...base(p)}>
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2Zm1 15h-2v-2h2Zm0-4h-2V7h2Z" />
</svg>
);
export const IconEye = (p: P) => (
<svg {...base(p)}>
<path d="M12 5C6 5 2 12 2 12s4 7 10 7 10-7 10-7-4-7-10-7Zm0 11a4 4 0 1 1 4-4 4 4 0 0 1-4 4Zm0-6a2 2 0 1 0 2 2 2 2 0 0 0-2-2Z" />
</svg>
);
export const IconCaretDown = (p: P) => (
<svg {...base(p)}>
<path d="m7 10 5 5 5-5Z" />
</svg>
);

View File

@@ -0,0 +1,139 @@
import {
useState,
type ButtonHTMLAttributes,
type InputHTMLAttributes,
type ReactNode,
} from "react";
import { IconEye } from "./icons";
// ---------------------------------------------------------------------------
// Spinner
// ---------------------------------------------------------------------------
export function Spinner() {
return <div className="spinner" role="status" aria-label="Загрузка" />;
}
export function CenterSpinner() {
return (
<div className="spinner-center">
<Spinner />
</div>
);
}
// ---------------------------------------------------------------------------
// Button
// ---------------------------------------------------------------------------
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "info" | "ghost";
block?: boolean;
icon?: ReactNode;
};
export function Button({
variant = "primary",
block,
icon,
children,
className = "",
...rest
}: ButtonProps) {
const cls = [
"btn",
variant === "info" ? "btn-info" : "",
variant === "ghost" ? "btn-ghost" : "",
block ? "btn-block" : "",
className,
]
.filter(Boolean)
.join(" ");
return (
<button className={cls} {...rest}>
{icon}
{children}
</button>
);
}
// ---------------------------------------------------------------------------
// Card
// ---------------------------------------------------------------------------
export function Card({
children,
alt,
onClick,
className = "",
}: {
children: ReactNode;
alt?: boolean;
onClick?: () => void;
className?: string;
}) {
const cls = ["card", alt ? "card-alt" : "", onClick ? "clickable" : "", className]
.filter(Boolean)
.join(" ");
return (
<div className={cls} onClick={onClick}>
{children}
</div>
);
}
// ---------------------------------------------------------------------------
// TextField
// ---------------------------------------------------------------------------
type FieldProps = Omit<InputHTMLAttributes<HTMLInputElement>, "prefix"> & {
label?: string;
icon?: ReactNode;
password?: boolean;
};
export function TextField({ label, icon, password, ...rest }: FieldProps) {
const [revealed, setRevealed] = useState(false);
const type = password ? (revealed ? "text" : "password") : rest.type ?? "text";
return (
<label className="field">
{label && <span className="field-label">{label}</span>}
<span className="field-input-wrap">
{icon && <span className="ficon">{icon}</span>}
<input {...rest} type={type} />
{password && (
<button
type="button"
className="reveal-btn"
onClick={() => setRevealed((v) => !v)}
tabIndex={-1}
aria-label="Показать пароль"
>
<IconEye />
</button>
)}
</span>
</label>
);
}
// ---------------------------------------------------------------------------
// Modal
// ---------------------------------------------------------------------------
export function Modal({
title,
children,
actions,
onClose,
}: {
title: string;
children?: ReactNode;
actions: ReactNode;
onClose?: () => void;
}) {
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-title">{title}</div>
{children && <div className="muted">{children}</div>}
<div className="modal-actions">{actions}</div>
</div>
</div>
);
}

10
web-react/src/main.tsx Normal file
View File

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

View File

@@ -0,0 +1,20 @@
import { useNavigate } from "react-router-dom";
import { Layout } from "../components/Layout";
export function HomePage() {
const navigate = useNavigate();
return (
<Layout title="Добро пожаловать в ЦОПП!">
<div className="home-grid">
<div className="home-tile" onClick={() => navigate("/tests")}>
<img src="/icons/clipboard-question_16542596.svg" alt="" />
<span>Все тесты</span>
</div>
<div className="home-tile" onClick={() => navigate("/my-tests")}>
<img src="/icons/leaderboard-trophy_14227576.svg" alt="" />
<span>Мои тесты</span>
</div>
</div>
</Layout>
);
}

View File

@@ -0,0 +1,79 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { Button, TextField } from "../components/ui";
import { AgreementDialog } from "../components/AgreementDialog";
import { IconPerson, IconLock } from "../components/icons";
export function LoginPage() {
const { login } = useAuth();
const navigate = useNavigate();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [askReg, setAskReg] = useState(false);
const doLogin = async () => {
if (!username.trim() || !password) {
setError("Заполните логин и пароль");
return;
}
setBusy(true);
setError("");
const err = await login(username.trim(), password);
setBusy(false);
if (err) {
setError(err);
} else {
navigate("/");
}
};
return (
<div className="auth-wrap">
<div className="auth-logo">ЦОПП</div>
<div className="auth-sub">
Это проект ЦОПП по проведению исследовательских тестирований
</div>
<form
className="auth-card"
onSubmit={(e) => {
e.preventDefault();
doLogin();
}}
>
<TextField
label="Логин"
placeholder="Введите логин..."
icon={<IconPerson />}
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<TextField
label="Пароль"
placeholder="Введите пароль..."
icon={<IconLock />}
password
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && <div className="error-text">{error}</div>}
<Button type="submit" disabled={busy} block>
{busy ? "Вход..." : "Войти"}
</Button>
<button type="button" className="link-btn" onClick={() => setAskReg(true)}>
Нет аккаунта? Зарегистрироваться
</button>
</form>
{askReg && (
<AgreementDialog
onClose={() => setAskReg(false)}
onAccept={() => navigate("/register")}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import * as api from "../api/client";
import { useAuth } from "../auth/AuthContext";
import { Layout } from "../components/Layout";
import { Card, CenterSpinner } from "../components/ui";
import { IconChevronRight } from "../components/icons";
interface Row {
id: string;
title: string;
submittedAt: string;
}
export function MyTestsPage() {
const navigate = useNavigate();
const { user } = useAuth();
const [rows, setRows] = useState<Row[] | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
if (!user) return;
const responses = await api.getUserResponses(user.id);
if (cancelled) return;
if (responses.length === 0) {
setRows([]);
return;
}
// параллельно подгружаем названия опросов
const pollIds = [...new Set(responses.map((r) => r.poll_id))];
const polls = await Promise.all(pollIds.map((id) => api.getPoll(id)));
if (cancelled) return;
const titleById = new Map<string, string>();
polls.forEach((p) => {
if (p) titleById.set(p.id, p.title);
});
const mapped: Row[] = responses
.map((r) => ({
id: r.id,
title: titleById.get(r.poll_id) ?? "Неизвестный тест",
submittedAt: r.submitted_at,
}))
.sort((a, b) => b.submittedAt.localeCompare(a.submittedAt));
setRows(mapped);
})();
return () => {
cancelled = true;
};
}, [user]);
return (
<Layout title="Мои тесты" back="/" showProfile={false}>
{rows === null ? (
<CenterSpinner />
) : rows.length === 0 ? (
<p className="muted">Вы ещё не проходили тесты</p>
) : (
<div className="stack">
{rows.map((r) => (
<Card key={r.id} onClick={() => navigate(`/response/${r.id}`)}>
<div className="list-card">
<div className="col-gap" style={{ gap: "0.25rem" }}>
<span className="bold">{r.title}</span>
<span className="text-sm muted">
Пройден: {r.submittedAt.slice(0, 19).replace("T", " ")}
</span>
</div>
<IconChevronRight className="chevron" />
</div>
</Card>
))}
</div>
)}
</Layout>
);
}

View File

@@ -0,0 +1,192 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import * as api from "../api/client";
import { useAuth } from "../auth/AuthContext";
import { Layout } from "../components/Layout";
import { Button, Card, CenterSpinner, Modal } from "../components/ui";
import { RadarBlock } from "../components/RadarBlock";
import { IconCheckCircle, IconRadioUnchecked, IconStop, IconError } from "../components/icons";
type Phase =
| { kind: "loading" }
| { kind: "empty"; message: string }
| { kind: "running" }
| { kind: "submitting" }
| { kind: "error"; message: string }
| { kind: "success"; responseId: string; hasRadar: boolean };
export function PollPage() {
const { pollId = "" } = useParams();
const navigate = useNavigate();
const { user } = useAuth();
const [questions, setQuestions] = useState<api.Question[]>([]);
const [answers, setAnswers] = useState<Record<string, string>>({});
const [current, setCurrent] = useState(0);
const [phase, setPhase] = useState<Phase>({ kind: "loading" });
const [askAbort, setAskAbort] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
const p = await api.getPoll(pollId);
if (cancelled) return;
if (!p) {
setPhase({ kind: "empty", message: "Тест не найден" });
return;
}
const qs = p.questions ?? [];
setQuestions(qs);
if (qs.length === 0) {
setPhase({ kind: "empty", message: "В тесте нет вопросов" });
} else {
setPhase({ kind: "running" });
}
})();
return () => {
cancelled = true;
};
}, [pollId]);
const total = questions.length;
const allAnswered = total > 0 && questions.every((q) => answers[q.id]);
const selectAnswer = (questionId: string, choiceId: string) => {
setAnswers((prev) => ({ ...prev, [questionId]: choiceId }));
// авто-переход к следующему вопросу, если он не последний
if (current < total - 1) {
setCurrent((c) => c + 1);
}
};
const submit = async () => {
const firstUnanswered = questions.findIndex((q) => !answers[q.id]);
if (firstUnanswered !== -1) {
setCurrent(firstUnanswered);
return;
}
setPhase({ kind: "submitting" });
const payload: api.Answer[] = Object.entries(answers).map(([question_id, choice_id]) => ({
question_id,
choice_id,
text: null,
}));
const { data: responseId, error } = await api.submitResponse(pollId, payload, {
user_id: user?.id,
group_id: user?.groupId || null,
organization_id: user?.orgId || null,
});
if (error || !responseId) {
setPhase({ kind: "error", message: error ?? "Не удалось отправить ответы" });
return;
}
const radar = await api.getRadarResult(responseId);
setPhase({ kind: "success", responseId, hasRadar: !!radar?.image_url });
};
// ── рендер по фазам ──────────────────────────────────────────────────
let body: React.ReactNode;
if (phase.kind === "loading") {
body = <CenterSpinner />;
} else if (phase.kind === "empty") {
body = <p className="muted">{phase.message}</p>;
} else if (phase.kind === "submitting") {
body = <CenterSpinner />;
} else if (phase.kind === "error") {
body = (
<div className="center-col">
<span style={{ color: "var(--primary)", fontSize: "2.5rem" }}>
<IconError />
</span>
<span>{phase.message}</span>
<Button onClick={() => setPhase({ kind: "running" })}>Попробовать снова</Button>
</div>
);
} else if (phase.kind === "success") {
body = (
<Card className="success-box">
<div className="center-col">
<span style={{ color: "var(--primary-light)", fontSize: "2.6rem" }}>
<IconCheckCircle />
</span>
<span className="title-lg">Тест пройден!</span>
{phase.hasRadar && <RadarBlock responseId={phase.responseId} />}
<Button onClick={() => navigate("/tests")}>Вернуться к тестам</Button>
</div>
</Card>
);
} else {
// running
const q = questions[current];
const selected = answers[q.id];
body = (
<div className="stack">
<div className="center-col" style={{ gap: "0.5rem" }}>
<span className="counter-badge">
Вопрос {current + 1} из {total}
</span>
<div className="wrap-row" style={{ justifyContent: "center" }}>
<Button
variant="ghost"
disabled={current === 0}
onClick={() => setCurrent((c) => Math.max(0, c - 1))}
>
{current > 0 ? "← Предыдущий вопрос" : "←"}
</Button>
<Button variant="ghost" icon={<IconStop />} onClick={() => setAskAbort(true)}>
Прервать тест
</Button>
</div>
</div>
<div className="question-box">{q.text}</div>
<div className="col-gap" style={{ gap: "0.4rem" }}>
{q.choices.map((c) => {
const isSel = c.id === selected;
return (
<div
key={c.id}
className={"choice-tile" + (isSel ? " selected" : "")}
onClick={() => selectAnswer(q.id, c.id)}
>
<span className="check">{isSel ? <IconCheckCircle /> : <IconRadioUnchecked />}</span>
<span>{c.text}</span>
</div>
);
})}
</div>
{current === total - 1 && allAnswered && (
<div className="row" style={{ justifyContent: "flex-end" }}>
<Button onClick={submit}>Завершить тест </Button>
</div>
)}
</div>
);
}
return (
<Layout title="Прохождение теста" back="/tests" showProfile={false}>
{body}
{askAbort && (
<Modal
title="Прервать тест?"
onClose={() => setAskAbort(false)}
actions={
<>
<Button variant="ghost" onClick={() => setAskAbort(false)}>
Отмена
</Button>
<Button onClick={() => navigate("/tests")}>Прервать</Button>
</>
}
>
Все ответы будут потеряны. Вы уверены?
</Modal>
)}
</Layout>
);
}

View File

@@ -0,0 +1,133 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import * as api from "../api/client";
import { useAuth } from "../auth/AuthContext";
import { useTheme } from "../theme/ThemeContext";
import { THEMES, SCALE_MIN, SCALE_MAX, SCALE_STEP } from "../theme/themes";
import { Layout } from "../components/Layout";
import { Button, TextField } from "../components/ui";
import { SearchableDropdown, type Option } from "../components/SearchableDropdown";
import { IconPerson } from "../components/icons";
export function ProfilePage() {
const navigate = useNavigate();
const { token, user, logout, setUserFromMe } = useAuth();
const { themeId, scale, setThemeId, setScale } = useTheme();
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [orgId, setOrgId] = useState<string | null>(null);
const [groupId, setGroupId] = useState<string | null>(null);
const [orgs, setOrgs] = useState<api.Organization[]>([]);
const [groups, setGroups] = useState<api.Group[]>([]);
const [loaded, setLoaded] = useState(false);
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<{ text: string; error: boolean } | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
if (!token) return;
const [me, o, g] = await Promise.all([
api.getMe(token),
api.getOrganizations(),
api.getGroups(),
]);
if (cancelled) return;
setOrgs(o);
setGroups(g);
if (me) {
setFirstName(me.first_name ?? "");
setLastName(me.last_name ?? "");
setOrgId(me.organization_id ?? null);
setGroupId(me.group_id ?? null);
}
setLoaded(true);
})();
return () => {
cancelled = true;
};
}, [token]);
const onOrgChange = (id: string | null) => {
setOrgId(id);
const org = orgs.find((o) => o.id === id);
if (org?.group_id) setGroupId(org.group_id);
};
const orgOptions: Option[] = orgs.map((o) => ({ key: o.id, text: o.name_organization }));
const groupOptions: Option[] = groups.map((g) => ({ key: g.id, text: g.name_group }));
const themeOptions: Option[] = THEMES.map((t) => ({ key: t.id, text: t.name }));
const save = async () => {
if (!token || !user) return;
setBusy(true);
setStatus(null);
const { data, error } = await api.updateUser(user.id, token, {
first_name: firstName.trim() || null,
last_name: lastName.trim() || null,
group_id: groupId,
organization_id: orgId,
});
setBusy(false);
if (error) {
setStatus({ text: error, error: true });
return;
}
if (data) setUserFromMe(data);
setStatus({ text: "Сохранено", error: false });
};
const doLogout = async () => {
await logout();
navigate("/login");
};
return (
<Layout title="Профиль" back="/" showProfile={false}>
{!loaded ? null : (
<div className="stack">
<span className="title-md">Редактировать профиль</span>
<TextField label="Имя" placeholder="Введите имя..." icon={<IconPerson />} value={firstName} onChange={(e) => setFirstName(e.target.value)} />
<TextField label="Фамилия" placeholder="Введите фамилию..." icon={<IconPerson />} value={lastName} onChange={(e) => setLastName(e.target.value)} />
<SearchableDropdown label="Организация" hint="Выберите организацию..." options={orgOptions} value={orgId} onChange={onOrgChange} />
<SearchableDropdown label="Группа" hint="Выберите группу..." options={groupOptions} value={groupId} onChange={setGroupId} />
<hr className="divider" />
<span className="title-md">Внешний вид</span>
<SearchableDropdown
label="Тема интерфейса"
hint="Выберите тему..."
options={themeOptions}
value={themeId}
onChange={(id) => id && setThemeId(id)}
/>
<div className="field">
<span className="field-label">Масштаб: {scale.toFixed(1)}×</span>
<input
className="scale-slider"
type="range"
min={SCALE_MIN}
max={SCALE_MAX}
step={SCALE_STEP}
value={scale}
onChange={(e) => setScale(parseFloat(e.target.value))}
/>
</div>
<div className="wrap-row" style={{ alignItems: "center" }}>
<Button onClick={save} disabled={busy}>
{busy ? "Сохранение..." : "Сохранить"}
</Button>
<Button variant="ghost" onClick={doLogout}>
Выйти
</Button>
{status && (
<span className={status.error ? "error-text" : "muted"}>{status.text}</span>
)}
</div>
</div>
)}
</Layout>
);
}

View File

@@ -0,0 +1,97 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import * as api from "../api/client";
import { Button, TextField } from "../components/ui";
import { SearchableDropdown, type Option } from "../components/SearchableDropdown";
import { IconPerson, IconAt, IconLock } from "../components/icons";
export function RegisterPage() {
const navigate = useNavigate();
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [orgId, setOrgId] = useState<string | null>(null);
const [groupId, setGroupId] = useState<string | null>(null);
const [orgs, setOrgs] = useState<api.Organization[]>([]);
const [groups, setGroups] = useState<api.Group[]>([]);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
const [o, g] = await Promise.all([api.getOrganizations(), api.getGroups()]);
if (cancelled) return;
setOrgs(o);
setGroups(g);
})();
return () => {
cancelled = true;
};
}, []);
// Автоподстановка группы по выбранной организации (как _on_org_change)
const onOrgChange = (id: string | null) => {
setOrgId(id);
const org = orgs.find((o) => o.id === id);
if (org?.group_id) setGroupId(org.group_id);
};
const orgOptions: Option[] = orgs.map((o) => ({ key: o.id, text: o.name_organization }));
const groupOptions: Option[] = groups.map((g) => ({ key: g.id, text: g.name_group }));
const doRegister = async () => {
if (!firstName.trim() || !lastName.trim() || !username.trim() || !password) {
setError("Заполните все обязательные поля");
return;
}
setBusy(true);
setError("");
const err = await api.register({
username: username.trim(),
password,
first_name: firstName.trim(),
last_name: lastName.trim(),
group_id: groupId,
organization_id: orgId,
});
setBusy(false);
if (err) {
setError(err);
} else {
navigate("/login");
}
};
return (
<div className="auth-wrap">
<div className="auth-logo">ЦОПП</div>
<div className="auth-sub">Регистрация в системе</div>
<form
className="auth-card"
onSubmit={(e) => {
e.preventDefault();
doRegister();
}}
>
<TextField label="Имя" placeholder="Введите имя..." icon={<IconPerson />} value={firstName} onChange={(e) => setFirstName(e.target.value)} />
<TextField label="Фамилия" placeholder="Введите фамилию..." icon={<IconPerson />} value={lastName} onChange={(e) => setLastName(e.target.value)} />
<TextField label="Логин" placeholder="Введите логин..." icon={<IconAt />} value={username} onChange={(e) => setUsername(e.target.value)} />
<TextField label="Пароль" placeholder="Введите пароль..." icon={<IconLock />} password value={password} onChange={(e) => setPassword(e.target.value)} />
<SearchableDropdown label="Организация" hint="Выберите организацию..." options={orgOptions} value={orgId} onChange={onOrgChange} />
<SearchableDropdown label="Группа" hint="Выберите группу..." options={groupOptions} value={groupId} onChange={setGroupId} />
{error && <div className="error-text">{error}</div>}
<div className="wrap-row">
<Button type="button" variant="ghost" onClick={() => navigate("/login")}>
Назад
</Button>
<Button type="submit" disabled={busy}>
{busy ? "Регистрация..." : "Зарегистрироваться"}
</Button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,102 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import * as api from "../api/client";
import { Layout } from "../components/Layout";
import { Card, CenterSpinner } from "../components/ui";
import { RadarBlock } from "../components/RadarBlock";
import { IconCheckCircle, IconRadioUnchecked } from "../components/icons";
interface State {
response: api.ResponseDetail;
poll: api.Poll;
hasRadar: boolean;
}
export function ResponsePage() {
const { responseId = "" } = useParams();
const [state, setState] = useState<State | null>(null);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
(async () => {
const response = await api.getResponse(responseId);
if (cancelled) return;
if (!response) {
setError("Результат не найден");
return;
}
const poll = await api.getPoll(response.poll_id);
if (cancelled) return;
if (!poll) {
setError("Тест не найден");
return;
}
const radar = await api.getRadarResult(responseId);
if (cancelled) return;
setState({ response, poll, hasRadar: !!radar?.image_url });
})();
return () => {
cancelled = true;
};
}, [responseId]);
return (
<Layout title="Результаты теста" back="/my-tests" showProfile={false}>
{error ? (
<p className="error-text">{error}</p>
) : !state ? (
<CenterSpinner />
) : (
<ResultBody {...state} responseId={responseId} />
)}
</Layout>
);
}
function ResultBody({ response, poll, hasRadar, responseId }: State & { responseId: string }) {
const questions = new Map(poll.questions?.map((q) => [q.id, q]) ?? []);
const submittedAt = response.submitted_at.slice(0, 19).replace("T", " ");
return (
<div className="stack">
<div className="col-gap" style={{ gap: "0.25rem" }}>
<span className="title-lg">{poll.title}</span>
<span className="text-sm muted">Пройден: {submittedAt}</span>
</div>
{hasRadar && (
<Card alt>
<div className="center-col">
<span className="title-md">Результат</span>
<RadarBlock responseId={responseId} />
</div>
</Card>
)}
<span className="title-md">Ответы</span>
{response.answers.map((ans, i) => {
const q = questions.get(ans.question_id);
if (!q) return null;
return (
<Card key={i}>
<div className="col-gap">
<span className="bold">{q.text}</span>
{q.choices.map((c) => {
const selected = c.id === ans.choice_id;
return (
<div key={c.id} className={"choice-tile" + (selected ? " selected" : "")}>
<span className="check">
{selected ? <IconCheckCircle /> : <IconRadioUnchecked />}
</span>
<span>{c.text}</span>
</div>
);
})}
</div>
</Card>
);
})}
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import * as api from "../api/client";
import { Layout } from "../components/Layout";
import { Card, CenterSpinner } from "../components/ui";
export function TestListPage() {
const navigate = useNavigate();
const [polls, setPolls] = useState<api.Poll[] | null>(null);
useEffect(() => {
let cancelled = false;
api.getPolls().then((p) => {
if (!cancelled) setPolls(p);
});
return () => {
cancelled = true;
};
}, []);
return (
<Layout title="Все тесты" back="/" showProfile={false}>
{polls === null ? (
<CenterSpinner />
) : polls.length === 0 ? (
<p className="muted">Тестов пока нет</p>
) : (
<div className="stack">
{polls.map((poll) => (
<Card key={poll.id} onClick={() => navigate(`/poll/${poll.id}`)}>
<div className="col-gap" style={{ gap: "0.3rem" }}>
<span className="title-md">{poll.title || "—"}</span>
{poll.description && <span className="text-sm muted">{poll.description}</span>}
</div>
</Card>
))}
</div>
)}
</Layout>
);
}

View File

@@ -0,0 +1,600 @@
/* ============================================================
Глобальные стили. Цвета и масштаб — через CSS-переменные,
которые задаёт ThemeContext. 1rem масштабируется слайдером.
============================================================ */
:root {
/* статическая палитра (web/designer.py) */
--primary: #f85a40;
--primary-dark: #d94530;
--primary-light: #ffeab7;
--accent: #ffc845;
--info: #007fbd;
--info-dark: #006aa3;
--success: #85c446;
/* задаются ThemeContext, но даём дефолты */
--bg: #f4f6f8;
--surface: #ffffff;
--surface-alt: #e4e8ed;
--text-primary: #1e2a3a;
--text-secondary: #5a6779;
--border: #dde3ea;
--scale: 1.3;
font-size: calc(13px * var(--scale));
}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
padding: 0;
min-height: 100%;
}
body {
background: var(--bg);
color: var(--text-primary);
font-family: "Segoe UI", system-ui, -apple-system, Roboto, Arial, sans-serif;
font-size: 1rem;
line-height: 1.45;
-webkit-font-smoothing: antialiased;
transition: background 0.2s ease;
}
#root {
display: flex;
flex-direction: column;
}
button {
font-family: inherit;
cursor: pointer;
}
a {
color: var(--primary);
text-decoration: none;
}
/* ---------- Layout ---------- */
.page {
width: 100%;
max-width: 56rem;
margin: 0 auto;
padding: 1rem 1.2rem 3rem;
flex: 1;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.app-title {
color: var(--primary);
font-size: 1.4rem;
font-weight: 700;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--primary);
padding: 0.4rem;
border-radius: 0.6rem;
font-size: 1.6rem;
line-height: 1;
transition: background 0.15s ease;
}
.icon-btn:hover {
background: var(--surface-alt);
}
/* ---------- Card ---------- */
.card {
background: var(--surface);
border-radius: 0.9rem;
padding: 1.1rem;
box-shadow: 0 1px 3px rgba(30, 42, 58, 0.06);
}
.card-alt {
background: var(--surface-alt);
}
.card.clickable {
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.card.clickable:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(30, 42, 58, 0.12);
}
/* ---------- Buttons ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border: none;
border-radius: 0.7rem;
padding: 0.75rem 1.3rem;
font-size: 1rem;
font-weight: 600;
color: var(--text-on-primary, #fff);
background: var(--primary);
transition: background 0.15s ease, opacity 0.15s ease;
}
.btn:hover:not(:disabled) {
background: var(--primary-dark);
}
.btn:disabled {
opacity: 0.55;
cursor: default;
}
.btn-info {
background: var(--info);
}
.btn-info:hover:not(:disabled) {
background: var(--info-dark);
}
.btn-ghost {
background: var(--surface-alt);
color: var(--primary);
}
.btn-ghost:hover:not(:disabled) {
background: var(--surface-alt);
filter: brightness(0.95);
}
.btn-block {
width: 100%;
}
.link-btn {
background: none;
border: none;
color: var(--primary);
font-size: 0.95rem;
padding: 0.3rem;
}
/* ---------- Fields ---------- */
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.field-label {
font-size: 0.85rem;
font-weight: 500;
color: var(--text-secondary);
}
.field-input-wrap {
display: flex;
align-items: center;
gap: 0.55rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 0.7rem;
padding: 0.7rem 0.9rem;
transition: border-color 0.15s ease;
}
.field-input-wrap:focus-within {
border-color: var(--primary);
}
.field-input-wrap .ficon {
color: var(--text-secondary);
font-size: 1.1rem;
flex-shrink: 0;
}
.field-input-wrap input {
border: none;
outline: none;
background: transparent;
color: var(--text-primary);
font-size: 1rem;
width: 100%;
font-family: inherit;
}
.field-input-wrap input::placeholder {
color: var(--border);
font-style: italic;
}
.reveal-btn {
background: none;
border: none;
color: var(--text-secondary);
padding: 0;
font-size: 1.05rem;
line-height: 1;
}
/* ---------- Dropdown ---------- */
.dropdown {
position: relative;
}
.dropdown-control {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 0.7rem;
padding: 0.7rem 0.9rem;
cursor: pointer;
}
.dropdown-value {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dropdown-value.placeholder {
color: var(--border);
font-style: italic;
}
.dropdown-menu {
position: absolute;
z-index: 30;
top: calc(100% + 0.3rem);
left: 0;
right: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 0.7rem;
box-shadow: 0 6px 20px rgba(30, 42, 58, 0.18);
max-height: 16rem;
overflow-y: auto;
}
.dropdown-search {
width: 100%;
border: none;
border-bottom: 1px solid var(--border);
padding: 0.6rem 0.9rem;
outline: none;
font-size: 0.95rem;
background: var(--surface);
color: var(--text-primary);
font-family: inherit;
position: sticky;
top: 0;
}
.dropdown-option {
padding: 0.6rem 0.9rem;
cursor: pointer;
color: var(--text-primary);
}
.dropdown-option:hover,
.dropdown-option.active {
background: var(--surface-alt);
}
.dropdown-empty {
padding: 0.7rem 0.9rem;
color: var(--text-secondary);
font-style: italic;
}
/* ---------- Auth screens ---------- */
.auth-wrap {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.5rem 1rem;
}
.auth-logo {
color: var(--primary);
font-size: 1.9rem;
font-weight: 700;
text-align: center;
}
.auth-sub {
color: var(--primary);
font-size: 1rem;
text-align: center;
margin: 0.5rem 0 1.4rem;
max-width: 28rem;
}
.auth-card {
width: 100%;
max-width: 26rem;
background: var(--surface-alt);
border-radius: 1rem;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
/* ---------- Misc ---------- */
.error-text {
color: #e23b3b;
font-size: 0.9rem;
}
.muted {
color: var(--text-secondary);
}
.center-col {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.8rem;
}
.row {
display: flex;
align-items: center;
}
.row-gap {
gap: 0.6rem;
}
.col-gap {
display: flex;
flex-direction: column;
gap: 0.7rem;
}
.stack {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.wrap-row {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
.spacer {
flex: 1;
}
/* ---------- Spinner ---------- */
.spinner {
width: 2.4rem;
height: 2.4rem;
border: 0.28rem solid var(--surface-alt);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.spinner-center {
display: flex;
justify-content: center;
padding: 2rem 0;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ---------- Modal ---------- */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 22, 33, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
padding: 1rem;
}
.modal {
background: var(--surface);
border-radius: 1rem;
padding: 1.4rem;
max-width: 24rem;
width: 100%;
display: flex;
flex-direction: column;
gap: 1rem;
}
.modal-title {
font-size: 1.2rem;
font-weight: 700;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
}
/* ---------- Home cards ---------- */
.home-grid {
display: flex;
flex-wrap: wrap;
gap: 1rem;
justify-content: center;
}
.home-tile {
width: 11rem;
height: 11rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.6rem;
background: var(--surface);
border-radius: 1rem;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.home-tile:hover {
transform: translateY(-3px);
box-shadow: 0 6px 16px rgba(30, 42, 58, 0.14);
}
.home-tile img {
width: 5.5rem;
height: 5.5rem;
}
.home-tile span {
font-weight: 600;
}
/* ---------- Poll / take test ---------- */
.counter-badge {
display: inline-block;
background: var(--surface-alt);
color: var(--text-secondary);
font-weight: 700;
border-radius: 0.7rem;
padding: 0.5rem 1.2rem;
text-align: center;
}
.question-box {
background: var(--surface);
border-radius: 1rem;
padding: 1.4rem;
text-align: center;
font-weight: 700;
font-size: 1.1rem;
}
.choice-tile {
display: flex;
align-items: center;
gap: 0.6rem;
border-radius: 0.6rem;
padding: 0.7rem 0.9rem;
background: var(--surface-alt);
cursor: pointer;
transition: background 0.12s ease;
}
.choice-tile.selected {
background: var(--primary-light);
}
.choice-tile .check {
color: var(--primary);
font-size: 1.2rem;
flex-shrink: 0;
}
.radar-img {
width: 100%;
max-width: 30rem;
height: auto;
display: block;
margin: 0 auto;
}
.success-box {
background: var(--accent);
border-radius: 1.1rem;
padding: 1.4rem;
}
/* ---------- Slider ---------- */
.scale-slider {
width: 100%;
accent-color: var(--primary);
}
/* ---------- List card row ---------- */
.list-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
}
.chevron {
color: var(--primary);
font-size: 1.4rem;
}
.bold {
font-weight: 700;
}
.title-lg {
font-size: 1.3rem;
font-weight: 700;
}
.title-md {
font-size: 1.05rem;
font-weight: 700;
}
.text-sm {
font-size: 0.85rem;
}
.divider {
height: 1px;
background: var(--border);
border: none;
margin: 0.4rem 0;
}
/* ---------- Agreement dialog ---------- */
.agr-modal {
max-width: 34rem;
max-height: 90vh;
}
.agr-body {
overflow-y: auto;
max-height: 50vh;
padding-right: 0.4rem;
}
.agr-h1 {
color: var(--primary);
font-size: 1.25rem;
font-weight: 700;
margin: 0.6rem 0 0.4rem;
}
.agr-h2 {
color: var(--info);
font-size: 1.05rem;
font-weight: 700;
margin: 0.7rem 0 0.3rem;
}
.agr-h3 {
color: var(--text-secondary);
font-size: 0.95rem;
font-weight: 700;
margin: 0.5rem 0 0.2rem;
}
.agr-p {
font-size: 0.85rem;
margin: 0.35rem 0;
}
.agr-ul {
margin: 0.2rem 0 0.4rem 1.1rem;
font-size: 0.85rem;
}
.agr-ul li {
margin: 0.15rem 0;
}
.agr-switch-row {
display: flex;
align-items: flex-start;
gap: 0.55rem;
font-size: 0.85rem;
cursor: pointer;
}
.agr-switch-row input {
width: 1.1rem;
height: 1.1rem;
accent-color: var(--primary);
flex-shrink: 0;
margin-top: 0.1rem;
}
/* ---------- SearchableDropdown dialog ---------- */
.sd-dialog {
max-width: 25rem;
height: 26rem;
max-height: 85vh;
}
.sd-list {
flex: 1;
overflow-y: auto;
margin: 0 -0.4rem;
}
.sd-dialog .dropdown-search {
position: static;
border: 1px solid var(--border);
border-radius: 0.6rem;
}

View File

@@ -0,0 +1,86 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import {
THEMES,
DEFAULT_THEME_ID,
SCALE_DEFAULT,
SCALE_MIN,
SCALE_MAX,
getTheme,
autoScale,
} from "./themes";
const THEME_KEY = "ui_theme";
const SCALE_KEY = "ui_scale";
interface ThemeCtx {
themeId: string;
scale: number;
setThemeId: (id: string) => void;
setScale: (s: number) => void;
}
const Ctx = createContext<ThemeCtx | null>(null);
function clampScale(s: number): number {
return Math.max(SCALE_MIN, Math.min(SCALE_MAX, s));
}
function applyTheme(themeId: string) {
const t = getTheme(themeId);
const root = document.documentElement;
root.style.setProperty("--bg", t.background);
root.style.setProperty("--surface", t.surface);
root.style.setProperty("--surface-alt", t.surfaceAlt);
// тёмная тема — светлый текст
const isDark = themeId === "dark";
root.style.setProperty("--text-primary", isDark ? "#F4F6F8" : "#1E2A3A");
root.style.setProperty("--text-secondary", isDark ? "#AEB9C7" : "#5A6779");
root.style.setProperty("--border", isDark ? "#4A5C72" : "#DDE3EA");
}
function applyScale(scale: number) {
document.documentElement.style.setProperty("--scale", String(scale));
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [themeId, setThemeIdState] = useState<string>(() => {
const saved = localStorage.getItem(THEME_KEY);
return saved && THEMES.some((t) => t.id === saved) ? saved : DEFAULT_THEME_ID;
});
const [scale, setScaleState] = useState<number>(() => {
const saved = localStorage.getItem(SCALE_KEY);
if (saved != null) {
const n = parseFloat(saved);
if (!Number.isNaN(n)) return clampScale(n);
}
return autoScale() || SCALE_DEFAULT;
});
useEffect(() => {
applyTheme(themeId);
}, [themeId]);
useEffect(() => {
applyScale(scale);
}, [scale]);
const setThemeId = (id: string) => {
setThemeIdState(id);
localStorage.setItem(THEME_KEY, id);
};
const setScale = (s: number) => {
const c = clampScale(s);
setScaleState(c);
localStorage.setItem(SCALE_KEY, String(c));
};
return <Ctx.Provider value={{ themeId, scale, setThemeId, setScale }}>{children}</Ctx.Provider>;
}
export function useTheme(): ThemeCtx {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
return ctx;
}

View File

@@ -0,0 +1,58 @@
// Темы и палитра. Перенос из web/theme_manager.py (THEMES) и web/designer.py (colors).
// Каждая тема переопределяет только фоновые цвета; остальная палитра общая.
export interface Theme {
id: string;
name: string;
background: string;
surface: string;
surfaceAlt: string;
}
export const THEMES: Theme[] = [
{ id: "default", name: "ЦОПП (оранжевая)", background: "#F4F6F8", surface: "#FFFFFF", surfaceAlt: "#E4E8ED" },
{ id: "warm", name: "Тёплая", background: "#FFF3E6", surface: "#FFFFFF", surfaceAlt: "#FFD9B3" },
{ id: "blue", name: "Синяя", background: "#E3F2FD", surface: "#FFFFFF", surfaceAlt: "#B8D9F5" },
{ id: "green", name: "Зелёная", background: "#EDF7E7", surface: "#FFFFFF", surfaceAlt: "#BEE3B0" },
{ id: "dark", name: "Тёмная", background: "#1E2A3A", surface: "#2D3F54", surfaceAlt: "#3D5068" },
];
export const DEFAULT_THEME_ID = "default";
// Постоянная палитра (web/designer.py: class colors)
export const PALETTE = {
primary: "#F85A40",
primaryDark: "#D94530",
primaryLight: "#FFEAB7",
accent: "#FFC845",
accentLime: "#FFC845",
secondaryLight: "#FFEAB7",
info: "#007FBD",
infoDark: "#006AA3",
infoLight: "#D9F1F4",
success: "#85C446",
successLight: "#C0DE9E",
textPrimary: "#1E2A3A",
textSecondary: "#5A6779",
textOnPrimary: "#FFFFFF",
border: "#DDE3EA",
};
// Масштаб (web/theme_manager.py)
export const SCALE_MIN = 0.8;
export const SCALE_MAX = 2.5;
export const SCALE_STEP = 0.1;
export const SCALE_DEFAULT = 1.3;
// Автоопределение по ширине экрана
export const MOBILE_BREAKPOINT = 600;
export const SCALE_MOBILE = 1.0;
export const SCALE_DESKTOP = 1.5;
export function getTheme(id: string | null | undefined): Theme {
return THEMES.find((t) => t.id === id) ?? THEMES[0];
}
export function autoScale(): number {
if (typeof window === "undefined") return SCALE_DEFAULT;
return window.innerWidth < MOBILE_BREAKPOINT ? SCALE_MOBILE : SCALE_DESKTOP;
}

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

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

21
web-react/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

18
web-react/vite.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// В dev-режиме проксируем /api на запущенный локально API (docker compose up -d api).
// В проде эту роль выполняет nginx (web-react/nginx.conf).
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": {
target: process.env.API_URL || "http://localhost:8000",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""),
},
},
},
});