Init monorepo: Frontend + Backend

This commit is contained in:
2025-11-20 06:59:26 +09:00
commit 3fa73b869b
35 changed files with 7898 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
# --- Глобальные игноры ---
node_modules/
.DS_Store
*.log
# --- Frontend ---
survey-app/dist/
survey-app/node_modules/
survey-app/.env
survey-app/.env.local
# --- Backend ---
survey-backend/dist/
survey-backend/node_modules/
survey-backend/.env

24
survey-app/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

16
survey-app/README.md Normal file
View File

@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

View File

@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

13
survey-app/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>survey-app</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

3721
survey-app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
survey-app/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "survey-app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"clsx": "^2.1.1",
"lucide-react": "^0.554.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.6",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/postcss": "^4.1.17",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"autoprefixer": "^10.4.22",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.17",
"vite": "^7.2.2"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
survey-app/src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

39
survey-app/src/App.jsx Normal file
View File

@@ -0,0 +1,39 @@
import React from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './context/AuthContext';
import { ToastProvider } from './context/ToastContext'; // <--- ИМПОРТ
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Builder from './pages/Builder';
import Runner from './pages/Runner';
import Results from './pages/Results';
const ProtectedRoute = ({ children }) => {
const { user, loading } = useAuth();
if (loading) return <div className="h-screen flex items-center justify-center text-gray-500">Загрузка...</div>;
if (!user) return <Navigate to="/login" />;
return children;
};
export default function App() {
return (
<ToastProvider> {/* <--- ОБЕРТКА ЗДЕСЬ */}
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
<Route path="/create" element={<ProtectedRoute><Builder /></ProtectedRoute>} />
<Route path="/edit/:id" element={<ProtectedRoute><Builder /></ProtectedRoute>} />
<Route path="/survey/:id" element={<ProtectedRoute><Runner /></ProtectedRoute>} />
<Route path="/results/:id" element={<ProtectedRoute><Results /></ProtectedRoute>} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</BrowserRouter>
</AuthProvider>
</ToastProvider>
);
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { Button } from './ui'; // Импортируем твою кнопку
import { AlertTriangle } from 'lucide-react';
export const ConfirmModal = ({ isOpen, onClose, onConfirm, title, message, isDangerous = false }) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6 animate-in zoom-in-95 duration-200">
<div className="flex gap-4">
<div className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 ${isDangerous ? 'bg-red-100 text-red-600' : 'bg-indigo-100 text-indigo-600'}`}>
<AlertTriangle size={24} />
</div>
<div>
<h3 className="text-lg font-bold text-gray-900">{title}</h3>
<p className="text-gray-500 text-sm mt-1">{message}</p>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<Button variant="outline" onClick={onClose}>Отмена</Button>
<Button
variant={isDangerous ? 'destructive' : 'primary'}
onClick={() => { onConfirm(); onClose(); }}
className={isDangerous ? 'bg-red-600 hover:bg-red-700 text-white' : ''}
>
Подтвердить
</Button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,44 @@
import React from 'react';
import { Loader2 } from 'lucide-react';
import { cn } from '../lib/utils';
export const Button = ({ children, variant = 'primary', className, isLoading, type = "button", ...props }) => {
const styles = {
primary: "bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm",
outline: "border border-gray-200 bg-white hover:bg-gray-100 text-gray-900 shadow-sm",
ghost: "hover:bg-gray-100 text-gray-700",
destructive: "bg-red-50 text-red-600 hover:bg-red-100",
};
return (
<button
type={type}
className={cn(
"inline-flex items-center justify-center rounded-md text-sm font-medium h-10 px-4 py-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
styles[variant],
className
)}
disabled={isLoading}
{...props}
>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{children}
</button>
);
};
export const Input = ({ className, ...props }) => (
<input
className={cn(
"flex h-10 w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:outline-none",
className
)}
{...props}
/>
);
export const Card = ({ children, className }) => (
<div className={cn("rounded-xl border border-gray-200 bg-white text-gray-950 shadow-sm", className)}>
{children}
</div>
);

View File

@@ -0,0 +1,51 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { api } from '../lib/api';
// 1. Создаем контекст с пустым объектом, чтобы не было null error
const AuthContext = createContext({});
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// 2. Вся логика внутри асинхронной функции, чтобы избежать синхронных сбоев
const initAuth = async () => {
const token = localStorage.getItem('token');
if (!token) {
setLoading(false);
return;
}
try {
const userData = await api('/auth/me');
setUser(userData);
} catch (error) {
console.error("Auth failed", error);
localStorage.removeItem('token');
} finally {
setLoading(false);
}
};
initAuth();
}, []);
const login = (token, userData) => {
localStorage.setItem('token', token);
setUser(userData);
};
const logout = () => {
localStorage.removeItem('token');
setUser(null);
};
return (
<AuthContext.Provider value={{ user, login, logout, loading }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => useContext(AuthContext);

View File

@@ -0,0 +1,68 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
import { X, CheckCircle, AlertCircle, Info } from 'lucide-react';
const ToastContext = createContext();
export const useToast = () => useContext(ToastContext);
export const ToastProvider = ({ children }) => {
const [toasts, setToasts] = useState([]);
// Добавить уведомление
const addToast = useCallback((message, type = 'info') => {
const id = Date.now();
setToasts((prev) => [...prev, { id, message, type }]);
// Авто-удаление через 3 секунды
setTimeout(() => removeToast(id), 3000);
}, []);
// Удалить уведомление
const removeToast = useCallback((id) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);
// Хелперы
const toast = {
success: (msg) => addToast(msg, 'success'),
error: (msg) => addToast(msg, 'error'),
info: (msg) => addToast(msg, 'info'),
};
return (
<ToastContext.Provider value={toast}>
{children}
{/* Контейнер для уведомлений */}
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 pointer-events-none">
{toasts.map((t) => (
<div
key={t.id}
className={`
pointer-events-auto flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg border min-w-[300px] animate-in slide-in-from-right-full duration-300
${t.type === 'success' ? 'bg-white border-green-200 text-gray-800' : ''}
${t.type === 'error' ? 'bg-white border-red-200 text-gray-800' : ''}
${t.type === 'info' ? 'bg-white border-indigo-200 text-gray-800' : ''}
`}
>
{/* Иконка */}
<div className={`
p-1 rounded-full
${t.type === 'success' ? 'bg-green-100 text-green-600' : ''}
${t.type === 'error' ? 'bg-red-100 text-red-600' : ''}
${t.type === 'info' ? 'bg-indigo-100 text-indigo-600' : ''}
`}>
{t.type === 'success' && <CheckCircle size={16} />}
{t.type === 'error' && <AlertCircle size={16} />}
{t.type === 'info' && <Info size={16} />}
</div>
<p className="text-sm font-medium flex-1">{t.message}</p>
<button onClick={() => removeToast(t.id)} className="text-gray-400 hover:text-gray-600">
<X size={16} />
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
};

1
survey-app/src/index.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

26
survey-app/src/lib/api.js Normal file
View File

@@ -0,0 +1,26 @@
export const API_URL = 'http://localhost:5000/api';
export const api = async (endpoint, method = 'GET', body = null) => {
const token = localStorage.getItem('token');
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
// Мы просто выполняем запрос. Если он упадет (например, нет сети),
// ошибка сама полетит вверх к Login.jsx, где мы её и поймаем.
const res = await fetch(`${API_URL}${endpoint}`, {
method,
headers,
body: body ? JSON.stringify(body) : null
});
const contentType = res.headers.get("content-type");
if (!contentType || !contentType.includes("application/json")) {
throw new Error("Ошибка сервера: получен не JSON ответ. Проверьте консоль бэкенда.");
}
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка запроса');
return data;
};

View File

@@ -0,0 +1,6 @@
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs) {
return twMerge(clsx(inputs));
}

10
survey-app/src/main.jsx Normal file
View File

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

View File

@@ -0,0 +1,340 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { api } from '../lib/api';
import { useToast } from '../context/ToastContext';
import { Button, Input, Card } from '../components/ui';
import {
ArrowLeft, Save, Plus, Trash2, Circle, Square, GripVertical,
ChevronDown, Check, Trophy, Globe, Lock, Settings, FileText
} from 'lucide-react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragOverlay,
defaultDropAnimationSideEffects
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
const restrictToVerticalAxis = ({ transform }) => {
return {
...transform,
x: 0,
};
};
const dropAnimationConfig = {
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: {
opacity: '0.4',
},
},
}),
};
// --- 1. UI КОМПОНЕНТЫ ---
const QuestionTypeSelect = ({ value, onChange }) => {
const [isOpen, setIsOpen] = useState(false);
const ref = useRef(null);
const options = [{ value: 'SINGLE', label: 'Одиночный выбор', icon: Circle }, { value: 'MULTI', label: 'Множественный выбор', icon: Square }];
const currentOption = options.find(o => o.value === value);
const CurrentIcon = currentOption?.icon || Circle;
useEffect(() => {
const handleClickOutside = (e) => { if (ref.current && !ref.current.contains(e.target)) setIsOpen(false); };
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div className="relative min-w-[220px]" ref={ref}>
<button type="button" onClick={() => setIsOpen(!isOpen)} className={`w-full bg-gray-50 hover:bg-gray-100 border-0 rounded-lg py-2 pl-3 pr-10 text-left text-sm font-medium transition-all flex items-center gap-2 ${isOpen ? 'ring-2 ring-indigo-100 bg-white' : ''}`}>
<CurrentIcon size={16} className="text-indigo-500"/>
<span className="block truncate text-gray-700">{currentOption?.label}</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none"><ChevronDown className={`h-4 w-4 text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} /></span>
</button>
{isOpen && (
<div className="absolute z-50 mt-2 w-full overflow-hidden rounded-xl bg-white border border-gray-100 shadow-xl ring-1 ring-black ring-opacity-5 animate-in fade-in zoom-in-95 duration-200">
{options.map((opt) => {
const Icon = opt.icon;
return (
<div key={opt.value} onClick={() => { onChange(opt.value); setIsOpen(false); }} className={`relative cursor-pointer select-none py-3 pl-3 pr-4 flex items-center gap-3 transition-colors ${value === opt.value ? 'bg-indigo-50 text-indigo-900' : 'text-gray-700 hover:bg-gray-50'}`}>
<Icon size={16} className={value === opt.value ? 'text-indigo-600' : 'text-gray-400'}/>
<span className={`block truncate text-sm ${value === opt.value ? 'font-medium' : 'font-normal'}`}>{opt.label}</span>
{value === opt.value && <Check className="h-4 w-4 text-indigo-600 ml-auto" />}
</div>
);
})}
</div>
)}
</div>
);
};
// --- 2. ВИЗУАЛЬНАЯ КАРТОЧКА ---
const QuestionCard = ({ q, index, updateQ, removeQ, addOpt, updateOpt, toggleCorrect, dragHandleProps, isOverlay }) => {
return (
<div className={`
mb-6 bg-white rounded-xl p-6 relative transition-all duration-200
${isOverlay
? 'shadow-2xl ring-1 ring-indigo-500/50 cursor-grabbing scale-[1.02] z-50'
: 'shadow-sm border border-gray-200 hover:shadow-md'
}
`}>
{!isOverlay && <div className="absolute top-6 bottom-6 left-0 w-1 bg-indigo-500 rounded-r-full opacity-0 group-hover:opacity-100 transition-opacity"></div>}
<div
className="absolute left-4 top-6 cursor-grab active:cursor-grabbing text-gray-300 hover:text-gray-600 touch-none p-2 -ml-2 outline-none"
{...dragHandleProps}
>
<GripVertical size={20} />
</div>
<div className="pl-8">
<div className="flex flex-col sm:flex-row sm:items-center justify-between mb-5 gap-4">
<span className="text-xs font-bold text-gray-400 uppercase tracking-wider flex items-center gap-2 select-none">
Вопрос {index + 1}
</span>
<div className="flex items-center gap-3 ml-auto w-full sm:w-auto">
<QuestionTypeSelect value={q.type || 'SINGLE'} onChange={(val) => updateQ && updateQ(index, 'type', val)} />
<button onClick={() => removeQ && removeQ(index)} className="w-9 h-9 flex items-center justify-center rounded-lg text-gray-400 hover:text-red-500 hover:bg-red-50 transition-all" title="Удалить вопрос">
<Trash2 size={18}/>
</button>
</div>
</div>
<div className="mb-6">
<input
value={q.text}
onChange={e => updateQ && updateQ(index, 'text', e.target.value)}
placeholder="Введите текст вопроса..."
className="w-full text-lg font-medium border-b border-gray-200 pb-2 focus:border-indigo-500 focus:outline-none transition-colors bg-transparent placeholder:text-gray-400"
/>
</div>
<div className="space-y-3">
{q.options.map((opt, oIdx) => (
<div key={oIdx} className="flex items-center gap-3 group/opt">
<button onClick={() => toggleCorrect && toggleCorrect(index, oIdx)} className="transition-transform active:scale-95 focus:outline-none">
{q.type === 'MULTI'
? <Square size={22} className={opt.isCorrect ? "text-emerald-500 fill-emerald-50" : "text-gray-300"} />
: <Circle size={22} className={opt.isCorrect ? "text-emerald-500 fill-emerald-50" : "text-gray-300"} />
}
</button>
<input
value={opt.text}
onChange={e => updateOpt && updateOpt(index, oIdx, e.target.value)}
placeholder={`Вариант ${oIdx + 1}`}
className={`flex-1 h-10 bg-transparent border-b border-transparent hover:border-gray-200 focus:border-indigo-500 focus:outline-none transition-colors text-sm ${opt.isCorrect ? 'text-emerald-700 font-medium' : 'text-gray-700'}`}
/>
{q.options.length > 1 && (
<button onClick={() => {
if(!updateQ) return;
const n = [...q.options];
n.splice(oIdx, 1);
updateQ(index, 'options', n);
}} className="opacity-0 group-hover/opt:opacity-100 text-gray-300 hover:text-red-400 transition-all">
<Trash2 size={14}/>
</button>
)}
</div>
))}
<button onClick={() => addOpt && addOpt(index)} className="flex items-center gap-2 text-sm font-medium text-indigo-600 hover:text-indigo-700 mt-2 pl-1 py-1 rounded hover:bg-indigo-50 w-fit transition-colors">
<Plus size={16}/> Добавить вариант
</button>
</div>
</div>
</div>
);
};
// --- 3. SORTABLE WRAPPER ---
const SortableItem = ({ id, ...props }) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
const style = {
transform: CSS.Translate.toString(transform),
transition,
// ВОТ ОН: Возвращаем "Призрака" (полупрозрачный оригинал)
opacity: isDragging ? 0.4 : 1,
};
return (
<div ref={setNodeRef} style={style} className="relative group">
<QuestionCard {...props} dragHandleProps={{ ...attributes, ...listeners }} />
</div>
);
};
// --- MAIN ---
export default function Builder() {
const navigate = useNavigate();
const toast = useToast();
const { id } = useParams();
const isEditMode = Boolean(id);
const [activeId, setActiveId] = useState(null);
const [title, setTitle] = useState('Новый опрос');
const [accessType, setAccessType] = useState('PUBLIC');
const [allowedCodes, setAllowedCodes] = useState('');
const [loading, setLoading] = useState(false);
const [questions, setQuestions] = useState([
{ id: 'q-1', text: '', type: 'SINGLE', options: [{ text: '', isCorrect: true }] }
]);
const totalMaxScore = useMemo(() => questions.reduce((t, q) => t + q.options.filter(o => o.isCorrect).length, 0), [questions]);
useEffect(() => {
if (isEditMode) {
setLoading(true);
api(`/surveys/${id}?mode=edit`)
.then(data => {
setTitle(data.title);
setAccessType(data.accessType);
setAllowedCodes(data.allowedUsers.map(u => u.user.inviteCode).join(', '));
setQuestions(data.questions.map(q => ({ ...q, id: String(q.id), type: q.type || 'SINGLE' })));
})
.catch(() => toast.error("Ошибка загрузки"))
.finally(() => setLoading(false));
}
}, [id, isEditMode]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const handleDragStart = (event) => setActiveId(event.active.id);
const handleDragCancel = () => setActiveId(null);
const handleDragEnd = (event) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setQuestions((items) => {
const oldIndex = items.findIndex((i) => i.id === active.id);
const newIndex = items.findIndex((i) => i.id === over.id);
return arrayMove(items, oldIndex, newIndex);
});
}
setActiveId(null);
};
const updateQ = (i, f, v) => { const n = [...questions]; n[i][f] = v; setQuestions(n); };
const addOpt = (qi) => { const n = [...questions]; n[qi].options.push({ text: '', isCorrect: false }); setQuestions(n); };
const updateOpt = (qi, oi, v) => { const n = [...questions]; n[qi].options[oi].text = v; setQuestions(n); };
const toggleCorrect = (qi, oi) => {
const n = [...questions];
if (n[qi].type === 'MULTI') n[qi].options[oi].isCorrect = !n[qi].options[oi].isCorrect;
else n[qi].options.forEach((o, k) => o.isCorrect = k === oi);
setQuestions(n);
};
const addQuestion = () => {
const newId = `q-${Date.now()}`;
setQuestions([...questions, { id: newId, text: '', type: 'SINGLE', options: [{ text: '', isCorrect: true }] }]);
};
const save = async () => {
if (!title.trim()) return toast.error("Введите название опроса");
if (questions.some(q => !q.text.trim())) return toast.error("Заполните текст всех вопросов");
setLoading(true);
try {
const cleanQuestions = questions.map(({ id, ...rest }) => rest);
const cleanCodes = allowedCodes.split(',').map(s => s.trim().toUpperCase()).filter(Boolean);
const payload = { title, accessType, allowedUserCodes: cleanCodes, questions: cleanQuestions };
if (isEditMode) await api(`/surveys/${id}`, 'PUT', payload);
else await api('/surveys', 'POST', payload);
toast.success(isEditMode ? "Опрос обновлен" : "Опрос опубликован!");
navigate('/');
} catch (e) { toast.error(e.message); } finally { setLoading(false); }
};
const activeQuestion = useMemo(() => questions.find(q => q.id === activeId), [activeId, questions]);
const activeIndex = useMemo(() => questions.findIndex(q => q.id === activeId), [activeId, questions]);
return (
<div className="min-h-screen bg-[#F3F4F6] pb-32">
<header className="bg-white border-b border-gray-200 sticky top-0 z-20 px-6 h-16 flex items-center justify-between shadow-sm">
<div className="flex items-center gap-4 flex-1">
<Button variant="ghost" onClick={() => navigate('/')} className="text-gray-500 hover:text-gray-900"><ArrowLeft size={20}/></Button>
<div className="flex items-center gap-2 w-full max-w-md">
<FileText size={18} className="text-indigo-500 shrink-0"/>
<input value={title} onChange={e => setTitle(e.target.value)} className="font-bold text-lg text-gray-900 bg-transparent border-b border-transparent hover:border-gray-300 focus:border-indigo-500 focus:outline-none px-1 py-0.5 w-full transition-all" placeholder="Название опроса"/>
</div>
</div>
<div className="flex items-center gap-3">
<div className="hidden md:flex items-center gap-2 text-xs font-bold uppercase tracking-wide text-indigo-600 bg-indigo-50 px-3 py-1.5 rounded-full border border-indigo-100 mr-2">
<Trophy size={14} /><span>Баллы: {totalMaxScore}</span>
</div>
<Button onClick={save} isLoading={loading} className="bg-indigo-600 hover:bg-indigo-700 shadow-md shadow-indigo-200"><Save size={18} className="mr-2"/> {isEditMode ? 'Сохранить' : 'Опубликовать'}</Button>
</div>
</header>
<div className="max-w-6xl mx-auto p-6 mt-4 grid grid-cols-1 lg:grid-cols-[1fr_320px] gap-8 items-start">
<div className="space-y-6">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
modifiers={[restrictToVerticalAxis]}
>
<SortableContext items={questions} strategy={verticalListSortingStrategy}>
{questions.map((q, i) => (
<SortableItem
key={q.id} id={q.id} q={q} index={i}
updateQ={updateQ} removeQ={(idx) => setQuestions(questions.filter((_, x) => x !== idx))}
addOpt={addOpt} updateOpt={updateOpt} toggleCorrect={toggleCorrect}
/>
))}
</SortableContext>
<DragOverlay dropAnimation={dropAnimationConfig}>
{activeId ? <QuestionCard q={activeQuestion} index={activeIndex} isOverlay={true} /> : null}
</DragOverlay>
</DndContext>
<button onClick={addQuestion} className="w-full py-6 border-2 border-dashed border-gray-300 rounded-xl flex flex-col items-center justify-center text-gray-400 hover:border-indigo-400 hover:text-indigo-600 hover:bg-indigo-50/50 transition-all duration-200 group">
<div className="w-10 h-10 rounded-full bg-white border border-gray-200 flex items-center justify-center mb-2 shadow-sm group-hover:scale-110 transition-transform"><Plus size={20} /></div>
<span className="font-medium">Добавить вопрос</span>
</button>
</div>
<div className="lg:sticky lg:top-24 space-y-6">
<div className="bg-white p-5 rounded-xl border border-gray-200 shadow-sm">
<div className="flex items-center gap-2 mb-4 text-gray-900 font-bold"><Settings size={18} className="text-gray-400"/><h3>Параметры доступа</h3></div>
<div className="space-y-3">
<div onClick={() => setAccessType('PUBLIC')} className={`cursor-pointer p-3 rounded-lg border-2 transition-all flex items-start gap-3 ${accessType === 'PUBLIC' ? 'border-emerald-500 bg-emerald-50/30' : 'border-gray-100 hover:border-gray-200'}`}>
<div className={`mt-0.5 w-5 h-5 rounded-full border flex items-center justify-center ${accessType === 'PUBLIC' ? 'border-emerald-500 bg-emerald-500 text-white' : 'border-gray-300'}`}>{accessType === 'PUBLIC' && <Check size={12}/>}</div>
<div><div className="text-sm font-bold text-gray-900 flex items-center gap-2"><Globe size={14}/> Публичный</div><div className="text-xs text-gray-500 mt-0.5">Доступен всем</div></div>
</div>
<div onClick={() => setAccessType('INVITE_ONLY')} className={`cursor-pointer p-3 rounded-lg border-2 transition-all flex items-start gap-3 ${accessType === 'INVITE_ONLY' ? 'border-indigo-500 bg-indigo-50/30' : 'border-gray-100 hover:border-gray-200'}`}>
<div className={`mt-0.5 w-5 h-5 rounded-full border flex items-center justify-center ${accessType === 'INVITE_ONLY' ? 'border-indigo-500 bg-indigo-500 text-white' : 'border-gray-300'}`}>{accessType === 'INVITE_ONLY' && <Check size={12}/>}</div>
<div><div className="text-sm font-bold text-gray-900 flex items-center gap-2"><Lock size={14}/> По приглашению</div><div className="text-xs text-gray-500 mt-0.5">Только по кодам</div></div>
</div>
</div>
{accessType === 'INVITE_ONLY' && (
<div className="mt-4 pt-4 border-t border-gray-100 animate-in slide-in-from-top-2 fade-in duration-200">
<label className="text-xs font-bold text-gray-500 uppercase mb-2 block">Коды доступа</label>
<textarea placeholder="#USER-1234, #USER-5678" value={allowedCodes} onChange={e => setAllowedCodes(e.target.value)} className="w-full text-sm p-3 rounded-lg border border-gray-200 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200 outline-none min-h-[80px] resize-y font-mono bg-gray-50"/>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,247 @@
import React, { useEffect, useState, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useToast } from '../context/ToastContext';
import { ConfirmModal } from '../components/ConfirmModal';
import { api } from '../lib/api';
import { Button, Card } from '../components/ui';
import {
Plus, Trash2, BarChart3, Play, LogOut, Share2, Copy,
Globe, Lock, Edit, LayoutGrid, List, Users, CheckCircle2
} from 'lucide-react';
export default function Dashboard() {
const { user, logout } = useAuth();
const toast = useToast();
const [activeTab, setActiveTab] = useState('my');
const [mySurveys, setMySurveys] = useState([]);
const [feedSurveys, setFeedSurveys] = useState([]);
const [deleteId, setDeleteId] = useState(null);
const loadMy = () => api('/surveys/my').then(setMySurveys);
const loadFeed = () => api('/surveys/feed').then(setFeedSurveys);
useEffect(() => {
loadMy();
loadFeed();
}, []);
// --- СТАТИСТИКА (Вычисляем на лету) ---
const stats = useMemo(() => {
const totalSurveys = mySurveys.length;
const totalResponses = mySurveys.reduce((acc, s) => acc + (s._count?.submissions || 0), 0);
return { totalSurveys, totalResponses };
}, [mySurveys]);
// --------------------------------------
const confirmDelete = async () => {
try {
await api(`/surveys/${deleteId}`, 'DELETE');
toast.success('Опрос удален');
loadMy();
loadFeed();
} catch (e) {
toast.error('Ошибка удаления');
}
};
const copyLink = (id) => {
const link = `${window.location.origin}/survey/${id}`;
navigator.clipboard.writeText(link);
toast.info('Ссылка скопирована');
};
const copyCode = () => {
navigator.clipboard.writeText(user.inviteCode);
toast.info('Код приглашения скопирован');
};
// --- НОВЫЙ КОМПОНЕНТ КАРТОЧКИ ---
const SurveyCard = ({ s, isMy }) => (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm hover:shadow-lg transition-all duration-300 flex flex-col overflow-hidden group">
{/* Тело карточки */}
<div className="p-6 flex-1">
<div className="flex justify-between items-start mb-4">
{s.accessType === 'PUBLIC'
? <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-100 text-emerald-800 border border-emerald-200"><Globe size={12} className="mr-1"/> Публичный</span>
: <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800 border border-indigo-200"><Lock size={12} className="mr-1"/> Приватный</span>
}
{isMy && (
<button onClick={() => setDeleteId(s.id)} className="text-gray-300 hover:text-red-500 transition-colors opacity-0 group-hover:opacity-100">
<Trash2 size={18} />
</button>
)}
</div>
<h3 className="text-xl font-bold text-gray-900 mb-2 line-clamp-2">{s.title}</h3>
<div className="flex items-center text-sm text-gray-500 gap-4">
<div className="flex items-center gap-1">
<Users size={14}/>
<span>{isMy ? s._count?.submissions : 'Автор: ' + (s.author?.name || 'Unknown')}</span>
</div>
<div className="text-xs text-gray-400">
{new Date(s.createdAt || Date.now()).toLocaleDateString()}
</div>
</div>
</div>
{/* Подвал карточки (Footer) - Серый фон */}
<div className="bg-gray-50 px-6 py-4 border-t border-gray-100 flex items-center gap-3">
<Link to={`/survey/${s.id}`} className="flex-1">
<Button variant="primary" className="w-full h-9 text-xs shadow-none bg-indigo-600 hover:bg-indigo-700">
<Play size={14} className="mr-2 fill-current"/> Пройти
</Button>
</Link>
{isMy ? (
<>
<Link to={`/edit/${s.id}`}>
<button className="h-9 w-9 flex items-center justify-center rounded-md border border-gray-200 bg-white text-gray-600 hover:bg-gray-50 hover:text-indigo-600 transition-colors" title="Редактировать">
<Edit size={16}/>
</button>
</Link>
<Link to={`/results/${s.id}`}>
<button className="h-9 w-9 flex items-center justify-center rounded-md border border-gray-200 bg-white text-gray-600 hover:bg-gray-50 hover:text-indigo-600 transition-colors" title="Результаты">
<BarChart3 size={16}/>
</button>
</Link>
</>
) : null}
{s.accessType === 'PUBLIC' && (
<button onClick={() => copyLink(s.id)} className="h-9 w-9 flex items-center justify-center rounded-md border border-gray-200 bg-white text-gray-600 hover:bg-gray-50 hover:text-indigo-600 transition-colors" title="Поделиться">
<Share2 size={16}/>
</button>
)}
</div>
</div>
);
return (
<div className="min-h-screen bg-gray-50/50 pb-20">
<ConfirmModal
isOpen={!!deleteId}
onClose={() => setDeleteId(null)}
onConfirm={confirmDelete}
title="Удалить опрос?"
message="Это действие нельзя отменить."
isDangerous={true}
/>
{/* --- 1. HERO HEADER (Градиент) --- */}
<div className="bg-gradient-to-r from-indigo-900 via-indigo-800 to-violet-900 text-white pt-10 pb-24 px-6">
<div className="max-w-6xl mx-auto">
<div className="flex justify-between items-center mb-8">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-white/10 rounded-xl flex items-center justify-center backdrop-blur-sm border border-white/20">
<LayoutGrid className="text-white" size={20}/>
</div>
<h1 className="text-2xl font-bold">Survey App</h1>
</div>
<Button variant="ghost" onClick={logout} className="text-white hover:bg-white/10 hover:text-white border border-white/20">
<LogOut size={16} className="mr-2"/> Выход
</Button>
</div>
<div className="flex flex-col md:flex-row justify-between items-end gap-6">
<div>
<h2 className="text-3xl font-bold mb-2">Привет, {user?.name || 'Guest'}! 👋</h2>
<p className="text-indigo-200">Вот сводка вашей активности за сегодня.</p>
</div>
<Link to="/create">
<Button className="bg-white text-indigo-900 hover:bg-gray-100 border-none shadow-xl">
<Plus size={18} className="mr-2"/> Создать новый опрос
</Button>
</Link>
</div>
</div>
</div>
{/* --- 2. СТАТИСТИКА (Карточки поднимаются наверх) --- */}
<div className="max-w-6xl mx-auto px-6 -mt-12 grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Карточка 1: Код */}
<div className="bg-white p-6 rounded-xl shadow-lg shadow-indigo-900/5 border border-gray-100 flex items-center justify-between">
<div>
<p className="text-gray-500 text-xs font-bold uppercase tracking-wider mb-1">Ваш код доступа</p>
<div className="flex items-center gap-2 cursor-pointer group" onClick={copyCode}>
<code className="text-xl font-bold text-indigo-600">{user?.inviteCode}</code>
<Copy size={16} className="text-gray-300 group-hover:text-indigo-500 transition-colors"/>
</div>
</div>
<div className="w-10 h-10 bg-indigo-50 rounded-full flex items-center justify-center text-indigo-600">
<Lock size={20}/>
</div>
</div>
{/* Карточка 2: Опросы */}
<div className="bg-white p-6 rounded-xl shadow-lg shadow-indigo-900/5 border border-gray-100 flex items-center justify-between">
<div>
<p className="text-gray-500 text-xs font-bold uppercase tracking-wider mb-1">Создано опросов</p>
<p className="text-2xl font-bold text-gray-900">{stats.totalSurveys}</p>
</div>
<div className="w-10 h-10 bg-emerald-50 rounded-full flex items-center justify-center text-emerald-600">
<List size={20}/>
</div>
</div>
{/* Карточка 3: Ответы */}
<div className="bg-white p-6 rounded-xl shadow-lg shadow-indigo-900/5 border border-gray-100 flex items-center justify-between">
<div>
<p className="text-gray-500 text-xs font-bold uppercase tracking-wider mb-1">Получено ответов</p>
<p className="text-2xl font-bold text-gray-900">{stats.totalResponses}</p>
</div>
<div className="w-10 h-10 bg-amber-50 rounded-full flex items-center justify-center text-amber-600">
<CheckCircle2 size={20}/>
</div>
</div>
</div>
{/* --- 3. КОНТЕНТ --- */}
<div className="max-w-6xl mx-auto px-6 mt-10">
{/* Табы (Pills style) */}
<div className="flex bg-gray-200/50 p-1 rounded-xl w-fit mb-8">
<button
onClick={() => setActiveTab('my')}
className={`px-6 py-2 rounded-lg text-sm font-medium transition-all ${activeTab === 'my' ? 'bg-white text-indigo-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
>
Мои опросы
</button>
<button
onClick={() => setActiveTab('feed')}
className={`px-6 py-2 rounded-lg text-sm font-medium transition-all ${activeTab === 'feed' ? 'bg-white text-indigo-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
>
Лента опросов
</button>
</div>
{/* Сетка опросов */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{(activeTab === 'my' ? mySurveys : feedSurveys).map(s => (
<SurveyCard key={s.id} s={s} isMy={activeTab === 'my'} />
))}
</div>
{/* Пустое состояние */}
{(activeTab === 'my' ? mySurveys : feedSurveys).length === 0 && (
<div className="text-center py-20 bg-white rounded-2xl border border-dashed border-gray-300">
<div className="w-16 h-16 bg-gray-50 rounded-full flex items-center justify-center mx-auto mb-4">
<List className="text-gray-300" size={32}/>
</div>
<h3 className="text-lg font-medium text-gray-900">Список пуст</h3>
<p className="text-gray-500 max-w-xs mx-auto mt-1">
{activeTab === 'my' ? 'Вы еще не создали ни одного опроса.' : 'Вам пока не доступен ни один опрос.'}
</p>
{activeTab === 'my' && (
<Link to="/create">
<Button variant="outline" className="mt-4">Создать первый опрос</Button>
</Link>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,146 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useToast } from '../context/ToastContext'; // Используем тосты для ошибок
import { api } from '../lib/api';
import { Button } from '../components/ui';
import { LayoutGrid, Mail, Lock, User, ArrowRight, Loader2 } from 'lucide-react';
export default function Login() {
const { login, user } = useAuth();
const navigate = useNavigate();
const toast = useToast();
const [isReg, setIsReg] = useState(false);
const [loading, setLoading] = useState(false);
const [form, setForm] = useState({ email: '', password: '', name: '' });
// Если уже вошел — редирект
useEffect(() => {
if (user) navigate('/');
}, [user, navigate]);
const submit = async (e) => {
e.preventDefault();
setLoading(true);
try {
const endpoint = isReg ? '/auth/register' : '/auth/login';
const res = await api(endpoint, 'POST', form);
login(res.token, res.user);
toast.success(isReg ? 'Аккаунт создан! Добро пожаловать.' : 'С возвращением!');
navigate('/');
} catch (err) {
console.error(err);
toast.error(err.message || "Ошибка входа");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-indigo-900 via-violet-900 to-purple-900 p-4">
{/* Декоративные круги на фоне (Blur effect) */}
<div className="fixed top-0 left-0 w-full h-full overflow-hidden pointer-events-none">
<div className="absolute top-[20%] left-[20%] w-96 h-96 bg-indigo-500/20 rounded-full blur-3xl animate-pulse"></div>
<div className="absolute bottom-[20%] right-[20%] w-96 h-96 bg-purple-500/20 rounded-full blur-3xl animate-pulse delay-1000"></div>
</div>
<div className="w-full max-w-md bg-white/95 backdrop-blur-xl rounded-2xl shadow-2xl border border-white/20 overflow-hidden relative z-10 animate-in fade-in zoom-in-95 duration-300">
{/* Верхняя часть с Логотипом */}
<div className="bg-gray-50/50 px-8 py-6 border-b border-gray-100 text-center">
<div className="w-12 h-12 bg-indigo-600 rounded-xl flex items-center justify-center mx-auto mb-4 shadow-lg shadow-indigo-500/30 text-white">
<LayoutGrid size={24}/>
</div>
<h2 className="text-2xl font-bold text-gray-900">
{isReg ? 'Создать аккаунт' : 'Добро пожаловать'}
</h2>
<p className="text-gray-500 text-sm mt-1">
{isReg ? 'Начните создавать опросы бесплатно' : 'Войдите, чтобы управлять опросами'}
</p>
</div>
{/* Форма */}
<div className="p-8">
<form onSubmit={submit} className="space-y-5">
{isReg && (
<div className="space-y-1.5">
<label className="text-xs font-semibold text-gray-700 uppercase ml-1">Имя</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<input
className="w-full pl-10 pr-4 py-2.5 bg-gray-50 border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:bg-white focus:border-indigo-500 transition-all outline-none text-sm"
placeholder="Иван Иванов"
value={form.name}
onChange={e => setForm({...form, name: e.target.value})}
required
/>
</div>
</div>
)}
<div className="space-y-1.5">
<label className="text-xs font-semibold text-gray-700 uppercase ml-1">Email</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<input
type="email"
className="w-full pl-10 pr-4 py-2.5 bg-gray-50 border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:bg-white focus:border-indigo-500 transition-all outline-none text-sm"
placeholder="name@example.com"
value={form.email}
onChange={e => setForm({...form, email: e.target.value})}
required
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-semibold text-gray-700 uppercase ml-1">Пароль</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<input
type="password"
className="w-full pl-10 pr-4 py-2.5 bg-gray-50 border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:bg-white focus:border-indigo-500 transition-all outline-none text-sm font-mono"
placeholder="••••••••"
value={form.password}
onChange={e => setForm({...form, password: e.target.value})}
required
/>
</div>
</div>
<Button type="submit" className="w-full h-11 text-base mt-2 shadow-lg shadow-indigo-500/20" isLoading={loading}>
{isReg ? 'Зарегистрироваться' : 'Войти в систему'}
{!loading && <ArrowRight size={18} className="ml-2 opacity-80"/>}
</Button>
</form>
{/* Переключатель */}
<div className="mt-6 text-center">
<div className="relative">
<div className="absolute inset-0 flex items-center"><div className="w-full border-t border-gray-100"></div></div>
<div className="relative flex justify-center text-xs uppercase"><span className="bg-white px-2 text-gray-400">или</span></div>
</div>
<button
type="button"
onClick={() => { setIsReg(!isReg); setForm({ email: '', password: '', name: '' }); }}
className="mt-4 text-sm font-medium text-indigo-600 hover:text-indigo-700 hover:underline transition-all"
>
{isReg ? 'У меня уже есть аккаунт' : 'Создать новый аккаунт'}
</button>
</div>
</div>
</div>
{/* Футер страницы */}
<div className="absolute bottom-4 text-center text-indigo-200/40 text-xs">
© 2025 Survey App. All rights reserved.
</div>
</div>
);
}

View File

@@ -0,0 +1,205 @@
import React, { useEffect, useState, useMemo } from 'react';
import { useParams, Link } from 'react-router-dom';
import { api } from '../lib/api';
import { Button, Card } from '../components/ui';
import { ArrowLeft, Trophy, Users, Calculator, Download, Calendar, Award } from 'lucide-react';
export default function Results() {
const { id } = useParams();
const [subs, setSubs] = useState([]);
const [loading, setLoading] = useState(true);
const [title, setTitle] = useState('Результаты');
useEffect(() => {
const load = async () => {
try {
// Загружаем результаты
const data = await api(`/surveys/${id}/results`);
setSubs(data);
// Загружаем название опроса (для красоты в заголовке)
const surveyInfo = await api(`/surveys/${id}?mode=edit`);
setTitle(surveyInfo.title);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
load();
}, [id]);
// --- ВЫЧИСЛЯЕМ СТАТИСТИКУ ---
const stats = useMemo(() => {
if (!subs.length) return null;
const total = subs.length;
// Средний балл
const avgScore = (subs.reduce((acc, s) => acc + s.score, 0) / total).toFixed(1);
// Максимально возможный балл (берем из первого результата, так как он у всех одинаковый для теста)
const maxPossible = subs[0].maxScore;
// Лучший результат среди участников
const bestScore = Math.max(...subs.map(s => s.score));
return { total, avgScore, maxPossible, bestScore };
}, [subs]);
// Хелпер для цвета бейджика с баллами
const getScoreColor = (score, max) => {
const percent = (score / max) * 100;
if (percent === 100) return 'bg-emerald-100 text-emerald-700 border-emerald-200';
if (percent >= 70) return 'bg-blue-100 text-blue-700 border-blue-200';
if (percent >= 40) return 'bg-amber-100 text-amber-700 border-amber-200';
return 'bg-red-100 text-red-700 border-red-200';
};
// Хелпер для аватарок (Инициалы)
const getInitials = (name) => {
return name ? name.substring(0, 2).toUpperCase() : '??';
};
// Заглушка для экспорта
const handleExport = () => {
alert("В реальном проекте здесь начнется скачивание .CSV файла");
};
if (loading) return <div className="h-screen flex items-center justify-center text-gray-500">Загрузка аналитики...</div>;
return (
<div className="min-h-screen bg-gray-50/50 p-6">
<div className="max-w-5xl mx-auto space-y-8">
{/* ХЕДЕР */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<Link to="/" className="inline-flex items-center text-sm text-gray-500 hover:text-indigo-600 mb-2 transition-colors">
<ArrowLeft size={16} className="mr-1" /> Вернуться назад
</Link>
<h1 className="text-2xl font-bold text-gray-900">{title}</h1>
<p className="text-gray-500 text-sm mt-1">Аналитика и список ответов</p>
</div>
<Button variant="outline" onClick={handleExport}>
<Download size={16} className="mr-2"/> Экспорт в CSV
</Button>
</div>
{/* КАРТОЧКИ СТАТИСТИКИ (KPI) */}
{stats ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Карточка 1: Участники */}
<Card className="p-5 border-l-4 border-l-indigo-500 flex items-center justify-between">
<div>
<p className="text-gray-500 text-xs font-bold uppercase tracking-wider">Всего участников</p>
<p className="text-3xl font-bold text-gray-900 mt-1">{stats.total}</p>
</div>
<div className="w-12 h-12 bg-indigo-50 rounded-full flex items-center justify-center text-indigo-600">
<Users size={24}/>
</div>
</Card>
{/* Карточка 2: Средний балл */}
<Card className="p-5 border-l-4 border-l-amber-500 flex items-center justify-between">
<div>
<p className="text-gray-500 text-xs font-bold uppercase tracking-wider">Средний балл</p>
<div className="flex items-baseline gap-1 mt-1">
<p className="text-3xl font-bold text-gray-900">{stats.avgScore}</p>
<span className="text-gray-400 text-sm">/ {stats.maxPossible}</span>
</div>
</div>
<div className="w-12 h-12 bg-amber-50 rounded-full flex items-center justify-center text-amber-600">
<Calculator size={24}/>
</div>
</Card>
{/* Карточка 3: Лучший результат */}
<Card className="p-5 border-l-4 border-l-emerald-500 flex items-center justify-between">
<div>
<p className="text-gray-500 text-xs font-bold uppercase tracking-wider">Рекорд</p>
<div className="flex items-baseline gap-1 mt-1">
<p className="text-3xl font-bold text-gray-900">{stats.bestScore}</p>
<span className="text-gray-400 text-sm">/ {stats.maxPossible}</span>
</div>
</div>
<div className="w-12 h-12 bg-emerald-50 rounded-full flex items-center justify-center text-emerald-600">
<Trophy size={24}/>
</div>
</Card>
</div>
) : (
<Card className="p-8 text-center text-gray-500 border-dashed">
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-3">
<Users className="text-gray-400" />
</div>
Пока нет ответов на этот опрос.
</Card>
)}
{/* ТАБЛИЦА РЕЗУЛЬТАТОВ */}
{subs.length > 0 && (
<Card className="overflow-hidden border-gray-200 shadow-sm">
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex justify-between items-center">
<h3 className="font-bold text-gray-700">Детальный список</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="bg-gray-50 text-gray-500 border-b border-gray-100">
<tr>
<th className="px-6 py-3 font-medium">Участник</th>
<th className="px-6 py-3 font-medium">Результат</th>
<th className="px-6 py-3 font-medium">Эффективность</th>
<th className="px-6 py-3 font-medium">Дата</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{subs.map((s, i) => (
<tr key={i} className="hover:bg-gray-50/80 transition-colors">
{/* Имя + Аватар */}
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-white font-bold text-xs shadow-sm">
{getInitials(s.user?.name)}
</div>
<div>
<p className="font-medium text-gray-900">{s.user?.name || 'Аноним'}</p>
<p className="text-xs text-gray-500">{s.user?.email}</p>
</div>
</div>
</td>
{/* Баллы */}
<td className="px-6 py-4">
<span className="font-mono font-bold text-gray-700">{s.score}</span>
<span className="text-gray-400 text-xs"> / {s.maxScore}</span>
</td>
{/* Процент успеха (Бейджик) */}
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${getScoreColor(s.score, s.maxScore)}`}>
{s.score === s.maxScore && <Award size={12} className="mr-1" />}
{Math.round((s.score / s.maxScore) * 100)}%
</span>
</td>
{/* Дата */}
<td className="px-6 py-4 text-gray-500">
<div className="flex items-center gap-1.5 text-xs">
<Calendar size={14}/>
{new Date(s.completedAt).toLocaleDateString()}
<span className="text-gray-300">|</span>
{new Date(s.completedAt).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,211 @@
import React, { useEffect, useState, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { api } from '../lib/api';
import { Button, Card } from '../components/ui';
import { ConfirmModal } from '../components/ConfirmModal';
import { AlertTriangle, Check, ArrowLeft, Clock, User } from 'lucide-react';
export default function Runner() {
const { id } = useParams();
const navigate = useNavigate();
const [survey, setSurvey] = useState(null);
const [answers, setAnswers] = useState({});
const [result, setResult] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showConfirm, setShowConfirm] = useState(false);
useEffect(() => {
api(`/surveys/${id}`)
.then(setSurvey)
.catch(err => setError(err))
.finally(() => setLoading(false));
}, [id]);
// Вычисляем прогресс
const progress = useMemo(() => {
if (!survey) return 0;
const answeredCount = Object.keys(answers).length;
const totalCount = survey.questions.length;
return Math.round((answeredCount / totalCount) * 100);
}, [answers, survey]);
const handleSelect = (qId, type, optId) => {
setAnswers(prev => {
const current = prev[qId] || [];
const qType = type || 'SINGLE';
if (qType === 'SINGLE') return { ...prev, [qId]: [optId] };
else {
if (current.includes(optId)) return { ...prev, [qId]: current.filter(id => id !== optId) };
else return { ...prev, [qId]: [...current, optId] };
}
});
};
const handleSubmitClick = () => {
const unanswered = survey.questions.filter(q => !answers[q.id] || answers[q.id].length === 0);
if (unanswered.length > 0) setShowConfirm(true);
else finishSubmit();
};
const finishSubmit = async () => {
const payload = Object.entries(answers).flatMap(([qId, opts]) => opts.map(oId => ({ questionId: Number(qId), optionId: oId })));
try {
const res = await api(`/surveys/${id}/submit`, 'POST', { answers: payload });
setResult(res);
} catch(e) { console.error(e); }
};
if (loading) return <div className="min-h-screen flex items-center justify-center text-gray-500 bg-[#F3F4F6]">Загрузка опроса...</div>;
if (error) return (
<div className="min-h-screen flex items-center justify-center bg-[#F3F4F6] p-4">
<Card className="max-w-md w-full p-8 text-center border border-red-100 shadow-lg">
<div className="w-16 h-16 bg-red-50 text-red-500 rounded-full flex items-center justify-center mx-auto mb-4"><AlertTriangle size={32} /></div>
<h1 className="text-xl font-bold text-gray-900 mb-2">Опрос недоступен</h1>
<p className="text-gray-500 mb-6">Возможно, ссылка устарела или у вас нет доступа.</p>
<Button onClick={() => navigate('/')} className="w-full bg-white border border-gray-300 text-gray-700 hover:bg-gray-50">На главную</Button>
</Card>
</div>
);
if (result) return (
<div className="min-h-screen flex items-center justify-center bg-[#F3F4F6] p-4">
<Card className="p-12 text-center max-w-lg w-full animate-in fade-in zoom-in duration-300 shadow-xl border-0">
<div className="w-20 h-20 bg-indigo-100 text-indigo-600 rounded-full flex items-center justify-center mx-auto mb-6">
<TrophyIcon className="w-10 h-10" />
</div>
<h1 className="text-4xl font-bold mb-2 text-gray-900">Результат</h1>
<p className="text-gray-500 mb-8">Вы успешно завершили опрос</p>
<div className="bg-gray-50 rounded-2xl p-6 mb-8 border border-gray-100">
<div className="text-6xl font-black text-indigo-600 tracking-tighter">{result.score} <span className="text-2xl text-gray-400 font-medium">/ {result.maxScore}</span></div>
<div className="text-sm text-gray-400 uppercase tracking-widest font-bold mt-2">Баллов набрано</div>
</div>
<Button onClick={() => navigate('/')} size="lg" className="w-full bg-indigo-600 hover:bg-indigo-700 h-12 text-lg shadow-lg shadow-indigo-200">В меню</Button>
</Card>
</div>
);
return (
<div className="min-h-screen bg-[#F3F4F6] pb-32">
<ConfirmModal
isOpen={showConfirm} onClose={() => setShowConfirm(false)} onConfirm={finishSubmit}
title="Завершить тест?" message="Вы ответили не на все вопросы. Вы уверены?" isDangerous={false}
/>
{/* --- STICKY TOP BAR (PROGRESS) --- */}
<div className="sticky top-0 z-20 bg-white/80 backdrop-blur-md border-b border-gray-200 px-6 h-14 flex items-center justify-between">
<div className="flex items-center gap-4">
<button onClick={() => navigate('/')} className="text-gray-400 hover:text-gray-800 transition-colors"><ArrowLeft size={20}/></button>
<span className="font-semibold text-sm text-gray-600 hidden sm:block truncate max-w-[200px]">{survey.title}</span>
</div>
<div className="flex items-center gap-3 flex-1 max-w-xs ml-auto">
<span className="text-xs font-bold text-indigo-600">{progress}%</span>
<div className="h-2 flex-1 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-indigo-600 transition-all duration-500 ease-out" style={{ width: `${progress}%` }}></div>
</div>
</div>
</div>
<div className="max-w-2xl mx-auto p-6 space-y-8 mt-4">
{/* --- HEADER --- */}
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-200/60 text-center">
<h1 className="text-3xl font-bold text-gray-900 mb-3 leading-tight">{survey.title}</h1>
{survey.description && <p className="text-gray-500 text-lg mb-6">{survey.description}</p>}
<div className="flex justify-center gap-4 text-xs font-medium text-gray-400 uppercase tracking-wider">
<div className="flex items-center gap-1"><User size={14}/> {survey.author?.name || 'Автор'}</div>
<div className="flex items-center gap-1"><Clock size={14}/> {new Date(survey.createdAt).toLocaleDateString()}</div>
</div>
</div>
{/* --- QUESTIONS --- */}
{survey.questions.map((q, index) => {
const selected = answers[q.id] || [];
const isMulti = q.type === 'MULTI';
return (
<div key={q.id} className="bg-white p-6 md:p-8 rounded-2xl shadow-sm border border-gray-200/60">
<div className="flex items-start gap-4 mb-6">
<div className="flex-shrink-0 w-8 h-8 rounded-lg bg-indigo-50 text-indigo-600 flex items-center justify-center font-bold text-sm">
{index + 1}
</div>
<div>
<h3 className="text-lg font-medium text-gray-900 leading-snug">{q.text}</h3>
{isMulti && <p className="text-xs text-gray-400 mt-1 font-medium">Выберите несколько вариантов</p>}
</div>
</div>
<div className="space-y-3 pl-0 md:pl-12">
{q.options.map(opt => {
const isSelected = selected.includes(opt.id);
return (
<label
key={opt.id}
className={`
group flex items-center gap-4 p-4 rounded-xl border-2 cursor-pointer transition-all duration-200 relative overflow-hidden
${isSelected
? 'border-indigo-600 bg-indigo-50/50 z-10'
: 'border-gray-100 bg-white hover:border-indigo-200 hover:bg-gray-50'
}
`}
>
{/* Индикатор выбора */}
<div className={`
w-6 h-6 flex items-center justify-center flex-shrink-0 border-2 transition-all duration-200
${isMulti ? 'rounded-md' : 'rounded-full'}
${isSelected
? 'border-indigo-600 bg-indigo-600 text-white scale-110 shadow-md shadow-indigo-200'
: 'border-gray-300 bg-white group-hover:border-indigo-300'
}
`}>
{isMulti
? <Check size={14} strokeWidth={3} className={`transition-transform ${isSelected ? 'scale-100' : 'scale-0'}`}/>
: <div className={`w-2.5 h-2.5 bg-white rounded-full transition-transform ${isSelected ? 'scale-100' : 'scale-0'}`}/>
}
</div>
<input
type={isMulti ? 'checkbox' : 'radio'}
name={`q-${q.id}`}
onChange={() => handleSelect(q.id, q.type, opt.id)}
className="hidden"
checked={isSelected}
/>
<span className={`font-medium transition-colors text-base ${isSelected ? 'text-indigo-900' : 'text-gray-700'}`}>
{opt.text}
</span>
</label>
);
})}
</div>
</div>
);
})}
</div>
{/* --- BOTTOM BAR (FLOATING) --- */}
<div className="fixed bottom-0 left-0 w-full bg-white border-t border-gray-200 p-4 flex justify-center md:justify-end md:px-10 z-20 shadow-[0_-4px_20px_rgba(0,0,0,0.05)]">
<div className="w-full max-w-2xl flex justify-end">
<Button
onClick={handleSubmitClick}
className="w-full md:w-auto px-8 h-12 text-lg font-medium bg-indigo-600 hover:bg-indigo-700 shadow-lg shadow-indigo-200 rounded-xl transition-transform active:scale-95"
>
Завершить опрос
</Button>
</div>
</div>
</div>
);
}
// Маленькая иконка для экрана результатов
function TrophyIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/></svg>
)
}

View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})

17
survey-backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
# Зависимости
node_modules/
server/node_modules/
# Сборка
dist/
server/dist/
build/
# Среда и секреты (Никогда не отправляй их!)
.env
server/.env
# Логи и системные файлы
*.log
.DS_Store
.vscode/

View File

@@ -0,0 +1,15 @@
version: '3.8'
services:
postgres:
image: postgres:15
environment:
POSTGRES_USER: admin
POSTGRES_PASSWORD: password123
POSTGRES_DB: surveydb
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:

2107
survey-backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
{
"name": "survey-backend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "nodemon src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@prisma/client": "^6.19.0",
"bcryptjs": "^3.0.3",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"jsonwebtoken": "^9.0.2",
"prisma": "^6.19.0",
"zod": "^4.1.12"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.5",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.10.1",
"nodemon": "^3.1.11",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
}
}

View File

@@ -0,0 +1,12 @@
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
engine: "classic",
datasource: {
url: env("DATABASE_URL"),
},
});

View File

@@ -0,0 +1,100 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// --- МОДЕЛИ ---
model User {
id Int @id @default(autoincrement())
email String @unique
password String // Хеш пароля
name String?
inviteCode String @unique // Уникальный код типа "#USER-1234"
surveys Survey[] @relation("CreatedSurveys") // Опросы, которые я создал
submissions Submission[] // Опросы, которые я прошел
allowedIn AllowedAccess[] // Куда меня пригласили
}
model Survey {
id Int @id @default(autoincrement())
title String
description String?
isPublished Boolean @default(false)
accessType AccessType @default(PUBLIC) // PUBLIC, INVITE_ONLY
createdAt DateTime @default(now())
authorId Int
author User @relation("CreatedSurveys", fields: [authorId], references: [id])
questions Question[]
submissions Submission[]
allowedUsers AllowedAccess[]
}
enum AccessType {
PUBLIC
INVITE_ONLY
}
model Question {
id Int @id @default(autoincrement())
surveyId Int
survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade)
text String
type QuestionType @default(SINGLE)
points Int @default(0)
order Int @default(0)
options Option[]
}
enum QuestionType {
SINGLE // Один выбор (Radio)
MULTI // Множественный (Checkbox)
// TEXT - можно добавить позже
}
model Option {
id Int @id @default(autoincrement())
questionId Int
question Question @relation(fields: [questionId], references: [id], onDelete: Cascade)
text String
isCorrect Boolean @default(false) // Правильный ли это ответ?
}
// Белый список для приватных опросов
model AllowedAccess {
id Int @id @default(autoincrement())
surveyId Int
userId Int
// ДОБАВЛЕНО: onDelete: Cascade
survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id])
@@unique([surveyId, userId])
}
// Результаты прохождения
model Submission {
id Int @id @default(autoincrement())
surveyId Int
userId Int?
score Int
maxScore Int
completedAt DateTime @default(now())
// ДОБАВЛЕНО: onDelete: Cascade
survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id])
}

248
survey-backend/src/index.ts Normal file
View File

@@ -0,0 +1,248 @@
import express from 'express';
import type { Request, Response, NextFunction } from 'express';
import cors from 'cors';
import { PrismaClient } from '@prisma/client';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
// Логируем SQL запросы для отладки (опционально)
const prisma = new PrismaClient({
log: ['error', 'warn'],
});
const app = express();
const PORT = 5000;
const SECRET_KEY = process.env.JWT_SECRET || 'dev-secret';
app.use(cors());
app.use(express.json());
// --- MIDDLEWARE ---
interface AuthRequest extends Request { user?: { id: number; email: string }; }
const authMiddleware = (req: AuthRequest, res: Response, next: NextFunction) => {
const header = req.headers.authorization;
if (!header) { res.status(401).json({ error: "No token" }); return; }
try {
const token = header.split(' ')[1];
req.user = jwt.verify(token, SECRET_KEY) as any;
next();
} catch (e) { res.status(401).json({ error: "Invalid token" }); }
};
// --- AUTH ---
app.post('/api/auth/register', async (req, res) => {
try {
const { email, password, name } = req.body;
const hashedPassword = await bcrypt.hash(password, 10);
// FIX 1: Генерируем код ВСЕГДА В ВЕРХНЕМ РЕГИСТРЕ
const inviteCode = `#USER-${Math.floor(10000 + Math.random() * 90000)}`.toUpperCase();
const user = await prisma.user.create({ data: { email, password: hashedPassword, name, inviteCode } });
const token = jwt.sign({ id: user.id, email: user.email }, SECRET_KEY);
console.log(`[AUTH] Registered: ${email} with code ${inviteCode}`);
res.json({ token, user: { id: user.id, name: user.name, inviteCode: user.inviteCode } });
} catch (e) {
console.error(e);
res.status(400).json({ error: "User exists" });
}
});
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await prisma.user.findUnique({ where: { email } });
if (!user || !await bcrypt.compare(password, user.password)) { res.status(401).json({ error: "Invalid credentials" }); return; }
const token = jwt.sign({ id: user.id, email: user.email }, SECRET_KEY);
res.json({ token, user: { id: user.id, name: user.name, inviteCode: user.inviteCode } });
});
app.get('/api/auth/me', authMiddleware, async (req: AuthRequest, res) => {
const user = await prisma.user.findUnique({ where: { id: req.user!.id } });
res.json(user);
});
// --- SURVEYS CRUD ---
// CREATE
app.post('/api/surveys', authMiddleware, async (req: AuthRequest, res) => {
// ... (код создания оставляем как был в прошлом шаге, он рабочий)
const { title, description, questions, accessType, allowedUserCodes } = req.body;
console.log(`[CREATE] Survey "${title}"`);
try {
let allowedIds: number[] = [];
if (accessType === 'INVITE_ONLY' && allowedUserCodes?.length) {
const users = await prisma.user.findMany({
where: { inviteCode: { in: allowedUserCodes, mode: 'insensitive' } }
});
allowedIds = users.map(u => u.id);
}
const survey = await prisma.survey.create({
data: {
title, description, accessType, authorId: req.user!.id, isPublished: true,
questions: {
create: questions.map((q: any) => ({
text: q.text,
type: q.type || 'SINGLE', // <--- Важно: сохраняем тип
points: q.points || 0,
options: { create: q.options.map((o: any) => ({ text: o.text, isCorrect: o.isCorrect || false })) }
}))
},
allowedUsers: { create: allowedIds.map(uid => ({ userId: uid })) }
}
});
res.json(survey);
} catch (e) { console.error(e); res.status(500).json({ error: "Create failed" }); }
});
// 2. UPDATE (NEW: Редактирование)
app.put('/api/surveys/:id', authMiddleware, async (req: AuthRequest, res) => {
const id = Number(req.params.id);
const { title, questions, accessType, allowedUserCodes } = req.body;
// Проверяем права
const existing = await prisma.survey.findUnique({ where: { id } });
if (!existing || existing.authorId !== req.user!.id) {
res.status(403).json({ error: "Access denied" });
return;
}
try {
// Транзакция: Удаляем старое -> Создаем новое (Самый надежный способ для MVP)
await prisma.$transaction([
// 1. Обновляем шапку
prisma.survey.update({
where: { id },
data: { title, accessType }
}),
// 2. Удаляем старые доступы и вопросы (каскадно удалятся варианты)
prisma.allowedAccess.deleteMany({ where: { surveyId: id } }),
prisma.question.deleteMany({ where: { surveyId: id } }),
]);
// 3. Создаем заново (как при CREATE)
let allowedIds: number[] = [];
if (accessType === 'INVITE_ONLY' && allowedUserCodes?.length) {
const users = await prisma.user.findMany({ where: { inviteCode: { in: allowedUserCodes, mode: 'insensitive' } } });
allowedIds = users.map(u => u.id);
}
// 4. Добавляем новые вопросы
// Мы не можем использовать nested update, поэтому делаем отдельный update
const updated = await prisma.survey.update({
where: { id },
data: {
questions: {
create: questions.map((q: any) => ({
text: q.text, type: q.type || 'SINGLE', points: q.points || 0,
options: { create: q.options.map((o: any) => ({ text: o.text, isCorrect: o.isCorrect || false })) }
}))
},
allowedUsers: { create: allowedIds.map(uid => ({ userId: uid })) }
}
});
res.json(updated);
} catch (e) { console.error(e); res.status(500).json({ error: "Update failed" }); }
});
// GET MY, DELETE, FEED, GET SINGLE -> Оставляем без изменений (код из прошлого сообщения)
app.get('/api/surveys/my', authMiddleware, async (req: AuthRequest, res) => {
const surveys = await prisma.survey.findMany({
where: { authorId: req.user!.id },
include: { _count: { select: { submissions: true } } },
orderBy: { createdAt: 'desc' }
});
res.json(surveys);
});
app.delete('/api/surveys/:id', authMiddleware, async (req: AuthRequest, res) => {
try {
await prisma.survey.delete({ where: { id: Number(req.params.id), authorId: req.user!.id } });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: "Delete failed" }); }
});
app.get('/api/surveys/feed', authMiddleware, async (req: AuthRequest, res) => {
// ... (Код из прошлого сообщения про Feed)
const userId = req.user!.id;
const publicSurveys = await prisma.survey.findMany({ where: { accessType: 'PUBLIC', isPublished: true, authorId: { not: userId } }, include: { author: { select: { name: true } }, _count: { select: { submissions: true } } } });
const invitedSurveys = await prisma.survey.findMany({ where: { accessType: 'INVITE_ONLY', isPublished: true, allowedUsers: { some: { userId: userId } } }, include: { author: { select: { name: true } }, _count: { select: { submissions: true } } } });
const allSurveys = [...publicSurveys, ...invitedSurveys].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
res.json(allSurveys);
});
app.get('/api/surveys/:id', authMiddleware, async (req: AuthRequest, res) => {
// ... (Код получения одного опроса из прошлого сообщения)
const id = Number(req.params.id);
const isCreator = req.query.mode === 'edit';
const survey = await prisma.survey.findUnique({ where: { id }, include: { allowedUsers: { include: { user: true } }, questions: { include: { options: true } } } });
if (!survey) { res.status(404).json({ error: "Not found" }); return; }
if (!isCreator) {
if (survey.accessType === 'INVITE_ONLY') {
const isAllowed = survey.allowedUsers.some(u => u.userId === req.user!.id);
if (!isAllowed && survey.authorId !== req.user!.id) { res.status(403).json({ error: "Access denied" }); return; }
}
survey.questions.forEach(q => q.options.forEach(o => (o as any).isCorrect = undefined));
} else { if (survey.authorId !== req.user!.id) { res.status(403).json({ error: "Not your survey" }); return; } }
res.json(survey);
});
// SUBMIT (NEW LOGIC: Поддержка MULTI)
app.post('/api/surveys/:id/submit', authMiddleware, async (req: AuthRequest, res) => {
const surveyId = Number(req.params.id);
const { answers } = req.body; // answers: { questionId: number, optionId: number }[]
const survey = await prisma.survey.findUnique({
where: { id: surveyId },
include: { questions: { include: { options: true } } }
});
if (!survey) return;
let totalScore = 0; // Сколько набрал юзер
let maxScore = 0; // Сколько можно было набрать (сумма всех правильных галочек)
survey.questions.forEach(q => {
// 1. Считаем, сколько всего правильных ответов в этом вопросе
// Если вопрос SINGLE, там будет 1 правильный. Если MULTI — может быть несколько.
const correctOptionIds = q.options.filter(o => o.isCorrect).map(o => o.id);
maxScore += correctOptionIds.length;
// 2. Смотрим, что выбрал юзер
const userSelectedOptionIds = answers
.filter((a: any) => a.questionId === q.id)
.map((a: any) => a.optionId);
// 3. Начисляем баллы: +1 за каждое совпадение
userSelectedOptionIds.forEach((selectedId: number) => {
if (correctOptionIds.includes(selectedId)) {
totalScore += 1;
} else {
// Опционально: Можно отнимать баллы за ошибки (totalScore -= 1),
// но пока оставим просто 0 (не угадал - не получил).
}
});
});
const sub = await prisma.submission.create({
data: { surveyId, userId: req.user!.id, score: totalScore, maxScore }
});
res.json({ score: totalScore, maxScore, id: sub.id });
});
app.get('/api/surveys/:id/results', authMiddleware, async (req: AuthRequest, res) => {
// ... (старый код)
const surveyId = Number(req.params.id);
const survey = await prisma.survey.findUnique({ where: { id: surveyId } });
if (survey?.authorId !== req.user!.id) { res.status(403).json({ error: "Access denied" }); return; }
const submissions = await prisma.submission.findMany({ where: { surveyId }, include: { user: { select: { name: true, email: true } } }, orderBy: { completedAt: 'desc' } });
res.json(submissions);
});
app.listen(PORT, () => console.log(`Server running on ${PORT}`));

View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"noImplicitAny": false
}
}