Init
This commit is contained in:
22
.dockerignore
Normal file
22
.dockerignore
Normal file
@@ -0,0 +1,22 @@
|
||||
node_modules
|
||||
build
|
||||
coverage
|
||||
.git
|
||||
.gitignore
|
||||
.github
|
||||
.vscode
|
||||
.idea
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
docker-compose*.yaml
|
||||
README.md
|
||||
.claude
|
||||
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
||||
# URL бэкенда (без завершающего слэша)
|
||||
# В Dokploy задаётся через UI → Environment
|
||||
BACKEND_URL=http://26.22.232.18:24454
|
||||
|
||||
# Порт, по которому фронтенд доступен на хосте
|
||||
FRONTEND_PORT=3000
|
||||
|
||||
# Dev-only: используется при локальном `npm start`
|
||||
REACT_APP_BACKEND_URL=http://26.22.232.18:24454
|
||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
package.json
|
||||
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
6
.idea/misc.xml
generated
Normal file
6
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="SvnConfiguration">
|
||||
<configuration>C:\Users\Dozzy\AppData\Roaming\Subversion</configuration>
|
||||
</component>
|
||||
</project>
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/test-front.iml" filepath="$PROJECT_DIR$/.idea/test-front.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
8
.idea/test-front.iml
generated
Normal file
8
.idea/test-front.iml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
43
Dockerfile
Normal file
43
Dockerfile
Normal file
@@ -0,0 +1,43 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# ─── Stage 1: build React ────────────────────────────────────────
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Устанавливаем зависимости (кэшируется слой)
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
# Копируем исходники
|
||||
COPY public ./public
|
||||
COPY src ./src
|
||||
|
||||
ENV GENERATE_SOURCEMAP=false \
|
||||
CI=true \
|
||||
NODE_OPTIONS=--max_old_space_size=4096
|
||||
|
||||
RUN npm run build
|
||||
|
||||
|
||||
# ─── Stage 2: runtime (nginx) ────────────────────────────────────
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
|
||||
# nginx сам запустит envsubst для шаблона перед стартом
|
||||
ENV NGINX_ENVSUBST_TEMPLATE_SUFFIX=.template \
|
||||
NGINX_ENVSUBST_OUTPUT_DIR=/etc/nginx/conf.d \
|
||||
BACKEND_URL=http://backend:8000
|
||||
|
||||
# Убираем дефолтный конфиг, кладём наш шаблон
|
||||
RUN rm /etc/nginx/conf.d/default.conf
|
||||
COPY docker/nginx/default.conf.template /etc/nginx/templates/default.conf.template
|
||||
|
||||
# Билд статики
|
||||
COPY --from=builder /app/build /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget -qO- http://localhost/ > /dev/null 2>&1 || exit 1
|
||||
|
||||
# Точка входа от nginx-image уже знает про templates
|
||||
70
README.md
Normal file
70
README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Getting Started with Create React App
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm start`
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
|
||||
|
||||
The page will reload when you make changes.\
|
||||
You may also see any lint errors in the console.
|
||||
|
||||
### `npm test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `npm run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
|
||||
|
||||
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
|
||||
|
||||
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
|
||||
### Code Splitting
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
|
||||
|
||||
### Analyzing the Bundle Size
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
|
||||
|
||||
### Making a Progressive Web App
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
|
||||
|
||||
### Deployment
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
||||
|
||||
### `npm run build` fails to minify
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
||||
25
docker-compose.yml
Normal file
25
docker-compose.yml
Normal file
@@ -0,0 +1,25 @@
|
||||
services:
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: fool-stack-frontend:latest
|
||||
container_name: fool-stack-frontend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# URL бэкенда, к которому nginx проксирует /api и /static/avatars
|
||||
BACKEND_URL: ${BACKEND_URL:-https://back.fool-stack.ru/}
|
||||
expose:
|
||||
- "80"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost/"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- dokploy-network
|
||||
|
||||
networks:
|
||||
dokploy-network:
|
||||
external: true
|
||||
83
docker/nginx/default.conf.template
Normal file
83
docker/nginx/default.conf.template
Normal file
@@ -0,0 +1,83 @@
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
# Gzip
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_comp_level 6;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/json
|
||||
application/xml
|
||||
image/svg+xml
|
||||
font/ttf
|
||||
font/otf
|
||||
font/woff
|
||||
font/woff2;
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Hashed static assets — агрессивный кэш
|
||||
location /static/ {
|
||||
expires 1y;
|
||||
access_log off;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# HTML не кэшируется (чтобы обновления прилетали сразу)
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
expires -1;
|
||||
}
|
||||
|
||||
# API + WebSocket → backend
|
||||
location /api/ {
|
||||
proxy_pass ${BACKEND_URL};
|
||||
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_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
|
||||
# Загруженные аватары → backend
|
||||
location /static/avatars/ {
|
||||
proxy_pass ${BACKEND_URL};
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
error_page 404 /index.html;
|
||||
}
|
||||
17410
package-lock.json
generated
Normal file
17410
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
23
public/index.html
Normal file
23
public/index.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0f141c" />
|
||||
<meta name="description" content="Fool-Stack — современная канбан-доска для командной работы" />
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>Fool-Stack · Канбан</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>Включите JavaScript, чтобы запустить приложение.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
BIN
public/logo192.png
Normal file
BIN
public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/logo512.png
Normal file
BIN
public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
25
public/manifest.json
Normal file
25
public/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
3
public/robots.txt
Normal file
3
public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
40
src/App.js
Normal file
40
src/App.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
import './css/App.css';
|
||||
import './css/Modal.css';
|
||||
|
||||
const Login = lazy(() => import('./Login'));
|
||||
const Profile = lazy(() => import('./Profile'));
|
||||
const Registration = lazy(() => import('./Registration'));
|
||||
const Mainpage = lazy(() => import('./Mainpage'));
|
||||
const KBBoardsList = lazy(() => import('./KBBoardsList'));
|
||||
const KBBoard = lazy(() => import('./KBBoard'));
|
||||
const OtherProfile = lazy(() => import('./OtherProfile'));
|
||||
|
||||
const Loader = () => (
|
||||
<div className="app-loader">
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
);
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<Loader />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/main" replace />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
<Route path="/registration" element={<Registration />} />
|
||||
<Route path="/main" element={<Mainpage />} />
|
||||
<Route path="/kanban-boards-list" element={<KBBoardsList />} />
|
||||
<Route path="/kanban-board/:id" element={<KBBoard />} />
|
||||
<Route path="/profile/:id" element={<OtherProfile />} />
|
||||
<Route path="*" element={<Mainpage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
8
src/App.test.js
Normal file
8
src/App.test.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
65
src/Header.js
Normal file
65
src/Header.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import './css/Header.css';
|
||||
|
||||
const Header = () => {
|
||||
const [user, setUser] = useState({ display_name: '', avatar: '' });
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isAnimated, setIsAnimated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let animationTimer;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const { data } = await axios.get('/api/users/me', { signal: controller.signal });
|
||||
setUser({ display_name: data.display_name, avatar: data.avatar_url });
|
||||
} catch {}
|
||||
setIsLoading(false);
|
||||
animationTimer = setTimeout(() => setIsAnimated(true), 150);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
clearTimeout(animationTimer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header className="header">
|
||||
<div className="header-container">
|
||||
<div className="logo">
|
||||
<Link to="/main">
|
||||
<h1>Fool-Stack</h1>
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="nav">
|
||||
{!isLoading && (
|
||||
<ul className={`nav-list ${isAnimated ? 'visible' : ''}`}>
|
||||
{user.display_name ? (
|
||||
<>
|
||||
<li><Link to="/kanban-boards-list">Канбан-доски</Link></li>
|
||||
<li>
|
||||
<Link to="/profile">
|
||||
<span className="nav-username">{user.display_name}</span>
|
||||
{user.avatar && <img className="nav-avatar" src={user.avatar} alt="" />}
|
||||
</Link>
|
||||
</li>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<li><Link to="/registration">Регистрация</Link></li>
|
||||
<li><Link to="/login">Вход</Link></li>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Header);
|
||||
91
src/KBBoard/API.js
Normal file
91
src/KBBoard/API.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export const loadBoardDataAPI = async (boardId) => {
|
||||
return axios.post('/api/boards/load', {id: boardId});
|
||||
};
|
||||
|
||||
export const createTaskAPI = async (taskCateg, taskTitle, taskDescription, taskDedline, memberId) => {
|
||||
return axios.post('/api/boards/categories/tasks/create', {
|
||||
category_id: taskCateg, title: taskTitle, description: taskDescription, deadline: taskDedline, assigned_user: memberId
|
||||
});
|
||||
};
|
||||
|
||||
export const createCategoryAPI = async (boardId, categTitle) => {
|
||||
return axios.post('/api/boards/categories/create', {
|
||||
board_id: boardId, title: categTitle
|
||||
});
|
||||
};
|
||||
|
||||
export const updateTaskAPI = async (editedTaskId, editedTaskTitle, editedTaskDescription, editedTaskPosition, editedTaskDedline, editedTaskCategId) => {
|
||||
return axios.put('/api/boards/categories/tasks/update', {
|
||||
id: editedTaskId, title: editedTaskTitle, description: editedTaskDescription, position: editedTaskPosition, deadline: editedTaskDedline, category_id: editedTaskCategId
|
||||
});
|
||||
};
|
||||
|
||||
export const updateCategoryAPI = async (editedCategId, editedCategMethod, editedCategTitle) => {
|
||||
return axios.put('/api/boards/categories/update', {
|
||||
id: editedCategId, update_method: editedCategMethod, value: editedCategTitle
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteCategoryAPI = async (categId) => {
|
||||
return axios.delete('/api/boards/categories/delete', {
|
||||
data: {id: categId}
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteTaskAPI = async (taskId) => {
|
||||
return axios.delete('/api/boards/categories/tasks/delete', {
|
||||
data: {id: taskId}
|
||||
});
|
||||
};
|
||||
|
||||
export const assignMemberAPI = async (editedTaskId, memberId) => {
|
||||
return axios.put('/api/boards/categories/tasks/assign', {
|
||||
id: editedTaskId, member_id: memberId
|
||||
});
|
||||
};
|
||||
|
||||
export const unassignMemberAPI = async (editedTaskId, memberId) => {
|
||||
return axios.put('/api/boards/categories/tasks/unassign', {
|
||||
id: editedTaskId, member_id: memberId
|
||||
});
|
||||
};
|
||||
|
||||
export const addMemberAPI = async (boardId, username) => {
|
||||
return axios.post('/api/boards/members/add', {
|
||||
username: username, board_id: boardId
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteMemberAPI = async (boardId, memberId) => {
|
||||
return axios.delete('/api/boards/members/delete', {
|
||||
data: {member_id: memberId, board_id: boardId}
|
||||
});
|
||||
};
|
||||
|
||||
export const quitMemberAPI = async (boardId) => {
|
||||
return axios.delete('/api/boards/members/quit', {
|
||||
data: {board_id: boardId}
|
||||
});
|
||||
};
|
||||
|
||||
export const updateBoardsAPI = async (editedBoardId, editedBoardMethod, editedBoardValue) => {
|
||||
return axios.put('/api/boards/update', {
|
||||
id: editedBoardId, update_method: editedBoardMethod, value: editedBoardValue
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteBoardsAPI = async (boardId) => {
|
||||
return axios.delete('/api/boards/delete', {
|
||||
data: {id: boardId}
|
||||
});
|
||||
};
|
||||
|
||||
export const meAPI = () => {
|
||||
return axios.get('/api/users/me');
|
||||
};
|
||||
|
||||
export const getWsTicketAPI = async () => {
|
||||
return axios.get('/api/boards/ws-ticket');
|
||||
};
|
||||
250
src/KBBoard/Logic.js
Normal file
250
src/KBBoard/Logic.js
Normal file
@@ -0,0 +1,250 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
meAPI,
|
||||
loadBoardDataAPI, updateBoardsAPI, deleteBoardsAPI,
|
||||
createTaskAPI, updateTaskAPI, deleteTaskAPI,
|
||||
createCategoryAPI, updateCategoryAPI, deleteCategoryAPI,
|
||||
addMemberAPI, assignMemberAPI, unassignMemberAPI, deleteMemberAPI, quitMemberAPI
|
||||
} from './API';
|
||||
|
||||
export const useBoardLogic = (id, setError, setInfo, setCategories, setLoading, setItems) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const loadBoardData = useCallback(async (ws) => {
|
||||
if (!ws){
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
setError('');
|
||||
const response = await loadBoardDataAPI(id);
|
||||
setInfo(response.data);
|
||||
setItems(response.data.members)
|
||||
setCategories(response.data.categories || []);
|
||||
} catch (err) {
|
||||
if (err.response?.data?.message === 'Token Error' ||
|
||||
err.response?.data?.message === 'Invalid Token') {
|
||||
setError('Вы не авторизованы');
|
||||
setTimeout(() => navigate('/login'), 1000);
|
||||
} else if (err.response?.data?.detail === "Доска не найдена.") {
|
||||
setError("Доска не существует");
|
||||
setTimeout(() => navigate('/kanban-boards-list'), 1000);
|
||||
} else {
|
||||
setError('Ошибка загрузки доски');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id, setError, setInfo, setCategories, setLoading, setItems, navigate]);
|
||||
|
||||
const checkOwner = useCallback(async (ownerId, setIsOwner) => {
|
||||
setLoading(true);
|
||||
const result = await meAPI(ownerId);
|
||||
const res = (result?.data?.id === ownerId);
|
||||
setLoading(false);
|
||||
setIsOwner(res);
|
||||
}, [setLoading]);
|
||||
|
||||
const createCategory = useCallback(async (categoryTitle, modalCrCateg) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await createCategoryAPI(id, categoryTitle);
|
||||
await loadBoardData();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || 'Ошибка создания категории');
|
||||
} finally {
|
||||
modalCrCateg();
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id, loadBoardData, setLoading, setError]);
|
||||
|
||||
const createTask = useCallback(async (taskCategori, taskTitle, taskDescription, modalCrTask, taskDedline, memberId) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await createTaskAPI(taskCategori, taskTitle, taskDescription, taskDedline, memberId);
|
||||
await loadBoardData();
|
||||
modalCrTask(null)();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || 'Ошибка создания задачи');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loadBoardData, setLoading, setError]);
|
||||
|
||||
|
||||
|
||||
const editBoard = useCallback(async (editedBoardId, boardTitle, boardDescription, modalEditBoard) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateBoardsAPI( editedBoardId, 'title', boardTitle );
|
||||
await updateBoardsAPI( editedBoardId, 'description', boardDescription );
|
||||
await modalEditBoard();
|
||||
await loadBoardData();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || 'Ошибка редактирования доски');
|
||||
} finally {
|
||||
modalEditBoard();
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loadBoardData, setLoading, setError]);
|
||||
|
||||
const editCategory = useCallback(async (editedCategId, categoryTitle, modalEditCateg) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateCategoryAPI( editedCategId, 'title', categoryTitle );
|
||||
await loadBoardData();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || 'Ошибка редактирования категории');
|
||||
} finally {
|
||||
modalEditCateg({})();
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loadBoardData, setLoading, setError]);
|
||||
|
||||
const editCategoryPosition = useCallback(async (editedCategId, categoryPosition) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateCategoryAPI( editedCategId, 'position', categoryPosition );
|
||||
await loadBoardData();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || 'Ошибка смены позиции');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loadBoardData, setLoading, setError]);
|
||||
|
||||
const editTask = useCallback(async (editedTaskId, taskTitle, taskDescription, taskPosition, taskDeadline, taskCategory, modalEditTask) => {
|
||||
if(modalEditTask !== null) {
|
||||
setLoading(true);
|
||||
}
|
||||
try {
|
||||
if (typeof taskDeadline != "string") {
|
||||
taskDeadline = null
|
||||
}
|
||||
await updateTaskAPI(editedTaskId, taskTitle, taskDescription, taskPosition, taskDeadline, taskCategory);
|
||||
if(modalEditTask !== null) {
|
||||
await loadBoardData();
|
||||
modalEditTask({}, null)();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || 'Ошибка редактирования задачи');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loadBoardData, setLoading, setError]);
|
||||
|
||||
|
||||
|
||||
const deleteBoards = useCallback(async (boardId, modalDelBoard) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await deleteBoardsAPI(boardId);
|
||||
modalDelBoard();
|
||||
navigate('/kanban-boards-list');
|
||||
} catch (err) {
|
||||
setError('Ошибка удаления доски: '+ (err.response?.data?.message || err.response?.data?.detail));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [setLoading, setError, navigate]);
|
||||
|
||||
const deleteCategory = useCallback(async (categoryId, modalDelCateg, modalEditCateg) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await deleteCategoryAPI(categoryId);
|
||||
await loadBoardData();
|
||||
modalDelCateg();
|
||||
modalEditCateg({})();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || 'Ошибка удаления категории');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loadBoardData, setLoading, setError]);
|
||||
|
||||
const deleteTask = useCallback(async (taskId, modalDelTask, modalEditTask) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await deleteTaskAPI(taskId);
|
||||
await loadBoardData();
|
||||
modalDelTask();
|
||||
modalEditTask({}, null)();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || 'Ошибка удаления задачи');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loadBoardData, setLoading, setError]);
|
||||
|
||||
const addMember = useCallback(async (username, boardId, modalAddMember) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await addMemberAPI( boardId, username );
|
||||
await loadBoardData();
|
||||
modalAddMember();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || 'Ошибка добавления участника на доску');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loadBoardData, setLoading, setError]);
|
||||
|
||||
const assignMember = useCallback(async (editedTaskId, memberId, act, modalAssignMember) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (act) {
|
||||
await assignMemberAPI( editedTaskId, memberId );
|
||||
} else if (!act) {
|
||||
await unassignMemberAPI( editedTaskId, memberId );
|
||||
}
|
||||
await loadBoardData();
|
||||
modalAssignMember(false)();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || 'Ошибка назначения или открепления участника от задачи');
|
||||
} finally{
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loadBoardData, setLoading, setError]);
|
||||
|
||||
|
||||
const quitMember = useCallback(async (boardId, modalQuitMember ) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setLoading(true);
|
||||
await quitMemberAPI( boardId );
|
||||
await loadBoardData();
|
||||
modalQuitMember();
|
||||
navigate('/kanban-boards-list');
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || 'Ошибка: Не удалось покинуть доску');
|
||||
} finally{
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loadBoardData, setLoading, setError, navigate]);
|
||||
|
||||
const deleteMember = useCallback(async (boardId, memberId, modalDelitMember ) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setLoading(true);
|
||||
await deleteMemberAPI( boardId, memberId );
|
||||
await loadBoardData();
|
||||
modalDelitMember();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || 'Ошибка: Не удалось удалить пользователя');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loadBoardData, setLoading, setError]);
|
||||
|
||||
|
||||
|
||||
|
||||
return {
|
||||
loadBoardData, checkOwner, editBoard, deleteBoards,
|
||||
createTask, editTask, deleteTask,
|
||||
createCategory, editCategory, deleteCategory,
|
||||
addMember, assignMember, quitMember, deleteMember,
|
||||
editCategoryPosition
|
||||
};
|
||||
};
|
||||
892
src/KBBoard/index.js
Normal file
892
src/KBBoard/index.js
Normal file
@@ -0,0 +1,892 @@
|
||||
import {useEffect, useState } from 'react';
|
||||
import {useNavigate, useParams} from 'react-router-dom';
|
||||
import {useBoardLogic} from './Logic';
|
||||
import Header from '../Header';
|
||||
import './../css/Board.css';
|
||||
import {getWsTicketAPI} from "./API";
|
||||
|
||||
const KBBoard = () => {
|
||||
const navigate = useNavigate();
|
||||
const {id} = useParams();
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [info, setInfo] = useState({});
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [isOwner, setIsOwner] = useState(null);
|
||||
const [items, setItems] = useState([]);
|
||||
const [draggedItem, setDraggedItem] = useState(null);
|
||||
const [draggedType, setDraggedType] = useState(null);
|
||||
|
||||
const [memList, setMemList] = useState(false);
|
||||
const [crTask, setCrTask] = useState(false);
|
||||
const [crCateg, setCrCateg] = useState(false);
|
||||
const [edTask, setEdTask] = useState(false);
|
||||
const [edCateg, setEdCateg] = useState(false);
|
||||
const [edBoard, setEdBoard] = useState(false);
|
||||
const [delTask, setDelTask] = useState(false);
|
||||
const [delCateg, setDelCateg] = useState(false);
|
||||
const [asgnMember, setAsgnMember] = useState(false);
|
||||
const [assignAction, setAssignAction] = useState(false);
|
||||
const [addMemb, setAddMemb] = useState(false);
|
||||
const [delBoards, setDelBoards] = useState(false);
|
||||
const [qtMember, setQtMember] = useState(false);
|
||||
const [delMember, setDelMember] = useState(false);
|
||||
|
||||
const [boardTitle, setBoardTitle] = useState('');
|
||||
const [boardDescription, setBoardDescription] = useState('');
|
||||
const [categoryTitle, setCategoryTitle] = useState('');
|
||||
const [taskTitle, setTaskTitle] = useState('');
|
||||
const [taskDescription, setTaskDescription] = useState('');
|
||||
const [taskCategory, setTaskCategory] = useState(null);
|
||||
const [taskCategori, setTaskCategori] = useState(null);
|
||||
const [taskDeadline, setTaskDeadline] = useState(null);
|
||||
const [assignedMembers, setAssignedMembers] = useState(null);
|
||||
const [editedTask, setEditedTask] = useState({});
|
||||
const [editedCateg, setEditedCateg] = useState({});
|
||||
const [taskPosition, setTaskPosition] = useState(null);
|
||||
const [assignedMember, setAssignedMember] = useState(0);
|
||||
const [addedUsername, setAddedUsername] = useState('');
|
||||
const [deletedMember, setDeletedMember] = useState('');
|
||||
const {
|
||||
loadBoardData,
|
||||
createTask,
|
||||
checkOwner,
|
||||
createCategory,
|
||||
editBoard,
|
||||
editTask,
|
||||
editCategory,
|
||||
editCategoryPosition,
|
||||
deleteCategory,
|
||||
deleteTask,
|
||||
assignMember,
|
||||
addMember,
|
||||
deleteBoards,
|
||||
quitMember,
|
||||
deleteMember,
|
||||
} = useBoardLogic(id, setError, setInfo, setCategories, setLoading, setItems);
|
||||
|
||||
function ListItem({item}) {
|
||||
if (!item) return null;
|
||||
const user = () => {
|
||||
navigate('/profile/' + item.id);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<button onClick={user}>
|
||||
<div className="row">
|
||||
<h3>{item.display_name}<img className='members-avatar' src={item.avatar_url} alt=''></img></h3>
|
||||
<p><strong>Описание:</strong> {item.description ? item.description : 'Отсутствует'}</p>
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const [socket, setSocket] = useState(null);
|
||||
useEffect(() => {
|
||||
let ws;
|
||||
const connect = async () => {
|
||||
try {
|
||||
const res = await getWsTicketAPI();
|
||||
const token = res.data.token;
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
ws = new WebSocket(`${protocol}//26.22.232.18:24454/api/boards/ws/${id}?token=${token}`);
|
||||
//ws = new WebSocket(`${protocol}//ws.back.fool-stack.ru/api/boards/ws/${id}?token=${token}`);
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket соединение установлено');
|
||||
setSocket(ws);
|
||||
};
|
||||
ws.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
loadBoardData(true);
|
||||
};
|
||||
ws.onclose = () => console.log('WebSocket соединение закрыто');
|
||||
ws.onerror = (error) => console.error('Ошибка WebSocket:', error);
|
||||
} catch (e) {
|
||||
console.error('Не удалось получить ws-ticket:', e);
|
||||
}
|
||||
};
|
||||
connect();
|
||||
return () => ws && ws.close();
|
||||
}, [id, loadBoardData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) loadBoardData();
|
||||
}, [id, loadBoardData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (info?.owner?.id !== undefined) checkOwner(info?.owner?.id, setIsOwner);
|
||||
}, [info?.owner?.id, checkOwner, setIsOwner]);
|
||||
|
||||
const modalMemList = () => {
|
||||
setMemList(!memList);
|
||||
}
|
||||
const modalCrTask = (categori) => () => {
|
||||
setCrTask(!crTask);
|
||||
setTaskCategori(categori);
|
||||
setTaskTitle('');
|
||||
setTaskDescription('');
|
||||
setTaskDeadline(null)
|
||||
}
|
||||
const modalCrCateg = () => {
|
||||
setCrCateg(!crCateg);
|
||||
setCategoryTitle('');
|
||||
}
|
||||
const modalEditBoard = () => {
|
||||
setEdBoard(!edBoard);
|
||||
setBoardTitle(info.title);
|
||||
setBoardDescription(info.description);
|
||||
}
|
||||
const modalEditTask = (task, id_categ) => () => {
|
||||
setEdTask(!edTask);
|
||||
setTaskCategori(id_categ);
|
||||
setEditedTask(task);
|
||||
setTaskTitle(task.title);
|
||||
setTaskDescription(task.description);
|
||||
setTaskPosition(task.position);
|
||||
setTaskCategory(task.category_id);
|
||||
setTaskDeadline(task.deadline)
|
||||
}
|
||||
const modalEditCateg = (categ) => () => {
|
||||
setEdCateg(!edCateg);
|
||||
setEditedCateg(categ);
|
||||
setCategoryTitle(categ.title);
|
||||
}
|
||||
const modalDelTask = () => {
|
||||
setDelTask(!delTask);
|
||||
}
|
||||
const modalDelCateg = () => {
|
||||
setDelCateg(!delCateg);
|
||||
}
|
||||
const modalAssignMember = (action) => () => {
|
||||
setAsgnMember(!asgnMember);
|
||||
setAssignAction(action)
|
||||
}
|
||||
const modalAddMember = () => {
|
||||
setAddMemb(!addMemb);
|
||||
}
|
||||
const modalDeleteBoards = () => {
|
||||
setDelBoards(!delBoards);
|
||||
}
|
||||
const modalDeleteMember = () => {
|
||||
setDelMember(!delMember);
|
||||
}
|
||||
const modalQuitMember = () => {
|
||||
setQtMember(!qtMember);
|
||||
}
|
||||
|
||||
|
||||
const handleCreateCategory = async (e) => {
|
||||
e.preventDefault();
|
||||
await createCategory(categoryTitle, modalCrCateg);
|
||||
};
|
||||
const handleCreateTask = async (e) => {
|
||||
e.preventDefault();
|
||||
await createTask(taskCategori, taskTitle, taskDescription, modalCrTask, taskDeadline, assignedMembers);
|
||||
};
|
||||
const handleEditBoard = async (e) => {
|
||||
e.preventDefault();
|
||||
await editBoard(id, boardTitle, boardDescription, modalEditBoard);
|
||||
};
|
||||
const handleEditCategory = async (e) => {
|
||||
e.preventDefault();
|
||||
await editCategory(editedCateg.id, categoryTitle, modalEditCateg);
|
||||
};
|
||||
const handleEditTask = async (e) => {
|
||||
e.preventDefault();
|
||||
await editTask(editedTask.id, taskTitle, taskDescription, taskPosition, taskDeadline, taskCategory, modalEditTask);
|
||||
};
|
||||
const handleDeleteTask = async (e) => {
|
||||
e.preventDefault();
|
||||
await deleteTask(editedTask.id, modalDelTask, modalEditTask);
|
||||
};
|
||||
const handleDeleteCategory = async (e) => {
|
||||
e.preventDefault();
|
||||
await deleteCategory(editedCateg.id, modalDelCateg, modalEditCateg);
|
||||
};
|
||||
const handleAssignMember = async (e) => {
|
||||
e.preventDefault();
|
||||
await assignMember(editedTask.id, assignedMember, assignAction, modalAssignMember);
|
||||
};
|
||||
const handleAddMember = async (e) => {
|
||||
e.preventDefault();
|
||||
await addMember(addedUsername, id, modalAddMember);
|
||||
};
|
||||
const handleDeleteBoards = async (e) => {
|
||||
e.preventDefault();
|
||||
await deleteBoards(id, modalDeleteBoards);
|
||||
};
|
||||
const handleQuitMember = async (e) => {
|
||||
e.preventDefault();
|
||||
await quitMember(id, modalQuitMember);
|
||||
};
|
||||
const handleDeleteMember = async (e) => {
|
||||
e.preventDefault();
|
||||
await deleteMember(id, deletedMember, modalDeleteMember);
|
||||
};
|
||||
|
||||
const handleCategoryDragStart = (e, category) => {
|
||||
e.stopPropagation();
|
||||
setDraggedType('c');
|
||||
setDraggedItem(category);
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({
|
||||
id: category.id,
|
||||
type: 'c',
|
||||
position: category.position
|
||||
}));
|
||||
e.currentTarget.style.opacity = '0.5';
|
||||
};
|
||||
|
||||
const handleTaskDragStart = (e, task, categoryId) => {
|
||||
e.stopPropagation();
|
||||
setDraggedType('t');
|
||||
setDraggedItem({...task, categoryId});
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({
|
||||
id: task.id,
|
||||
type: 't',
|
||||
position: task.position,
|
||||
categoryId: categoryId
|
||||
}));
|
||||
e.currentTarget.style.opacity = '0.5';
|
||||
e.currentTarget.style.transform = 'scale(0.95)';
|
||||
};
|
||||
|
||||
|
||||
const handleDragEnd = (e) => {
|
||||
setDraggedType(null);
|
||||
setDraggedItem(null);
|
||||
e.currentTarget.style.opacity = '1';
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
};
|
||||
const handleDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
const data = e.dataTransfer.getData('text/plain');
|
||||
if (!data) return;
|
||||
try {
|
||||
const draggedData = JSON.parse(data);
|
||||
if (draggedData.type === 't') {
|
||||
e.currentTarget.style.boxShadow = '0 0 10px rgba(0, 0, 255, 0.3)';
|
||||
e.currentTarget.style.border = '2px dashed #007bff';
|
||||
} else if (draggedData.type === 'c') {
|
||||
e.currentTarget.style.backgroundColor = '#f0f0f0';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка парсинга данных drag:', err);
|
||||
}
|
||||
};
|
||||
const handleDragLeave = (e) => {
|
||||
e.currentTarget.style.backgroundColor = '';
|
||||
e.currentTarget.style.boxShadow = '';
|
||||
e.currentTarget.style.border = '';
|
||||
};
|
||||
|
||||
const handleCategoryReorder = async (e, targetPosition) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.boxShadow = '';
|
||||
e.currentTarget.style.border = '';
|
||||
e.currentTarget.style.backgroundColor = '';
|
||||
e.currentTarget.style.transform = '';
|
||||
|
||||
const data = e.dataTransfer.getData('text/plain');
|
||||
if (!data) return;
|
||||
|
||||
try {
|
||||
const draggedData = JSON.parse(data);
|
||||
if (draggedData.type !== 'c') return;
|
||||
|
||||
if (draggedType === 'c' && draggedItem.position !== targetPosition) {
|
||||
setCategories((prevItems) => {
|
||||
const draggedIndex = prevItems.findIndex(item => item.position === draggedItem.position);
|
||||
const targetIndex = prevItems.findIndex(item => item.position === targetPosition);
|
||||
const newItems = [...prevItems];
|
||||
newItems.splice(draggedIndex, 1);
|
||||
newItems.splice(targetIndex, 0, draggedItem);
|
||||
const updatedItems = newItems.map((item, index) => ({
|
||||
...item,
|
||||
position: index
|
||||
}));
|
||||
return updatedItems;
|
||||
});
|
||||
await editCategoryPosition(draggedItem.id, targetPosition);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при переупорядочивании категорий:', err);
|
||||
setError('Не удалось переупорядочить категории');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleTaskReorder = async (e, targetPosition, targetCategoryPos) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.boxShadow = '';
|
||||
e.currentTarget.style.border = '';
|
||||
e.currentTarget.style.backgroundColor = '';
|
||||
e.currentTarget.style.transform = '';
|
||||
|
||||
const data = e.dataTransfer.getData('text/plain');
|
||||
if (!data) return;
|
||||
|
||||
if (!draggedItem || draggedType !== 't') return;
|
||||
try {
|
||||
const draggedData = JSON.parse(data);
|
||||
|
||||
// Обрабатываем только задачи
|
||||
if (draggedData.type !== 't' || !draggedItem) return;
|
||||
|
||||
// Находим целевую категорию по позиции
|
||||
const targetCat = categories.find(cat => cat.position === targetCategoryPos);
|
||||
if (!targetCat) return;
|
||||
|
||||
// Проверяем, что задача не перемещается в ту же позицию той же категории
|
||||
if ((draggedItem.position === targetPosition ||
|
||||
(draggedItem.position === targetCat.length - 1 && targetPosition === targetCat.length)) &&
|
||||
draggedItem.category_id === targetCat.id) return;
|
||||
|
||||
|
||||
await editTask(draggedItem.id, draggedItem.title,
|
||||
draggedItem.description, targetPosition,
|
||||
draggedItem.deadline, targetCat.id, null);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Ошибка при переупорядочивании задач:', err);
|
||||
setError('Не удалось переупорядочить задачи');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<Header/>
|
||||
<div className="page-container">
|
||||
{
|
||||
error && <div className="error">{error}</div>
|
||||
}
|
||||
<div className="inf-panel">
|
||||
<div className="row">
|
||||
<h3>{info.title}</h3>
|
||||
<p>
|
||||
<button onClick={modalMemList}>
|
||||
<strong>Участники: </strong>
|
||||
{(info.members || []).map((member) => (
|
||||
<div key={member.id}>
|
||||
{(member.id !== info.owner?.id) ? (
|
||||
<img className='members-avatar' src={member.avatar_url}
|
||||
alt={member.display_name || 'Участник'}></img>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</button>
|
||||
</p>
|
||||
<p>
|
||||
<button onClick={() => navigate('/profile/' + info.owner.id)}>
|
||||
<strong>Владелец: </strong> {" " + info.owner?.display_name}
|
||||
<img className="nav-avatar" src={info.owner?.avatar_url} alt=''></img>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
<div className="row">
|
||||
<p><strong>Описание: </strong> {info.description ? info.description : 'Отсутствует'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="set-panel">
|
||||
{loading ? (
|
||||
<>
|
||||
|
||||
</>
|
||||
) : isOwner ? (
|
||||
<>
|
||||
<button onClick={modalAddMember}>
|
||||
Добавить участника
|
||||
</button>
|
||||
<button onClick={modalDeleteMember}>
|
||||
Выгнать участника
|
||||
</button>
|
||||
<button onClick={modalEditBoard}>
|
||||
Настройки доски
|
||||
</button>
|
||||
<button className='Important-button' onClick={modalDeleteBoards}>
|
||||
Удаление доски
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button onClick={modalQuitMember}>
|
||||
Покинуть доску
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="board-panel">
|
||||
{categories.map((category) => (
|
||||
<div className="categori" key={category.position}
|
||||
draggable="true"
|
||||
|
||||
onDragStart={(e) => handleCategoryDragStart(e, category)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleCategoryReorder(e, category.position)}
|
||||
>
|
||||
<button onClick={modalEditCateg(category)}><h3>{category.title}</h3></button>
|
||||
<div className='task create'>
|
||||
<button onClick={modalCrTask(category.id)}>
|
||||
Новая задача
|
||||
</button>
|
||||
</div>
|
||||
<div className='task-list'>
|
||||
{category.tasks.length > 0 ? (
|
||||
category.tasks.map((task) => (
|
||||
<button className='task' onClick={modalEditTask(task, category.id)}
|
||||
key={task.position}
|
||||
draggable="true"
|
||||
onDragStart={(e) => handleTaskDragStart(e, task, category.position)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleTaskReorder(e, task.position, category.position)}
|
||||
|
||||
>
|
||||
<div>{task.title}</div>
|
||||
<div>{task.description} </div>
|
||||
{task.deadline && (
|
||||
<div>Дедлайн: {new Date(task.deadline).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'UTC'
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{task.assigned_users[0] !== undefined && (
|
||||
<div>Исполнители:
|
||||
{task.assigned_users.map((member) => (
|
||||
<img key={member.id} className='members-avatar'
|
||||
src={member.avatar_url} alt=''></img>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<p>Нет задач</p>
|
||||
)}
|
||||
<div className='task move'
|
||||
onDrop={(e) => handleTaskReorder(e, category.tasks.length, category.position)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{categories.length < 10 ? (
|
||||
<div className="categori create">
|
||||
<div className="bib">
|
||||
<button onClick={modalCrCateg}>
|
||||
<div>
|
||||
+
|
||||
</div>
|
||||
Новая категория
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{memList && (
|
||||
<div className="confirm-modal">
|
||||
<div className="modal-content modal-member">
|
||||
<div><h3>Изменение задачи</h3></div>
|
||||
<label>Участники:</label>
|
||||
<div className='task-list members-list'>
|
||||
{items.length > 0 ? (
|
||||
items.map((item) => (
|
||||
<ListItem key={item.id} item={item}/>
|
||||
))
|
||||
) : (
|
||||
<p>Нет участников</p>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={modalMemList}>Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{crCateg && (
|
||||
<div className="confirm-modal">
|
||||
<div className="modal-content">
|
||||
<div><h3>Новая категория</h3></div>
|
||||
<form onSubmit={handleCreateCategory}>
|
||||
<div>
|
||||
<label>Название:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={categoryTitle}
|
||||
onChange={(e) => setCategoryTitle(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? 'Создание...' : 'Создать'}
|
||||
</button>
|
||||
</form>
|
||||
<button onClick={modalCrCateg}>Отменить</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{crTask && (
|
||||
<div className="confirm-modal">
|
||||
<div className="modal-content">
|
||||
<div><h3>Новая задача</h3></div>
|
||||
<form onSubmit={handleCreateTask}>
|
||||
<div>
|
||||
<label>Название:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={taskTitle}
|
||||
onChange={(e) => setTaskTitle(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<label>Описание:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={taskDescription}
|
||||
onChange={(e) => setTaskDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Дедлайн:</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={taskDeadline}
|
||||
onChange={(e) => setTaskDeadline(e.target.value)}
|
||||
step="1" // для секунд
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Исполнитель:</label>
|
||||
<select value={assignedMembers}
|
||||
onChange={(e) => setAssignedMembers(e.target.value)}>
|
||||
<option value={null}>
|
||||
Выберите пользователя
|
||||
</option>
|
||||
{info.members.map((member) => (
|
||||
<option key={member.display_name} value={member.id}>
|
||||
{member.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? 'Создание...' : 'Создать'}
|
||||
</button>
|
||||
</form>
|
||||
<button onClick={modalCrTask(null)}>Отменить</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{edBoard && (
|
||||
<div className="confirm-modal">
|
||||
<div className="modal-content">
|
||||
<div><h3>Изменение доски</h3></div>
|
||||
<form onSubmit={handleEditBoard}>
|
||||
<div>
|
||||
<label>Название:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={boardTitle}
|
||||
onChange={(e) => setBoardTitle(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<label>Описание:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={boardDescription}
|
||||
onChange={(e) => setBoardDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? 'Изменение...' : 'Изменить'}
|
||||
</button>
|
||||
</form>
|
||||
<button onClick={modalEditBoard}>Отменить</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{edCateg && (
|
||||
<div className="confirm-modal">
|
||||
<div className="modal-content">
|
||||
<div><h3>Изменение категории</h3></div>
|
||||
<form onSubmit={handleEditCategory}>
|
||||
<div>
|
||||
<label>Название:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={categoryTitle}
|
||||
onChange={(e) => setCategoryTitle(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? 'Изменение...' : 'Изменить'}
|
||||
</button>
|
||||
</form>
|
||||
<button onClick={modalEditCateg({})}>Отменить</button>
|
||||
<button className="Important-button" onClick={modalDelCateg}>Удалить</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{edTask && (
|
||||
<div className="confirm-modal">
|
||||
<div className="modal-content">
|
||||
<div><h3>Изменение задачи</h3></div>
|
||||
<form onSubmit={handleEditTask}>
|
||||
<div>
|
||||
<label>Название:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={taskTitle}
|
||||
onChange={(e) => setTaskTitle(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<label>Описание:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={taskDescription}
|
||||
onChange={(e) => setTaskDescription(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<label>Дедлайн:
|
||||
{editedTask.deadline != null ? new Date(editedTask.deadline).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'UTC'
|
||||
}) : ""}
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={taskDeadline}
|
||||
onChange={(e) => setTaskDeadline(e.target.value)}
|
||||
step="1" // для секунд
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Категория:</label>
|
||||
<select value={taskCategory} onChange={(e) => setTaskCategory(e.target.value)}>
|
||||
{categories.map((category) => (
|
||||
<option key={category.position} value={category.id}>
|
||||
{category.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Исполнитель:</label>
|
||||
<button type="button" onClick={modalAssignMember(true)}>Назначить</button>
|
||||
<button type="button" onClick={modalAssignMember(false)}>Снять</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? 'Изменение...' : 'Изменить'}
|
||||
</button>
|
||||
</form>
|
||||
<button onClick={modalEditTask({}, null)}>Отменить</button>
|
||||
<button className="Important-button" onClick={modalDelTask}>Удалить</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{delTask && (
|
||||
<div className="confirm-modal">
|
||||
<div className="modal-content">
|
||||
<div><h3>Удаление задачи</h3></div>
|
||||
<form onSubmit={handleDeleteTask}>
|
||||
<label>Вы точно хотите удалить задачу {editedTask.title}</label>
|
||||
<button onClick={modalDelTask}>Отменить</button>
|
||||
<button className="Important-button" type="submit" disabled={loading}>
|
||||
{loading ? 'Удаление...' : 'Удалить'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{delCateg && (
|
||||
<div className="confirm-modal">
|
||||
<div className="modal-content">
|
||||
<div><h3>Удаление категории</h3></div>
|
||||
<form onSubmit={handleDeleteCategory}>
|
||||
<label>Вы точно хотите удалить эту категорию</label>
|
||||
<button onClick={modalDelCateg}>Отменить</button>
|
||||
<button className="Important-button" type="submit" disabled={loading}>
|
||||
{loading ? 'Удаление...' : 'Удалить'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{delBoards && (
|
||||
<div className="confirm-modal">
|
||||
<div className="modal-content">
|
||||
<div><h3>Удаление доски</h3></div>
|
||||
<form onSubmit={handleDeleteBoards}>
|
||||
<label>Вы точно хотите удалить эту Канбан доску</label>
|
||||
<button onClick={modalDeleteBoards} type='reset'>Отменить</button>
|
||||
<button className="Important-button" type="submit" disabled={loading}>
|
||||
{loading ? 'Удаление...' : 'Удалить'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{addMemb && (
|
||||
<div className="confirm-modal">
|
||||
<div className="modal-content">
|
||||
<div><h3>Добавление участника</h3></div>
|
||||
<form onSubmit={handleAddMember}>
|
||||
<label>Введите логин человека которого хотите пригласить</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по логину..."
|
||||
value={addedUsername}
|
||||
onChange={(e) => setAddedUsername(e.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? 'Добавление...' : 'Добавить'}
|
||||
</button>
|
||||
<button onClick={modalAddMember} type='reset'>Отменить</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{asgnMember && (
|
||||
<div className="confirm-modal">
|
||||
{assignAction ? (
|
||||
<div className="modal-content">
|
||||
<div><h3>Назначение пользователя</h3></div>
|
||||
<form onSubmit={handleAssignMember}>
|
||||
<div>
|
||||
<div>
|
||||
<label>Исполнитель:</label>
|
||||
<select value={assignedMember}
|
||||
onChange={(e) => setAssignedMember(e.target.value)}>
|
||||
<option value={0}>
|
||||
Выберите пользователя
|
||||
</option>
|
||||
{info.members.map((member) => (
|
||||
<option key={member.display_name} value={member.id}>
|
||||
{member.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? 'Назначение...' : 'Назначить'}
|
||||
</button>
|
||||
</form>
|
||||
<button onClick={modalAssignMember(null)}>Отменить</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="modal-content">
|
||||
<div><h3>Снятие пользователя</h3></div>
|
||||
<form onSubmit={handleAssignMember}>
|
||||
<div>
|
||||
<div>
|
||||
<label>Исполнитель:</label>
|
||||
<select value={assignedMember}
|
||||
onChange={(e) => setAssignedMember(e.target.value)}>
|
||||
<option value={0}>
|
||||
Выберите пользователя
|
||||
</option>
|
||||
{info.members.map((member) => (
|
||||
<option key={member.display_name} value={member.id}>
|
||||
{member.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? 'Снятие...' : 'Снять'}
|
||||
</button>
|
||||
</form>
|
||||
<button onClick={modalAssignMember(null)}>Отменить</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{qtMember && (
|
||||
<div className="confirm-modal">
|
||||
<div className="modal-content">
|
||||
<div><h3>Покинуть доску</h3></div>
|
||||
<form onSubmit={handleQuitMember}>
|
||||
<label>Вы точно хотите покинуть эту Канбан доску?</label>
|
||||
<button onClick={modalQuitMember} type='reset'>Отменить</button>
|
||||
<button className="Important-button" type="submit" disabled={loading}>
|
||||
{loading ? 'Покинуть...' : 'Покинуть'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{delMember && (
|
||||
<div className="confirm-modal">
|
||||
<div className="modal-content">
|
||||
<div><h3>Выгнать участника</h3></div>
|
||||
<form onSubmit={handleDeleteMember}>
|
||||
<label>Выберите кого вы хотите выгнать?</label>
|
||||
<div>
|
||||
<label>Участник:</label>
|
||||
<select value={deletedMember} onChange={(e) => setDeletedMember(e.target.value)}>
|
||||
<option value={''}>
|
||||
Выберите пользователя
|
||||
</option>
|
||||
{info.members.map((member) => (
|
||||
(member.id !== info.owner.id) ? (
|
||||
<option key={member.display_name} value={member.id}>
|
||||
{member.display_name}
|
||||
</option>) : (
|
||||
<></>
|
||||
)
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button onClick={modalDeleteMember} type='reset'>Отменить</button>
|
||||
<button className="Important-button" type="submit" disabled={loading}>
|
||||
{loading ? 'Удаление...' : 'Удалить'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default KBBoard;
|
||||
197
src/KBBoardsList.js
Normal file
197
src/KBBoardsList.js
Normal file
@@ -0,0 +1,197 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import Header from './Header';
|
||||
import './css/BoardList.css';
|
||||
|
||||
const BoardListItem = memo(({ item, onOpen }) => {
|
||||
if (!item) return null;
|
||||
return (
|
||||
<li>
|
||||
<button onClick={() => onOpen(item.id)}>
|
||||
<div className="sort-row">
|
||||
<h3>{item.title}</h3>
|
||||
<p><strong>Владелец:</strong> {item.owner_display_name}</p>
|
||||
</div>
|
||||
<div className="sort-row">
|
||||
<p><strong>Описание:</strong> {item.description || 'Отсутствует'}</p>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
});
|
||||
|
||||
const Pagination = memo(({ totalPages, page, setPage, loading }) => {
|
||||
if (totalPages <= 1) return null;
|
||||
return (
|
||||
<div className="pagination">
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((pageNum) => (
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => setPage(pageNum)}
|
||||
className={page === pageNum ? 'active' : ''}
|
||||
disabled={loading}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const KBBoardsList = () => {
|
||||
const navigate = useNavigate();
|
||||
const debounceRef = useRef(null);
|
||||
const timerRef = useRef(400);
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
|
||||
const [sort_method, setSortMethod] = useState('title');
|
||||
const [reverse, setReverse] = useState(false);
|
||||
const [search_text, setSearchText] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const limit = 5;
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [items, setItems] = useState([]);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
|
||||
const openBoard = useCallback((id) => navigate(`/kanban-board/${id}`), [navigate]);
|
||||
|
||||
const loadBoardList = useCallback(async () => {
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const { data } = await axios.post(
|
||||
'/api/boards/list',
|
||||
{ sort_method, reverse, search_text, page, limit },
|
||||
{ withCredentials: true }
|
||||
);
|
||||
if (Array.isArray(data.boards)) {
|
||||
setItems(data.boards);
|
||||
setTotalItems(data.count || data.boards.length);
|
||||
} else {
|
||||
setItems([]);
|
||||
setTotalItems(0);
|
||||
}
|
||||
setError('');
|
||||
} catch (err) {
|
||||
const msg = err.response?.data?.message;
|
||||
if (msg === 'Token Error' || msg === 'Invalid Token') {
|
||||
setError('Вы не авторизованы');
|
||||
setTimeout(() => navigate('/login'), 1000);
|
||||
} else {
|
||||
setError('Ошибка загрузки досок');
|
||||
setItems([]);
|
||||
}
|
||||
}
|
||||
timerRef.current = 400;
|
||||
}, timerRef.current);
|
||||
}, [sort_method, reverse, search_text, page, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
loadBoardList();
|
||||
return () => clearTimeout(debounceRef.current);
|
||||
}, [loadBoardList]);
|
||||
|
||||
const setFilter = (method) => () => {
|
||||
setSortMethod(method);
|
||||
setReverse((prev) => (sort_method === method ? !prev : false));
|
||||
timerRef.current = 100;
|
||||
};
|
||||
|
||||
const createBoard = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
await axios.post('/api/boards/create', { title, description });
|
||||
setShowCreateModal(false);
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
await loadBoardList();
|
||||
} catch {
|
||||
setError('Ошибка создания доски');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeCreateModal = () => {
|
||||
setShowCreateModal(false);
|
||||
setDescription('');
|
||||
setTitle('');
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(totalItems / limit);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<div className="profile-page">
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
<div className="kan-ban-list-sort">
|
||||
<h3>Сортировка по:</h3>
|
||||
<div className="nav-sort">
|
||||
<button className={sort_method === 'title' ? 'active' : ''} onClick={setFilter('title')}>Названию</button>
|
||||
<button className={sort_method === 'owner' ? 'active' : ''} onClick={setFilter('owner')}>Создателю</button>
|
||||
<button className={sort_method === 'update_time' ? 'active' : ''} onClick={setFilter('update_time')}>Дате обновления</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по названию..."
|
||||
value={search_text}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="kan-ban-list">
|
||||
<div className="inf">
|
||||
<h3>Доступные канбан-доски:</h3>
|
||||
<button onClick={() => setShowCreateModal(true)}>Создать канбан-доску</button>
|
||||
</div>
|
||||
{items.length > 0 ? (
|
||||
<ul>
|
||||
{items.map((item) => (
|
||||
<BoardListItem key={item.id} item={item} onOpen={openBoard} />
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p>Нет данных</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Pagination totalPages={totalPages} page={page} setPage={setPage} loading={loading} />
|
||||
</div>
|
||||
|
||||
{showCreateModal && (
|
||||
<div className="confirm-modal">
|
||||
<div className="modal-content">
|
||||
<p><strong>Придумайте название и описание канбан-доски</strong></p>
|
||||
<form onSubmit={createBoard}>
|
||||
<div>
|
||||
<label>Название:</label>
|
||||
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} required />
|
||||
</div>
|
||||
<div>
|
||||
<label>Описание:</label>
|
||||
<input type="text" value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||
</div>
|
||||
<div className="modal-buttons">
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? 'Создание...' : 'Создать'}
|
||||
</button>
|
||||
<button type="button" onClick={closeCreateModal}>Отменить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default KBBoardsList;
|
||||
6
src/Login/API.js
Normal file
6
src/Login/API.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export const loginAPI = async (username, password) => {
|
||||
const newUser = { username, password };
|
||||
return axios.post('/api/users/login', newUser, { withCredentials: true });
|
||||
};
|
||||
26
src/Login/Logic.js
Normal file
26
src/Login/Logic.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { loginAPI } from './API';
|
||||
|
||||
export const useLogic = (setError, setLoading) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const login = useCallback(async (username, password) => {
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await loginAPI(username, password);
|
||||
setTimeout(() => {
|
||||
navigate('/kanban-boards-list');
|
||||
}, 500);
|
||||
} catch (err) {
|
||||
setError(err.response.data.detail || 'Ошибка входа');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [setLoading, setError, navigate]);
|
||||
|
||||
return {
|
||||
login
|
||||
};
|
||||
};
|
||||
64
src/Login/index.js
Normal file
64
src/Login/index.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Header from '../Header';
|
||||
import { useLogic } from './Logic';
|
||||
|
||||
|
||||
const Login = () => {
|
||||
const navigate = useNavigate();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { login } = useLogic(setError, setLoading);
|
||||
|
||||
const handleRegisterClick = () => {
|
||||
navigate('/registration'); // Переход к регистрации
|
||||
};
|
||||
|
||||
const handleLogin = async (e) => {
|
||||
e.preventDefault();
|
||||
await login(username, password);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<div className="login-page">
|
||||
<h2>Вход в систему</h2>
|
||||
{
|
||||
error && <div className="error">{error}</div>
|
||||
}
|
||||
<form onSubmit={handleLogin}>
|
||||
<div>
|
||||
<label>Логин:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Пароль:</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? 'Вход...' : 'Войти'}
|
||||
</button>
|
||||
</form>
|
||||
<button onClick={handleRegisterClick}>
|
||||
Зарегистрироваться
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
116
src/Mainpage.js
Normal file
116
src/Mainpage.js
Normal file
@@ -0,0 +1,116 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import Header from './Header';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import './css/Mainpage.css';
|
||||
|
||||
const DATE_FORMAT = new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
const formatDeadline = (deadline) => {
|
||||
if (!deadline) return 'Отсутствует';
|
||||
return DATE_FORMAT.format(new Date(deadline)).replace(',', ' ·');
|
||||
};
|
||||
|
||||
const TaskItem = memo(({ task, onClick }) => (
|
||||
<li className="task-item" onClick={() => onClick(task.board_id)}>
|
||||
<div className="task-item__title">{task.title}</div>
|
||||
|
||||
{task.category_title && (
|
||||
<div className="task-item__meta">
|
||||
<span className="task-item__label">Статус</span>
|
||||
<span className="task-item__badge">{task.category_title}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.board_title && (
|
||||
<div className="task-item__meta">
|
||||
<span className="task-item__label">Доска</span>
|
||||
<span className="task-item__value task-item__value--truncate">{task.board_title}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="task-item__meta">
|
||||
<span className="task-item__label">Дедлайн</span>
|
||||
<span className={`task-item__value${!task.deadline ? ' task-item__value--muted' : ''}`}>
|
||||
{formatDeadline(task.deadline)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
));
|
||||
|
||||
const Mainpage = () => {
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [error_message, setErrorMessage] = useState(null);
|
||||
const [count, setCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
(async () => {
|
||||
try {
|
||||
const { data } = await axios.get('/api/users/my_tasks', { signal: controller.signal });
|
||||
setTasks(data.tasks || []);
|
||||
setCount(data.count || 0);
|
||||
setErrorMessage(null);
|
||||
} catch (err) {
|
||||
if (err.name === 'CanceledError') return;
|
||||
setErrorMessage(err.response?.data?.message || 'Ошибка авторизации');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
const handleTaskClick = useCallback((boardId) => {
|
||||
navigate(`/kanban-board/${boardId}`);
|
||||
}, [navigate]);
|
||||
|
||||
const isAuthenticated = !error_message;
|
||||
const showScrollbar = useMemo(() => count > 3, [count]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<div className="mainpage">
|
||||
<div className="mainpage__intro">
|
||||
<div className="left-content">
|
||||
<h1>Добро пожаловать в Kanban!</h1>
|
||||
<p>
|
||||
Kanban-доска помогает видеть весь процесс целиком: что запланировано,
|
||||
что уже в работе и что готово. Перетаскивайте карточки между колонками,
|
||||
фиксируйте договорённости и не теряйте контекст — всё в одном месте.
|
||||
</p>
|
||||
</div>
|
||||
<div className="tasks-container">
|
||||
<h2 className="tasks-title">Мои задачи ({count})</h2>
|
||||
{loading ? (
|
||||
<p className="tasks-loading">Загрузка...</p>
|
||||
) : isAuthenticated ? (
|
||||
tasks.length === 0 ? (
|
||||
<p className="tasks-empty">Нет задач</p>
|
||||
) : (
|
||||
<ul className={`tasks-list${showScrollbar ? ' tasks-list--scroll' : ''}`}>
|
||||
{tasks.map((task) => (
|
||||
<TaskItem key={task.id} task={task} onClick={handleTaskClick} />
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
) : (
|
||||
<p className="tasks-empty">Войдите, чтобы увидеть свои задачи.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Mainpage;
|
||||
51
src/OtherProfile.js
Normal file
51
src/OtherProfile.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import {useState, useEffect} from 'react';
|
||||
import './css/OtherProfile.css';
|
||||
import {useParams, useNavigate} from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import Header from './Header';
|
||||
|
||||
const OtherProfile = () => {
|
||||
const navigate = useNavigate();
|
||||
const {id} = useParams('');
|
||||
const [user, setUser] = useState(null);
|
||||
const [avatar, setAvatar] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [description, setUserDescription] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const checkSession = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/users/' + id);
|
||||
setUser(response.data.display_name);
|
||||
setAvatar(response.data.avatar_url);
|
||||
if (response.data.description === '') {
|
||||
setUserDescription('Описание отсутствует')
|
||||
} else {
|
||||
setUserDescription(response.data.description);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Вы не авторизованы');
|
||||
setTimeout(() => {
|
||||
navigate('/login');
|
||||
}, 1500);
|
||||
}
|
||||
};
|
||||
checkSession();
|
||||
}, [id, navigate]);
|
||||
|
||||
return (
|
||||
<><Header/>
|
||||
<div className="profile-page user-info">
|
||||
{error && <div className="error">{error}</div>}
|
||||
<div className="profile-info">
|
||||
<div className="name-with-avatar">
|
||||
<div className="user-name">{user}</div>
|
||||
<img className="profile-avatar" src={avatar} alt={''}/>
|
||||
</div>
|
||||
<div className="user-description">{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export default OtherProfile;
|
||||
471
src/Profile.js
Normal file
471
src/Profile.js
Normal file
@@ -0,0 +1,471 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Header from './Header';
|
||||
|
||||
const Profile = () => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [userName, setUserName] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [showConfirm, setShowConfirm] = useState(false); // Состояние для поп-апа
|
||||
const [ShowConfirmUserName, setShowConfirmUserName] = useState(false);
|
||||
const [new_username, setnew_username] = useState('');
|
||||
const [ShowConfirmName, setShowConfirmName] = useState(false);
|
||||
//const [display_name, setdisplay_name] = useState('');
|
||||
const [new_display_name, setnew_display_name] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const [showChangePassword, setShowChangePassword] = useState(false);
|
||||
const [old_password, setold_password] = useState('');
|
||||
const [new_password, setnew_password] = useState('');
|
||||
const [new_password_confirm, setnew_password_confirm] = useState('');
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
const [showDescriptionModal, setShowDescriptionModal] = useState(false);
|
||||
const [profileDescription, setProfileDescription] = useState('');
|
||||
const [descriptionError, setDescriptionError] = useState('');
|
||||
const [avatarUrl, setAvatarUrl] = useState(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkSession = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/users/me');
|
||||
setUser(response.data.display_name);
|
||||
setUserName(response.data.username);
|
||||
setAvatarUrl(response.data.avatar_url || null);
|
||||
} catch (err) {
|
||||
setError('Вы не авторизованы');
|
||||
setTimeout(() => {
|
||||
navigate('/login');
|
||||
}, 1500);
|
||||
}
|
||||
};
|
||||
checkSession();
|
||||
}, [navigate]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await axios.post('/api/users/logout');
|
||||
setUser(null);
|
||||
navigate('/login'); // Редирект после выхода
|
||||
} catch (err) {
|
||||
setError('Ошибка');
|
||||
}
|
||||
};
|
||||
|
||||
const uploadAvatar = async (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
setIsUploading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('new_avatar', file);
|
||||
|
||||
await axios.put(
|
||||
'/api/users/change_avatar',
|
||||
formData,
|
||||
{
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
window.location.reload();
|
||||
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Ошибка загрузки аватара');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const NewDisplayName = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
await axios.put('api/users/change_display_name', {new_display_name},
|
||||
{
|
||||
withCredentials: true
|
||||
/*headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}*/
|
||||
}
|
||||
);
|
||||
setShowConfirmName(false);
|
||||
try {
|
||||
const response = await axios.get('/api/users/me');
|
||||
setUser(response.data.display_name);
|
||||
setUserName(response.data.username);
|
||||
} catch (err) {
|
||||
// Если нет сессии - редирект на логин
|
||||
setError('Вы не авторизованы');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login';
|
||||
}, 1500);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.response.data.detail || 'Ошибка входа');
|
||||
}
|
||||
};
|
||||
|
||||
const NewUserName = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
await axios.put('api/users/change_username', {new_username},
|
||||
{
|
||||
withCredentials: true
|
||||
/*headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}*/
|
||||
}
|
||||
);
|
||||
setShowConfirmUserName(false);
|
||||
try {
|
||||
const response = await axios.get('/api/users/me');
|
||||
setUserName(response.data.username);
|
||||
setUser(response.data.display_name);
|
||||
} catch (err) {
|
||||
// Если нет сессии - редирект на логин
|
||||
setError('Вы не авторизованы');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.response.data.detail || 'Ошибка входа');
|
||||
}
|
||||
};
|
||||
|
||||
const changePassword = async (e) => {
|
||||
e.preventDefault();
|
||||
setPasswordError('');
|
||||
|
||||
try {
|
||||
const NewPassword = {old_password, new_password, new_password_confirm}
|
||||
await axios.put('/api/users/change_password', NewPassword, { withCredentials: true });
|
||||
alert('Пароль успешно изменён!');
|
||||
setShowChangePassword(false);
|
||||
setold_password('');
|
||||
setnew_password('');
|
||||
setnew_password_confirm('');
|
||||
} catch (err) {
|
||||
setPasswordError( err.response?.data?.detail || 'Ошибка при смене пароля' );
|
||||
}
|
||||
};
|
||||
|
||||
const saveProfileDescription = async (e) => {
|
||||
e.preventDefault();
|
||||
setDescriptionError('');
|
||||
|
||||
try {
|
||||
await axios.put('/api/users/change_description',
|
||||
{ new_description: profileDescription },
|
||||
{ withCredentials: true }
|
||||
);
|
||||
try {
|
||||
const response = await axios.get('/api/users/me');
|
||||
setUser(response.data.display_name);
|
||||
setUserName(response.data.username);
|
||||
// Обновляем описание, если оно есть в ответе
|
||||
if (response.data.description) {
|
||||
setProfileDescription(response.data.description);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Ошибка обновления данных профиля');
|
||||
}
|
||||
|
||||
setShowDescriptionModal(false); // Закрываем модальное окно
|
||||
|
||||
} catch (err) {
|
||||
setDescriptionError(
|
||||
err.response?.data?.detail || 'Ошибка при сохранении описания'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const OpenWindowDelete = () => {
|
||||
setShowConfirm(true); // Показываем окно подтверждения
|
||||
};
|
||||
|
||||
const closeWindowDelete = () => {
|
||||
setShowConfirm(false);
|
||||
};
|
||||
|
||||
const OpenWindowName = () => {
|
||||
setShowConfirmName(true); // Показываем окно подтверждения
|
||||
};
|
||||
|
||||
const closeWindowName = () => {
|
||||
setShowConfirmName(false);
|
||||
};
|
||||
|
||||
const OpenWindowUserName = () => {
|
||||
setShowConfirmUserName(true); // Показываем окно подтверждения
|
||||
};
|
||||
|
||||
const closeWindowUserName = () => {
|
||||
setShowConfirmUserName(false); // Показываем окно подтверждения
|
||||
};
|
||||
|
||||
const openDescriptionModal = async () => {
|
||||
setDescriptionError('');
|
||||
try {
|
||||
const response = await axios.get('/api/users/me');
|
||||
setProfileDescription(response.data.description || '');
|
||||
setShowDescriptionModal(true);
|
||||
} catch (err) {
|
||||
setError('Не удалось загрузить данные профиля');
|
||||
console.error('Ошибка загрузки данных пользователя:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const closeDescriptionModal = () => {
|
||||
setShowDescriptionModal(false);
|
||||
setDescriptionError('');
|
||||
};
|
||||
const deleteAccount = async () => {
|
||||
try {
|
||||
await axios.delete('/api/users/me', { withCredentials: true });
|
||||
setUser(null);
|
||||
navigate('/login');
|
||||
setShowConfirm(false); // Закрываем окно после удаления
|
||||
} catch (err) {
|
||||
setError('Ошибка удаления');
|
||||
setShowConfirm(false); // Закрываем окно при ошибке
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<div className="profile-page">
|
||||
{
|
||||
error && <div className="error">{error}</div>
|
||||
}
|
||||
{user && (
|
||||
<div className="user-info">
|
||||
<div className="avatar-wrapper">
|
||||
<label className="avatar-upload-label">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={uploadAvatar}
|
||||
disabled={isUploading}
|
||||
className="avatar-file-input"
|
||||
/>
|
||||
|
||||
<div className="avatar-container">
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt="Аватар пользователя"
|
||||
className="avatar-image"
|
||||
/>
|
||||
) : (
|
||||
<div className="avatar-placeholder">
|
||||
{user?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/*Overlay с плюсом (появляется при наведении)*/}
|
||||
<div className="avatar-overlay">
|
||||
<div className="plus-icon">+</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{isUploading && <div className="uploading-spinner">Загружается...</div>}
|
||||
</div>
|
||||
<p><strong>Привет, {user}! Добро пожаловать в личный кабинет.</strong></p>
|
||||
<div className="text-block">Здесь ты сможешь управлять данными своей учётной записи.</div>
|
||||
<p>Информация об учётной записи</p>
|
||||
<div className="com">Отображаемое имя</div>
|
||||
<div className="buttonName">
|
||||
<div className="frame"><p>{user}</p></div>
|
||||
<button onClick={OpenWindowName}>✎</button>
|
||||
</div>
|
||||
<p>Описание профиля</p>
|
||||
<div className="com">
|
||||
Добавьте краткое описание о себе, которое увидят другие пользователи.
|
||||
</div>
|
||||
<button onClick={openDescriptionModal} className="button-small">
|
||||
{profileDescription ? 'Редактировать описание' : 'Добавить описание'}
|
||||
</button>
|
||||
<div className="com">Логин</div>
|
||||
<div className="buttonName">
|
||||
<div className="frame"><p>{userName}</p></div>
|
||||
<button onClick={OpenWindowUserName}>✎</button>
|
||||
</div>
|
||||
<p>Изменить пароль</p>
|
||||
<div className="com">В целях безопасности мы рекомендуем выбрать пароль, который ещё не использовался вами в других учётных записях.</div>
|
||||
<button onClick={() => setShowChangePassword(true)} className="button-small">Изменить пароль</button>
|
||||
<button onClick={handleLogout} className="margin-top-large">Выйти из аккаунта</button>
|
||||
<button className="Important-button" onClick={OpenWindowDelete}>Удалить аккаунт</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ShowConfirmUserName && (
|
||||
<div className="confirm-modal">
|
||||
<div className="modal-content">
|
||||
<div><h3>Обновите логин</h3></div>
|
||||
<div><p>Текущий логин: {userName}</p></div>
|
||||
<div className="com">Новый логин</div>
|
||||
<form onSubmit={NewUserName}>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={new_username}
|
||||
onChange={(e) => setnew_username(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit">Подтвердить</button>
|
||||
</form>
|
||||
<button className="Important-button" onClick={closeWindowUserName}>Отменить</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ShowConfirmName && (
|
||||
<div className="confirm-modal">
|
||||
<div className="modal-content">
|
||||
<div><h3>Обновите отображаемое имя</h3></div>
|
||||
<div><p>Текущее отображаемое имя: {user}</p></div>
|
||||
<div className="com">Новое отображаемое имя</div>
|
||||
<form onSubmit={NewDisplayName}>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={new_display_name}
|
||||
onChange={(e) => setnew_display_name(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit">Подтвердить</button>
|
||||
</form>
|
||||
<button className="Important-button" onClick={closeWindowName}>Отменить</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showChangePassword && (
|
||||
<div className="confirm-modal">
|
||||
<div className="modal-content">
|
||||
<h3>Изменить пароль</h3>
|
||||
|
||||
{passwordError && (
|
||||
<div className="error" style={{ color: 'red' }}>
|
||||
{passwordError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={changePassword}>
|
||||
<div className="form-group">
|
||||
<label>Текущий пароль:</label>
|
||||
<input
|
||||
type="password"
|
||||
value={old_password}
|
||||
onChange={(e) => setold_password(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Новый пароль:</label>
|
||||
<input
|
||||
type="password"
|
||||
value={new_password}
|
||||
onChange={(e) => setnew_password(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Повторите новый пароль:</label>
|
||||
<input
|
||||
type="password"
|
||||
value={new_password_confirm}
|
||||
onChange={(e) => setnew_password_confirm(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit">Изменить пароль</button>
|
||||
<button
|
||||
className="Important-button"
|
||||
type="button"
|
||||
onClick={() => setShowChangePassword(false)}
|
||||
>
|
||||
Отменить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDescriptionModal && (
|
||||
<div className="confirm-modal">
|
||||
<div className="modal-content">
|
||||
<h3>{profileDescription ? 'Редактировать описание профиля' : 'Добавить описание профиля'}</h3>
|
||||
|
||||
{descriptionError && (
|
||||
<div className="error" style={{ color: 'red' }}>
|
||||
{descriptionError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={saveProfileDescription}>
|
||||
<div className="form-group">
|
||||
<label>Описание:</label>
|
||||
<textarea
|
||||
className="description-textarea"
|
||||
value={profileDescription}
|
||||
onChange={(e) => setProfileDescription(e.target.value)}
|
||||
placeholder="Расскажите о себе, своих интересах или профессиональных навыках..."
|
||||
rows={6}
|
||||
maxLength={500}
|
||||
required
|
||||
/>
|
||||
<div className="char-counter">
|
||||
{profileDescription.length}/500 символов
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit">Сохранить описание</button>
|
||||
<button
|
||||
className="Important-button"
|
||||
type="button"
|
||||
onClick={closeDescriptionModal}
|
||||
>
|
||||
Отменить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Всплывающее окно подтверждения */}
|
||||
{showConfirm && (
|
||||
<div className="confirm-modal">
|
||||
<div className="modal-content">
|
||||
<p><strong>Вы действительно хотите удалить аккаунта?</strong></p>
|
||||
<div className="modal-buttons">
|
||||
<button onClick={deleteAccount}>Подтвердить</button>
|
||||
<button onClick={closeWindowDelete}>Отменить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profile;
|
||||
6
src/Registration/API.js
Normal file
6
src/Registration/API.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export const registrationAPI = async (username, display_name, password, password_confirm) => {
|
||||
const newUser = { username, display_name, password, password_confirm};
|
||||
await axios.post('/api/users/register', newUser);
|
||||
};
|
||||
25
src/Registration/Logic.js
Normal file
25
src/Registration/Logic.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { registrationAPI } from './API';
|
||||
|
||||
export const useLogic = (setError, setLoading) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const registration = useCallback(async (username, display_name, password, password_confirm) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await registrationAPI(username, display_name, password, password_confirm);
|
||||
alert("Регистрация прошла успешно")
|
||||
navigate('/login');
|
||||
} catch (err) {
|
||||
setError(err.response.data.detail || 'Пароль должен иметь длинну от 8 до 16 символов, содержать заглавные и строчные буквы, цифры и спец символ(_-?.!@\'`)');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [ setLoading, setError, navigate]);
|
||||
|
||||
return {
|
||||
registration
|
||||
};
|
||||
};
|
||||
78
src/Registration/index.js
Normal file
78
src/Registration/index.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLogic } from './Logic';
|
||||
import Header from './../Header';
|
||||
|
||||
const Registration = () => {
|
||||
const navigate = useNavigate();
|
||||
const [username, setuserName] = useState('');
|
||||
const [display_name, setdisplayName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [password_confirm, setPassword_confirm] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const { registration } = useLogic(setError, setLoading);
|
||||
|
||||
const handleReg = async (e) => {
|
||||
e.preventDefault();
|
||||
await registration(username, display_name, password, password_confirm);
|
||||
};
|
||||
|
||||
const handleLoginClick = () => { navigate('/login') };
|
||||
|
||||
return (
|
||||
<><Header />
|
||||
<div className="login-page">
|
||||
<h2>Регистрация</h2>
|
||||
{
|
||||
error && <div className="error">{error}</div>
|
||||
}
|
||||
<form onSubmit={handleReg}>
|
||||
<div>
|
||||
<label>Логин:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setuserName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Никнейм:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={display_name}
|
||||
onChange={(e) => setdisplayName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Пароль:</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Подтверждение пароля:</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password_confirm}
|
||||
onChange={(e) => setPassword_confirm(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? 'Регистрация...' : 'Зарегистрироваться'}
|
||||
</button>
|
||||
</form>
|
||||
<button onClick={handleLoginClick}>
|
||||
Уже есть аккаунт? Войти
|
||||
</button>
|
||||
</div></>
|
||||
);
|
||||
}
|
||||
|
||||
export default Registration;
|
||||
6
src/_template/API.js
Normal file
6
src/_template/API.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export const functionAPI = async (first, second) => {
|
||||
const data = { first, second };
|
||||
return axios.post('/api/target/func', data);
|
||||
};
|
||||
25
src/_template/Logic.js
Normal file
25
src/_template/Logic.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { functionAPI } from './API';
|
||||
|
||||
export const useLogic = (setError, setLoading) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const function1 = useCallback(async (first, second) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await functionAPI(first, second);
|
||||
setTimeout(() => {
|
||||
navigate('/kanban-boards-list');
|
||||
}, 500);
|
||||
} catch (err) {
|
||||
setError(err.response.data.detail || 'Ошибка');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [setLoading, setError, navigate]);
|
||||
|
||||
return {
|
||||
function1
|
||||
};
|
||||
};
|
||||
39
src/_template/index.js
Normal file
39
src/_template/index.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Header from '../Header';
|
||||
import { useLogic } from './Logic';
|
||||
|
||||
|
||||
const Name = () => {
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { function1 } = useLogic(setError, setLoading);
|
||||
|
||||
const handleClick = () => {
|
||||
navigate('/target'); // Переход
|
||||
};
|
||||
|
||||
const handleFunction1 = async (e) => {
|
||||
e.preventDefault();
|
||||
await function1(first, second);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<div className="">
|
||||
<h2>Заголовок</h2>
|
||||
{
|
||||
error && <div className="error">{error}</div>
|
||||
}
|
||||
<div>
|
||||
...
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Name;
|
||||
448
src/css/App.css
Normal file
448
src/css/App.css
Normal file
@@ -0,0 +1,448 @@
|
||||
@font-face {
|
||||
font-family: "Roboto Regular";
|
||||
src: url("./../fonts/roboto-regular/roboto-regular.woff2") format("woff2"),
|
||||
url("./../fonts/roboto-regular/roboto-regular.woff") format("woff"),
|
||||
url("./../fonts/roboto-regular/roboto-regular.ttf") format("truetype");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
.App { text-align: center; }
|
||||
|
||||
.app-loader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: 3px solid rgba(91, 140, 255, 0.2);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
.app {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ── Страницы ── */
|
||||
.login-page,
|
||||
.profile-page {
|
||||
background: var(--gradient-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: var(--space-2xl);
|
||||
margin: var(--space-xl) auto;
|
||||
animation: fadeIn var(--transition-slow) ease both;
|
||||
}
|
||||
|
||||
.login-page {
|
||||
max-width: 420px;
|
||||
margin-top: 64px;
|
||||
}
|
||||
|
||||
.profile-page {
|
||||
max-width: 1200px;
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
.page-container {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-x: auto;
|
||||
background: var(--gradient-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: var(--space-xl);
|
||||
margin: var(--space-md) 0;
|
||||
max-width: 99%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ── Типографика ── */
|
||||
h1, h2, h3 {
|
||||
color: var(--color-text);
|
||||
margin: 0 0 var(--space-lg) 0;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
font-size: 1.6rem;
|
||||
background: var(--gradient-accent);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-content p {
|
||||
text-align: left;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.modal-content div {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.text-block {
|
||||
text-align: left;
|
||||
color: var(--color-text-dim);
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
.com {
|
||||
color: var(--color-text-dim);
|
||||
text-align: left;
|
||||
margin: var(--space-sm) 0 auto;
|
||||
}
|
||||
|
||||
.com p { color: var(--color-text); font-size: 1rem; }
|
||||
|
||||
.user-info p {
|
||||
text-align: left;
|
||||
color: var(--color-text);
|
||||
margin: var(--space-md) 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* ── Формы ── */
|
||||
form div {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
input[type="datetime-local"],
|
||||
input[type="email"],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 11px 14px;
|
||||
background-color: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text);
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
transition: border-color var(--transition-base), box-shadow var(--transition-base), background-color var(--transition-base);
|
||||
}
|
||||
|
||||
input::placeholder { color: var(--color-text-dim); }
|
||||
|
||||
input:hover, select:hover, textarea:hover {
|
||||
border-color: var(--color-border-strong);
|
||||
}
|
||||
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-glow);
|
||||
background-color: var(--color-surface);
|
||||
}
|
||||
|
||||
/* ── Кнопки ── */
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 11px 18px;
|
||||
margin: 8px 0;
|
||||
background: var(--gradient-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
letter-spacing: 0.01em;
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-base), filter var(--transition-base);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-glow);
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
button:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background: var(--color-surface-3);
|
||||
color: var(--color-text-dim);
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.Important-button {
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
}
|
||||
.Important-button:hover:not(:disabled) {
|
||||
box-shadow: 0 0 24px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.button-small {
|
||||
width: auto;
|
||||
min-width: auto;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
/* Switch-link */
|
||||
.switch-link {
|
||||
margin-top: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.switch-link button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
text-decoration: underline;
|
||||
padding: 0;
|
||||
width: auto;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ── Ошибки ── */
|
||||
.error {
|
||||
background-color: rgba(239, 68, 68, 0.12);
|
||||
color: #fca5a5;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
animation: fadeIn var(--transition-base) ease both;
|
||||
}
|
||||
|
||||
/* ── Modal buttons ── */
|
||||
.modal-buttons {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
margin-top: var(--space-xl);
|
||||
}
|
||||
|
||||
.modal-buttons button { margin: 0; }
|
||||
|
||||
/* Small-button row */
|
||||
.buttonName {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.buttonName button {
|
||||
background: var(--color-surface-2);
|
||||
margin: var(--space-md) 0;
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
padding: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Рамка (Profile) */
|
||||
.frame {
|
||||
background: var(--color-surface-2);
|
||||
margin: var(--space-md) 0;
|
||||
width: 100%;
|
||||
max-width: 344px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
transition: border-color var(--transition-base), transform var(--transition-base);
|
||||
}
|
||||
|
||||
.frame p { font-size: 0.95rem; }
|
||||
|
||||
.frame:hover {
|
||||
border-color: var(--color-primary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ── Profile: description ── */
|
||||
.description-textarea {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
background-color: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text);
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
max-height: 240px;
|
||||
}
|
||||
|
||||
.description-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-glow);
|
||||
}
|
||||
|
||||
.char-counter {
|
||||
text-align: right;
|
||||
color: var(--color-text-dim);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.margin-top-large { margin-top: 10em; }
|
||||
|
||||
/* ── Avatar uploader ── */
|
||||
.avatar-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
float: right;
|
||||
margin: 24px;
|
||||
}
|
||||
|
||||
.avatar-upload-label {
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.avatar-file-input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
position: relative;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--color-surface-3);
|
||||
box-shadow: 0 0 0 4px var(--color-surface), var(--shadow-md);
|
||||
transition: transform var(--transition-base), box-shadow var(--transition-base);
|
||||
}
|
||||
|
||||
.avatar-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform var(--transition-base), filter var(--transition-base);
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 64px;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
background: var(--gradient-primary);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.avatar-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(15, 20, 28, 0.65);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity var(--transition-base), visibility var(--transition-base);
|
||||
border-radius: 50%;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.rounded-plus {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
background: var(--gradient-primary);
|
||||
color: #fff;
|
||||
font-size: 56px;
|
||||
font-weight: 300;
|
||||
border-radius: 50%;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: transform var(--transition-base);
|
||||
}
|
||||
|
||||
.avatar-upload-label:hover .rounded-plus { transform: scale(1.08); }
|
||||
.avatar-upload-label:hover .avatar-overlay { opacity: 1; visibility: visible; }
|
||||
.avatar-upload-label:hover .avatar-container { transform: scale(1.03); box-shadow: 0 0 0 4px var(--color-surface), var(--shadow-lg); }
|
||||
.avatar-upload-label:hover .avatar-image { filter: brightness(0.8); }
|
||||
|
||||
.uploading-spinner {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Form-group ── */
|
||||
.form-group { margin-bottom: var(--space-md); }
|
||||
.form-group label { text-align: left; }
|
||||
332
src/css/Board.css
Normal file
332
src/css/Board.css
Normal file
@@ -0,0 +1,332 @@
|
||||
.inf-panel {
|
||||
flex: 0 0 auto;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
padding: var(--space-lg);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.inf-panel strong {
|
||||
margin-right: 1ch;
|
||||
color: var(--color-text-dim);
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.inf-panel .row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-md);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.row p, .row h3 {
|
||||
display: flex;
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
align-items: center;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.row h3 {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.row p {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.modal-content .row { margin: 0; }
|
||||
|
||||
.members-avatar {
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
margin-left: var(--space-sm);
|
||||
border-radius: var(--radius-full);
|
||||
object-fit: cover;
|
||||
border: 2px solid var(--color-surface-2);
|
||||
margin-right: -14px;
|
||||
transition: transform var(--transition-base);
|
||||
}
|
||||
|
||||
.members-avatar:hover { transform: translateY(-2px) scale(1.08); z-index: 2; }
|
||||
|
||||
.row button {
|
||||
width: auto;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.row button:hover:not(:disabled) {
|
||||
background: var(--color-surface-3);
|
||||
color: var(--color-text);
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Members-list */
|
||||
.members-list {
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.members-list button {
|
||||
border-radius: var(--radius-md);
|
||||
margin: 0;
|
||||
padding: var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
background: var(--color-surface-3);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.members-list button:hover {
|
||||
background: var(--color-surface-3);
|
||||
box-shadow: 0 0 0 2px var(--color-primary-glow);
|
||||
}
|
||||
|
||||
.modal-member {
|
||||
flex: 0 1 auto;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* Setting panel */
|
||||
.set-panel {
|
||||
flex: 0 0 auto;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.set-panel button {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
flex: 1 1 140px;
|
||||
}
|
||||
|
||||
/* Board */
|
||||
.board-panel {
|
||||
flex: 1 1 auto;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.categori {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background: var(--color-surface-3);
|
||||
border: 1px solid var(--color-border);
|
||||
min-width: 300px;
|
||||
max-width: 340px;
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
transition: border-color var(--transition-base), box-shadow var(--transition-base);
|
||||
}
|
||||
|
||||
.categori:hover {
|
||||
border-color: var(--color-border-strong);
|
||||
}
|
||||
|
||||
.categori > button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
background: transparent;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
margin: 0 0 var(--space-sm);
|
||||
justify-content: flex-start;
|
||||
color: var(--color-text);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.categori > button:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.categori > button h3 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.bib { width: 100%; height: 100%; }
|
||||
|
||||
.task-list {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
width: 100%;
|
||||
margin-top: var(--space-sm);
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.task {
|
||||
text-align: left;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
padding: var(--space-md);
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
flex-direction: column;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.92rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
gap: 6px;
|
||||
transition: transform var(--transition-base), border-color var(--transition-base), box-shadow var(--transition-base);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.task:active { cursor: grabbing; }
|
||||
|
||||
.task:hover {
|
||||
background: var(--color-surface-3);
|
||||
border-color: var(--color-primary);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.task > div:first-child {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.task > div:not(:first-child) {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.create {
|
||||
background: transparent;
|
||||
border: 2px dashed var(--color-border-strong);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
transition: border-color var(--transition-base), background var(--transition-base);
|
||||
}
|
||||
|
||||
.create button {
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.create button:hover:not(:disabled) {
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.create:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: rgba(91, 140, 255, 0.06);
|
||||
}
|
||||
|
||||
.categori .create {
|
||||
margin-top: 0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.categori.create {
|
||||
margin-top: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 200px;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.categori.create .bib button {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.categori.create .bib button div { font-size: 2rem; font-weight: 300; line-height: 1; }
|
||||
|
||||
.task.create { margin-top: 0; padding: var(--space-sm); }
|
||||
|
||||
.task.create button { font-size: 0.88rem; padding: 8px; }
|
||||
|
||||
.task.move {
|
||||
min-height: 30px;
|
||||
opacity: 0;
|
||||
background: transparent;
|
||||
border: 2px dashed transparent;
|
||||
transition: opacity var(--transition-base), border-color var(--transition-base);
|
||||
}
|
||||
|
||||
label { text-align: left; }
|
||||
|
||||
/* Scrollbars */
|
||||
.task-list::-webkit-scrollbar,
|
||||
.board-panel::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 10px;
|
||||
}
|
||||
.task-list::-webkit-scrollbar-track,
|
||||
.board-panel::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.task-list::-webkit-scrollbar-thumb,
|
||||
.board-panel::-webkit-scrollbar-thumb {
|
||||
background: var(--color-surface-3);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
.task-list::-webkit-scrollbar-thumb:hover,
|
||||
.board-panel::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
210
src/css/BoardList.css
Normal file
210
src/css/BoardList.css
Normal file
@@ -0,0 +1,210 @@
|
||||
.kan-ban-list-sort {
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
padding: var(--space-lg);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.kan-ban-list-sort h3 {
|
||||
font-size: 1.05rem;
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-sort {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.nav-sort button {
|
||||
width: auto;
|
||||
padding: 8px 16px;
|
||||
font-size: 0.88rem;
|
||||
margin: 0;
|
||||
background: var(--color-surface-3);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.nav-sort button:hover:not(:disabled) {
|
||||
background: var(--color-surface-3);
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-glow);
|
||||
}
|
||||
|
||||
.nav-sort button.active {
|
||||
background: var(--gradient-primary);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.kan-ban-list-sort input {
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.kan-ban-list {
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
padding: var(--space-lg);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.kan-ban-list .inf {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.kan-ban-list .inf h3 {
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.kan-ban-list .inf button {
|
||||
width: auto;
|
||||
min-width: 200px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.kan-ban-list ul {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.kan-ban-list ul li { list-style: none; }
|
||||
|
||||
.kan-ban-list ul button {
|
||||
width: 100%;
|
||||
padding: var(--space-lg);
|
||||
background: var(--color-surface-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
text-align: left;
|
||||
color: var(--color-text);
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
transition: transform var(--transition-base), border-color var(--transition-base), box-shadow var(--transition-base);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.kan-ban-list ul button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 3px;
|
||||
background: var(--gradient-primary);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-base);
|
||||
}
|
||||
|
||||
.kan-ban-list ul button:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--shadow-md);
|
||||
background: var(--color-surface-3);
|
||||
}
|
||||
|
||||
.kan-ban-list ul button:hover::before { opacity: 1; }
|
||||
|
||||
.kan-ban-list .sort-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sort-row p, .sort-row h3 {
|
||||
margin: 4px 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.sort-row h3 {
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.sort-row p {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.sort-row p strong {
|
||||
color: var(--color-text-dim);
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.sort-row + .sort-row p {
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
/* Пагинация */
|
||||
.pagination {
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-lg);
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
padding: 0;
|
||||
background: var(--color-surface-3);
|
||||
cursor: pointer;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.pagination button:hover:not(.active):not(:disabled) {
|
||||
background: var(--color-surface-3);
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.pagination button.active {
|
||||
background: var(--gradient-primary);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.pagination button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.kan-ban-list .inf { flex-direction: column; align-items: stretch; }
|
||||
.kan-ban-list .inf button { min-width: 0; }
|
||||
}
|
||||
104
src/css/Header.css
Normal file
104
src/css/Header.css
Normal file
@@ -0,0 +1,104 @@
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
flex: 0 0 auto;
|
||||
height: 64px;
|
||||
width: 100%;
|
||||
background: rgba(28, 37, 51, 0.75);
|
||||
backdrop-filter: blur(16px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.header-container {
|
||||
max-width: 1280px;
|
||||
height: 64px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 24px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo a {
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
background: var(--gradient-accent);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
transition: filter var(--transition-base);
|
||||
}
|
||||
|
||||
.logo h1:hover { filter: brightness(1.2); }
|
||||
|
||||
.nav-list {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
transition: opacity var(--transition-slow), transform var(--transition-slow);
|
||||
}
|
||||
|
||||
.nav-list.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.nav-list li {
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-list a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: 10px 14px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-md);
|
||||
transition: color var(--transition-base), background-color var(--transition-base);
|
||||
}
|
||||
|
||||
.nav-list a:hover {
|
||||
color: var(--color-text);
|
||||
background-color: rgba(91, 140, 255, 0.10);
|
||||
}
|
||||
|
||||
.nav-username { line-height: 1; }
|
||||
|
||||
.nav-avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: var(--radius-full);
|
||||
object-fit: cover;
|
||||
border: 2px solid var(--color-border);
|
||||
transition: border-color var(--transition-base);
|
||||
}
|
||||
|
||||
.nav-list a:hover .nav-avatar {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 560px) {
|
||||
.header-container { padding: 0 14px; }
|
||||
.nav-list a { padding: 8px 10px; font-size: 0.88rem; }
|
||||
.nav-avatar { width: 26px; height: 26px; }
|
||||
}
|
||||
247
src/css/Mainpage.css
Normal file
247
src/css/Mainpage.css
Normal file
@@ -0,0 +1,247 @@
|
||||
.mainpage {
|
||||
padding: 0 16px;
|
||||
animation: fadeIn var(--transition-slow) ease both;
|
||||
}
|
||||
|
||||
.mainpage__intro {
|
||||
background: var(--gradient-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: var(--space-2xl);
|
||||
margin: var(--space-xl) auto;
|
||||
max-width: 1200px;
|
||||
display: flex;
|
||||
gap: var(--space-xl);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* Left */
|
||||
.left-content {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2xl);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.left-content::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -40%;
|
||||
right: -20%;
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
background: radial-gradient(circle, var(--color-primary-glow), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.left-content h1 {
|
||||
position: relative;
|
||||
margin: 0 0 var(--space-md) 0;
|
||||
font-size: 1.75rem;
|
||||
line-height: 1.2;
|
||||
background: var(--gradient-accent);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.left-content p {
|
||||
position: relative;
|
||||
margin: var(--space-sm) 0 0;
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
color: var(--color-text-muted);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Right — tasks */
|
||||
.tasks-container {
|
||||
flex: 1 1 0;
|
||||
max-width: 480px;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-xl);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tasks-title {
|
||||
margin: 0 0 var(--space-lg);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
text-align: left;
|
||||
padding-bottom: var(--space-md);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--color-surface-2);
|
||||
-webkit-background-clip: initial;
|
||||
background-clip: initial;
|
||||
-webkit-text-fill-color: var(--color-text);
|
||||
}
|
||||
|
||||
.tasks-loading,
|
||||
.tasks-empty {
|
||||
color: var(--color-text-dim);
|
||||
text-align: center;
|
||||
padding: var(--space-xl) 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tasks-list {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.tasks-list--scroll {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding-right: var(--space-sm);
|
||||
}
|
||||
|
||||
.tasks-list::-webkit-scrollbar { width: 6px; }
|
||||
|
||||
/* Task card */
|
||||
.task-item {
|
||||
background: var(--color-surface-3);
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--color-border);
|
||||
transition: transform var(--transition-base), box-shadow var(--transition-base), border-color var(--transition-base);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 3px;
|
||||
background: var(--gradient-primary);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-base);
|
||||
}
|
||||
|
||||
.task-item:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.task-item:hover::before { opacity: 1; }
|
||||
|
||||
.task-item__title {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
color: var(--color-text);
|
||||
margin-bottom: var(--space-sm);
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.task-item__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-top: 6px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-item__label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-dim);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.task-item__value {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
word-break: break-word;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-item__value--truncate {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 220px;
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.task-item__value--muted {
|
||||
color: var(--color-text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.task-item__badge {
|
||||
display: inline-block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
background: rgba(91, 140, 255, 0.14);
|
||||
color: #a8c0ff;
|
||||
border: 1px solid rgba(91, 140, 255, 0.28);
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Tablet */
|
||||
@media (max-width: 960px) {
|
||||
.mainpage__intro { padding: var(--space-xl); gap: var(--space-lg); }
|
||||
.tasks-container { max-width: 380px; padding: var(--space-lg); }
|
||||
.task-item__value--truncate { max-width: 160px; }
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 768px) {
|
||||
.mainpage { padding: 0 var(--space-sm); }
|
||||
.mainpage__intro {
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-lg);
|
||||
margin: var(--space-sm) auto;
|
||||
}
|
||||
.left-content { padding: var(--space-xl); }
|
||||
.left-content h1 { font-size: 1.5rem; }
|
||||
.tasks-container { max-width: 100%; }
|
||||
.tasks-list--scroll { max-height: 320px; }
|
||||
.task-item__value--truncate {
|
||||
max-width: none;
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: unset;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.mainpage__intro { padding: var(--space-md); }
|
||||
.task-item { padding: 10px 12px; }
|
||||
.task-item__title { font-size: 0.95rem; }
|
||||
}
|
||||
50
src/css/Modal.css
Normal file
50
src/css/Modal.css
Normal file
@@ -0,0 +1,50 @@
|
||||
.confirm-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(8, 12, 18, 0.7);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
padding: var(--space-lg);
|
||||
animation: fadeIn var(--transition-base) ease both;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--gradient-surface);
|
||||
border: 1px solid var(--color-border-strong);
|
||||
padding: var(--space-xl);
|
||||
border-radius: var(--radius-lg);
|
||||
text-align: center;
|
||||
min-width: 320px;
|
||||
max-width: 560px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-lg), 0 0 0 1px rgba(91, 140, 255, 0.08);
|
||||
animation: modalPop var(--transition-slow) cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
@keyframes modalPop {
|
||||
from { opacity: 0; transform: translateY(12px) scale(0.96); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
text-align: center;
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.modal-buttons {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
margin-top: var(--space-xl);
|
||||
}
|
||||
|
||||
.modal-buttons button {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
64
src/css/OtherProfile.css
Normal file
64
src/css/OtherProfile.css
Normal file
@@ -0,0 +1,64 @@
|
||||
.profile-info {
|
||||
margin: 0 0 var(--space-md);
|
||||
animation: fadeIn var(--transition-slow) ease both;
|
||||
}
|
||||
|
||||
.name-with-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-lg);
|
||||
padding: var(--space-lg) var(--space-xl);
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.user-name {
|
||||
margin: 0;
|
||||
font-size: 2.4rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--color-text);
|
||||
flex: 1 1 auto;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
background: var(--gradient-accent);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 3px solid transparent;
|
||||
background:
|
||||
linear-gradient(var(--color-surface-2), var(--color-surface-2)) padding-box,
|
||||
var(--gradient-primary) border-box;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.user-description {
|
||||
padding: var(--space-lg) var(--space-xl);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-2);
|
||||
font-size: 1rem;
|
||||
line-height: 1.65;
|
||||
color: var(--color-text);
|
||||
text-align: start;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
max-width: 100%;
|
||||
margin-top: var(--space-lg);
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.user-name { font-size: 1.8rem; }
|
||||
.profile-avatar { width: 88px; height: 88px; }
|
||||
.name-with-avatar { padding: var(--space-md); }
|
||||
}
|
||||
10004
src/fonts/roboto-regular/roboto-regular.svg
Normal file
10004
src/fonts/roboto-regular/roboto-regular.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 663 KiB |
BIN
src/fonts/roboto-regular/roboto-regular.ttf
Normal file
BIN
src/fonts/roboto-regular/roboto-regular.ttf
Normal file
Binary file not shown.
BIN
src/fonts/roboto-regular/roboto-regular.woff
Normal file
BIN
src/fonts/roboto-regular/roboto-regular.woff
Normal file
Binary file not shown.
BIN
src/fonts/roboto-regular/roboto-regular.woff2
Normal file
BIN
src/fonts/roboto-regular/roboto-regular.woff2
Normal file
Binary file not shown.
2693
src/fonts/robotoslab-extralight/robotoslab-extralight.svg
Normal file
2693
src/fonts/robotoslab-extralight/robotoslab-extralight.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 446 KiB |
BIN
src/fonts/robotoslab-extralight/robotoslab-extralight.ttf
Normal file
BIN
src/fonts/robotoslab-extralight/robotoslab-extralight.ttf
Normal file
Binary file not shown.
BIN
src/fonts/robotoslab-extralight/robotoslab-extralight.woff2
Normal file
BIN
src/fonts/robotoslab-extralight/robotoslab-extralight.woff2
Normal file
Binary file not shown.
109
src/index.css
Normal file
109
src/index.css
Normal file
@@ -0,0 +1,109 @@
|
||||
:root {
|
||||
/* Palette */
|
||||
--color-bg: #0f141c;
|
||||
--color-bg-alt: #151c28;
|
||||
--color-surface: #1c2533;
|
||||
--color-surface-2: #232e40;
|
||||
--color-surface-3: #2e3a52;
|
||||
--color-border: rgba(255, 255, 255, 0.07);
|
||||
--color-border-strong: rgba(255, 255, 255, 0.14);
|
||||
--color-text: #e5ebf2;
|
||||
--color-text-muted: #9aa6b8;
|
||||
--color-text-dim: #6b778a;
|
||||
|
||||
--color-primary: #5b8cff;
|
||||
--color-primary-hover: #6f9bff;
|
||||
--color-primary-glow: rgba(91, 140, 255, 0.35);
|
||||
--color-accent: #08e8de;
|
||||
--color-danger: #ef4444;
|
||||
--color-danger-hover: #dc2626;
|
||||
--color-success: #22c55e;
|
||||
--color-warning: #f59e0b;
|
||||
|
||||
/* Gradients */
|
||||
--gradient-primary: linear-gradient(135deg, #5b8cff 0%, #8b5bff 100%);
|
||||
--gradient-surface: linear-gradient(180deg, #1c2533 0%, #151c28 100%);
|
||||
--gradient-accent: linear-gradient(135deg, #08e8de 0%, #5b8cff 100%);
|
||||
|
||||
/* Radii */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-xl: 20px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 2px 6px rgba(0, 0, 0, 0.25);
|
||||
--shadow-md: 0 6px 20px rgba(0, 0, 0, 0.35);
|
||||
--shadow-lg: 0 14px 40px rgba(0, 0, 0, 0.45);
|
||||
--shadow-glow: 0 0 24px var(--color-primary-glow);
|
||||
|
||||
/* Motion */
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-base: 240ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* Spacing */
|
||||
--space-xs: 4px;
|
||||
--space-sm: 8px;
|
||||
--space-md: 12px;
|
||||
--space-lg: 16px;
|
||||
--space-xl: 24px;
|
||||
--space-2xl: 32px;
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto',
|
||||
'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: var(--color-bg);
|
||||
background-image:
|
||||
radial-gradient(circle at 10% 0%, rgba(91, 140, 255, 0.10), transparent 40%),
|
||||
radial-gradient(circle at 90% 100%, rgba(139, 91, 255, 0.08), transparent 45%);
|
||||
background-attachment: fixed;
|
||||
color: var(--color-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', source-code-pro, Menlo, Monaco, Consolas,
|
||||
'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-surface-3);
|
||||
border-radius: var(--radius-full);
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-primary);
|
||||
background-clip: padding-box;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background: var(--color-primary-glow);
|
||||
color: var(--color-text);
|
||||
}
|
||||
17
src/index.js
Normal file
17
src/index.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
||||
1
src/logo.svg
Normal file
1
src/logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
13
src/reportWebVitals.js
Normal file
13
src/reportWebVitals.js
Normal file
@@ -0,0 +1,13 @@
|
||||
const reportWebVitals = onPerfEntry => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
||||
22
src/setupProxy.js
Normal file
22
src/setupProxy.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const { createProxyMiddleware } = require('http-proxy-middleware');
|
||||
|
||||
const BACKEND_URL = process.env.REACT_APP_BACKEND_URL || 'http://26.22.232.18:24454';
|
||||
|
||||
module.exports = function (app) {
|
||||
app.use(
|
||||
'/api',
|
||||
createProxyMiddleware({
|
||||
target: BACKEND_URL,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
pathRewrite: { '^/api': '/api' },
|
||||
})
|
||||
);
|
||||
app.use(
|
||||
'/static/avatars',
|
||||
createProxyMiddleware({
|
||||
target: BACKEND_URL,
|
||||
changeOrigin: true,
|
||||
})
|
||||
);
|
||||
};
|
||||
5
src/setupTests.js
Normal file
5
src/setupTests.js
Normal file
@@ -0,0 +1,5 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
Reference in New Issue
Block a user