2 Commits

Author SHA1 Message Date
a27c99a267 full, i am sure 2025-12-16 14:14:01 +04:00
03f774c8f4 full 2025-12-12 15:34:40 +04:00
135 changed files with 22325 additions and 8770 deletions

View File

@@ -16,7 +16,7 @@
<nav class="navbar navbar-expand-lg navbar-light bg-light fixed-top shadow-sm">
<div class="container">
<a class="navbar-brand d-flex align-items-center" href="index.html">
<img src="logo.png" alt="Логотип" class="logo me-2" />
<img src="../server/src/main/resources/static/assets/logo.png" alt="Логотип" class="logo me-2" />
<span class="d-none d-lg-block">Книжный магазин "Тома"</span>
<span class="d-lg-none">"Тома"</span>
</a>

View File

@@ -1,76 +1,182 @@
import React, { useState, useEffect } from 'react';
import { Modal, Button, Form } from 'react-bootstrap';
import { Modal, Button, Form, Alert } from 'react-bootstrap';
import api from '../services/api';
const BookModal = ({ show, onHide, bookId, genres, onSave }) => {
const [formData, setFormData] = useState({
title: '',
author: '',
genreId: '',
price: '',
description: '',
image: ''
});
const [selectedGenreIds, setSelectedGenreIds] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (bookId) {
// Загружаем данные книги для редактирования
api.fetchBook(bookId).then(response => {
const book = response.data;
setFormData({
title: book.title || '',
author: book.author || '',
genreId: book.genre?.id || genres[0]?.id || '',
price: book.price || '',
description: book.description || '',
image: book.image || ''
});
}).catch(error => {
console.error('Ошибка загрузки книги:', error);
});
if (bookId && show) {
loadBookData();
} else {
// Сбрасываем форму для новой книги
setFormData({
title: '',
author: '',
genreId: genres[0]?.id || '',
price: '',
description: '',
image: ''
});
resetForm();
}
}, [bookId, genres, show]); // Добавил show в зависимости
}, [bookId, show]);
const handleSubmit = (e) => {
e.preventDefault();
const bookData = {
...formData,
price: Number(formData.price) || 0,
genreId: Number(formData.genreId),
// Убираем автоматическое добавление 'images/' - сохраняем как есть
image: formData.image
};
onSave(bookData);
const loadBookData = async () => {
if (!bookId) return;
try {
setLoading(true);
setError('');
// Загружаем данные книги
const bookResponse = await api.fetchBook(bookId);
const book = bookResponse.data;
setFormData({
title: book.title || '',
author: book.author || '',
price: book.price || '',
description: book.description || '',
image: book.image || ''
});
// Загружаем жанры книги, если есть ID
if (bookId) {
try {
const genresResponse = await api.fetchBookGenres(bookId);
const bookGenres = genresResponse.data || [];
setSelectedGenreIds(bookGenres.map(gb => gb.genre.id));
} catch (genreError) {
console.error('Ошибка загрузки жанров книги:', genreError);
setSelectedGenreIds([]);
}
}
} catch (error) {
console.error('Ошибка загрузки книги:', error);
setError('Не удалось загрузить данные книги');
} finally {
setLoading(false);
}
};
const handleClose = () => {
const resetForm = () => {
setFormData({
title: '',
author: '',
genreId: '',
price: '',
description: '',
image: ''
});
setSelectedGenreIds([]);
setError('');
};
const handleGenreChange = (genreId) => {
setSelectedGenreIds(prev => {
if (prev.includes(genreId)) {
return prev.filter(id => id !== genreId);
} else {
return [...prev, genreId];
}
});
};
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
try {
const bookData = {
title: formData.title,
author: formData.author,
price: Number(formData.price) || 0,
description: formData.description,
image: formData.image
};
let savedBook;
if (bookId) {
// Обновляем книгу
savedBook = await api.updateBook(bookId, bookData);
// Обновляем жанры книги
const currentGenresResponse = await api.fetchBookGenres(bookId);
const currentGenreIds = (currentGenresResponse.data || []).map(gb => gb.genre.id);
// Удаляем жанры, которые были убраны
for (const currentGenreId of currentGenreIds) {
if (!selectedGenreIds.includes(currentGenreId)) {
await api.deleteBookGenre(bookId, currentGenreId);
}
}
// Добавляем новые жанры
for (const newGenreId of selectedGenreIds) {
if (!currentGenreIds.includes(newGenreId)) {
await api.addBookGenre(bookId, {
genreId: newGenreId,
date: new Date().toISOString().split('T')[0] // Текущая дата в формате YYYY-MM-DD
});
}
}
} else {
// Создаем новую книгу
savedBook = await api.createBook(bookData);
const newBookId = savedBook.data.id;
// Добавляем жанры для новой книги
for (const genreId of selectedGenreIds) {
await api.addBookGenre(newBookId, {
genreId: genreId,
date: new Date().toISOString().split('T')[0]
});
}
}
onSave(bookData);
} catch (error) {
console.error('Ошибка сохранения книги:', error);
setError('Не удалось сохранить книгу');
}
};
const handleClose = () => {
resetForm();
onHide();
};
if (loading) {
return (
<Modal show={show} onHide={handleClose} size="lg">
<Modal.Header closeButton>
<Modal.Title>{bookId ? 'Редактировать книгу' : 'Добавить книгу'}</Modal.Title>
</Modal.Header>
<Modal.Body className="text-center">
<div className="py-4">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Загрузка...</span>
</div>
<p className="mt-2">Загрузка данных...</p>
</div>
</Modal.Body>
</Modal>
);
}
return (
<Modal show={show} onHide={handleClose} size="lg">
<Modal.Header closeButton>
<Modal.Title>{bookId ? 'Редактировать книгу' : 'Добавить книгу'}</Modal.Title>
</Modal.Header>
<Modal.Body>
{error && (
<Alert variant="danger" className="mb-3">
{error}
</Alert>
)}
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3">
<Form.Label>Название книги *</Form.Label>
@@ -95,17 +201,27 @@ const BookModal = ({ show, onHide, bookId, genres, onSave }) => {
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Жанр *</Form.Label>
<Form.Select
value={formData.genreId}
onChange={(e) => setFormData({...formData, genreId: e.target.value})}
required
>
<option value="">Выберите жанр</option>
{genres.map(genre => (
<option key={genre.id} value={genre.id}>{genre.name}</option>
))}
</Form.Select>
<Form.Label>Жанры</Form.Label>
<div className="border rounded p-3" style={{ maxHeight: '200px', overflowY: 'auto' }}>
{genres.length === 0 ? (
<p className="text-muted mb-0">Нет доступных жанров</p>
) : (
genres.map(genre => (
<Form.Check
key={genre.id}
type="checkbox"
id={`genre-${genre.id}`}
label={genre.name}
checked={selectedGenreIds.includes(genre.id)}
onChange={() => handleGenreChange(genre.id)}
className="mb-2"
/>
))
)}
</div>
<Form.Text className="text-muted">
Можно выбрать несколько жанров
</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
@@ -122,13 +238,12 @@ const BookModal = ({ show, onHide, bookId, genres, onSave }) => {
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Описание *</Form.Label>
<Form.Label>Описание</Form.Label>
<Form.Control
as="textarea"
rows={3}
value={formData.description}
onChange={(e) => setFormData({...formData, description: e.target.value})}
required
placeholder="Введите описание книги"
/>
</Form.Group>
@@ -147,7 +262,9 @@ const BookModal = ({ show, onHide, bookId, genres, onSave }) => {
</Form.Group>
<Modal.Footer>
<Button variant="secondary" onClick={handleClose}>Отмена</Button>
<Button variant="secondary" onClick={handleClose}>
Отмена
</Button>
<Button variant="primary" type="submit">
{bookId ? 'Обновить' : 'Добавить'}
</Button>

View File

@@ -1,11 +1,12 @@
import { Link } from 'react-router-dom';
import logoImage from '../images/logo.png';
const Navbar = ({ cartCount }) => {
return (
<nav className="navbar navbar-expand-lg navbar-light bg-light fixed-top shadow-sm">
<div className="container">
<Link className="navbar-brand d-flex align-items-center" to="/">
<img src="/logo.png" alt="Логотип" className="logo me-2" />
<img src={logoImage} alt="Логотип" className="logo me-2" />
<span className="d-none d-lg-block">Книжный магазин "Тома"</span>
<span className="d-lg-none">"Тома"</span>
</Link>

View File

@@ -0,0 +1,50 @@
import React from 'react';
import { Form } from 'react-bootstrap';
const PageSizeSelector = ({
pageSize,
onPageSizeChange,
// Меняем значения в выпадающем списке
options = [5, 10, 20]
}) => {
// Маппинг: что пользователь видит -> что на самом деле запрашиваем
const sizeMapping = {
3: 5, // Пользователь видит "3", запрашиваем 5
5: 10, // Пользователь видит "5", запрашиваем 10
10: 20, // Пользователь видит "10", запрашиваем 20
};
// Находим отображаемое значение для текущего реального
const getDisplayValue = (realValue) => {
for (const [display, real] of Object.entries(sizeMapping)) {
if (real === realValue) return Number(display);
}
return realValue;
};
const displayValue = getDisplayValue(pageSize);
return (
<div className="d-flex align-items-center">
<span className="me-2">Книг на странице:</span>
<Form.Select
size="sm"
style={{ width: 'auto' }}
value={displayValue}
onChange={(e) => {
const selectedDisplayValue = Number(e.target.value);
const realValue = sizeMapping[selectedDisplayValue] || selectedDisplayValue;
onPageSizeChange(realValue);
}}
>
{options.map(option => (
<option key={option} value={option}>
{option}
</option>
))}
</Form.Select>
</div>
);
};
export default PageSizeSelector;

View File

@@ -0,0 +1,93 @@
import React from 'react';
import { Pagination as BootstrapPagination } from 'react-bootstrap';
const Pagination = ({
currentPage,
totalPages,
onPageChange,
maxVisiblePages = 5
}) => {
if (totalPages <= 1) return null;
const items = [];
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
// Корректируем, если не хватает страниц
if (endPage - startPage + 1 < maxVisiblePages) {
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
// Кнопка "Назад"
items.push(
<BootstrapPagination.Prev
key="prev"
disabled={currentPage === 1}
onClick={() => currentPage > 1 && onPageChange(currentPage - 1)}
/>
);
// Первая страница
if (startPage > 1) {
items.push(
<BootstrapPagination.Item
key={1}
active={1 === currentPage}
onClick={() => onPageChange(1)}
>
1
</BootstrapPagination.Item>
);
if (startPage > 2) {
items.push(<BootstrapPagination.Ellipsis key="start-ellipsis" />);
}
}
// Видимые страницы
for (let page = startPage; page <= endPage; page++) {
items.push(
<BootstrapPagination.Item
key={page}
active={page === currentPage}
onClick={() => onPageChange(page)}
>
{page}
</BootstrapPagination.Item>
);
}
// Последняя страница
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
items.push(<BootstrapPagination.Ellipsis key="end-ellipsis" />);
}
items.push(
<BootstrapPagination.Item
key={totalPages}
active={totalPages === currentPage}
onClick={() => onPageChange(totalPages)}
>
{totalPages}
</BootstrapPagination.Item>
);
}
// Кнопка "Вперед"
items.push(
<BootstrapPagination.Next
key="next"
disabled={currentPage === totalPages}
onClick={() => currentPage < totalPages && onPageChange(currentPage + 1)}
/>
);
return (
<BootstrapPagination className="justify-content-center mt-4">
{items}
</BootstrapPagination>
);
};
export default Pagination;

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View File

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 817 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

View File

@@ -6,8 +6,8 @@
<title>Книжный интернет-магазин "Тома"</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" />
<script type="module" crossorigin src="/assets/main-9YKRXbpb.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BLg3Q5Rn.css">
<script type="module" crossorigin src="./assets/js/index-D_bqwX26.js"></script>
<link rel="stylesheet" crossorigin href="./assets/css/index-DvDKDXQB.css">
</head>
<body>
<div id="root"></div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

BIN
MyWebSite/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -5,12 +5,9 @@
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "vite build",
"serve": "http-server -p 3000 ./dist/",
"prod": "npm-run-all build serve",
"lint": "eslint . --ext js --report-unused-disable-directives --max-warnings 0",
"server": "json-server --watch db.json --port 3001",
"dev": "vite",
"start": "npm-run-all -p server dev"
"start": "npm-run-all -p dev"
},
"dependencies": {
"@popperjs/core": "^2.11.8",

View File

@@ -1,11 +1,13 @@
import { useEffect, useState } from 'react';
import { Button, Container, Row, Spinner } from 'react-bootstrap';
import { Button, Container, Row, Spinner, Form, Alert } from 'react-bootstrap';
import BookComponent from '../components/BookComponent';
import BookModal from '../components/BookModal';
import CartModal from '../components/CartModal';
import Footer from '../components/Footer';
import GenreModal from '../components/GenreModal';
import Navbar from '../components/Navbar';
import Pagination from '../components/Pagination';
import PageSizeSelector from '../components/PageSizeSelector';
import api from '../services/api';
const CatalogPage = () => {
@@ -17,40 +19,176 @@ const CatalogPage = () => {
const [currentBookId, setCurrentBookId] = useState(null);
const [cartCount, setCartCount] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Состояние для пагинации книг
const [bookPagination, setBookPagination] = useState({
currentPage: 1,
pageSize: 20,
totalPages: 1,
totalItems: 0
});
// Состояние для фильтрации
const [selectedGenre, setSelectedGenre] = useState(null);
const [searchQuery, setSearchQuery] = useState('');
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
// Функция загрузки данных с пагинацией
const fetchData = async (
bookPage = bookPagination.currentPage,
bookSize = bookPagination.pageSize
) => {
try {
setLoading(true);
const [genresResponse, booksResponse, cartResponse] = await Promise.all([
api.fetchGenres(),
api.fetchBooks(),
api.fetchCartItems()
]);
setError(null);
console.log('Fetching data...');
// Загружаем жанры (без пагинации для фильтров)
const genresResponse = await api.fetchGenres();
console.log('Genres response:', genresResponse);
let genresData = [];
if (genresResponse.data && genresResponse.data.items) {
// Если ответ содержит пагинацию, берем items
genresData = genresResponse.data.items;
} else if (Array.isArray(genresResponse.data)) {
// Если это просто массив
genresData = genresResponse.data;
}
console.log('Genres data:', genresData);
setGenres(genresData);
// Загружаем книги в зависимости от фильтров
let booksResponse;
if (selectedGenre) {
// Фильтруем по жанру через бэкенд
console.log('Fetching books by genre:', selectedGenre);
booksResponse = await api.getBooksByGenre(selectedGenre, bookPage, bookSize);
} else if (searchQuery.trim()) {
// Ищем книги через бэкенд
console.log('Searching books:', searchQuery);
booksResponse = await api.searchBooks(searchQuery.trim(), bookPage, bookSize);
} else {
// Загружаем все книги с пагинацией
console.log('Fetching all books');
booksResponse = await api.fetchBooksPaginated(bookPage, bookSize);
}
console.log('Books response:', booksResponse);
const booksData = booksResponse.data || {};
// Загружаем корзину
const cartResponse = await api.fetchCartItems();
console.log('Cart response:', cartResponse);
// Обновляем состояние книг
setBooks(booksData.items || []);
setBookPagination({
currentPage: booksData.currentPage || 1,
pageSize: booksData.currentSize || bookSize, // Используем текущий размер страницы
totalPages: booksData.totalPages || 1,
totalItems: booksData.totalItems || 0
});
setGenres(genresResponse.data || []);
setBooks(booksResponse.data || []);
updateCartCount(cartResponse.data || []);
} catch (error) {
console.error('Ошибка загрузки данных:', error);
setError('Не удалось загрузить данные. Проверьте подключение к серверу.');
} finally {
setLoading(false);
}
};
const updateCartCount = (cartItems) => {
if (!Array.isArray(cartItems)) {
console.error('Cart items is not an array:', cartItems);
setCartCount(0);
return;
}
const count = cartItems.reduce((sum, item) => sum + (item.quantity || 0), 0);
setCartCount(count);
};
// Обработчики для пагинации книг
const handleBookPageChange = (page) => {
setBookPagination(prev => ({ ...prev, currentPage: page }));
fetchData(page, bookPagination.pageSize);
};
const handleBookPageSizeChange = (size) => {
setBookPagination(prev => ({ ...prev, pageSize: size, currentPage: 1 }));
fetchData(1, size);
};
// Обработчик фильтрации по жанру
const handleGenreFilter = (genreId) => {
const newSelectedGenre = genreId === selectedGenre ? null : genreId;
setSelectedGenre(newSelectedGenre);
setSearchQuery(''); // Сбрасываем поиск
setBookPagination(prev => ({ ...prev, currentPage: 1 }));
fetchData(1, bookPagination.pageSize);
};
// Обработчик поиска
const handleSearch = () => {
setSelectedGenre(null); // Сбрасываем фильтр по жанру
setBookPagination(prev => ({ ...prev, currentPage: 1 }));
fetchData(1, bookPagination.pageSize);
};
// Обработчик сброса фильтров
const handleResetFilters = () => {
setSelectedGenre(null);
setSearchQuery('');
setBookPagination(prev => ({ ...prev, currentPage: 1 }));
fetchData(1, bookPagination.pageSize);
};
// Функция для группировки книг по жанрам (используется только когда НЕ выбран конкретный жанр)
const getBooksByGenre = (genreId) => {
if (!Array.isArray(books) || selectedGenre) {
return []; // Не группируем при выбранном жанре
}
return books.filter(book =>
book.genres?.some(bookGenre => bookGenre.genre?.id === genreId)
);
};
// Получаем жанры для отображения (с книгами)
const getGenresWithBooks = () => {
if (!Array.isArray(genres) || !Array.isArray(books)) {
return [];
}
if (selectedGenre) {
// Если выбран конкретный жанр, показываем только его
const selectedGenreObj = genres.find(g => g.id === selectedGenre);
return selectedGenreObj ? [selectedGenreObj] : [];
}
// Иначе показываем все жанры, у которых есть книги в текущей выборке
return genres.filter(genre =>
books.some(book => book.genres?.some(bg => bg.genre?.id === genre.id))
);
};
const allGenres = getGenresWithBooks();
// Обработчик удаления жанра
const handleDeleteGenre = async (genreId) => {
if (window.confirm('Вы уверены, что хотите удалить этот жанр? Все книги этого жанра будут без жанра.')) {
if (window.confirm('Вы уверены, что хотите удалить этот жанр?')) {
try {
await api.deleteGenre(genreId);
await fetchData(); // Полная перезагрузка данных
await fetchData();
} catch (error) {
console.error('Ошибка удаления жанра:', error);
alert('Нельзя удалить жанр, если есть книги с этим жанром');
@@ -81,21 +219,18 @@ const CatalogPage = () => {
const handleAddToCart = async (bookId) => {
try {
// Проверяем, есть ли уже эта книга в корзине
const cartResponse = await api.fetchCartItems();
const existingItem = cartResponse.data.find(item => item.book?.id === bookId);
const cartItems = cartResponse.data || [];
const existingItem = cartItems.find(item => item.book?.id === bookId);
if (existingItem) {
// Обновляем количество существующего элемента
await api.updateCartItemQuantity(existingItem.id, existingItem.quantity + 1);
} else {
// Добавляем новый элемент
await api.addToCart(bookId);
}
// Обновляем счетчик корзины
const updatedCart = await api.fetchCartItems();
updateCartCount(updatedCart.data);
updateCartCount(updatedCart.data || []);
} catch (error) {
console.error('Ошибка добавления в корзину:', error);
@@ -119,8 +254,7 @@ const CatalogPage = () => {
const handleSaveGenre = async (genreData) => {
try {
await api.createGenre(genreData);
const genresResponse = await api.fetchGenres();
setGenres(genresResponse.data || []);
await fetchData();
setShowGenreModal(false);
} catch (error) {
console.error('Ошибка сохранения жанра:', error);
@@ -138,19 +272,6 @@ const CatalogPage = () => {
}
};
const booksByGenre = (genreId) => {
return books.filter(book => book.genre?.id === genreId);
};
const genresWithBooks = genres.filter(genre =>
books.some(book => book.genre?.id === genre.id)
);
// Добавляем жанры без книг
const allGenres = [...genresWithBooks, ...genres.filter(genre =>
!genresWithBooks.some(g => g.id === genre.id)
)];
if (loading) {
return (
<div className="d-flex justify-content-center align-items-center vh-100">
@@ -168,43 +289,143 @@ const CatalogPage = () => {
<Container>
<h2 className="text-center mb-5">Каталог книг</h2>
<div className="d-flex justify-content-between mb-4">
<div>
<Button variant="success" onClick={handleAddBook}>
Добавить книгу
</Button>
<Button
variant="primary"
className="ms-2"
onClick={() => setShowGenreModal(true)}
>
Добавить жанр
</Button>
{error && (
<Alert variant="danger" className="mb-4">
{error}
</Alert>
)}
{/* Панель управления */}
<div className="card mb-4">
<div className="card-body">
<div className="row">
<div className="col-md-8">
<div className="d-flex flex-wrap gap-2">
<Button variant="success" onClick={handleAddBook}>
Добавить книгу
</Button>
<Button
variant="primary"
onClick={() => setShowGenreModal(true)}
>
Добавить жанр
</Button>
{/* Фильтр по жанрам */}
{Array.isArray(genres) && genres.length > 0 && (
<div className="dropdown">
<Button variant="outline-secondary" id="genreFilter">
{selectedGenre && genres.find(g => g.id === selectedGenre)
? `Жанр: ${genres.find(g => g.id === selectedGenre)?.name}`
: 'Все жанры'}
</Button>
<div className="dropdown-menu" style={{ maxHeight: '300px', overflowY: 'auto' }}>
<button
className="dropdown-item"
onClick={() => handleGenreFilter(null)}
>
Все жанры
</button>
{genres.map(genre => (
<button
key={genre.id}
className="dropdown-item"
onClick={() => handleGenreFilter(genre.id)}
>
{genre.name}
</button>
))}
</div>
</div>
)}
{/* Кнопка сброса фильтров */}
{(selectedGenre || searchQuery) && (
<Button
variant="outline-secondary"
onClick={handleResetFilters}
>
Сбросить фильтры
</Button>
)}
</div>
</div>
<div className="col-md-4">
<div className="d-flex">
<Form.Control
type="text"
placeholder="Поиск книг..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
/>
<Button
variant="outline-primary"
className="ms-2"
onClick={handleSearch}
>
Найти
</Button>
</div>
</div>
</div>
{/* Информация о текущих фильтрах */}
{(selectedGenre || searchQuery) && (
<div className="mt-2">
<small className="text-muted">
{selectedGenre && `Фильтр: ${genres.find(g => g.id === selectedGenre)?.name}`}
{selectedGenre && searchQuery && ' | '}
{searchQuery && `Поиск: "${searchQuery}"`}
</small>
</div>
)}
</div>
</div>
{allGenres.length === 0 ? (
{/* Информация о пагинации */}
<div className="d-flex justify-content-between align-items-center mb-3">
<div className="text-muted">
{selectedGenre && Array.isArray(genres) && ` в жанре "${genres.find(g => g.id === selectedGenre)?.name}"`}
{searchQuery && ` по запросу "${searchQuery}"`}
{!selectedGenre && !searchQuery && 'Все книги'}
</div>
<PageSizeSelector
pageSize={bookPagination.pageSize}
onPageSizeChange={handleBookPageSizeChange}
/>
</div>
{/* Список книг */}
{books.length === 0 ? (
<div className="text-center py-5">
<h4>Нет данных для отображения</h4>
<p className="text-muted">Добавьте жанры и книги для начала работы</p>
<p className="text-muted">
{Array.isArray(genres) && genres.length === 0
? 'Добавьте жанры и книги для начала работы'
: 'Нет книг для отображения'}
</p>
</div>
) : (
allGenres.map(genre => (
<section key={genre.id} className="mb-5">
<div className="genre-title bg-light p-3 rounded text-center mb-4">
<h3 className="mb-2">{genre.name}</h3>
<Button
variant="outline-danger"
size="sm"
onClick={() => handleDeleteGenre(genre.id)}
title="Удалить жанр"
>
Удалить жанр
</Button>
</div>
<Row>
{booksByGenre(genre.id).length > 0 ? (
booksByGenre(genre.id).map(book => (
<>
{/* Режим: выбран конкретный жанр */}
{selectedGenre ? (
<section className="mb-5">
<div className="genre-title bg-light p-3 rounded text-center mb-4">
<h3 className="mb-2">
{genres.find(g => g.id === selectedGenre)?.name}
</h3>
<Button
variant="outline-secondary"
size="sm"
onClick={() => setSelectedGenre(null)}
>
Показать все жанры
</Button>
</div>
<Row>
{books.map(book => (
<BookComponent
key={book.id}
book={book}
@@ -212,15 +433,65 @@ const CatalogPage = () => {
onDelete={handleDeleteBook}
onAddToCart={handleAddToCart}
/>
))
) : (
<div className="text-center py-3">
<p className="text-muted">В этом жанре пока нет книг</p>
))}
</Row>
</section>
) : (
/* Режим: все жанры с группировкой */
allGenres.map(genre => (
<section key={genre.id} className="mb-5">
<div className="genre-title bg-light p-3 rounded text-center mb-4">
<h3 className="mb-2">{genre.name}</h3>
<div className="d-flex justify-content-center gap-2">
<Button
variant="outline-primary"
size="sm"
onClick={() => handleGenreFilter(genre.id)}
>
Показать только этот жанр
</Button>
<Button
variant="outline-danger"
size="sm"
onClick={() => handleDeleteGenre(genre.id)}
title="Удалить жанр"
>
Удалить жанр
</Button>
</div>
</div>
)}
</Row>
</section>
))
<Row>
{getBooksByGenre(genre.id).length > 0 ? (
getBooksByGenre(genre.id).map(book => (
<BookComponent
key={book.id}
book={book}
onEdit={handleEditBook}
onDelete={handleDeleteBook}
onAddToCart={handleAddToCart}
/>
))
) : (
<div className="text-center py-3">
<p className="text-muted">В этом жанре пока нет книг</p>
</div>
)}
</Row>
</section>
))
)}
</>
)}
{/* Пагинация книг */}
{bookPagination.totalPages > 1 && (
<div className="mt-4">
<Pagination
currentPage={bookPagination.currentPage}
totalPages={bookPagination.totalPages}
onPageChange={handleBookPageChange}
/>
</div>
)}
</Container>
</main>
@@ -231,7 +502,7 @@ const CatalogPage = () => {
show={showBookModal}
onHide={() => setShowBookModal(false)}
bookId={currentBookId}
genres={genres}
genres={Array.isArray(genres) ? genres : []}
onSave={handleSaveBook}
/>

View File

@@ -5,6 +5,11 @@ import Navbar from '../components/Navbar';
import Footer from '../components/Footer';
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap/dist/js/bootstrap.bundle.min';
import Book1 from '../images/the_girl_with_the_dragon_tattoo.jpg';
import Book2 from '../images/the_hobbit.webp';
import Book3 from '../images/dune.jpg';
import defBook from '../images/default-book.jpg';
const DiscountsPage = () => {
const discountedBooks = [
@@ -12,7 +17,7 @@ const DiscountsPage = () => {
id: 1,
title: "Девушка с татуировкой дракона",
author: "Стиг Ларссон",
image: "images/the_girl_with_the_dragon_tattoo.jpg",
image: Book1,
originalPrice: 700,
discountPrice: 525,
discountPercent: 25
@@ -21,7 +26,7 @@ const DiscountsPage = () => {
id: 2,
title: "Хоббит",
author: "Дж.Р.Р. Толкин",
image: "images/the_hobbit.webp",
image: Book2,
originalPrice: 750,
discountPrice: 563,
discountPercent: 25
@@ -30,7 +35,7 @@ const DiscountsPage = () => {
id: 3,
title: "Дюна",
author: "Фрэнк Герберт",
image: "images/dune.jpg",
image: Book2,
originalPrice: 500,
discountPrice: 375,
discountPercent: 25
@@ -60,7 +65,7 @@ const DiscountsPage = () => {
src={book.image}
className="p-3"
alt={book.title}
onError={(e) => { e.target.src = 'images/default-book.jpg' }}
onError={(e) => { e.target.src = defBook }}
/>
<Card.Body className="text-center d-flex flex-column">
<Card.Title>{book.title}</Card.Title>

View File

@@ -4,23 +4,28 @@ import { Button, Card, Col, Row } from 'react-bootstrap';
import { BiCart } from 'react-icons/bi';
import Footer from '../components/Footer';
import Navbar from '../components/Navbar';
import Book1 from '../images/Book1.jpg';
import Book2 from '../images/Book2.jpg';
import Book3 from '../images/Book3.jpg';
import defBook from '../images/default-book.jpg';
const HomePage = () => {
const bestsellers = [
{
id: 1,
title: "Тимоти Брук «Шляпа Вермеера»",
image: "images/Book1.jpg"
image: Book1
},
{
id: 2,
title: "Пол Линч «Песнь пророка»",
image: "images/Book2.jpg"
image: Book2
},
{
id: 3,
title: "Яна Вагнер «Тоннель»",
image: "images/Book3.jpg"
image: Book3
}
];
@@ -61,7 +66,7 @@ const HomePage = () => {
src={book.image}
className="p-3"
alt={book.title}
onError={(e) => { e.target.src = 'images/default-book.jpg' }}
onError={(e) => { e.target.src = defBook }}
/>
<Card.Body className="text-center d-flex flex-column">
<Card.Title>{book.title}</Card.Title>

View File

@@ -1,54 +0,0 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.5.5'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
description = 'Demo project for Spring Boot'
jar {
enabled = false
}
bootJar {
archiveFileName = String.format("%s-%s.jar", rootProject.name, version)
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
ext {
springdocVersion = "2.8.11"
mockitoVersion = "5.19.0"
}
configurations {
mockitoAgent
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:${springdocVersion}"
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
testImplementation "org.mockito:mockito-core:${mockitoVersion}"
mockitoAgent("org.mockito:mockito-core:${mockitoVersion}") {
transitive = false
}
}
tasks.named('test') {
useJUnitPlatform()
jvmArgs += "-Xshare:off"
jvmArgs += "-javaagent:${configurations.mockitoAgent.asPath}"
}

View File

@@ -1,68 +0,0 @@
package com.example.server;
import com.example.server.mapper.BookMapper;
import com.example.server.mapper.CartItemMapper;
import com.example.server.mapper.GenreMapper;
import com.example.server.service.BookService;
import com.example.server.service.CartItemService;
import com.example.server.service.GenreService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import java.util.Objects;
@SpringBootApplication
public class ServerApplication implements CommandLineRunner {
private final Logger log = LoggerFactory.getLogger(ServerApplication.class);
private final BookService bookService;
private final GenreService genreService;
private final CartItemService cartItemService;
private final BookMapper bookMapper;
private final GenreMapper genreMapper;
private final CartItemMapper cartItemMapper;
public ServerApplication(BookService bookService, GenreService genreService,
CartItemService cartItemService, BookMapper bookMapper,
GenreMapper genreMapper, CartItemMapper cartItemMapper) {
this.bookService = bookService;
this.bookMapper = bookMapper;
this.genreService = genreService;
this.genreMapper = genreMapper;
this.cartItemService = cartItemService;
this.cartItemMapper = cartItemMapper;
}
private void populateData() {
log.info("Create default genres");
final var genre1 = genreService.create(genreMapper.toRqDto("Роман"));
final var genre2 = genreService.create(genreMapper.toRqDto("Фантастика"));
final var genre3 = genreService.create(genreMapper.toRqDto("Ужасы"));
log.info("Create default books");
bookService.create(bookMapper.toRqDto("Основание", "Айзек Азимов",
700, "Сага о падении и возрождении галактической империи, основанная на научных принципах.", "images/foundation.jpg", genre1.getId()));
bookService.create(bookMapper.toRqDto("Дюна", "Фрэнк Герберт",
900, "Эпическая история о борьбе за контроль над планетой Арракис, источником самого ценного вещества во вселенной.", "images/dune.jpg", genre2.getId()));
bookService.create(bookMapper.toRqDto("Убийство в Восточном экспрессе", "Агата Кристи",
750, "Загадочное убийство на поезде, где каждый пассажир может быть подозреваемым.", "images/murder_on_the_orient_express.jpg", genre3.getId()));
}
public static void main(String[] args) {
SpringApplication.run(ServerApplication.class, args);
}
@Override
public void run(String... args) throws Exception {
if (args.length == 0) {
return;
}
if (Objects.equals("--populate", args[0])) {
populateData();
}
}
}

View File

@@ -1,59 +0,0 @@
package com.example.server.api.book;
import java.util.List;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicInteger;
import com.example.server.api.genre.GenreController;
import com.example.server.error.NotFoundException;
import com.example.server.service.BookService;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.*;
import com.example.server.configuration.Constants;
@RestController
@RequestMapping(Constants.API_URL + BookController.URL)
public class BookController {
public static final String URL = "/book";
private final BookService bookService;
public BookController(BookService bookService) {
this.bookService = bookService;
}
/* @GetMapping
public List<BookRs> getAll() {
return bookService.getAll();
}*/
@GetMapping
public List<BookRs> getAll(@RequestParam(required = false) Long genreId) {
if (genreId != null) {
return bookService.getByGenre(genreId);
}
return bookService.getAll();
}
@GetMapping("/{id}")
public BookRs get(@PathVariable Long id) {
return bookService.get(id);
}
@PostMapping
public BookRs create(@RequestBody @Valid BookRq dto) {
return bookService.create(dto);
}
@PutMapping("/{id}")
public BookRs update(@PathVariable Long id, @RequestBody @Valid BookRq dto) {
return bookService.update(id, dto);
}
@DeleteMapping("/{id}")
public BookRs delete(@PathVariable Long id) {
return bookService.delete(id);
}
}

View File

@@ -1,73 +0,0 @@
package com.example.server.api.book;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
public class BookRq {
@JsonProperty("title")
@NotBlank(message = "название обязателен")
@Size(min = 2, max = 50, message = "Название должно быть от 2 до 50 символов")
private String title;
@JsonProperty("author")
@NotBlank(message = "автор обязателен")
@Size(min = 2, max = 50, message = "ФИО должно быть от 2 до 50 символов")
private String author;
@NotNull
@Min(value = 1, message = "Цена не может быть меньше 1 рубля")
private int price;
private String description;
@NotNull
private Long genreId;
private String image;
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getAuthor() {
return author;
}
public void setAuthor(String author) {
this.author = author;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public int getPrice() {
return price;
}
public void setPrice(int price) {
this.price = price;
}
public Long getGenreId() {
return genreId;
}
public void setGenreId(Long genreId) {
this.genreId = genreId;
}
public String getImage() {
return image;
}
public void setImage(String image) {
this.image = image;
}
}

View File

@@ -1,84 +0,0 @@
package com.example.server.api.book;
import com.example.server.api.genre.GenreRs;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
public class BookRs {
@NotBlank(message = "id обязательно")
private Long Id;
@JsonProperty("title")
@NotBlank(message = "Название обязательно")
@Size(min = 2, max = 50, message = "Название должно быть от 2 до 50 символов")
private String title;
@JsonProperty("author")
@NotBlank(message = "автор обязателен")
@Size(min = 2, max = 50, message = "ФИО должно быть от 2 до 50 символов")
private String author;
@NotNull
@Min(value = 1, message = "Цена не может быть меньше 1 рубля")
private int price;
private String description;
@NotNull
private GenreRs genre;
private String image;
public Long getId() {
return Id;
}
public void setId(Long id) {
Id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getAuthor() {
return author;
}
public void setAuthor(String author) {
this.author = author;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public int getPrice() {
return price;
}
public void setPrice(int price) {
this.price = price;
}
public GenreRs getGenre() {
return genre;
}
public void setGenre(GenreRs genre) {
this.genre = genre;
}
public String getImage() {
return image;
}
public void setImage(String image) {
this.image = image;
}
}

View File

@@ -1,29 +0,0 @@
package com.example.server.api.cartItem;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
public class CartItemRq {
@NotNull
private Long bookId;
@NotNull
@Min(value = 0, message = "Кол-во не может быть меньше 0")
private int quantity;
public int getQuantity() {
return quantity;
}
public void setQuantity(int quantity) {
this.quantity = quantity;
}
public Long getBookId() {
return bookId;
}
public void setBookId(Long bookId) {
this.bookId = bookId;
}
}

View File

@@ -1,39 +0,0 @@
package com.example.server.api.cartItem;
import com.example.server.api.book.BookRs;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
public class CartItemRs {
@NotNull
private Long id;
@NotNull
private BookRs book;
@NotNull
@Min(value = 0, message = "Кол-во не может быть меньше 0")
private int quantity;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public int getQuantity() {
return quantity;
}
public void setQuantity(int quantity) {
this.quantity = quantity;
}
public BookRs getBook() {
return book;
}
public void setBook(BookRs book) {
this.book = book;
}
}

View File

@@ -1,18 +0,0 @@
package com.example.server.api.genre;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public class GenreRq {
@NotBlank(message = "Название обязательно")
@Size(min = 2, max = 50, message = "Название должно быть от 2 до 50 символов")
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

View File

@@ -1,29 +0,0 @@
package com.example.server.api.genre;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
public class GenreRs {
@NotNull
private Long id;
@NotBlank(message = "Название обязательно")
@Size(min = 2, max = 50, message = "Название должно быть от 2 до 50 символов")
private String name;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

View File

@@ -1,15 +0,0 @@
package com.example.server.entity;
public abstract class BaseEntity {
protected Long Id;
protected BaseEntity() {}
public Long getId() {
return Id;
}
public void setId(Long id) {
Id = id;
}
}

View File

@@ -1,21 +0,0 @@
package com.example.server.entity;
public class GenreEntity extends BaseEntity{
private String name;
public GenreEntity() {
super();
}
public GenreEntity(String name) {
this();
this.name = name;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

View File

@@ -1,48 +0,0 @@
package com.example.server.mapper;
import com.example.server.api.book.BookRq;
import com.example.server.api.book.BookRs;
import com.example.server.entity.BookEntity;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.StreamSupport;
@Component
public class BookMapper {
private final GenreMapper genreMapper;
public BookMapper(GenreMapper genreMapper) {
this.genreMapper = genreMapper;
}
public BookRq toRqDto(
String title, String author, int price, String description, String image, Long genreId) {
final BookRq dto = new BookRq();
dto.setTitle(title);
dto.setAuthor(author);
dto.setPrice(price);
dto.setDescription(description);
dto.setImage(image);
dto.setGenreId(genreId);
return dto;
}
public BookRs toRsDto(BookEntity entity) {
final BookRs dto = new BookRs();
dto.setId(entity.getId());
dto.setTitle(entity.getTitle());
dto.setAuthor(entity.getAuthor());
dto.setPrice(entity.getPrice());
dto.setDescription(entity.getDescription());
dto.setGenre(genreMapper.toRsDto(entity.getGenre()));
dto.setImage(entity.getImage());
return dto;
}
public List<BookRs> toRsListDto(Iterable<BookEntity> entities) {
return StreamSupport.stream(entities.spliterator(), false)
.map(this::toRsDto)
.toList();
}
}

View File

@@ -1,40 +0,0 @@
package com.example.server.mapper;
import com.example.server.api.cartItem.CartItemRq;
import com.example.server.api.cartItem.CartItemRs;
import com.example.server.entity.CartItemEntity;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.StreamSupport;
@Component
public class CartItemMapper {
private final BookMapper bookMapper;
public CartItemMapper(BookMapper bookMapper) {
this.bookMapper = bookMapper;
}
public CartItemRq toRqDto(Long bookId, int quantity) {
final CartItemRq dto = new CartItemRq();
dto.setBookId(bookId);
dto.setQuantity(quantity);
return dto;
}
public CartItemRs toRsDto(CartItemEntity entity) {
final CartItemRs dto = new CartItemRs();
dto.setId(entity.getId());
dto.setBook(bookMapper.toRsDto(entity.getBook()));
dto.setQuantity(entity.getQuantity());
return dto;
}
public List<CartItemRs> toRsListDto(Iterable<CartItemEntity> entities) {
return StreamSupport.stream(entities.spliterator(), false)
.map(this::toRsDto)
.toList();
}
}

View File

@@ -1,31 +0,0 @@
package com.example.server.mapper;
import com.example.server.api.genre.GenreRq;
import com.example.server.api.genre.GenreRs;
import com.example.server.entity.GenreEntity;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.StreamSupport;
@Component
public class GenreMapper {
public GenreRq toRqDto(String name) {
final GenreRq dto = new GenreRq();
dto.setName(name);
return dto;
}
public GenreRs toRsDto(GenreEntity entity) {
final GenreRs dto = new GenreRs();
dto.setId(entity.getId());
dto.setName(entity.getName());
return dto;
}
public List<GenreRs> toRsDtoList(Iterable<GenreEntity> entities) {
return StreamSupport.stream(entities.spliterator(), false)
.map(this::toRsDto)
.toList();
}
}

View File

@@ -1,8 +0,0 @@
package com.example.server.repository;
import com.example.server.entity.BookEntity;
import org.springframework.stereotype.Repository;
@Repository
public class BookRepository extends MapRepository<BookEntity> {
}

View File

@@ -1,10 +0,0 @@
package com.example.server.repository;
import com.example.server.entity.CartItemEntity;
import org.springframework.stereotype.Repository;
import java.util.Map;
@Repository
public class CartItemRepository extends MapRepository<CartItemEntity> {
}

View File

@@ -1,17 +0,0 @@
package com.example.server.repository;
import com.example.server.entity.CartItemEntity;
import java.util.Optional;
public interface CommonRepository<E, T> {
Iterable<E> findAll();
Optional<E> findById(T id);
E save(E entity);
void delete(E entity);
void deleteAll();
}

View File

@@ -1,8 +0,0 @@
package com.example.server.repository;
import com.example.server.entity.GenreEntity;
import org.springframework.stereotype.Repository;
@Repository
public class GenreRepository extends MapRepository<GenreEntity> {
}

View File

@@ -1,69 +0,0 @@
package com.example.server.repository;
import com.example.server.entity.BaseEntity;
import com.example.server.entity.CartItemEntity;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentNavigableMap;
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.concurrent.atomic.AtomicLong;
public abstract class MapRepository<E extends BaseEntity> implements CommonRepository<E, Long> {
private final ConcurrentNavigableMap<Long, E> entities = new ConcurrentSkipListMap<>();
private final AtomicLong idGenerator = new AtomicLong(0L);
protected MapRepository() {
}
private boolean isNew(E entity) {
return Objects.isNull(entity.getId());
}
private E create(E entity) {
final Long lastId = idGenerator.incrementAndGet();
entity.setId(lastId);
entities.put(lastId, entity);
return entity;
}
private E update(E entity) {
if (findById(entity.getId()).isEmpty()) {
return null;
}
entities.put(entity.getId(), entity);
return entity;
}
@Override
public Iterable<E> findAll() {
return entities.values();
}
@Override
public Optional<E> findById(Long id) {
return Optional.ofNullable(entities.get(id));
}
@Override
public E save(E entity) {
if (isNew(entity)) {
return create(entity);
}
return update(entity);
}
@Override
public void delete(E entity) {
if (findById(entity.getId()).isEmpty()) {
return;
}
entities.remove(entity.getId());
}
@Override
public void deleteAll() {
entities.clear();
idGenerator.set(0L);
}
}

View File

@@ -1,79 +0,0 @@
package com.example.server.service;
import com.example.server.api.book.BookRq;
import com.example.server.api.book.BookRs;
import com.example.server.entity.BookEntity;
import com.example.server.entity.GenreEntity;
import com.example.server.error.NotFoundException;
import com.example.server.mapper.BookMapper;
import com.example.server.repository.BookRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;
@Service
public class BookService {
private final BookRepository repository;
private final GenreService typeService;
private final BookMapper mapper;
public BookService(BookRepository repository, GenreService typeService, BookMapper mapper) {
this.repository = repository;
this.typeService = typeService;
this.mapper = mapper;
}
public BookEntity getEntity(Long id) {
return repository.findById(id)
.orElseThrow(() -> new NotFoundException(BookEntity.class, id));
}
public List<BookRs> getAll() {
return mapper.toRsListDto(repository.findAll());
}
public List<BookRs> getByGenre(Long genreId) {
return StreamSupport.stream(repository.findAll().spliterator(), false)
.filter(book -> book.getGenre().getId().equals(genreId))
.map(mapper::toRsDto)
.collect(Collectors.toList());
}
public BookRs get(Long id) {
final BookEntity entity = getEntity(id);
return mapper.toRsDto(entity);
}
public BookRs create(BookRq dto) {
final GenreEntity genre = typeService.getEntity(dto.getGenreId());
BookEntity entity = new BookEntity(
dto.getTitle(),
dto.getAuthor(),
dto.getPrice(),
dto.getDescription(),
genre,
dto.getImage());
entity = repository.save(entity);
return mapper.toRsDto(entity);
}
public BookRs update(Long id, BookRq dto) {
BookEntity entity = getEntity(id);
entity.setTitle(dto.getTitle());
entity.setAuthor(dto.getAuthor());
entity.setPrice(dto.getPrice());
entity.setDescription(dto.getDescription());
entity.setGenre(typeService.getEntity(dto.getGenreId()));
entity.setImage(dto.getImage());
entity = repository.save(entity);
return mapper.toRsDto(entity);
}
public BookRs delete(Long id) {
final BookEntity entity = getEntity(id);
repository.delete(entity);
return mapper.toRsDto(entity);
}
}

View File

@@ -1,55 +0,0 @@
package com.example.server.service;
import com.example.server.api.genre.GenreRq;
import com.example.server.api.genre.GenreRs;
import com.example.server.entity.GenreEntity;
import com.example.server.error.NotFoundException;
import com.example.server.mapper.GenreMapper;
import com.example.server.repository.GenreRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class GenreService {
private final GenreRepository repository;
private final GenreMapper mapper;
public GenreService(GenreRepository repository, GenreMapper mapper) {
this.repository = repository;
this.mapper = mapper;
}
public GenreEntity getEntity(Long id) {
return repository.findById(id)
.orElseThrow(() -> new NotFoundException(GenreEntity.class, id));
}
public List<GenreRs> getAll() {
return mapper.toRsDtoList(repository.findAll());
}
public GenreRs get(Long id) {
final GenreEntity entity = getEntity(id);
return mapper.toRsDto(entity);
}
public GenreRs create(GenreRq dto) {
GenreEntity entity = new GenreEntity(dto.getName());
entity = repository.save(entity);
return mapper.toRsDto(entity);
}
public GenreRs update(Long id, GenreRq dto) {
GenreEntity entity = getEntity(id);
entity.setName(dto.getName());
entity = repository.save(entity);
return mapper.toRsDto(entity);
}
public GenreRs delete(Long id) {
final GenreEntity entity = getEntity(id);
repository.delete(entity);
return mapper.toRsDto(entity);
}
}

View File

@@ -1,14 +0,0 @@
spring.application.name=server
spring.main.banner-mode=off
bootRun.args=--populate
logging.level.com.server=DEBUG
# ???????? ??? ????????? ????????
logging.level.org.springframework.web.filter.CommonsRequestLoggingFilter=DEBUG
# ?????????? ??? HTTP ???????
server.tomcat.accesslog.enabled=true
server.tomcat.accesslog.directory=logs
server.tomcat.accesslog.prefix=access_log
server.tomcat.accesslog.suffix=.log

View File

@@ -1,128 +0,0 @@
package com.example.server.service;
import com.example.server.api.book.BookRs;
import com.example.server.error.NotFoundException;
import com.example.server.mapper.BookMapper;
import com.example.server.mapper.GenreMapper;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
@SpringBootTest
@TestMethodOrder(OrderAnnotation.class)
class BookServiceTests {
@Autowired
private BookService service;
@Autowired
private GenreService genreService;
@Autowired
private BookMapper mapper;
@Autowired
private GenreMapper genreMapper;
@Test
void getTest() {
Assertions.assertThrows(NotFoundException.class, () -> service.get(0L));
}
@Test
@Order(1)
void createTest() {
var genre1 = genreService.create(genreMapper.toRqDto("Роман"));
var genre2 = genreService.create(genreMapper.toRqDto("Фантастика"));
service.create(mapper.toRqDto("Книга 1", "Автор 1", 500, "Описание 1", "image1.jpg", 1L));
service.create(mapper.toRqDto("Книга 2", "Автор 2", 600, "Описание 2", "image2.jpg", 2L));
final BookRs last = service.create(mapper.toRqDto("Книга 3", "Автор 3", 700, "Описание 3", "image3.jpg", 1L));
Assertions.assertEquals(3, service.getAll().size());
final BookRs cmpEntity = service.get(3L);
Assertions.assertEquals(last.getId(), cmpEntity.getId());
Assertions.assertEquals(last.getTitle(), cmpEntity.getTitle());
}
@Test
@Order(2)
void updateTest() {
final String newTitle = "Обновленная книга";
final BookRs entity = service.get(3L);
final String oldTitle = entity.getTitle();
final BookRs newEntity = service.update(3L, mapper.toRqDto(newTitle, "Автор 3", 750, "Новое описание", "new_image.jpg", 1L));
Assertions.assertEquals(3, service.getAll().size());
Assertions.assertEquals(newTitle, newEntity.getTitle());
Assertions.assertNotEquals(oldTitle, newEntity.getTitle());
final BookRs cmpEntity = service.get(3L);
Assertions.assertEquals(newEntity.getId(), cmpEntity.getId());
Assertions.assertEquals(newEntity.getTitle(), cmpEntity.getTitle());
}
@Test
@Order(3)
void getByGenreTest() {
List<BookRs> booksByGenre1 = service.getByGenre(1L);
List<BookRs> booksByGenre2 = service.getByGenre(2L);
Assertions.assertEquals(2, booksByGenre1.size()); // 2 книги в жанре 1
Assertions.assertEquals(1, booksByGenre2.size()); // 1 книга в жанре 2
booksByGenre1.forEach(book ->
Assertions.assertEquals(1L, book.getGenre().getId())
);
}
@Test
@Order(4)
void deleteTest() {
service.delete(3L);
Assertions.assertEquals(2, service.getAll().size());
final BookRs last = service.get(2L);
Assertions.assertEquals(2L, last.getId());
final BookRs newEntity = service.create(mapper.toRqDto("Новая книга", "Новый автор", 800, "Описание", "image.jpg", 2L));
Assertions.assertEquals(3, service.getAll().size());
Assertions.assertEquals(4L, newEntity.getId());
}
@Test
@Order(5)
void getAllTest() {
Assertions.assertEquals(3, service.getAll().size());
final BookRs first = service.get(1L);
final BookRs second = service.get(2L);
final BookRs third = service.get(4L);
Assertions.assertEquals("Книга 1", first.getTitle());
Assertions.assertEquals("Книга 2", second.getTitle());
Assertions.assertEquals("Новая книга", third.getTitle());
}
@Test
void getNonExistentTest() {
Assertions.assertThrows(NotFoundException.class, () -> service.get(999L));
}
@Test
void updateNonExistentTest() {
Assertions.assertThrows(NotFoundException.class, () ->
service.update(999L, mapper.toRqDto("Несуществующая", "Автор", 100, "Описание", "image.jpg", 1L)));
}
@Test
void deleteNonExistentTest() {
Assertions.assertThrows(NotFoundException.class, () -> service.delete(999L));
}
}

View File

@@ -1,173 +0,0 @@
package com.example.server.service;
import com.example.server.api.cartItem.CartItemRs;
import com.example.server.error.NotFoundException;
import com.example.server.mapper.BookMapper;
import com.example.server.mapper.CartItemMapper;
import com.example.server.mapper.GenreMapper;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.Optional;
@SpringBootTest
@TestMethodOrder(OrderAnnotation.class)
class CartItemServiceTests {
@Autowired
private CartItemService service;
@Autowired
private BookService bookService;
@Autowired
private GenreService genreService;
@Autowired
private CartItemMapper mapper;
@Autowired
private BookMapper bookMapper;
@Autowired
private GenreMapper genreMapper;
@Test
void getTest() {
Assertions.assertThrows(NotFoundException.class, () -> service.get(0L));
}
@Test
@Order(1)
void createTest() {
var genre1 = genreService.create(genreMapper.toRqDto("Роман"));
var genre2 = genreService.create(genreMapper.toRqDto("Фантастика"));
bookService.create(bookMapper.toRqDto("Книга 1", "Автор 1", 500, "Описание 1", "image1.jpg", 1L));
bookService.create(bookMapper.toRqDto("Книга 2", "Автор 2", 600, "Описание 2", "image2.jpg", 2L));
bookService.create(bookMapper.toRqDto("Книга 3", "Автор 3", 600, "Описание 3", "image3.jpg", 1L));
bookService.create(bookMapper.toRqDto("Книга 4", "Автор 4", 500, "Описание 4", "image4.jpg", 2L));
service.create(mapper.toRqDto(1L, 2));
service.create(mapper.toRqDto(2L, 1));
final CartItemRs last = service.create(mapper.toRqDto(4L, 3));
Assertions.assertEquals(3, service.getAll().size());
final CartItemRs cmpEntity = service.get(3L);
Assertions.assertEquals(last.getId(), cmpEntity.getId());
Assertions.assertEquals(last.getQuantity(), cmpEntity.getQuantity());
}
@Test
@Order(2)
void updateTest() {
final CartItemRs entity = service.get(3L);
final int oldQuantity = entity.getQuantity();
final CartItemRs newEntity = service.update(3L, mapper.toRqDto(4L, 5));
Assertions.assertEquals(3, service.getAll().size());
Assertions.assertEquals(5, newEntity.getQuantity());
Assertions.assertNotEquals(oldQuantity, newEntity.getQuantity());
final CartItemRs cmpEntity = service.get(3L);
Assertions.assertEquals(newEntity.getId(), cmpEntity.getId());
Assertions.assertEquals(newEntity.getQuantity(), cmpEntity.getQuantity());
}
@Test
@Order(3)
void updateQuantityTest() {
final CartItemRs updated = service.updateQuantity(1L, 10);
Assertions.assertEquals(10, updated.getQuantity());
final CartItemRs entity = service.get(1L);
Assertions.assertEquals(10, entity.getQuantity());
}
@Test
@Order(4)
void getByBookIdTest() {
Optional<CartItemRs> cartItem = service.getByBookId(4L);
Assertions.assertTrue(cartItem.isPresent());
Assertions.assertEquals(4L, cartItem.get().getBook().getId());
Assertions.assertEquals(5, cartItem.get().getQuantity());
Optional<CartItemRs> nonExistent = service.getByBookId(999L);
Assertions.assertFalse(nonExistent.isPresent());
}
@Test
@Order(5)
void deleteTest() {
service.delete(3L);
Assertions.assertEquals(2, service.getAll().size());
final CartItemRs last = service.get(2L);
Assertions.assertEquals(2L, last.getId());
final CartItemRs newEntity = service.create(mapper.toRqDto(1L, 1));
Assertions.assertEquals(3, service.getAll().size());
Assertions.assertEquals(4L, newEntity.getId());
}
@Test
@Order(6)
void clearCartTest() {
service.deleteAll();
Assertions.assertEquals(0, service.getAll().size());
// Создаем новый элемент после очистки
final CartItemRs newEntity = service.create(mapper.toRqDto(2L, 2));
Assertions.assertEquals(1, service.getAll().size());
Assertions.assertEquals(1L, newEntity.getId()); // ID продолжает инкрементироваться
}
@Test
void getNonExistentTest() {
Assertions.assertThrows(NotFoundException.class, () -> service.get(999L));
}
@Test
void updateNonExistentTest() {
Assertions.assertThrows(NotFoundException.class, () ->
service.update(999L, mapper.toRqDto(1L, 1)));
}
@Test
void updateQuantityNonExistentTest() {
Assertions.assertThrows(NotFoundException.class, () ->
service.updateQuantity(999L, 5));
}
@Test
void deleteNonExistentTest() {
Assertions.assertThrows(NotFoundException.class, () -> service.delete(999L));
}
@Test
@Order(7)
void createDuplicateBookTest() {
// Можно добавить одну и ту же книгу в корзину несколько раз
final CartItemRs item1 = service.create(mapper.toRqDto(1L, 1));
final CartItemRs item2 = service.create(mapper.toRqDto(1L, 2));
Assertions.assertNotEquals(item1.getId(), item2.getId());
Assertions.assertEquals(1L, item1.getBook().getId());
Assertions.assertEquals(1L, item2.getBook().getId());
}
@Test
@Order(8)
void updateQuantityToZeroTest() {
// Обновление количества до 0 должно удалить элемент
final CartItemRs deleted = service.updateQuantity(1L, 0);
Assertions.assertEquals(0, deleted.getQuantity());
// Элемент должен быть удален
Assertions.assertThrows(NotFoundException.class, () -> service.get(5L));
}
}

View File

@@ -1,120 +0,0 @@
package com.example.server.service;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import com.example.server.api.genre.GenreRq;
import com.example.server.api.genre.GenreRs;
import com.example.server.error.NotFoundException;
import com.example.server.mapper.GenreMapper;
@SpringBootTest
@TestMethodOrder(OrderAnnotation.class)
class GenreServiceTests {
@Autowired
private GenreService service;
@Autowired
private GenreMapper mapper;
@Test
void getTest() {
Assertions.assertThrows(NotFoundException.class, () -> service.get(0L));
}
@Test
@Order(1)
void createTest() {
service.create(mapper.toRqDto("Роман"));
service.create(mapper.toRqDto("Фантастика"));
final GenreRs last = service.create(mapper.toRqDto("Детектив"));
Assertions.assertEquals(3, service.getAll().size());
final GenreRs cmpEntity = service.get(3L);
Assertions.assertEquals(last.getId(), cmpEntity.getId());
Assertions.assertEquals(last.getName(), cmpEntity.getName());
}
@Test
@Order(2)
void updateTest() {
final String test = "Ужасы";
final GenreRs entity = service.get(3L);
final String oldName = entity.getName();
final GenreRs newEntity = service.update(3L, mapper.toRqDto(test));
Assertions.assertEquals(3, service.getAll().size());
Assertions.assertEquals(test, newEntity.getName());
Assertions.assertNotEquals(oldName, newEntity.getName());
final GenreRs cmpEntity = service.get(3L);
Assertions.assertEquals(newEntity.getId(), cmpEntity.getId());
Assertions.assertEquals(newEntity.getName(), cmpEntity.getName());
}
@Test
@Order(3)
void deleteTest() {
service.delete(3L);
Assertions.assertEquals(2, service.getAll().size());
final GenreRs last = service.get(2L);
Assertions.assertEquals(2L, last.getId());
final GenreRs newEntity = service.create(mapper.toRqDto("Приключения"));
Assertions.assertEquals(3, service.getAll().size());
Assertions.assertEquals(4L, newEntity.getId());
}
@Test
@Order(4)
void getAllTest() {
Assertions.assertEquals(3, service.getAll().size());
final GenreRs first = service.get(1L);
final GenreRs second = service.get(2L);
final GenreRs third = service.get(4L);
Assertions.assertEquals("Роман", first.getName());
Assertions.assertEquals("Фантастика", second.getName());
Assertions.assertEquals("Приключения", third.getName());
}
@Test
@Order(5)
void createDuplicateTest() {
final GenreRs duplicate = service.create(mapper.toRqDto("Роман"));
Assertions.assertEquals(4, service.getAll().size());
Assertions.assertEquals("Роман", duplicate.getName());
}
@Test
void getNonExistentTest() {
Assertions.assertThrows(NotFoundException.class, () -> service.get(999L));
}
@Test
void updateNonExistentTest() {
Assertions.assertThrows(NotFoundException.class, () ->
service.update(999L, mapper.toRqDto("Несуществующий")));
}
@Test
void deleteNonExistentTest() {
Assertions.assertThrows(NotFoundException.class, () -> service.delete(999L));
}
@Test
void createWithEmptyNameTest() {
GenreRq dto = new GenreRq();
dto.setName(""); // Пустое имя
Assertions.assertDoesNotThrow(() -> service.create(dto));
}
}

View File

@@ -2,29 +2,68 @@ import axios from "axios";
const API_URL = "http://localhost:8080/api/1.0";
const apiClient = axios.create({
baseURL: API_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
}
});
apiClient.interceptors.response.use(
(response) => response,
(error) => {
console.error('API Error:', error.response?.data || error.message);
return Promise.reject(error);
}
);
export default {
// Жанры
fetchGenres: () => axios.get(`${API_URL}/genre`),
createGenre: (genreData) => axios.post(`${API_URL}/genre`, genreData),
deleteGenre: (id) => axios.delete(`${API_URL}/genre/${id}`),
fetchGenres: () => apiClient.get(`/genre/all`), // Добавить endpoint на бэкенде
// Или использовать пагинированную версию:
fetchGenres: () => apiClient.get(`/genre`).then(response => {
// Преобразуем ответ к массиву
if (response.data && response.data.items) {
return { data: response.data.items };
}
return response;
}),
fetchGenresPaginated: (page = 1, size = 10) =>
apiClient.get(`/genre?page=${page}&size=${size}`),
createGenre: (genreData) => apiClient.post(`/genre`, genreData),
deleteGenre: (id) => apiClient.delete(`/genre/${id}`),
// Книги
fetchBooks: () => axios.get(`${API_URL}/book`),
fetchBooksByGenre: (genreId) => axios.get(`${API_URL}/book?genreId=${genreId}`),
fetchBook: (id) => axios.get(`${API_URL}/book/${id}`),
createBook: (bookData) => axios.post(`${API_URL}/book`, bookData),
updateBook: (id, bookData) => axios.put(`${API_URL}/book/${id}`, bookData),
deleteBook: (id) => axios.delete(`${API_URL}/book/${id}`),
fetchBooks: () => apiClient.get(`/book/all`),
fetchBooksPaginated: (page = 1, size = 10) =>
apiClient.get(`/book?page=${page}&size=${size}`),
fetchBook: (id) => apiClient.get(`/book/${id}`),
createBook: (bookData) => apiClient.post(`/book`, bookData),
updateBook: (id, bookData) => apiClient.put(`/book/${id}`, bookData),
deleteBook: (id) => apiClient.delete(`/book/${id}`),
// Поиск и фильтрация книг
searchBooks: (query, page = 1, size = 10) =>
apiClient.get(`/book/search?query=${encodeURIComponent(query)}&page=${page}&size=${size}`),
getBooksByGenre: (genreId, page = 1, size = 10) =>
apiClient.get(`/book/genre/${genreId}?page=${page}&size=${size}`),
getBooksByGenreAll: (genreId) =>
apiClient.get(`/book/genre/${genreId}/all`),
// Жанры книг
fetchBookGenres: (bookId) => apiClient.get(`/book/${bookId}/genres`),
addBookGenre: (bookId, genreData) => apiClient.post(`/book/${bookId}/genres`, genreData),
deleteBookGenre: (bookId, genreId) => apiClient.delete(`/book/${bookId}/genres/${genreId}`),
// Корзина
fetchCartItems: () => axios.get(`${API_URL}/cart`),
addToCart: (bookId) => axios.post(`${API_URL}/cart`, { bookId, quantity: 1 }),
getCartItemByBookId: (bookId) => axios.get(`${API_URL}/cart/search?bookId=${bookId}`),
updateCartItem: (id, data) => axios.put(`${API_URL}/cart/${id}`, data),
updateCartItemQuantity: (id, quantity) => axios.patch(`${API_URL}/cart/${id}/${quantity}`),
removeFromCart: (id) => axios.delete(`${API_URL}/cart/${id}`),
clearCart: () =>
axios
.get(`${API_URL}/cart`)
.then((response) => Promise.all(response.data.map((item) =>
axios.delete(`${API_URL}/cart/${item.id}`)))),
};
fetchCartItems: () => apiClient.get(`/cart`),
addToCart: (bookId) => apiClient.post(`/cart`, { bookId, quantity: 1 }),
getCartItemByBookId: (bookId) => apiClient.get(`/cart/search?bookId=${bookId}`),
updateCartItem: (id, data) => apiClient.put(`/cart/${id}`, data),
updateCartItemQuantity: (id, quantity) => apiClient.patch(`/cart/${id}/${quantity}`),
removeFromCart: (id) => apiClient.delete(`/cart/${id}`),
clearCart: () => apiClient.delete(`/cart`),
};

View File

@@ -5,12 +5,36 @@ import react from "@vitejs/plugin-react";
import { resolve } from "path";
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
input: {
main: resolve(__dirname, "index.html"),
},
plugins: [react()],
base: "./", // КРИТИЧЕСКИ ВАЖНО для JAR!
build: {
outDir: "dist", // Собираем во фронтенд-проекте
emptyOutDir: true,
assetsDir: "assets", // Стандартное имя папки
rollupOptions: {
output: {
assetFileNames: (assetInfo) => {
const ext = assetInfo.name.split('.').pop();
if (/png|jpe?g|svg|gif|tiff|bmp|ico/i.test(ext)) {
return `assets/images/[name]-[hash][extname]`;
}
if (/css/i.test(ext)) {
return `assets/css/[name]-[hash][extname]`;
}
return `assets/[name]-[hash][extname]`;
},
chunkFileNames: "assets/js/[name]-[hash].js",
entryFileNames: "assets/js/[name]-[hash].js",
},
},
},
server: {
port: 3000,
proxy: {
"/api": {
target: "http://localhost:8080",
changeOrigin: true,
},
},
},
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,21 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Книжный интернет-магазин "Тома"</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" />
<script type="module" crossorigin src="/assets/index-DbQzQPoN.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BLg3Q5Rn.css">
</head>
<body>
<div id="root"></div>
<!-- Подключение React и Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.6/dist/umd/popper.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.min.js"></script>
<!-- Подключение скриптов приложения -->
</body>

7181
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,40 +0,0 @@
{
"name": "mywebsite",
"version": "1.0.0",
"main": "vite.config.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "vite build",
"lint": "eslint . --ext js --report-unused-disable-directives --max-warnings 0",
"dev": "vite",
"start": "npm-run-all -p dev"
},
"dependencies": {
"@popperjs/core": "^2.11.8",
"axios": "^1.9.0",
"bootstrap": "^5.3.6",
"bootstrap-icons": "1.11.3",
"json-server": "^0.17.4",
"react": "^19.1.0",
"react-bootstrap": "^2.10.10",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"react-router-dom": "^7.6.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.4.1",
"eslint": "8.56.0",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.0.2",
"eslint-plugin-html": "8.1.2",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-prettier": "5.2.3",
"http-server": "14.1.1",
"npm-run-all": "4.1.5",
"vite": "^6.3.5"
},
"keywords": [],
"author": "denis",
"license": "ISC",
"description": ""
}

114
server/build.front.gradle Normal file
View File

@@ -0,0 +1,114 @@
apply plugin: "com.github.node-gradle.node"
logger.quiet("Configure front builder")
ext {
// Используем относительный путь
frontDir = file("${project.projectDir}/../MyWebSite")
if (!frontDir.exists()) {
throw new GradleException("Frontend app directory does not exist: ${frontDir}")
}
logger.quiet("Webapp dir is {}", frontDir.toString())
}
node {
version = "22.17.1"
npmVersion = "10.9.2"
download = true
nodeProjectDir = frontDir
}
tasks.register("frontClean", Delete) {
group = "MyWebSite"
description = "Clean frontend build directories"
delete "${frontDir}/dist"
delete "${project.projectDir}/src/main/resources/static"
}
tasks.register("frontDepsInstall", NpmTask) {
group = "MyWebSite"
description = "Installs dependencies from package.json"
logger.quiet(description)
workingDir = frontDir
args = ["install"]
}
tasks.register("frontBuild", NpmTask) {
group = "MyWebSite"
description = "Build frontend webapp"
logger.quiet(description)
workingDir = frontDir
dependsOn frontDepsInstall
args = ["run", "build"]
doLast {
def staticDir = file("${project.projectDir}/src/main/resources/static")
def frontDistDir = file("${frontDir}/dist")
logger.quiet("Frontend built in: ${frontDistDir}")
logger.quiet("Copying to: ${staticDir}")
if (frontDistDir.exists()) {
// Очищаем статическую директорию
delete staticDir
staticDir.mkdirs()
// Копируем все содержимое
copy {
from frontDistDir
into staticDir
include "**/*"
}
// Логируем что скопировалось
logger.quiet("Copied files:")
staticDir.eachFileRecurse { file ->
if (!file.isDirectory()) {
logger.quiet(" - ${file.path.replace(staticDir.path, '')}")
}
}
// Проверяем наличие критических файлов
def requiredFiles = [
"index.html",
"assets/",
"assets/images/"
]
requiredFiles.each { required ->
def checkFile = file("${staticDir}/${required}")
if (required.endsWith("/")) {
if (checkFile.exists() && checkFile.isDirectory()) {
logger.quiet("✓ Directory exists: ${required}")
} else {
logger.warn("✗ Directory missing: ${required}")
}
} else {
if (checkFile.exists()) {
logger.quiet("✓ File exists: ${required}")
} else {
logger.warn("✗ File missing: ${required}")
}
}
}
} else {
throw new GradleException("Frontend build failed: ${frontDistDir} not found")
}
}
}
// Очистка перед сборкой
frontBuild.dependsOn frontClean
// Связываем с процессом сборки
if (frontDir.exists()) {
processResources.dependsOn frontBuild
bootJar.dependsOn frontBuild
// Проверяем, что статические файлы включены в JAR
bootJar {
from("${project.projectDir}/src/main/resources/static") {
into "BOOT-INF/classes/static"
}
}
}

172
server/build.gradle Normal file
View File

@@ -0,0 +1,172 @@
plugins {
id "java"
id "org.springframework.boot" version "3.5.6"
id "io.spring.dependency-management" version "1.1.7"
id "com.github.node-gradle.node" version "7.1.0" apply false
id "org.liquibase.gradle" version "2.2.2" apply false
}
group = "com.example"
version = "0.0.1-SNAPSHOT"
description = "Bookstore Application"
def jdkVersion = "21"
defaultTasks "bootRun"
jar {
enabled = false
}
bootJar {
archiveFileName = String.format("%s-%s.jar", rootProject.name, version)
}
assert System.properties["java.specification.version"] == jdkVersion
java {
toolchain {
languageVersion = JavaLanguageVersion.of(jdkVersion)
}
}
repositories {
mavenCentral()
}
ext {
springdocVersion = "2.8.11"
h2Version = "2.4.240"
postgresVersion = "42.7.8"
liquibaseVersion = "4.33.0"
mockitoVersion = "5.19.0"
// Определяем активные профили на основе параметров Gradle
springProfiles = []
// Добавляем фронтенд если указан параметр front
if (project.hasProperty("front")) {
springProfiles.add("front")
}
// Определяем основной профиль (dev или prod)
if (project.hasProperty("prod")) {
springProfiles.add("prod")
logger.quiet("Using PRODUCTION profile")
} else {
springProfiles.add("dev")
logger.quiet("Using DEVELOPMENT profile")
}
currentProfiles = springProfiles.join(",")
logger.quiet("Active profiles: ${currentProfiles}")
}
configurations {
mockitoAgent
}
apply from: "build.front.gradle"
// Подключаем миграции только для dev профиля
if (springProfiles.contains("dev")) {
apply from: "build.migrations.gradle"
}
dependencies {
// Spring Boot Starters
implementation "org.springframework.boot:spring-boot-starter-web"
implementation "org.springframework.boot:spring-boot-starter-validation"
implementation "org.springframework.boot:spring-boot-starter-data-jpa"
implementation "org.springframework.boot:spring-boot-starter-actuator"
// OpenAPI/Swagger
implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:${springdocVersion}"
// Миграции
implementation "org.liquibase:liquibase-core:${liquibaseVersion}"
// Базы данных
runtimeOnly "org.postgresql:postgresql:${postgresVersion}"
// Для dev профиля добавляем H2
if (springProfiles.contains("dev")) {
runtimeOnly "com.h2database:h2:${h2Version}"
}
// Тестирование
testImplementation "org.springframework.boot:spring-boot-starter-test"
testImplementation "org.mockito:mockito-core:${mockitoVersion}"
testImplementation "com.h2database:h2:${h2Version}" // Для тестов всегда используем H2
testRuntimeOnly "org.junit.platform:junit-platform-launcher"
mockitoAgent("org.mockito:mockito-core:${mockitoVersion}") {
transitive = false
}
}
bootRun {
// Всегда используем профили, определенные через параметры Gradle
def launchArgs = ["--spring.profiles.active=${currentProfiles}"]
// Добавляем пользовательские аргументы если есть
if (project.hasProperty("args")) {
launchArgs.addAll(project.args.tokenize())
}
args = launchArgs
logger.lifecycle("Starting with arguments: ${launchArgs}")
// Настройка JVM
jvmArgs = [
"-Xmx512m",
"-Xms256m",
"-Dfile.encoding=UTF-8"
]
// Передаем системные свойства
systemProperties = System.properties
}
test {
useJUnitPlatform()
jvmArgs += "-Xshare:off"
jvmArgs += "-javaagent:${configurations.mockitoAgent.asPath}"
systemProperty "spring.profiles.active", "test"
}
// Убираем фильтрацию application.yml - Spring Boot будет читать профили из файла
// processResources остается без изменений
// Автоматическое создание миграций при сборке для тестов
if (project.hasProperty('autoGenerateMigrations')) {
tasks.named('test') {
dependsOn generateDiff
}
}
// Дополнительные задачи для удобства
tasks.register("runDev") {
group = "application"
description = "Run application with dev profile (default)"
dependsOn bootRun
}
tasks.register("runprod") {
group = "application"
description = "Run application with prod profile"
doFirst {
project.ext.set("prod", true)
project.ext.set("front", true)
}
dependsOn bootRun
}
tasks.register("runApiOnly") {
group = "application"
description = "Run application without frontend"
doFirst {
// Убираем front профиль
project.extensions.extraProperties.remove("front")
}
dependsOn bootRun
}

View File

@@ -0,0 +1,64 @@
apply plugin: "org.liquibase.gradle"
logger.quiet("Configure migrations builder")
ext {
picocliVersion = "4.7.7"
timestamp = new Date().format("yyyy-MM-dd-HHmmss")
}
liquibase {
activities {
main {
changelogFile "db/master.yml"
url "jdbc:h2:file:./data/bookstore"
username "sa"
password "sa"
driver "org.h2.Driver"
logLevel "info"
classpath "src/main/resources"
referenceUrl "hibernate:spring:com.example.server.entity?dialect=org.hibernate.dialect.H2Dialect"
}
}
}
update.dependsOn processResources
dependencies {
liquibaseRuntime "org.liquibase.ext:liquibase-hibernate6:${liquibaseVersion}"
liquibaseRuntime "info.picocli:picocli:${picocliVersion}"
liquibaseRuntime sourceSets.main.runtimeClasspath
liquibaseRuntime sourceSets.main.output
}
tasks.register("generateFull") {
group = "migrations"
description = "Generate changelog from existing database"
doFirst(){
liquibase {
activities {
main {
changeLogFile "src/main/resources/db/generated-full-${timestamp}.yml"
}
}
}
}
finalizedBy generateChangelog
}
tasks.register("generateDiff") {
group = "liquibase"
description = "Generate diff between DB and JPA entities"
doFirst(){
liquibase {
activities {
main {
changeLogFile "src/main/resources/db/generated-diff-${timestamp}.yml"
}
}
}
}
finalizedBy diffChangelog
}
diffChangelog.dependsOn compileJava

BIN
server/data.mv.db Normal file

Binary file not shown.

17746
server/data.trace.db Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
#FileLock
#Tue Dec 16 14:10:21 GMT+04:00 2025
hostName=LAPTOP-IUA50AR5
id=19b26a389dbd2bd3398104c2366bdfd02b77488ebde
method=file
server=26.200.18.239\:60656

BIN
server/data/bookstore.mv.db Normal file

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,118 @@
package com.example.server;
import com.example.server.api.book.BookGenreRq;
import com.example.server.api.book.BookRq;
import com.example.server.api.genre.GenreRq;
import com.example.server.service.BookService;
import com.example.server.service.CartItemService;
import com.example.server.service.GenreService;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.core.env.Environment;
import java.util.Arrays;
import java.util.Objects;
@SpringBootApplication
public class ServerApplication implements CommandLineRunner {
private final Logger log = LoggerFactory.getLogger(ServerApplication.class);
private final Environment environment;
private final BookService bookService;
private final GenreService genreService;
private final CartItemService cartItemService;
public ServerApplication(Environment environment, BookService bookService,
GenreService genreService, CartItemService cartItemService) {
this.environment = environment;
this.bookService = bookService;
this.genreService = genreService;
this.cartItemService = cartItemService;
}
public static void main(String[] args) {
SpringApplication.run(ServerApplication.class, args);
}
@PostConstruct
public void logProfiles() {
// Логируем активные профили при старте приложения
String[] activeProfiles = environment.getActiveProfiles();
if (activeProfiles.length == 0) {
log.info("No active profiles set, using default");
} else {
log.info("Active profiles: {}", Arrays.toString(activeProfiles));
}
// Логируем используемую базу данных
String datasourceUrl = environment.getProperty("spring.datasource.url");
if (datasourceUrl != null) {
String maskedUrl = datasourceUrl.replaceAll("password=.*?(&|$)", "password=****");
log.info("Database URL: {}", maskedUrl);
}
}
@Override
public void run(String... args) throws Exception {
log.info("Application started with arguments: {}", Arrays.toString(args));
// Проверяем, нужно ли заполнять данные
boolean shouldPopulate = args.length > 0 && Objects.equals("--populate", args[0]);
boolean isDevProfile = Arrays.asList(environment.getActiveProfiles()).contains("dev");
if (shouldPopulate) {
if (isDevProfile) {
populateData();
} else {
log.warn("Data population is only allowed in dev profile");
}
}
}
private void populateData() {
log.info("Starting data population...");
try {
// Проверяем, не заполнены ли данные уже
if (!genreService.getAll().isEmpty()) {
log.info("Data already exists, skipping population");
return;
}
log.info("Creating genres...");
var genre1 = genreService.create(new GenreRq("Роман"));
var genre2 = genreService.create(new GenreRq("Фантастика"));
var genre3 = genreService.create(new GenreRq("Ужасы"));
var genre4 = genreService.create(new GenreRq("Детектив"));
log.info("Creating books...");
var book1 = bookService.create(new BookRq(
"Основание", "Айзек Азимов", 700,
"Сага о падении и возрождении галактической империи.", "images/foundation.jpg"
));
var book2 = bookService.create(new BookRq(
"Дюна", "Фрэнк Герберт", 900,
"Эпическая история о борьбе за контроль над планетой Арракис.", "images/dune.jpg"
));
var book3 = bookService.create(new BookRq(
"Убийство в Восточном экспрессе", "Агата Кристи", 750,
"Загадочное убийство на поезде.", "images/murder_on_the_orient_express.jpg"
));
log.info("Creating genre-book relationships...");
bookService.addGenre(book1.id(), new BookGenreRq(genre2.id(), "2024-01-15"));
bookService.addGenre(book2.id(), new BookGenreRq(genre2.id(), "2024-01-16"));
bookService.addGenre(book3.id(), new BookGenreRq(genre4.id(), "2024-01-17"));
log.info("Data population completed successfully!");
} catch (Exception e) {
log.error("Error during data population: {}", e.getMessage(), e);
}
}
}

View File

@@ -0,0 +1,14 @@
package com.example.server.api;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
public class PageHelper {
private PageHelper() {
}
public static Pageable toPageable(int page, int size) {
return PageRequest.of(page - 1, size, Sort.by("id"));
}
}

View File

@@ -0,0 +1,39 @@
package com.example.server.api;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.function.Function;
import org.springframework.data.domain.Page;
public record PageRs<D>(
List<D> items,
int itemsCount,
int currentPage,
int currentSize,
int totalPages,
long totalItems,
boolean isFirst,
boolean isLast,
boolean hasNext,
boolean hasPrevious) {
public List<D> items() {
return Optional.ofNullable(items).orElse(Collections.emptyList());
}
public static <D, E> PageRs<D> from(Page<E> page, Function<E, D> mapper) {
return new PageRs<>(
page.getContent().stream().map(mapper::apply).toList(),
page.getNumberOfElements(),
page.getNumber() + 1,
page.getSize(),
page.getTotalPages(),
page.getTotalElements(),
page.isFirst(),
page.isLast(),
page.hasNext(),
page.hasPrevious());
}
}

View File

@@ -0,0 +1,13 @@
package com.example.server.api;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@Controller
public class SpaController {
@GetMapping(value = "/{path:^(?!api|assets|images|swagger-ui|.*\\.[a-zA-Z0-9]{2,10}).*}/**")
public String forwardToIndex(@PathVariable(required = false) String path) {
return "forward:/index.html";
}
}

View File

@@ -0,0 +1,99 @@
package com.example.server.api.book;
import com.example.server.api.PageHelper;
import com.example.server.api.PageRs;
import com.example.server.service.BookService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import org.springframework.web.bind.annotation.*;
import com.example.server.configuration.Constants;
import java.util.List;
@RestController
@RequestMapping(Constants.API_URL + BookController.URL)
public class BookController {
public static final String URL = "/book";
private final BookService bookService;
public BookController(BookService bookService) {
this.bookService = bookService;
}
@GetMapping
public PageRs<BookRs> getAll(
@RequestParam(defaultValue = "1") @Min(1) int page,
@RequestParam(defaultValue = "20") @Min(1) int size) {
return bookService.getAll(PageHelper.toPageable(page, size));
}
@GetMapping("/all")
public List<BookRs> getAllWithoutPagination() {
return bookService.getAll();
}
@GetMapping("/{id}")
public BookRs get(@PathVariable Long id) {
return bookService.get(id);
}
@PostMapping
public BookRs create(@RequestBody @Valid BookRq dto) {
return bookService.create(dto);
}
@PutMapping("/{id}")
public BookRs update(@PathVariable Long id, @RequestBody @Valid BookRq dto) {
return bookService.update(id, dto);
}
@DeleteMapping("/{id}")
public BookRs delete(@PathVariable Long id) {
return bookService.delete(id);
}
@GetMapping("/{id}/genres")
public List<BookGenreRs> getGenres(@PathVariable Long id) {
return bookService.getBookGenres(id);
}
@PostMapping("/{id}/genres")
public BookGenreRs addGenre(@PathVariable Long id, @RequestBody @Valid BookGenreRq dto) {
return bookService.addGenre(id, dto);
}
@PutMapping("/{id}/genres/{genreId}")
public BookGenreRs updateGenre(@PathVariable Long id, @PathVariable Long genreId,
@RequestBody @Valid BookGenreUpdateRq dto) {
return bookService.updateGenre(id, genreId, dto);
}
@DeleteMapping("/{id}/genres/{genreId}")
public BookGenreRs deleteGenre(@PathVariable Long id, @PathVariable Long genreId) {
return bookService.deleteGenre(id, genreId);
}
// ===== НОВЫЕ ЭНДПОИНТЫ ДЛЯ ФИЛЬТРАЦИИ И ПОИСКА =====
@GetMapping("/genre/{genreId}")
public PageRs<BookRs> getBooksByGenre(
@PathVariable Long genreId,
@RequestParam(defaultValue = "1") @Min(1) int page,
@RequestParam(defaultValue = "20") @Min(1) int size) {
return bookService.getBooksByGenre(genreId, PageHelper.toPageable(page, size));
}
@GetMapping("/genre/{genreId}/all")
public List<BookRs> getBooksByGenreWithoutPagination(@PathVariable Long genreId) {
return bookService.getBooksByGenreWithoutPagination(genreId);
}
@GetMapping("/search")
public PageRs<BookRs> searchBooks(
@RequestParam(required = false) String query,
@RequestParam(defaultValue = "1") @Min(1) int page,
@RequestParam(defaultValue = "20") @Min(1) int size) {
return bookService.searchBooks(query, PageHelper.toPageable(page, size));
}
}

View File

@@ -0,0 +1,9 @@
package com.example.server.api.book;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
public record BookGenreRq(
@NotNull Long genreId,
@NotNull @Pattern(regexp = "^\\d{4}-\\d{2}-\\d{2}$", message = "Date must be in YYYY-MM-DD format") String date) {
}

View File

@@ -0,0 +1,23 @@
package com.example.server.api.book;
import com.example.server.api.genre.GenreRs;
import com.example.server.entity.GenreBookEntity;
import java.util.List;
import java.util.stream.StreamSupport;
public record BookGenreRs(
GenreRs genre,
String date) {
public static BookGenreRs from(GenreBookEntity entity) {
return new BookGenreRs(
GenreRs.from(entity.getGenre()),
entity.getDate().toString());
}
public static List<BookGenreRs> fromList(Iterable<GenreBookEntity> entities) {
return StreamSupport.stream(entities.spliterator(), false)
.map(BookGenreRs::from)
.toList();
}
}

View File

@@ -0,0 +1,8 @@
package com.example.server.api.book;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
public record BookGenreUpdateRq(
@NotNull @Pattern(regexp = "^\\d{4}-\\d{2}-\\d{2}$", message = "Date must be in YYYY-MM-DD format") String date) {
}

View File

@@ -0,0 +1,15 @@
package com.example.server.api.book;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
public record BookRq(
@NotBlank @Size(min = 2, max = 50) String title,
@NotBlank @Size(min = 2, max = 50) String author,
@NotNull @Min(1) int price,
String description,
String image) {
}

View File

@@ -0,0 +1,48 @@
package com.example.server.api.book;
import com.example.server.entity.BookEntity;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
import java.util.stream.StreamSupport;
public record BookRs(
@NotNull Long id,
@NotBlank(message = "Название обязательно")
@Size(min = 2, max = 50, message = "Название должно быть от 2 до 50 символов")
String title,
@NotBlank(message = "автор обязателен")
@Size(min = 2, max = 50, message = "ФИО должно быть от 2 до 50 символов")
String author,
@NotNull
@Min(value = 1, message = "Цена не может быть меньше 1 рубля")
Integer price,
String description,
String image,
List<BookGenreRs> genres
) {
public static BookRs from(BookEntity entity) {
return new BookRs(
entity.getId(),
entity.getTitle(),
entity.getAuthor(),
entity.getPrice(),
entity.getDescription(),
entity.getImage(),
BookGenreRs.fromList(entity.getGenreBooks()) // Добавляем жанры
);
}
public static List<BookRs> fromList(Iterable<BookEntity> entities) {
return StreamSupport.stream(entities.spliterator(), false)
.map(BookRs::from)
.toList();
}
}

View File

@@ -0,0 +1,10 @@
package com.example.server.api.cartItem;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
public record CartItemRq(
@NotNull Long bookId,
@NotNull @Min(1) Integer quantity) {
}

View File

@@ -0,0 +1,28 @@
package com.example.server.api.cartItem;
import com.example.server.api.book.BookRs;
import com.example.server.entity.CartItemEntity;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import java.util.stream.StreamSupport;
public record CartItemRs(
Long id,
BookRs book,
Integer quantity) {
public static CartItemRs from(CartItemEntity entity) {
return new CartItemRs(
entity.getId(),
BookRs.from(entity.getBook()),
entity.getQuantity());
}
public static List<CartItemRs> fromList(Iterable<CartItemEntity> entities) {
return StreamSupport.stream(entities.spliterator(), false)
.map(CartItemRs::from)
.toList();
}
}

View File

@@ -4,9 +4,12 @@ import java.util.List;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicInteger;
import com.example.server.api.PageHelper;
import com.example.server.api.PageRs;
import com.example.server.error.NotFoundException;
import com.example.server.service.GenreService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import org.springframework.web.bind.annotation.*;
import com.example.server.configuration.Constants;
@@ -23,7 +26,14 @@ public class GenreController {
}
@GetMapping
public List<GenreRs> getAll() {
public PageRs<GenreRs> getAll(
@RequestParam(defaultValue = "1") @Min(1) int page,
@RequestParam(defaultValue = "20") @Min(1) int size) {
return genreService.getAll(PageHelper.toPageable(page, size));
}
@GetMapping("/all")
public List<GenreRs> getAllWithoutPagination() {
return genreService.getAll();
}
@@ -45,4 +55,14 @@ public class GenreController {
public GenreRs delete(@PathVariable Long id) {
return genreService.delete(id);
}
@GetMapping("/stats")
public List<GenreStatsRs> getAllStats() {
return genreService.getAllGenresStats();
}
@GetMapping("/{id}/stats")
public GenreStatsRs getStats(@PathVariable Long id) {
return genreService.getGenreStats(id);
}
}

View File

@@ -0,0 +1,8 @@
package com.example.server.api.genre;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record GenreRq(
@NotBlank @Size(min = 2, max = 50) String name) {
}

View File

@@ -0,0 +1,24 @@
package com.example.server.api.genre;
import com.example.server.entity.GenreEntity;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
import java.util.stream.StreamSupport;
public record GenreRs(
Long id,
String name) {
public static GenreRs from(GenreEntity entity) {
return new GenreRs(entity.getId(), entity.getName());
}
public static List<GenreRs> fromList(Iterable<GenreEntity> entities) {
return StreamSupport.stream(entities.spliterator(), false)
.map(GenreRs::from)
.toList();
}
}

View File

@@ -0,0 +1,29 @@
package com.example.server.api.genre;
import com.example.server.entity.projection.GenreStatsProjection;
import java.util.List;
import java.util.stream.StreamSupport;
public record GenreStatsRs(
GenreRs genre,
Long totalBooks,
Long totalCartItems,
Double averagePrice) {
public static GenreStatsRs from(GenreStatsProjection projection) {
if (projection == null) {
return new GenreStatsRs(null, 0L, 0L, 0.0);
}
return new GenreStatsRs(
GenreRs.from(projection.getGenre()),
projection.getTotalBooks(),
projection.getTotalCartItems(),
projection.getAveragePrice());
}
public static List<GenreStatsRs> fromList(Iterable<GenreStatsProjection> projections) {
return StreamSupport.stream(projections.spliterator(), false)
.map(GenreStatsRs::from)
.toList();
}
}

View File

@@ -0,0 +1,25 @@
package com.example.server.entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.MappedSuperclass;
import jakarta.persistence.SequenceGenerator;
@MappedSuperclass
public abstract class BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
@SequenceGenerator(name = "hibernate_sequence")
protected Long id;
protected BaseEntity() {}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
}

View File

@@ -1,24 +1,47 @@
package com.example.server.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy;
import jakarta.persistence.Table;
import java.util.HashSet;
import java.util.Set;
@Entity
@Table(name = "books")
public class BookEntity extends BaseEntity{
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String author;
private int price;
private String description;
@ManyToOne
@JoinColumn(name = "genre_id") // добавлена аннотация для связи
private GenreEntity genre;
private String image;
@OneToMany(mappedBy = "book")
@OrderBy("id ASC")
private Set<GenreBookEntity> genreBooks = new HashSet<>(); // добавлена обратная связь
public BookEntity() {
super();
}
public BookEntity(String title, String author, int price, String description, GenreEntity gente, String image) {
public BookEntity(String title, String author, int price, String description, GenreEntity genre, String image) {
this();
this.title = title;
this.author = author;
this.price = price;
this.description = description;
this.genre = gente;
this.genre = genre;
this.image = image;
}
@@ -69,4 +92,15 @@ public class BookEntity extends BaseEntity{
public void setGenre(GenreEntity genre) {
this.genre = genre;
}
public Set<GenreBookEntity> getGenreBooks() {
return genreBooks;
}
public void addGenreBook(GenreBookEntity genreBook) {
if (genreBook.getBook() != this) {
genreBook.setBook(this);
}
genreBooks.add(genreBook);
}
}

View File

@@ -1,7 +1,18 @@
package com.example.server.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "cartitems")
public class CartItemEntity extends BaseEntity{
@ManyToOne
@JoinColumn(name = "book_id", nullable = false) // добавлена аннотация для связи
private BookEntity book;
private int quantity;
public CartItemEntity() {
@@ -29,4 +40,4 @@ public class CartItemEntity extends BaseEntity{
public void setQuantity(int quantity) {
this.quantity = quantity;
}
}
}

View File

@@ -0,0 +1,85 @@
package com.example.server.entity;
import java.time.LocalDate;
import java.util.Objects;
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.MapsId;
import jakarta.persistence.Table;
@Entity
@Table(name = "genre_book")
public class GenreBookEntity {
@EmbeddedId
private GenreBookId id = new GenreBookId();
@ManyToOne
@MapsId("genreId")
@JoinColumn(name = "genre_id", nullable = false)
private GenreEntity genre;
@ManyToOne
@MapsId("bookId")
@JoinColumn(name = "book_id", nullable = false)
private BookEntity book;
private LocalDate date;
public GenreBookEntity() {
}
public GenreBookEntity(GenreEntity genre, BookEntity book, LocalDate date) {
this.genre = genre;
this.book = book;
this.date = date;
this.id = new GenreBookId(genre.getId(), book.getId());
}
public GenreBookId getId() {
return id;
}
public void setId(GenreBookId id) {
this.id = id;
}
public GenreEntity getGenre() {
return genre;
}
public void setGenre(GenreEntity genre) {
this.genre = genre;
}
public BookEntity getBook() {
return book;
}
public void setBook(BookEntity book) {
this.book = book;
}
public LocalDate getDate() {
return date;
}
public void setDate(LocalDate date) {
this.date = date;
}
@Override
public int hashCode() {
return Objects.hash(id);
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
GenreBookEntity other = (GenreBookEntity) obj;
return Objects.equals(id, other.id);
}
}

View File

@@ -0,0 +1,51 @@
package com.example.server.entity;
import java.util.Objects;
import jakarta.persistence.Embeddable;
@Embeddable
public class GenreBookId {
private Long genreId;
private Long bookId;
public GenreBookId() {
}
public GenreBookId(Long genreId, Long bookId) {
this.genreId = genreId;
this.bookId = bookId;
}
public Long getGenreId() {
return genreId;
}
public void setGenreId(Long genreId) {
this.genreId = genreId;
}
public Long getBookId() {
return bookId;
}
public void setBookId(Long bookId) {
this.bookId = bookId;
}
@Override
public int hashCode() {
return Objects.hash(genreId, bookId);
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
GenreBookId other = (GenreBookId) obj;
return Objects.equals(genreId, other.genreId) && Objects.equals(bookId, other.bookId);
}
}

View File

@@ -0,0 +1,47 @@
package com.example.server.entity;
import java.util.HashSet;
import java.util.Set;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy;
import jakarta.persistence.Table;
@Entity
@Table(name = "genres")
public class GenreEntity extends BaseEntity{
@Column(length = 100, nullable = false, unique = true)
private String name;
@OneToMany(mappedBy = "genre")
@OrderBy("id ASC")
private Set<GenreBookEntity> genreBooks = new HashSet<>();
public GenreEntity() {
super();
}
public GenreEntity(String name) {
this();
this.name = name;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Set<GenreBookEntity> getGenreBooks() {
return genreBooks;
}
public void addBook(GenreBookEntity genreBook) {
if (genreBook.getGenre() != this) {
genreBook.setGenre(this);
}
genreBooks.add(genreBook);
}
}

View File

@@ -0,0 +1,10 @@
package com.example.server.entity.projection;
import com.example.server.entity.GenreEntity;
public interface GenreStatsProjection {
GenreEntity getGenre();
Long getTotalBooks();
Long getTotalCartItems();
Double getAveragePrice();
}

Some files were not shown because too many files have changed in this diff Show More