This commit is contained in:
Vladiysss
2026-04-18 14:55:18 +03:00
commit 31c2764936
58 changed files with 34544 additions and 0 deletions

40
src/App.js Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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); }
}

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 663 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 446 KiB

109
src/index.css Normal file
View 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
View 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
View 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
View 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
View 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
View 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';