Initial commit

parents
node_modules
\ No newline at end of file
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"[javascript]": {
"editor.formatOnSave": true
},
"[typescript]": {
"editor.formatOnSave": true
},
"[typescriptreact]": {
"editor.formatOnSave": true
},
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
"typescript.tsdk": "node_modules/typescript/lib",
}
\ No newline at end of file
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
'prettier',
'plugin:react/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 'latest',
sourceType: 'module',
// project: './tsconfig.json',
},
plugins: ['react-refresh', 'react', '@typescript-eslint', 'prettier'],
rules: {
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'react/react-in-jsx-scope': 0,
},
settings: {
'import/resolver': {
typescript: {},
},
},
};
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
node_modules
build
.next
dist
\ No newline at end of file
module.exports = {
tabWidth: 2,
singleQuote: true,
trailingComma: 'es5',
printWidth: 100,
useTabs: false,
};
# Eslint_Prettier_VScode
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
{
"name": "redux-counter",
"private": true,
"version": "0.0.0",
"scripts": {
"dev": "pnpm i && vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint-fix": "eslint --fix 'src/**/*.{js,ts,tsx}'",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^5.6.1",
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@fontsource/roboto": "^5.0.12",
"@mui/icons-material": "^5.15.14",
"@mui/material": "^5.15.14",
"@reduxjs/toolkit": "^2.1.0",
"antd": "^5.24.7",
"axios": "^1.6.7",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^9.1.0",
"react-router-dom": "^6.22.3"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.20.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.55.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"prettier": "^3.2.5",
"typescript": "5.2.2",
"vite": "^5.0.8"
}
}
This diff is collapsed.
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
\ No newline at end of file
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
import { Box, Container, CssBaseline } from '@mui/material';
import { AppToolBar } from './components/UI/AppToolbar';
import { Route, Routes, useNavigate } from 'react-router-dom';
import { Products } from './containers/Products';
import { NewProduct } from './containers/NewProduct';
import Auth from './containers/Auth';
import { ProtectedRoute } from './components/UI/ProtectedRoute';
import { useAppDispatch, useAppSelector } from './store/hooks';
import { shallowEqual } from 'react-redux';
import { useEffect } from 'react';
import { notification } from 'antd';
import './App.css';
import { refreshToken } from './features/usersSlice';
function App() {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const { user, error } = useAppSelector((state) => state.user, shallowEqual);
useEffect(() => {
if (user) {
navigate('/');
} else {
dispatch(refreshToken());
}
}, [dispatch, user]);
useEffect(() => {
if (error) {
notification.error({
message: error as unknown as string,
});
}
}, [error]);
return (
<>
<CssBaseline />
<header>
<AppToolBar />
</header>
<main>
<Container maxWidth="xl">
<Box component="main" sx={{ mt: 10 }}>
<Routes>
<Route
path="/"
element={
<ProtectedRoute user={user}>
<Products />
</ProtectedRoute>
}
/>
<Route
path="/products"
element={
<ProtectedRoute user={user}>
<NewProduct />
</ProtectedRoute>
}
/>
<Route path="/auth" element={<Auth />} />
</Routes>
</Box>
</Container>
</main>
</>
);
}
export default App;
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
\ No newline at end of file
import { Button, Col, Form, FormProps, Input, Switch } from 'antd';
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { useAppDispatch } from '../store/hooks';
import { login, register } from '../features/usersSlice';
import { useState } from 'react';
export type AuthFieldType = {
username: string;
password: string;
};
export function AuthForm() {
const dispatch = useAppDispatch();
const [isLogin, setIsLogin] = useState(true);
const onFinish: FormProps<AuthFieldType>['onFinish'] = (values) => {
dispatch(isLogin ? login(values) : register(values));
};
return (
<Col>
<Switch
checkedChildren="Login"
unCheckedChildren="Register"
style={{ marginBottom: '25px' }}
value={isLogin}
onChange={(val) => setIsLogin(val)}
/>
<Form
name="registration-form"
onFinish={onFinish}
title="Login/Register"
style={{ width: 800 }}
labelCol={{ span: 4 }}
wrapperCol={{ span: 16 }}
>
<Form.Item<AuthFieldType>
name="username"
label="User name"
rules={[{ required: true, message: 'Please input your Username!' }]}
>
<Input prefix={<UserOutlined className="site-form-item-icon" />} placeholder="Username" />
</Form.Item>
<Form.Item<AuthFieldType>
name="password"
label="password"
rules={[{ required: true, message: 'Please input your Password!' }]}
>
<Input
prefix={<LockOutlined className="site-form-item-icon" />}
type="password"
placeholder="Password"
/>
</Form.Item>
<Form.Item label={null}>
<Button type="primary" htmlType="submit">
Submit
</Button>
</Form.Item>
</Form>
</Col>
);
}
import { ChangeEvent, FormEvent, useState } from 'react';
import { FileInput } from './UI/FileInput';
import { Box, Button, Grid, TextField } from '@mui/material';
import { useAppDispatch } from '..//store/hooks';
import { useNavigate } from 'react-router-dom';
import { createProduct } from '../features/productsSlice';
interface NewProductState {
title: string;
price: number;
description?: string;
image?: File;
}
export function ProductForm() {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const [product, setProduct] = useState<NewProductState>({
price: 0,
title: '',
});
const submitFormHandler = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData();
Object.entries(product).forEach(([key, value]) => {
if (typeof value === 'object') {
formData.append(key, value);
} else {
formData.append(key, `${value}`);
}
});
await dispatch(createProduct(formData));
navigate('/');
};
const fileChangeHandler = (e: ChangeEvent<HTMLInputElement>) => {
if (e?.target?.files?.[0]) {
const file: File = e.target.files[0];
setProduct((prevProduct) => {
return { ...prevProduct, image: file };
});
}
};
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setProduct((prevState) => {
return { ...prevState, [name]: value };
});
};
return (
<Box component={'form'} autoComplete="off" onSubmit={submitFormHandler} paddingY={2}>
<Grid container direction="column" spacing={2}>
<Grid item xs>
<TextField
fullWidth
variant="outlined"
id="title"
label="Title"
value={product.title}
onChange={inputChangeHandler}
name="title"
/>
</Grid>
<Grid item xs>
<TextField
fullWidth
variant="outlined"
id="price"
label="Price"
value={product.price}
onChange={inputChangeHandler}
name="price"
/>
</Grid>
<Grid item xs>
<TextField
fullWidth
multiline
rows={3}
variant="outlined"
id="description"
label="Description"
value={product.description}
onChange={inputChangeHandler}
name="description"
/>
</Grid>
<Grid item xs>
<FileInput label="Image" name="image" onChange={fileChangeHandler} />
</Grid>
<Grid item xs>
<Button type="submit" color="primary" variant="contained">
Create
</Button>
</Grid>
</Grid>
</Box>
);
}
import {
Card,
CardActions,
CardContent,
CardHeader,
CardMedia,
Grid,
IconButton,
Typography,
} from '@mui/material';
import { IProduct } from '../containers/Products';
import { ArrowForward } from '@mui/icons-material';
import { Link } from 'react-router-dom';
interface Props {
product: IProduct;
}
export const ProductItem = ({ product }: Props) => {
const { title, description, price, id, image } = product;
let cardImage = 'http://localhost:5173/image_not_available.png';
if (image) {
cardImage = `http://localhost:5000/uploads/${image}`;
}
return (
<Grid item xs={12} sm={12} md={6} lg={4}>
<Card>
<CardHeader title={title} />
<CardMedia image={cardImage} title={title} sx={{ height: 200 }} />
<CardContent>
<Typography variant="body2">{description}</Typography>
<strong style={{ marginLeft: '10px' }}>Price: {price}$</strong>
</CardContent>
<CardActions>
<IconButton component={Link} to={`/products/${id}`}>
<ArrowForward />
</IconButton>
</CardActions>
</Card>
</Grid>
);
};
import { AppBar, Toolbar, Typography, styled } from '@mui/material';
import { Link } from 'react-router-dom';
const StyledLink = styled(Link)(() => ({
color: 'inherit',
textDecoration: 'none',
['&:hover']: { color: 'inherit' },
}));
export const AppToolBar = () => {
return (
<>
<AppBar position="fixed">
<Toolbar>
<Typography variant="h6" component={StyledLink} to={'/'}>
Computer parts shop
</Typography>
</Toolbar>
</AppBar>
</>
);
};
import { Button, Stack, styled, TextField } from '@mui/material';
import { ChangeEvent, useRef, useState } from 'react';
interface Props {
name: string;
label: string;
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
// ChangeEventHandler<HTMLInputElement>
}
const HiddenInput = styled('input')({ display: 'none' });
export function FileInput({ name, label, onChange }: Props) {
const inputRef = useRef<HTMLInputElement>(null);
const [fileName, setFileName] = useState('');
const activeInput = () => {
if (inputRef.current) {
inputRef.current.click();
}
};
const onFileChange = (e: ChangeEvent<HTMLInputElement>) => {
if (e?.target?.files?.[0]?.name) {
setFileName(e.target.files[0].name);
} else {
setFileName('');
}
onChange(e);
};
return (
<>
<HiddenInput type="file" ref={inputRef} name={name} onChange={onFileChange} />
<Stack direction="row" spacing={1} sx={{ mb: 1 }}>
<TextField
value={fileName}
variant="outlined"
label={label}
onClick={activeInput}
fullWidth
disabled
/>
<Button variant="contained" onClick={activeInput}>
Browse
</Button>
</Stack>
</>
);
}
import { IUser } from '@/containers/Auth';
import React from 'react';
import { Navigate } from 'react-router-dom';
type ProtectedRouteProps = {
user: IUser | null;
children: React.ReactNode;
};
export const ProtectedRoute = ({ user, children }: ProtectedRouteProps) => {
if (!user) {
return <Navigate to="/auth" replace />;
}
return children;
};
import { AuthForm } from '../components/AuthForm';
import { Row } from 'antd';
const Auth = () => {
return (
<Row style={{ height: '100%', width: '100%' }} justify={'center'} align={'middle'}>
<AuthForm />
</Row>
);
};
export default Auth;
export interface IUser {
id: number;
username: string;
accessToken: string;
}
import { ProductForm } from '../components/ProductForm';
import { Typography } from '@mui/material';
export interface ProductData {
title: string;
description?: string;
price: number;
}
export function NewProduct() {
return (
<>
<Typography variant="h4">New product</Typography>
<ProductForm />
</>
);
}
import { fetchProducts } from '../features/productsSlice';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import { Button, Grid, Typography } from '@mui/material';
import { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { ProductItem } from '../components/ProductItem';
export const Products = () => {
const dispatch = useAppDispatch();
const { products } = useAppSelector((state) => state.products);
useEffect(() => {
dispatch(fetchProducts());
}, [dispatch]);
return (
<>
<Grid container spacing={2}>
<Grid
item
container
direction={'row'}
justifyContent={'space-between'}
alignItems={'center'}
>
<Grid item>
<Typography variant="h4">Products</Typography>
</Grid>
</Grid>
<Grid item container direction={'row'} spacing={1}>
{products.map((product) => (
<ProductItem key={product.id} product={product} />
))}
</Grid>
<Grid item>
<Button color="primary" component={Link} to={'/products'}>
Add Product
</Button>
</Grid>
</Grid>
</>
);
};
export interface IProduct {
id: string;
title: string;
description?: string;
price: number;
image?: string;
}
import { IProduct } from '../containers/Products';
import { axiosApiClient } from '../helpers/axiosApiClient';
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
interface State {
products: IProduct[];
error: Error | null;
loading: boolean;
}
const initialState: State = {
products: [],
error: null,
loading: false,
};
export const fetchProducts = createAsyncThunk('fetch/products', async () => {
return await axiosApiClient
.get<IProduct[]>('/product', {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
})
.then((res) => res.data);
});
export const createProduct = createAsyncThunk('create/products', async (payload: FormData) => {
return await axiosApiClient
.post<IProduct>('/product', payload, {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
})
.then((res) => res.data);
});
const productsSlice = createSlice({
name: 'products',
initialState,
reducers: {},
extraReducers(builder) {
builder
.addCase(fetchProducts.fulfilled, (state, action) => {
state.products = action.payload;
state.loading = false;
})
.addCase(fetchProducts.rejected, (state, action) => {
state.loading = false;
state.error = action.error as Error;
})
.addCase(fetchProducts.pending, (state) => {
state.loading = true;
});
},
});
export default productsSlice.reducer;
import { IUser } from '@/containers/Auth';
import { axiosApiClient } from '../helpers/axiosApiClient';
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { AxiosError, isAxiosError } from 'axios';
interface State {
user: IUser | null;
error: Error | null;
loading: boolean;
}
const initialState: State = {
user: null,
error: null,
loading: false,
};
export const refreshToken = createAsyncThunk('auth/refreshToken', async () => {
return await axiosApiClient
.get<IUser | null>('/auth/refreshToken', {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
})
.then((res) => res.data)
.catch(() => {
localStorage.setItem('token', '');
});
});
export const register = createAsyncThunk(
'auth/register',
async (payload: { username: string; password: string }) => {
return await axiosApiClient.post<IUser | null>('/auth/register', payload).then((res) => {
localStorage.setItem('token', res.data?.accessToken || '');
return res.data;
});
}
);
export const login = createAsyncThunk(
'auth/login',
async (payload: { username: string; password: string }, { rejectWithValue }) => {
try {
return await axiosApiClient.post<IUser | null>('/auth/login', payload).then((res) => {
localStorage.setItem('token', res.data?.accessToken || '');
return res.data;
});
} catch (error) {
if (isAxiosError(error)) {
const err: AxiosError<{ message: string }> = error;
return rejectWithValue(err.response?.data || { error: { message: 'An error occurred' } });
}
throw error;
}
}
);
const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {},
extraReducers(builder) {
builder
// REGISTER
.addCase(register.fulfilled, (state, action) => {
state.user = action.payload;
state.loading = false;
})
.addCase(register.rejected, (state, action) => {
state.loading = false;
state.error = action.error as Error;
})
.addCase(register.pending, (state) => {
state.loading = true;
})
// LOGIN
.addCase(login.fulfilled, (state, action) => {
state.user = action.payload;
state.loading = false;
})
.addCase(login.rejected, (state, action) => {
state.loading = false;
state.error = action.payload.message;
})
.addCase(login.pending, (state) => {
state.loading = true;
})
// REFRESH TOKEN
.addCase(refreshToken.fulfilled, (state, action) => {
state.user = action.payload;
state.loading = false;
})
.addCase(refreshToken.rejected, (state, action) => {
state.loading = false;
state.error = action.payload.message;
})
.addCase(refreshToken.pending, (state) => {
state.loading = true;
});
},
});
export default usersSlice.reducer;
import axios from 'axios';
export const axiosApiClient = axios.create({
baseURL: 'http://localhost:5000',
});
body {
margin: 0;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell',
'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
import App from './App.tsx';
import './index.css';
import store from './store/index.tsx';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
</React.StrictMode>
);
import { useDispatch, useSelector } from 'react-redux';
import type { TypedUseSelectorHook } from 'react-redux';
import type { RootState, AppDispatch } from '.';
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
import { configureStore } from '@reduxjs/toolkit';
import productsReducer from '../features/productsSlice';
import userReducer from '../features/usersSlice';
const store = configureStore({
reducer: {
products: productsReducer,
user: userReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export default store;
/// <reference types="vite/client" />
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
});
{
"name": "shop-app",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"server": "cd ./server && pnpm run start:dev",
"client": "cd ./client && pnpm run dev",
"dev": "pnpm i && concurrently \"pnpm run client\" \"pnpm run server\""
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"concurrently": "^9.1.2"
},
"dependencies": {
"class-validator": "^0.14.1"
}
}
\ No newline at end of file
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
class-validator:
specifier: ^0.14.1
version: 0.14.1
devDependencies:
concurrently:
specifier: ^9.1.2
version: 9.1.2
packages:
'@types/validator@13.12.3':
resolution: {integrity: sha512-2ipwZ2NydGQJImne+FhNdhgRM37e9lCev99KnqkbFHd94Xn/mErARWI1RSLem1QA19ch5kOhzIZd7e8CA2FI8g==}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
class-validator@0.14.1:
resolution: {integrity: sha512-2VEG9JICxIqTpoK1eMzZqaV+u/EiwEJkMGzTrZf6sU/fwsnOITVgYJ8yojSy6CaXtO9V0Cc6ZQZ8h8m4UBuLwQ==}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
concurrently@9.1.2:
resolution: {integrity: sha512-H9MWcoPsYddwbOGM6difjVwVZHl63nwMEwDJG/L7VGtuaJhb12h2caPG2tVPWs7emuYix252iGfqOyrz1GczTQ==}
engines: {node: '>=18'}
hasBin: true
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
libphonenumber-js@1.12.6:
resolution: {integrity: sha512-PJiS4ETaUfCOFLpmtKzAbqZQjCCKVu2OhTV4SVNNE7c2nu/dACvtCqj4L0i/KWNnIgRv7yrILvBj5Lonv5Ncxw==}
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
rxjs@7.8.2:
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
shell-quote@1.8.2:
resolution: {integrity: sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==}
engines: {node: '>= 0.4'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
supports-color@8.1.1:
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
engines: {node: '>=10'}
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
validator@13.15.0:
resolution: {integrity: sha512-36B2ryl4+oL5QxZ3AzD0t5SsMNGvTtQHpjgFO5tbNxfXbMFkY822ktCDe1MnlqV3301QQI9SLHDNJokDI+Z9pA==}
engines: {node: '>= 0.10'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
snapshots:
'@types/validator@13.12.3': {}
ansi-regex@5.0.1: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
class-validator@0.14.1:
dependencies:
'@types/validator': 13.12.3
libphonenumber-js: 1.12.6
validator: 13.15.0
cliui@8.0.1:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
color-name@1.1.4: {}
concurrently@9.1.2:
dependencies:
chalk: 4.1.2
lodash: 4.17.21
rxjs: 7.8.2
shell-quote: 1.8.2
supports-color: 8.1.1
tree-kill: 1.2.2
yargs: 17.7.2
emoji-regex@8.0.0: {}
escalade@3.2.0: {}
get-caller-file@2.0.5: {}
has-flag@4.0.0: {}
is-fullwidth-code-point@3.0.0: {}
libphonenumber-js@1.12.6: {}
lodash@4.17.21: {}
require-directory@2.1.1: {}
rxjs@7.8.2:
dependencies:
tslib: 2.8.1
shell-quote@1.8.2: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
supports-color@8.1.1:
dependencies:
has-flag: 4.0.0
tree-kill@1.2.2: {}
tslib@2.8.1: {}
validator@13.15.0: {}
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
y18n@5.0.8: {}
yargs-parser@21.1.1: {}
yargs@17.7.2:
dependencies:
cliui: 8.0.1
escalade: 3.2.0
get-caller-file: 2.0.5
require-directory: 2.1.1
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 21.1.1
node_modules
build
dist
{
"tabWidth": 2,
"singleQuote": false,
"trailingComma": "es5",
"printWidth": 100,
"useTabs": false
}
import { defineConfig } from "eslint/config";
import globals from "globals";
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import pluginReact from "eslint-plugin-react";
export default defineConfig([
{ files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"] },
{ files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"], languageOptions: { globals: globals.browser } },
{ files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"], plugins: { js }, extends: ["js/recommended"] },
tseslint.configs.recommended,
pluginReact.configs.flat.recommended,
]);
\ No newline at end of file
{
"name": "server-temp",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"lint": "eslint .",
"format": "prettier --write .",
"lint:fix": "eslint . --fix "
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.23.0",
"@typescript-eslint/eslint-plugin": "^8.28.0",
"eslint": "^9.23.0",
"eslint-config-prettier": "^10.1.1",
"eslint-plugin-prettier": "^5.2.5",
"eslint-plugin-react": "^7.37.4",
"globals": "^16.0.0",
"typescript": "5.2.2",
"typescript-eslint": "^8.28.0"
}
}
This diff is collapsed.
/**
* @description Декоратор для свойства: Автоматическое логирование изменений
* @param target объект, к которому применяется декоратор
* @param propertyKey имя свойства или метода
*/
function logProperty(target: any, propertyKey: string) {
let value: string;
console.log("propertyKey", propertyKey, JSON.stringify(target));
const originalValue = target[propertyKey];
const getter = () => value;
const setter = (newValue: string) => {
console.log(`Изменение свойства ${propertyKey}: от ${originalValue} к ${newValue}`);
value = newValue;
}
Object.defineProperty(target, propertyKey, {
get: getter,
set: setter,
configurable: true,
enumerable: true,
})
}
class Person {
@logProperty
name!: string;
@logProperty
age!: number;
}
const person = new Person();
person.name = "Mary";
person.name = "John";
person.age = 12;
\ No newline at end of file
This diff is collapsed.
# compiled output
/dist
/node_modules
/build
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
uploads/*
!uploads/.gitkeep
\ No newline at end of file
{
"singleQuote": true,
"trailingComma": "all"
}
\ No newline at end of file
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ pnpm install
```
## Compile and run the project
```bash
# development
$ pnpm run start
# watch mode
$ pnpm run start:dev
# production mode
$ pnpm run start:prod
```
## Run tests
```bash
# unit tests
$ pnpm run test
# e2e tests
$ pnpm run test:e2e
# test coverage
$ pnpm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ pnpm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn'
},
},
);
\ No newline at end of file
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
{
"name": "classwork-89",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.0",
"@nestjs/mapped-types": "*",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/typeorm": "^11.0.0",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"multer": "1.4.5-lts.2",
"mysql2": "^3.14.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"pg": "^8.14.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.22"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@faker-js/faker": "^9.7.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@swc/cli": "^0.6.0",
"@swc/core": "^1.10.7",
"@types/bcrypt": "^5.0.2",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/multer": "^1.4.12",
"@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^29.7.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
"pnpm": {
"onlyBuiltDependencies": [
"@nestjs/core",
"@swc/core",
"bcrypt"
]
}
}
This diff is collapsed.
import { Module } from '@nestjs/common';
import { ProductModule } from './product/product.module';
import { CategoryModule } from './category/category.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Product } from './product/entities/product.entity';
import { Category } from './category/entities/category.entity';
import { User } from './user/entities/user.entity';
import { UserModule } from './user/user.module';
import { AuthModule } from './auth/auth.module';
import { SeedModule } from './seed/seed.module';
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
host: 'localhost',
port: 5432,
username: 'postgres',
password: 'root',
database: 'test_server',
schema: 'public',
entities: [Product, Category, User],
synchronize: true,
logging: true,
autoLoadEntities: true,
}),
ProductModule,
CategoryModule,
UserModule,
AuthModule,
SeedModule,
],
controllers: [],
providers: [],
})
export class AppModule {}
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import {
Controller,
Post,
Body,
Get,
UseGuards,
Request,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { JwtAuthGuard } from 'src/common/guards/jwt-auth.guarf';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('register')
create(@Body() registerAuthDto: RegisterDto) {
return this.authService.register(registerAuthDto);
}
@Post('login')
login(@Body() body: { username: string; password: string }) {
return this.authService.login(body.username, body.password);
}
@UseGuards(JwtAuthGuard)
@Get('refreshToken')
refreshToken(@Request() req) {
const username = req?.user?.username;
return this.authService.refreshToken(username as string);
}
}
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UserModule } from 'src/user/user.module';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { JwtStrategy } from 'src/common/strategy/jwt.strategy';
@Module({
imports: [
UserModule,
PassportModule,
JwtModule.register({
global: true,
secret: 'test',
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
})
export class AuthModule {}
import {
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { RegisterDto } from './dto/register.dto';
import { UserService } from 'src/user/user.service';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
const SALT_WORK_FACTOR = 10;
@Injectable()
export class AuthService {
constructor(
private userService: UserService,
private jwtService: JwtService,
) {}
async register(registerAuthDto: RegisterDto) {
const { accessToken, refreshToken } =
await this.generateToken(registerAuthDto);
const salt = await bcrypt.genSalt(SALT_WORK_FACTOR);
registerAuthDto.password = await bcrypt.hash(
registerAuthDto.password,
salt,
);
const user = await this.userService.create({
...registerAuthDto,
refreshToken,
});
return { accessToken, id: user.id, username: user.username };
}
async refreshToken(username: string) {
return await this.userService.findOneByUserName(username);
}
async login(username: string, password: string) {
const user = await this.userService.findOneByUserName(username);
if (!user) throw new NotFoundException(' Такого юзера нет ');
const isMatch = await bcrypt.compare(password, user?.password || '');
if (!isMatch) throw new UnauthorizedException('Неверный логин или пароль');
const { accessToken } = await this.generateToken({ username, password });
return { accessToken, id: user.id, username: user.username };
}
async signToken(registerAuthDto: RegisterDto, expiresIn: string) {
return await this.jwtService.signAsync(
{
username: registerAuthDto.username,
password: registerAuthDto.password,
},
{
secret: 'test',
expiresIn,
},
);
}
async validateUser(
username: string,
pass: string,
): Promise<{ id: number; username: string } | null> {
const user = await this.userService.findOneByUserName(username);
if (user && user.password === pass) {
return {
id: user.id,
username: user.username,
};
}
return null;
}
async generateToken(registerAuthDto: RegisterDto) {
const accessToken = await this.signToken(registerAuthDto, '30m');
const refreshToken = await this.signToken(registerAuthDto, '7d');
return {
accessToken,
refreshToken,
};
}
}
import { IsNotEmpty, IsString } from 'class-validator';
export class RegisterDto {
@IsString()
@IsNotEmpty()
username: string;
@IsString()
@IsNotEmpty()
password: string;
}
import { Controller, Get, Post, Body, Param, Delete } from '@nestjs/common';
import { CategoryService } from './category.service';
import { CreateCategoryDto } from './dto/create-category.dto';
@Controller('category')
export class CategoryController {
constructor(private readonly categoryService: CategoryService) {}
@Post()
create(@Body() createCategoryDto: CreateCategoryDto) {
return this.categoryService.create(createCategoryDto);
}
@Get()
findAll() {
return this.categoryService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.categoryService.findOne(+id);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.categoryService.remove(+id);
}
}
import { Module } from '@nestjs/common';
import { CategoryService } from './category.service';
import { CategoryController } from './category.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Category } from './entities/category.entity';
@Module({
imports: [TypeOrmModule.forFeature([Category])],
controllers: [CategoryController],
providers: [CategoryService],
exports: [CategoryModule, CategoryService],
})
export class CategoryModule {}
import { Injectable } from '@nestjs/common';
import { CreateCategoryDto } from './dto/create-category.dto';
import { InjectRepository } from '@nestjs/typeorm';
import { Category } from './entities/category.entity';
import { Repository } from 'typeorm';
@Injectable()
export class CategoryService {
constructor(
@InjectRepository(Category) private categoryRepo: Repository<Category>,
) {}
async create(createCategoryDto: CreateCategoryDto): Promise<Category> {
const category = this.categoryRepo.create(createCategoryDto);
await this.categoryRepo.save(category);
return category;
}
async findAll(): Promise<Category[]> {
return await this.categoryRepo.find();
}
async findOne(id: number): Promise<Category | null> {
return await this.categoryRepo.findOneBy({ id });
}
async clear() {
try {
const isEmpty = await this.categoryRepo.countBy({});
if (isEmpty) {
await this.categoryRepo.delete({});
}
} catch {
throw new Error('Clear error');
}
}
async remove(id: number): Promise<number> {
await this.categoryRepo.delete({ id });
return id;
}
}
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreateCategoryDto {
@IsString()
@IsNotEmpty()
name: string;
@IsOptional()
description: string;
}
import { PartialType } from '@nestjs/mapped-types';
import { CreateCategoryDto } from './create-category.dto';
export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {}
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
export class Category {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column({ nullable: true })
description: string;
}
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: 'test',
});
}
validate(payload: { username: string }) {
return { username: payload.username };
}
}
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import { join } from 'path';
import { NestExpressApplication } from '@nestjs/platform-express';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.enableCors();
app.useStaticAssets(join(__dirname, '..', 'uploads'), {
prefix: '/uploads/',
});
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
}),
);
await app.listen(process.env.PORT ?? 5000);
console.log('Nest start to localhost:5000');
}
bootstrap();
import { IsString, IsNumber, IsNotEmpty, IsOptional } from 'class-validator';
import { Type } from 'class-transformer';
export class CreateProductDto {
@IsString()
@IsNotEmpty()
title: string;
@IsString()
@IsNotEmpty()
description: string;
@Type(() => Number)
@IsNumber()
@IsNotEmpty()
price: number;
@Type(() => Number)
@IsOptional()
categoryId: number;
}
import { PartialType } from '@nestjs/mapped-types';
import { CreateProductDto } from './create-product.dto';
export class UpdateProductDto extends PartialType(CreateProductDto) {}
import { Category } from 'src/category/entities/category.entity';
import {
Entity,
Column,
PrimaryGeneratedColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
@Entity()
export class Product {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
@Column()
description: string;
@Column()
price: number;
@Column({ nullable: true })
image: string;
@ManyToOne(() => Category, () => {}, {
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
cascade: ['soft-remove', 'remove'],
})
@JoinColumn({ name: 'categoryId' })
category?: Category;
@Column({ nullable: true })
categoryId?: number;
}
import {
Controller,
Get,
Post,
Body,
Param,
Delete,
UseGuards,
UseInterceptors,
UploadedFile,
} from '@nestjs/common';
import { ProductService } from './product.service';
import { CreateProductDto } from './dto/create-product.dto';
import { JwtAuthGuard } from 'src/common/guards/jwt-auth.guarf';
import { FileInterceptor } from '@nestjs/platform-express';
@Controller('product')
export class ProductController {
constructor(private readonly productService: ProductService) {}
@Post()
@UseInterceptors(FileInterceptor('image'))
create(
@UploadedFile() file: Express.Multer.File,
@Body() createProductDto: CreateProductDto,
) {
return this.productService.create(createProductDto, file?.filename);
}
@UseGuards(JwtAuthGuard)
@Get()
findAll() {
return this.productService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.productService.findOne(+id);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.productService.remove(+id);
}
}
import { Module } from '@nestjs/common';
import { ProductService } from './product.service';
import { ProductController } from './product.controller';
import { Product } from './entities/product.entity';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Category } from 'src/category/entities/category.entity';
import { MulterModule } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { extname, join } from 'path';
@Module({
imports: [
TypeOrmModule.forFeature([Product, Category]),
MulterModule.register({
storage: diskStorage({
destination: join(__dirname, '..', '..', 'uploads'),
filename: (req, file, cb) => {
const unique = Date.now() + '-' + Math.round(Math.random() * 1e9);
cb(null, `${file.fieldname}-${unique}${extname(file.originalname)}`);
},
}),
}),
],
controllers: [ProductController],
providers: [ProductService],
exports: [TypeOrmModule, ProductModule, ProductService],
})
export class ProductModule {}
import { InjectRepository } from '@nestjs/typeorm';
import { CreateProductDto } from './dto/create-product.dto';
import { Product } from './entities/product.entity';
import { Repository } from 'typeorm';
import { Injectable, NotFoundException } from '@nestjs/common';
import { Category } from 'src/category/entities/category.entity';
@Injectable()
export class ProductService {
constructor(
@InjectRepository(Product) private productRepo: Repository<Product>,
@InjectRepository(Category) private categoryRepo: Repository<Category>,
) {}
async create(
createProductDto: CreateProductDto,
imageFilename?: string,
): Promise<Product> {
const category = await this.categoryRepo.findOneBy({
id: createProductDto.categoryId,
});
if (!category) throw new NotFoundException('Category not found');
const product = this.productRepo.create({
...createProductDto,
image: imageFilename,
categoryId: category?.id,
});
await this.productRepo.save(product);
return product;
}
async findAll(): Promise<Product[]> {
return await this.productRepo.find();
}
async clear() {
try {
const isEmpty = await this.productRepo.countBy({});
if (isEmpty) {
await this.productRepo.delete({});
}
} catch {
throw new Error('Clear error');
}
}
async findOne(id: number): Promise<Product | null> {
return await this.productRepo.findOneBy({ id });
}
async remove(id: number): Promise<number> {
await this.productRepo.delete({ id });
return id;
}
}
import { Controller, Post } from '@nestjs/common';
import { SeedService } from './seed.service';
@Controller('seed')
export class SeedController {
constructor(private readonly seedService: SeedService) {}
@Post()
create() {
return this.seedService.create();
}
}
import { Module } from '@nestjs/common';
import { SeedService } from './seed.service';
import { SeedController } from './seed.controller';
import { ProductModule } from 'src/product/product.module';
import { CategoryModule } from 'src/category/category.module';
@Module({
imports: [ProductModule, CategoryModule],
controllers: [SeedController],
providers: [SeedService],
})
export class SeedModule {}
import { Injectable } from '@nestjs/common';
import { CategoryService } from 'src/category/category.service';
import { ProductService } from 'src/product/product.service';
import { faker } from '@faker-js/faker';
import { Category } from 'src/category/entities/category.entity';
import { Product } from 'src/product/entities/product.entity';
@Injectable()
export class SeedService {
constructor(
private productService: ProductService,
private categoryService: CategoryService,
) {}
async create() {
await this.categoryService.clear();
await this.productService.clear();
const categories: Category[] = [];
const products: Product[] = [];
for (let i = 0; i < 50; i++) {
const name = faker.food.dish();
const description = faker.food.description();
const category = await this.categoryService.create({
name,
description,
});
categories.push(category);
}
for (let i = 0; i < categories.length; i++) {
const title = faker.food.meat();
const description = faker.food.description();
const price = faker.number.int({ min: 100, max: 1000 });
const product = await this.productService.create({
title,
description,
price,
categoryId: categories[i].id,
});
products.push(product);
}
return {
products,
categories,
};
}
}
import { IsNotEmpty, IsString } from 'class-validator';
export class CreateUserDto {
@IsString()
@IsNotEmpty()
username: string;
@IsString()
@IsNotEmpty()
password: string;
@IsString()
refreshToken: string;
}
import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';
export class UpdateUserDto extends PartialType(CreateUserDto) {}
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
username: string;
@Column()
password: string;
@Column({ nullable: true })
refreshToken?: string;
}
import { Controller, Get, Post, Body, Param, Delete } from '@nestjs/common';
import { UserService } from './user.service';
import { CreateUserDto } from './dto/create-user.dto';
@Controller('user')
export class UserController {
constructor(private readonly userService: UserService) {}
@Post()
create(@Body() createUserDto: CreateUserDto) {
return this.userService.create(createUserDto);
}
@Get()
findAll() {
return this.userService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.userService.findOne(+id);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.userService.remove(+id);
}
}
import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { UserController } from './user.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UserController],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { InjectRepository } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { Repository } from 'typeorm';
@Injectable()
export class UserService {
constructor(@InjectRepository(User) private userRepo: Repository<User>) {}
async create(createUserDto: CreateUserDto): Promise<User> {
const isMatchUser = await this.userRepo.findOneBy({
username: createUserDto.username,
});
if (isMatchUser) throw new Error('Юзер с таким логином уже есть');
const user = this.userRepo.create(createUserDto);
await this.userRepo.save(user);
return user;
}
async findAll(): Promise<User[]> {
return await this.userRepo.find();
}
async findOne(id: number): Promise<User | null> {
return await this.userRepo.findOneBy({ id });
}
async findOneByUserName(username: string): Promise<User | null> {
return await this.userRepo.findOneBy({ username });
}
async remove(id: number): Promise<number> {
const user = await this.userRepo.findOneBy({ id });
if (!user) throw new NotFoundException(' User not found ');
await this.userRepo.delete({ id });
return id;
}
}
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment