add first services

This commit is contained in:
Николай Вигдоров
2025-08-03 13:11:32 +03:00
parent 98de7cc8bd
commit a46bf6038b
23 changed files with 10661 additions and 0 deletions

32
.eslintrc.cjs Normal file
View File

@ -0,0 +1,32 @@
module.exports = {
root: true,
env: { browser: true, es2021: true, jest: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:jsx-a11y/recommended',
'prettier', // Важно: prettier должен быть последним
],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: './tsconfig.json', // Для правил, требующих информацию о типах
},
plugins: ['react', '@typescript-eslint', 'jsx-a11y'],
settings: {
react: {
version: 'detect', // Автоматически определять версию React
},
},
rules: {
// Отключаем правило, которое не нужно с новым JSX-трансформером
'react/react-in-jsx-scope': 'off',
// Мы используем TypeScript, поэтому prop-types не нужны
'react/prop-types': 'off',
// Пример кастомного правила: требовать явное указание возвращаемого типа функции
'@typescript-eslint/explicit-function-return-type': 'warn',
},
};

7
.prettierrc Normal file
View File

@ -0,0 +1,7 @@
{
"semi": true,
"trailingComma": "all",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2
}

23
jest.config.ts Normal file
View File

@ -0,0 +1,23 @@
export default {
// Указываем пресет для работы с TypeScript
preset: 'ts-jest',
// Указываем тестовую среду, эмулирующую DOM
testEnvironment: 'jest-environment-jsdom',
// Путь к файлу с глобальными настройками для тестов
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
// Трансформер для файлов TypeScript
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
// Маппинг для обработки импортов, которые Jest не понимает
moduleNameMapper: {
// Мокируем импорты стилей
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
// Настраиваем алиас '@' так же, как в Vite и tsconfig
'^@/(.*)$': '<rootDir>/src/$1',
},
};

3
jest.setup.ts Normal file
View File

@ -0,0 +1,3 @@
// Расширяет ожидания Jest с помощью удобных матчеров для DOM
// Например, позволяет писать expect(element).toBeInTheDocument()
import '@testing-library/jest-dom';

10076
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

55
package.json Normal file
View File

@ -0,0 +1,55 @@
{
"name": "task-tracker-for-students",
"version": "1.0.0",
"description": "Проект для примеры реализации приложения Task Tracker на основе макетов https://www.figma.com/design/oHNPqHqhNtil2xuWBjZfYW/Task-tracker",
"main": "index.js",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint:fix": "eslint . --ext ts,tsx --fix",
"format": "prettier --write \"**/*.{ts,tsx,json,css,md}\"",
"test": "jest"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@reatom/core": "3.10.1",
"@reatom/npm-react": "3.10.6",
"axios": "1.11.0",
"date-fns": "4.1.0",
"formik": "2.4.6",
"keycloak-js": "26.2.0",
"react": "19.1.1",
"react-dom": "19.1.1",
"react-jss": "10.10.0",
"react-router-dom": "7.7.1",
"uuid": "11.1.0"
},
"devDependencies": {
"@testing-library/jest-dom": "6.6.4",
"@testing-library/react": "16.3.0",
"@types/jest": "30.0.0",
"@types/react": "19.1.9",
"@types/react-dom": "19.1.7",
"@types/uuid": "10.0.0",
"@typescript-eslint/eslint-plugin": "8.38.0",
"@typescript-eslint/parser": "8.38.0",
"@vitejs/plugin-react": "4.7.0",
"eslint": "9.32.0",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.20",
"identity-obj-proxy": "3.0.0",
"jest": "30.0.5",
"jest-environment-jsdom": "30.0.5",
"prettier": "3.6.2",
"ts-jest": "29.4.0",
"ts-node": "10.9.2",
"typescript": "5.8.3",
"vite": "7.0.6"
}
}

View File

@ -0,0 +1,53 @@
import { BrowserRouter, Route, Routes } from 'react-router';
import { TasksPage } from '../pages/TasksPage';
import { BottomMenu } from '../BottomMenu/BottomMenu';
import { CalendarPage } from '../pages/CalendarPage';
import { BoardPage } from '../pages/BoardPage';
import { reatomContext as ReatomContext } from '@reatom/npm-react';
import { ctx } from '../../../core/services/ReatomContext';
import { AuthProvider } from '../../../core/services/AuthService';
import { AuthRoute } from '../../../core/components/AuthRoute';
export const App = () => {
return (
<ReatomContext.Provider value={ctx}>
<AuthProvider>
<BrowserRouter>
<div>
<Routes>
<Route path="/" element={<TasksPage />} />
<Route
path="/calendar"
element={
<AuthRoute>
<CalendarPage />
</AuthRoute>
}
/>
<Route path="/board" element={<BoardPage />} />
</Routes>
<BottomMenu
items={[
{
title: 'Tasks',
image: '',
route: '/',
},
{
title: 'Calendar',
image: '',
route: '/calendar',
},
{
title: 'Board',
image: '',
route: '/board',
},
]}
/>
</div>
</BrowserRouter>
</AuthProvider>
</ReatomContext.Provider>
);
};

View File

@ -0,0 +1,20 @@
import { FC } from 'react';
import { Link } from 'react-router-dom';
type BottomMenuProps = {
items: MenuItem[];
};
export const BottomMenu: FC<BottomMenuProps> = ({ items }) => {
return (
<div>
{items.map(({ title, image, route }) => {
return (
<Link key={route} to={route}>
{title}
</Link>
);
})}
</div>
);
};

View File

@ -0,0 +1,5 @@
type MenuItem = {
image: string;
title: string;
route: string;
};

View File

@ -0,0 +1,3 @@
export const BoardPage = () => {
return <div>Board page</div>;
};

View File

@ -0,0 +1,3 @@
export const CalendarPage = () => {
return <div>Calendar page</div>;
};

View File

@ -0,0 +1,36 @@
import { useAtom } from '@reatom/npm-react';
import { removeTask, tasksAtom } from '../../services/TasksService';
import { authAtom, login } from '../../../core/services/AuthService';
export const TasksPage = () => {
const [tasks] = useAtom(tasksAtom);
const [auth] = useAtom(authAtom);
return (
<div>
<div>
{auth.isAuth ? (
'Привет пользователь'
) : (
<button type="button" onClick={login}>
Войти
</button>
)}
</div>
{tasks.map(({ id, title, description }) => {
const handleRemoveTask = () => {
removeTask(id);
};
return (
<div key={id}>
<div>Title: {title}</div>
<div>Description: {description}</div>
<button type="button" onClick={handleRemoveTask}>
remove
</button>
</div>
);
})}
</div>
);
};

View File

@ -0,0 +1,29 @@
import { action, atom } from '@reatom/core';
import { v4 } from 'uuid';
import { ctx } from '../../core/services/ReatomContext';
type Uuid = string;
type Task = {
id: Uuid;
title: string;
description: string;
};
export const tasksAtom = atom<Task[]>(
[
{ id: v4(), title: 'Clean bathroom', description: 'Clean all corners' },
{ id: v4(), title: 'Buy a car', description: 'Go to shop and buy a car' },
],
'tasks',
);
const removeTaskAction = action((ctx, removeTaskId: Uuid) => {
const taskList = ctx.get(tasksAtom);
const filteredTaskList = taskList.filter((t) => t.id !== removeTaskId);
tasksAtom(ctx, filteredTaskList);
});
export const removeTask = (removeTaskId: Uuid) => removeTaskAction(ctx, removeTaskId);

View File

@ -0,0 +1,13 @@
import { FC, PropsWithChildren, ReactNode, useEffect } from 'react';
import { authAtom } from '../services/AuthService';
import {useAtom} from '@reatom/npm-react';
export const AuthRoute: FC<PropsWithChildren> = ({ children }) => {
const [auth] = useAtom(authAtom);
if (!auth.isAuth) {
return <div>you need auth</div>;
}
return children;
};

View File

@ -0,0 +1,67 @@
import { action, atom } from '@reatom/core';
import Keycloak from 'keycloak-js';
import { createContext, FC, PropsWithChildren, useContext, useEffect, useRef } from 'react';
import { ctx } from './ReatomContext';
const authInstance = new Keycloak({
url: 'https://auth.vigdorov.ru',
realm: 'dev-apps',
clientId: 'test-localapp',
});
const AuthContext = createContext<typeof authInstance | undefined>(undefined);
type AuthData =
| {
isAuth: false;
}
| {
isAuth: true;
userName: string;
};
export const authAtom = atom<AuthData>(
{
isAuth: false,
},
'auth',
);
const changeAuthAction = action((ctx, authData: AuthData) => {
authAtom(ctx, authData);
});
export const login = () => authInstance.login();
export const AuthProvider: FC<PropsWithChildren> = ({ children }) => {
const isInit = useRef(false);
useEffect(() => {
if (!isInit.current) {
isInit.current = true;
authInstance
.init({ onLoad: 'check-sso' })
.then((authenticated) => {
const userName: string =
authInstance.tokenParsed?.given_name || authInstance.tokenParsed?.preferred_username;
const authData = authenticated
? {
isAuth: authenticated,
userName,
}
: {
isAuth: authenticated,
};
changeAuthAction(ctx, authData);
})
.catch((error) => {
console.error('Ошибка инициализации Keycloak', error);
changeAuthAction(ctx, { isAuth: false });
});
}
}, []);
return <AuthContext.Provider value={authInstance}>{children}</AuthContext.Provider>;
};

View File

@ -0,0 +1,20 @@
import {useAtom} from '@reatom/npm-react';
import {createContext, FC, PropsWithChildren, useEffect, useRef} from 'react';
import {authAtom} from './AuthService';
type InitData = {
isInit: boolean;
};
const InitContext = createContext<InitData>({isInit: false});
export const InitProvider: FC<PropsWithChildren> = ({children}) => {
const isInit = useRef(false);
const [authData] = useAtom(authAtom);
useEffect(() => {
}, [authData])
}

View File

@ -0,0 +1,3 @@
import {createCtx} from '@reatom/core';
export const ctx = createCtx();

View File

@ -0,0 +1,138 @@
import axios from 'axios';
const BASE_URL = 'https://simple-storage.vigdorov.ru';
const STORAGE_NAME = 'task-tracker-app-for-students' as const;
type AuthRequest = {
login: string;
};
type AuthResponse = string;
type Uuid = string;
type Task = {
id: Uuid;
title: string;
description: string;
};
type StorageForStudentsListItem = {
user: string;
storageName: typeof STORAGE_NAME;
id: string;
};
type StorageForStudents = {
data: Task[];
user: string;
storageName: typeof STORAGE_NAME;
id: string;
};
type StorageCreateRequest = {
data: any;
storageName: string;
};
type AnyStorageListItem = {
user: string;
storageName: string;
id: string;
};
type UpdateStorageRequest = {
data: Task[];
};
type StorageListItem = StorageForStudentsListItem | AnyStorageListItem;
type StorageListResponse = StorageListItem[];
export class StorageService {
userName: string;
token: string;
id: string | undefined;
constructor(userName: string) {
this.userName = userName;
this.token = '';
axios
.post<void, AuthResponse, AuthRequest>(`${BASE_URL}/auth`, {
login: userName,
})
.then((token) => {
this.token = token;
})
.catch((error) => {
console.error(error);
this.token = '';
});
}
getStorage(): Promise<Task[]> {
return axios
.get<void, StorageListResponse>(`${BASE_URL}/storages`, {
headers: {
Authorization: this.token,
},
})
.then((storageListResponse) => {
const findStorage = storageListResponse.find(
(storage): storage is StorageForStudentsListItem => storage.storageName === STORAGE_NAME,
);
if (findStorage) {
return findStorage.id;
}
return axios
.post<void, StorageForStudentsListItem, StorageCreateRequest>(
`${BASE_URL}/storages`,
{
data: [],
storageName: STORAGE_NAME,
},
{
headers: {
Authorization: this.token,
},
},
)
.then((storageForStudentsListItem) => {
return storageForStudentsListItem.id;
});
})
.then((id) => {
this.id = id;
return axios
.get<void, StorageForStudents>(`${BASE_URL}/storages/${id}`, {
headers: {
Authorization: this.token,
},
})
.then((storageForStudents) => storageForStudents.data);
});
}
setStorage(taskList: Task[]): Promise<void> {
if (!this.id) {
return Promise.reject('Id отсутствует');
}
return axios.put<void, void, UpdateStorageRequest>(
`${BASE_URL}/storages/${this.id}`,
{
data: taskList,
},
{
headers: {
Authorization: this.token,
},
},
);
}
}

12
src/index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./init.tsx"></script>
</body>
</html>

9
src/init.tsx Normal file
View File

@ -0,0 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import {App} from './app/components/App/App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

27
tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Path Aliases */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
tsconfig.node.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts", "jest.config.ts"]
}

17
vite.config.ts Normal file
View File

@ -0,0 +1,17 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
root: 'src',
build: {
outDir: '../build',
emptyOutDir: true,
},
server: {
// Порт для сервера разработки
port: 3050,
},
});