Init
This commit is contained in:
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