4 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
9cb68ecec9 full 2025-10-17 10:16:18 +04:00
8a7eb58d7c LabWork_2.2 2025-10-02 13:27:17 +04:00
113 changed files with 23406 additions and 323 deletions

14
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,14 @@
{
"configurations": [
{
"type": "java",
"name": "Spring Boot-ServerApplication<server>",
"request": "launch",
"cwd": "${workspaceFolder}",
"mainClass": "com.example.server.ServerApplication",
"projectName": "server",
"args": "",
"envFile": "${workspaceFolder}/.env"
}
]
}

54
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,54 @@
{
"files.autoSave": "onFocusChange",
"files.eol": "\n",
"editor.detectIndentation": false,
"editor.formatOnType": false,
"editor.formatOnPaste": true,
"editor.formatOnSave": true,
"editor.tabSize": 4,
"editor.insertSpaces": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit",
"source.sortImports": "explicit"
},
"editor.snippetSuggestions": "bottom",
"debug.toolBarLocation": "commandCenter",
"debug.showVariableTypes": true,
"errorLens.gutterIconsEnabled": true,
"errorLens.messageEnabled": false,
"prettier.tabWidth": 4,
"prettier.singleQuote": false,
"prettier.printWidth": 120,
"prettier.trailingComma": "es5",
"prettier.useTabs": false,
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"java.compile.nullAnalysis.mode": "automatic",
"java.configuration.updateBuildConfiguration": "automatic",
"[java]": {
"editor.pasteAs.enabled": false,
"editor.codeActionsOnSave": {
"source.organizeImports": "never",
"source.sortImports": "explicit"
}
},
"gradle.nestedProjects": true,
"java.saveActions.organizeImports": true,
"java.dependency.packagePresentation": "hierarchical",
"java.codeGeneration.hashCodeEquals.useJava7Objects": true,
"spring-boot.ls.problem.boot2.JAVA_CONSTRUCTOR_PARAMETER_INJECTION": "WARNING",
"spring.initializr.defaultLanguage": "Java"
}

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

@@ -31,23 +31,23 @@ const BookComponent = ({ book, onEdit, onDelete, onAddToCart }) => {
{displayPrice}
</Card.Text>
<div>
<Button
variant="primary"
className="me-2 mb-2"
<Button
variant="primary"
className="me-2 mb-2"
onClick={() => onAddToCart(book.id)}
>
<BiCart /> В корзину
</Button>
<Button
variant="outline-secondary"
className="me-2 mb-2"
<Button
variant="outline-secondary"
className="me-2 mb-2"
onClick={() => onEdit(book.id)}
>
<BiEdit /> Редактировать
</Button>
<Button
variant="outline-danger"
className="mb-2"
<Button
variant="outline-danger"
className="mb-2"
onClick={() => onDelete(book.id)}
>
<BiTrash /> Удалить

View File

@@ -1,103 +1,239 @@
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.genreId,
price: book.price,
description: book.description,
image: book.image?.replace('images/', '') || ''
});
});
if (bookId && show) {
loadBookData();
} else {
setFormData({
title: '',
author: '',
genreId: genres[0]?.id || '',
price: '',
description: '',
image: ''
});
resetForm();
}
}, [bookId, genres]);
}, [bookId, show]);
const handleSubmit = (e) => {
e.preventDefault();
const bookData = {
...formData,
price: Number(formData.price),
genreId: Number(formData.genreId),
image: formData.image.startsWith('http')
? formData.image
: `images/${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 resetForm = () => {
setFormData({
title: '',
author: '',
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={onHide} size="lg">
<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>
<Form.Label>Название книги *</Form.Label>
<Form.Control
type="text"
value={formData.title}
onChange={(e) => setFormData({...formData, title: e.target.value})}
required
placeholder="Введите название книги"
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Автор</Form.Label>
<Form.Label>Автор *</Form.Label>
<Form.Control
type="text"
value={formData.author}
onChange={(e) => setFormData({...formData, author: e.target.value})}
required
placeholder="Введите автора"
/>
</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
>
{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">
<Form.Label>Цена</Form.Label>
<Form.Label>Цена *</Form.Label>
<Form.Control
type="number"
value={formData.price}
onChange={(e) => setFormData({...formData, price: e.target.value})}
required
min="0"
step="1"
placeholder="0"
/>
</Form.Group>
@@ -108,7 +244,7 @@ const BookModal = ({ show, onHide, bookId, genres, onSave }) => {
rows={3}
value={formData.description}
onChange={(e) => setFormData({...formData, description: e.target.value})}
required
placeholder="Введите описание книги"
/>
</Form.Group>
@@ -118,17 +254,20 @@ const BookModal = ({ show, onHide, bookId, genres, onSave }) => {
type="text"
value={formData.image}
onChange={(e) => setFormData({...formData, image: e.target.value})}
placeholder="Имя файла (например, book.jpg) или полный URL"
required
placeholder="URL изображения или путь к файлу"
/>
<Form.Text className="text-muted">
Можно указать имя файла из папки images (например, "book.jpg") или полный URL изображения
Можно указать полный URL (https://...) или относительный путь (images/book.jpg)
</Form.Text>
</Form.Group>
<Modal.Footer>
<Button variant="secondary" onClick={onHide}>Отмена</Button>
<Button variant="primary" type="submit">Сохранить</Button>
<Button variant="secondary" onClick={handleClose}>
Отмена
</Button>
<Button variant="primary" type="submit">
{bookId ? 'Обновить' : 'Добавить'}
</Button>
</Modal.Footer>
</Form>
</Modal.Body>

View File

@@ -1,11 +1,12 @@
import React, { useState, useEffect } from 'react';
import { Modal, Button, Card, Form } from 'react-bootstrap';
import { Modal, Button, Card, Form, Alert } from 'react-bootstrap';
import { BiTrash } from 'react-icons/bi';
import api from '../services/api';
const CartModal = ({ show, onHide, onCheckout }) => {
const [cartItems, setCartItems] = useState([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (show) {
@@ -15,25 +16,29 @@ const CartModal = ({ show, onHide, onCheckout }) => {
const loadCart = async () => {
try {
setLoading(true);
const response = await api.fetchCartItems();
const items = response.data;
const items = response.data || [];
setCartItems(items);
const newTotal = items.reduce((sum, item) => {
return sum + (item.book?.price || 0) * (item.quantity || 1);
}, 0);
setTotal(newTotal);
} catch (error) {
console.error('Ошибка загрузки корзины:', error);
} finally {
setLoading(false);
}
};
const handleQuantityChange = async (id, quantity) => {
if (quantity < 1) return;
try {
await api.updateCartItem(id, { quantity });
loadCart();
// Используем правильный метод для обновления количества
await api.updateCartItemQuantity(id, quantity);
await loadCart(); // Перезагружаем корзину
} catch (error) {
console.error('Ошибка обновления количества:', error);
}
@@ -42,77 +47,108 @@ const CartModal = ({ show, onHide, onCheckout }) => {
const handleRemoveItem = async (id) => {
try {
await api.removeFromCart(id);
loadCart();
await loadCart(); // Перезагружаем корзину
} catch (error) {
console.error('Ошибка удаления из корзины:', error);
}
};
const handleCheckoutClick = async () => {
try {
await onCheckout();
setCartItems([]);
setTotal(0);
} catch (error) {
console.error('Ошибка оформления заказа:', error);
}
};
return (
<Modal show={show} onHide={onHide} size="lg">
<Modal.Header closeButton>
<Modal.Title>Ваша корзина</Modal.Title>
<Modal.Title>🛒 Ваша корзина</Modal.Title>
</Modal.Header>
<Modal.Body>
{cartItems.length === 0 ? (
<p className="text-muted">Корзина пуста</p>
{loading ? (
<div className="text-center py-3">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Загрузка...</span>
</div>
</div>
) : cartItems.length === 0 ? (
<Alert variant="info" className="text-center">
Корзина пуста
</Alert>
) : (
<>
{cartItems.map(item => (
<Card key={item.id} className="mb-3">
<Card.Body>
<div className="row align-items-center">
<div className="col-md-2">
<img
src={item.book?.image || "images/default-book.jpg"}
alt={item.book?.title || "Без названия"}
className="img-fluid rounded"
onError={(e) => { e.target.src = 'images/default-book.jpg' }}
/>
<div className="cart-items">
{cartItems.map(item => (
<Card key={item.id} className="mb-3">
<Card.Body>
<div className="row align-items-center">
<div className="col-3 col-md-2">
<img
src={item.book?.image || "/images/default-book.jpg"}
alt={item.book?.title || "Без названия"}
className="img-fluid rounded"
style={{ maxHeight: '60px', objectFit: 'cover' }}
onError={(e) => {
e.target.src = '/images/default-book.jpg';
e.target.onerror = null;
}}
/>
</div>
<div className="col-9 col-md-6">
<h6 className="mb-1">{item.book?.title || "Без названия"}</h6>
<p className="text-muted small mb-1">{item.book?.author || "Автор не указан"}</p>
<p className="small mb-0">
<strong>Цена:</strong> {item.book?.price || 0} руб.
</p>
</div>
<div className="col-6 col-md-2 mt-2 mt-md-0">
<Form.Control
type="number"
min="1"
value={item.quantity || 1}
onChange={(e) => handleQuantityChange(item.id, parseInt(e.target.value) || 1)}
size="sm"
/>
</div>
<div className="col-6 col-md-2 text-end mt-2 mt-md-0">
<p className="fw-bold mb-1">
{(item.book?.price || 0) * (item.quantity || 1)} руб.
</p>
<Button
variant="outline-danger"
size="sm"
onClick={() => handleRemoveItem(item.id)}
>
<BiTrash />
</Button>
</div>
</div>
<div className="col-md-6">
<h5>{item.book?.title || "Без названия"}</h5>
<p className="text-muted">{item.book?.author || "Автор не указан"}</p>
<p>
Цена: {item.book?.price || 0} руб. × {item.quantity || 1} =
{(item.book?.price || 0) * (item.quantity || 1)} руб.
</p>
</div>
<div className="col-md-2">
<Form.Control
type="number"
min="1"
value={item.quantity || 1}
onChange={(e) => handleQuantityChange(item.id, parseInt(e.target.value))}
className="cart-item-quantity"
/>
</div>
<div className="col-md-2 text-center">
<Button
variant="outline-danger"
onClick={() => handleRemoveItem(item.id)}
>
<BiTrash />
</Button>
</div>
</div>
</Card.Body>
</Card>
))}
<div className="d-flex justify-content-between align-items-center mt-3">
<h4>Итого:</h4>
<h4>{total} руб.</h4>
</Card.Body>
</Card>
))}
</div>
<div className="border-top pt-3">
<div className="d-flex justify-content-between align-items-center">
<h5 className="mb-0">Общая стоимость:</h5>
<h5 className="mb-0 text-primary">{total} руб.</h5>
</div>
</div>
</>
)}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onHide}>Продолжить покупки</Button>
<Button
variant="success"
onClick={onCheckout}
disabled={cartItems.length === 0}
<Button variant="secondary" onClick={onHide}>
Продолжить покупки
</Button>
<Button
variant="success"
onClick={handleCheckoutClick}
disabled={cartItems.length === 0 || loading}
>
Оформить заказ
</Button>

View File

@@ -1,36 +1,59 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Modal, Button, Form } from 'react-bootstrap';
const GenreModal = ({ show, onHide, onSave }) => {
const [genreName, setGenreName] = useState('');
// Сбрасываем форму при открытии/закрытии
useEffect(() => {
if (show) {
setGenreName('');
}
}, [show]);
const handleSubmit = (e) => {
e.preventDefault();
if (!genreName.trim()) return;
onSave({ name: genreName });
if (!genreName.trim()) {
alert('Введите название жанра');
return;
}
onSave({ name: genreName.trim() });
setGenreName('');
};
const handleClose = () => {
setGenreName('');
onHide();
};
return (
<Modal show={show} onHide={onHide}>
<Modal show={show} onHide={handleClose}>
<Modal.Header closeButton>
<Modal.Title>Добавить жанр</Modal.Title>
<Modal.Title> Добавить жанр</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3">
<Form.Label>Название жанра</Form.Label>
<Form.Label>Название жанра *</Form.Label>
<Form.Control
type="text"
value={genreName}
onChange={(e) => setGenreName(e.target.value)}
placeholder="Например: Фантастика, Детектив, Роман"
required
autoFocus
/>
<Form.Text className="text-muted">
Введите уникальное название для нового жанра
</Form.Text>
</Form.Group>
<Modal.Footer>
<Button variant="secondary" onClick={onHide}>Отмена</Button>
<Button variant="primary" type="submit">Сохранить</Button>
<Button variant="secondary" onClick={handleClose}>
Отмена
</Button>
<Button variant="primary" type="submit" disabled={!genreName.trim()}>
Добавить жанр
</Button>
</Modal.Footer>
</Form>
</Modal.Body>

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

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

@@ -21,10 +21,8 @@ const BasketPage = () => {
const items = response.data;
setCartItems(items);
const newTotal = items.reduce((sum, item) => {
return sum + (item.book?.price || 0) * (item.quantity || 1);
}, 0);
setTotal(newTotal);
// Пересчитываем сумму
calculateTotal(items);
} catch (error) {
console.error('Ошибка загрузки корзины:', error);
} finally {
@@ -32,43 +30,40 @@ const BasketPage = () => {
}
};
const calculateTotal = (items) => {
const newTotal = items.reduce((sum, item) => {
return sum + (item.book?.price || 0) * (item.quantity || 1);
}, 0);
setTotal(newTotal);
};
const handleQuantityChange = async (id, quantity) => {
if (quantity < 1) return;
try {
await api.updateCartItem(id, { quantity });
// Оптимизация: обновляем локальное состояние вместо полной перезагрузки
setCartItems(prevItems =>
prevItems.map(item =>
item.id === id ? { ...item, quantity } : item
)
// Используем правильный метод API
await api.updateCartItemQuantity(id, quantity);
// Обновляем локальное состояние
const updatedItems = cartItems.map(item =>
item.id === id ? { ...item, quantity } : item
);
// Пересчитываем итоговую сумму
setCartItems(prevItems => {
const newTotal = prevItems.reduce((sum, item) => {
return sum + (item.book?.price || 0) * (item.quantity || 1);
}, 0);
setTotal(newTotal);
return prevItems;
});
setCartItems(updatedItems);
calculateTotal(updatedItems);
} catch (error) {
console.error('Ошибка обновления количества:', error);
// Перезагружаем корзину при ошибке
loadCart();
}
};
const handleRemoveItem = async (id) => {
try {
await api.removeFromCart(id);
// Оптимизация: обновляем локальное состояние
setCartItems(prevItems => prevItems.filter(item => item.id !== id));
// Пересчитываем итоговую сумму
setCartItems(prevItems => {
const newTotal = prevItems.reduce((sum, item) => {
return sum + (item.book?.price || 0) * (item.quantity || 1);
}, 0);
setTotal(newTotal);
return prevItems;
});
// Обновляем локальное состояние
const updatedItems = cartItems.filter(item => item.id !== id);
setCartItems(updatedItems);
calculateTotal(updatedItems);
} catch (error) {
console.error('Ошибка удаления из корзины:', error);
}
@@ -108,14 +103,21 @@ const BasketPage = () => {
return (
<div className="basket-page">
<Navbar cartCount={cartItems.reduce((sum, item) => sum + (item.quantity || 0), 0)} />
<main className="container mt-5 pt-5">
<Row className="justify-content-center">
<Col lg={10}>
<h2 className="text-center mb-4">Ваша корзина</h2>
{cartItems.length === 0 ? (
<Alert variant="info">Корзина пуста</Alert>
<Alert variant="info" className="text-center">
Корзина пуста
<div className="mt-3">
<Link to="/catalog" className="btn btn-primary">
Перейти к покупкам
</Link>
</div>
</Alert>
) : (
<>
<div className="cart-items">
@@ -125,32 +127,50 @@ const BasketPage = () => {
<Row className="align-items-center">
<Col md={2}>
<img
src={item.book?.image || "images/default-book.jpg"}
src={item.book?.image || "/images/default-book.jpg"}
alt={item.book?.title || "Без названия"}
className="img-fluid rounded"
onError={(e) => { e.target.src = 'images/default-book.jpg' }}
style={{ maxHeight: '100px', objectFit: 'cover' }}
onError={(e) => {
e.target.src = '/images/default-book.jpg';
e.target.onerror = null;
}}
/>
</Col>
<Col md={6}>
<h5>{item.book?.title || "Без названия"}</h5>
<p className="text-muted">{item.book?.author || "Автор не указан"}</p>
<p>
Цена: {item.book?.price || 0} руб. × {item.quantity || 1} =
{(item.book?.price || 0) * (item.quantity || 1)} руб.
<h5 className="mb-2">{item.book?.title || "Без названия"}</h5>
<p className="text-muted mb-1">{item.book?.author || "Автор не указан"}</p>
<p className="mb-1">
<strong>Цена:</strong> {item.book?.price || 0} руб.
</p>
<p className="mb-0">
<strong>Сумма:</strong> {(item.book?.price || 0) * (item.quantity || 1)} руб.
</p>
</Col>
<Col md={2}>
<input
type="number"
min="1"
value={item.quantity || 1}
onChange={(e) => handleQuantityChange(item.id, parseInt(e.target.value))}
className="form-control cart-item-quantity"
/>
<div className="d-flex align-items-center">
<Button
variant="outline-secondary"
size="sm"
onClick={() => handleQuantityChange(item.id, (item.quantity || 1) - 1)}
disabled={(item.quantity || 1) <= 1}
>
-
</Button>
<span className="mx-2">{item.quantity || 1}</span>
<Button
variant="outline-secondary"
size="sm"
onClick={() => handleQuantityChange(item.id, (item.quantity || 1) + 1)}
>
+
</Button>
</div>
</Col>
<Col md={2} className="text-center">
<Button
variant="outline-danger"
size="sm"
onClick={() => handleRemoveItem(item.id)}
>
Удалить
@@ -162,11 +182,11 @@ const BasketPage = () => {
))}
</div>
<Card className="mb-4 border-danger">
<Card className="mb-4 border-primary">
<Card.Body>
<div className="d-flex justify-content-between align-items-center">
<h4 className="mb-0">Общая стоимость:</h4>
<h4 className="mb-0">{total} руб.</h4>
<h4 className="mb-0 text-primary">{total} руб.</h4>
</div>
</Card.Body>
</Card>

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,56 +19,180 @@ 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(() => {
const fetchData = async () => {
try {
setLoading(true);
const [genresResponse, booksResponse, cartResponse] = await Promise.all([
api.fetchGenres(),
api.fetchBooks(),
api.fetchCartItems()
]);
setGenres(genresResponse.data);
setBooks(booksResponse.data);
setCartCount(cartResponse.data.reduce((sum, item) => sum + (item.quantity || 0), 0));
} catch (error) {
console.error('Ошибка загрузки данных:', error);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const handleDeleteGenre = async (genreId) => {
if (window.confirm('Вы уверены, что хотите удалить этот жанр? Все книги этого жанра будут без жанра.')) {
// Функция загрузки данных с пагинацией
const fetchData = async (
bookPage = bookPagination.currentPage,
bookSize = bookPagination.pageSize
) => {
try {
await api.deleteGenre(genreId);
const genresResponse = await api.fetchGenres();
setGenres(genresResponse.data);
await updateState(); // Обновляем книги, так как у некоторых мог измениться жанр
} catch (error) {
console.error('Ошибка удаления жанра:', error);
alert('Нельзя удалить жанр, если есть книги с этим жанром');
}
}
};
setLoading(true);
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
});
updateCartCount(cartResponse.data || []);
// Оптимизированная функция для обновления состояния
const updateState = async () => {
try {
const [booksResponse, cartResponse] = await Promise.all([
api.fetchBooks(),
api.fetchCartItems()
]);
setBooks(booksResponse.data);
setCartCount(cartResponse.data.reduce((sum, item) => sum + (item.quantity || 0), 0));
} catch (error) {
console.error('Ошибка обновления данных:', 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('Вы уверены, что хотите удалить этот жанр?')) {
try {
await api.deleteGenre(genreId);
await fetchData();
} catch (error) {
console.error('Ошибка удаления жанра:', error);
alert('Нельзя удалить жанр, если есть книги с этим жанром');
}
}
};
@@ -84,7 +210,7 @@ const CatalogPage = () => {
if (window.confirm('Вы уверены, что хотите удалить эту книгу?')) {
try {
await api.deleteBook(id);
await updateState();
await fetchData();
} catch (error) {
console.error('Ошибка удаления книги:', error);
}
@@ -93,20 +219,19 @@ const CatalogPage = () => {
const handleAddToCart = async (bookId) => {
try {
// Проверяем, есть ли уже эта книга в корзине
const cartResponse = await api.fetchCartItems();
const existingItem = cartResponse.data.find(item => item.bookId === 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();
setCartCount(updatedCart.data.reduce((sum, item) => sum + (item.quantity || 0), 0));
updateCartCount(updatedCart.data || []);
} catch (error) {
console.error('Ошибка добавления в корзину:', error);
}
@@ -119,7 +244,7 @@ const CatalogPage = () => {
} else {
await api.createBook(bookData);
}
await updateState();
await fetchData();
setShowBookModal(false);
} catch (error) {
console.error('Ошибка сохранения книги:', error);
@@ -129,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);
@@ -142,23 +266,17 @@ const CatalogPage = () => {
await api.clearCart();
setCartCount(0);
setShowCartModal(false);
alert("Заказ оформлен! Спасибо за покупку!");
} catch (error) {
console.error('Ошибка оформления заказа:', error);
}
};
const booksByGenre = (genreId) => {
return books.filter(book => book.genreId === genreId);
};
const genresWithBooks = genres.filter(genre =>
books.some(book => book.genreId === genre.id)
);
if (loading) {
return (
<div className="d-flex justify-content-center align-items-center vh-100">
<Spinner animation="border" variant="primary" />
<span className="ms-2">Загрузка...</span>
</div>
);
}
@@ -166,52 +284,215 @@ const CatalogPage = () => {
return (
<div className="catalog-page">
<Navbar cartCount={cartCount} onShowCart={() => setShowCartModal(true)} />
<main className="mt-5 pt-5">
<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>
{genresWithBooks.map(genre => (
<section key={genre.id} className="mb-5">
<div className="genre-title bg-light p-3 rounded text-center mb-4">
<h3>{genre.name}</h3>
<Button
variant="outline-danger"
size="sm"
onClick={() => handleDeleteGenre(genre.id)}
title="Удалить жанр"
>
Удалить
</Button>
</div>
<Row>
{booksByGenre(genre.id).map(book => (
<BookComponent
key={book.id}
book={book}
onEdit={handleEditBook}
onDelete={handleDeleteBook}
onAddToCart={handleAddToCart}
/>
))}
</Row>
</section>
))}
{/* Информация о пагинации */}
<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">
{Array.isArray(genres) && genres.length === 0
? 'Добавьте жанры и книги для начала работы'
: 'Нет книг для отображения'}
</p>
</div>
) : (
<>
{/* Режим: выбран конкретный жанр */}
{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}
onEdit={handleEditBook}
onDelete={handleDeleteBook}
onAddToCart={handleAddToCart}
/>
))}
</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>
{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>
@@ -221,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

@@ -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?_expand=book`),
addToCart: (bookId) => axios.post(`${API_URL}/cart`, { bookId, quantity: 1 }),
getCartItemByBookId: (bookId) => axios.get(`${API_URL}/cart?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

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>

3
server/.gitattributes vendored Normal file
View File

@@ -0,0 +1,3 @@
/gradlew text eol=lf
*.bat text eol=crlf
*.jar binary

37
server/.gitignore vendored Normal file
View File

@@ -0,0 +1,37 @@
HELP.md
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/

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

BIN
server/gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
server/gradlew vendored Normal file
View File

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
server/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

2
server/settings.gradle Normal file
View File

@@ -0,0 +1,2 @@
rootProject.name = 'server'

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,75 @@
package com.example.server.api.cartItem;
import java.util.List;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicInteger;
import com.example.server.api.book.BookController;
import com.example.server.entity.CartItemEntity;
import com.example.server.service.CartItemService;
import jakarta.validation.Valid;
import org.slf4j.*;
import org.springframework.web.bind.annotation.*;
import com.example.server.error.NotFoundException;
import com.example.server.configuration.Constants;
@RestController
@RequestMapping(Constants.API_URL + CartController.URL)
public class CartController {
public static final String URL = "/cart";
private final Logger log = LoggerFactory.getLogger(CartController.class);
private final CartItemService cartItemService;
public CartController(CartItemService cartItemService) {
this.cartItemService = cartItemService;
}
@GetMapping
public List<CartItemRs> getAll() {
log.debug("Get all cart items");
return cartItemService.getAll();
}
@GetMapping("/{id}")
public CartItemRs get(@PathVariable Long id) {
log.debug("Get cart item with id {}", id);
return cartItemService.get(id);
}
@GetMapping("/search")
public CartItemRs getByBookId(@RequestParam Long bookId) {
return cartItemService.getByBookId(bookId)
.orElseThrow(() -> new NotFoundException(CartItemEntity.class, bookId));
}
@PostMapping
public CartItemRs addToCart(@RequestBody @Valid CartItemRq newItem) {
log.debug("Add to cart: {}", newItem);
return cartItemService.create(newItem);
}
@PutMapping("/{id}")
public CartItemRs updateCartItem(@PathVariable Long id, @RequestBody @Valid CartItemRq updatedItem) {
log.debug("Update cart item with id {}: {}", id, updatedItem);
return cartItemService.update(id, updatedItem);
}
@DeleteMapping("/{id}")
public CartItemRs removeFromCart(@PathVariable Long id) {
log.debug("Remove from cart: {}", id);
return cartItemService.delete(id);
}
@PatchMapping("/{id}/{quantity}")
public CartItemRs updateQuantity(@PathVariable Long id, @PathVariable int quantity) {
log.debug("Update quantity for cart item with id {} to {}", id, quantity);
return cartItemService.updateQuantity(id, quantity);
}
@DeleteMapping
public void clearCart() {
log.debug("Clear cart");
cartItemService.deleteAll();
}
}

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

@@ -0,0 +1,68 @@
package com.example.server.api.genre;
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;
@RestController
@RequestMapping(Constants.API_URL + GenreController.URL)
public class GenreController {
public static final String URL = "/genre";
private final GenreService genreService;
public GenreController(GenreService genreService) {
this.genreService = genreService;
}
@GetMapping
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();
}
@GetMapping("/{id}")
public GenreRs get(@PathVariable Long id) {
return genreService.get(id);
}
@PostMapping
public GenreRs create(@RequestBody @Valid GenreRq dto) {
return genreService.create(dto);
}
@PutMapping("/{id}")
public GenreRs update(@PathVariable Long id, @RequestBody @Valid GenreRq dto) {
return genreService.update(id, dto);
}
@DeleteMapping("/{id}")
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,9 @@
package com.example.server.configuration;
public class Constants {
public static final String DEV_ORIGIN = "http://localhost:5173";
public static final String API_URL = "/api/1.0";
private Constants() {
}
}

View File

@@ -0,0 +1,19 @@
package com.example.server.configuration;
import org.springframework.context.annotation.Configuration;
import org.springframework.lang.NonNull;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfiguration implements WebMvcConfigurer{
@Override
public void addCorsMappings(@NonNull CorsRegistry registry) {
registry
.addMapping("/api/**") // Более широкий паттерн
.allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")
.allowedOrigins("http://localhost:5173", "http://127.0.0.1:5173")
.allowedHeaders("*")
.allowCredentials(true);
}
}

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

@@ -0,0 +1,106 @@
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 genre, String image) {
this();
this.title = title;
this.author = author;
this.price = price;
this.description = description;
this.genre = genre;
this.image = 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 int getPrice() {
return price;
}
public void setPrice(int price) {
this.price = price;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getImage() {
return image;
}
public void setImage(String image) {
this.image = image;
}
public GenreEntity getGenre() {
return genre;
}
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

@@ -0,0 +1,43 @@
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() {
super();
}
public CartItemEntity(BookEntity book, int quantity) {
this();
this.book = book;
this.quantity = quantity;
}
public BookEntity getBook() {
return book;
}
public void setBook(BookEntity book) {
this.book = book;
}
public int getQuantity() {
return quantity;
}
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();
}

View File

@@ -0,0 +1,26 @@
package com.example.server.error;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.http.HttpServletRequest;
@RestControllerAdvice
public class AdviceController {
private HttpStatus getStatus(HttpServletRequest request) {
final Integer code = (Integer) request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
final HttpStatus status = (code != null) ? HttpStatus.resolve(code) : null;
return (status != null) ? status : HttpStatus.INTERNAL_SERVER_ERROR;
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ResponseEntity<AdviceErrorBody> handleAnyException(HttpServletRequest request, Throwable ex) {
final HttpStatus status = getStatus(request);
return new ResponseEntity<>(new AdviceErrorBody(status.value(), ex.getMessage()), status);
}
}

View File

@@ -0,0 +1,5 @@
package com.example.server.error;
public record AdviceErrorBody(int status, String message) {
}

View File

@@ -0,0 +1,11 @@
package com.example.server.error;
public class AlreadyExistsException extends RuntimeException {
public <T> AlreadyExistsException(Class<T> entClass, String name) {
super(String.format("%s with name %s is already exists", entClass.getSimpleName(), name));
}
public <T> AlreadyExistsException(Class<T> entClass, Long id1, Long id2) {
super(String.format("%s with id [%s, %s] is already exists", entClass.getSimpleName(), id1, id2));
}
}

View File

@@ -0,0 +1,10 @@
package com.example.server.error;
public class NotFoundException extends RuntimeException {
public <T> NotFoundException(Class<T> clazz, Long id){
super(String.format("%s with id %s is not found", clazz.getSimpleName(), id));
}
public <T> NotFoundException(Class<T> entClass, Long id1, Long id2) {
super(String.format("%s with id [%s, %s] is not found", entClass.getSimpleName(), id1, id2));
}
}

View File

@@ -0,0 +1,31 @@
package com.example.server.repository;
import com.example.server.entity.BookEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface BookRepository extends JpaRepository<BookEntity, Long> {
// Поиск книг по жанру с пагинацией
@Query("SELECT DISTINCT b FROM BookEntity b " +
"JOIN b.genreBooks gb " +
"JOIN gb.genre g " +
"WHERE g.id = :genreId")
Page<BookEntity> findByGenreId(@Param("genreId") Long genreId, Pageable pageable);
// Поиск книг по названию или автору (без учета регистра)
@Query("SELECT b FROM BookEntity b " +
"WHERE LOWER(b.title) LIKE LOWER(CONCAT('%', :query, '%')) " +
"OR LOWER(b.author) LIKE LOWER(CONCAT('%', :query, '%')) " +
"OR LOWER(b.description) LIKE LOWER(CONCAT('%', :query, '%'))")
Page<BookEntity> searchBooks(@Param("query") String query, Pageable pageable);
// Альтернативный вариант поиска по жанру (через многие-ко-многим)
@Query("SELECT b FROM BookEntity b " +
"WHERE EXISTS (SELECT 1 FROM b.genreBooks gb WHERE gb.genre.id = :genreId)")
List<BookEntity> findBooksByGenreId(@Param("genreId") Long genreId);
}

View File

@@ -0,0 +1,15 @@
package com.example.server.repository;
import com.example.server.entity.CartItemEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Map;
public interface CartItemRepository extends JpaRepository<CartItemEntity, Long> {
@Query("SELECT ci FROM CartItemEntity ci WHERE ci.book.id = :bookId")
List<CartItemEntity> findByBookId(@Param("bookId") Long bookId);
}

View File

@@ -0,0 +1,11 @@
package com.example.server.repository;
import com.example.server.entity.GenreBookEntity;
import com.example.server.entity.GenreBookId;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface GenreBookRepository extends JpaRepository<GenreBookEntity, GenreBookId> {
Optional<GenreBookEntity> findOneByBookIdAndGenreId(Long bookId, Long genreId);
}

View File

@@ -0,0 +1,43 @@
package com.example.server.repository;
import com.example.server.entity.GenreEntity;
import com.example.server.entity.projection.GenreStatsProjection;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
public interface GenreRepository extends JpaRepository<GenreEntity, Long> {
Optional<GenreEntity> findOneByNameIgnoreCase(String name);
// Статистика по всем жанрам
@Query("select g as genre, " +
"count(b) as totalBooks, " +
"count(ci) as totalCartItems, " +
"avg(b.price) as averagePrice " +
"from GenreEntity g " +
"left join g.genreBooks gb " +
"left join gb.book b " +
"left join CartItemEntity ci on ci.book = b " +
"group by g " +
"having count(b) > 0 " +
"order by g.id")
List<GenreStatsProjection> getAllGenresStatistics();
// Статистика по конкретному жанру
@Query("select g as genre, " +
"count(b) as totalBooks, " +
"count(ci) as totalCartItems, " +
"avg(b.price) as averagePrice " +
"from GenreEntity g " +
"left join g.genreBooks gb " +
"left join gb.book b " +
"left join CartItemEntity ci on ci.book = b " +
"where g.id = :genreId " +
"group by g " +
"having count(b) > 0")
GenreStatsProjection getGenreStatistics(@Param("genreId") Long genreId);
}

View File

@@ -0,0 +1,184 @@
package com.example.server.service;
import com.example.server.api.PageRs;
import com.example.server.api.book.*;
import com.example.server.entity.BookEntity;
import com.example.server.entity.GenreBookEntity;
import com.example.server.entity.GenreEntity;
import com.example.server.error.AlreadyExistsException;
import com.example.server.error.NotFoundException;
import com.example.server.repository.BookRepository;
import com.example.server.repository.GenreBookRepository;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.List;
@Service
public class BookService {
private final BookRepository repository;
private final GenreBookRepository genreBookRepository;
private final GenreService genreService;
public BookService(BookRepository repository,GenreBookRepository genreBookRepository, GenreService genreService) {
this.repository = repository;
this.genreBookRepository = genreBookRepository;
this.genreService = genreService;
}
@Transactional(propagation = Propagation.MANDATORY)
public BookEntity getEntity(Long id) {
return repository.findById(id)
.orElseThrow(() -> new NotFoundException(BookEntity.class, id));
}
@Transactional(readOnly = true)
public List<BookRs> getAll() {
List<BookEntity> books = repository.findAll();
// Инициализируем lazy-коллекции
books.forEach(book -> {
if (book.getGenreBooks() != null) {
book.getGenreBooks().size();
}
});
return BookRs.fromList(books);
}
@Transactional(readOnly = true)
public PageRs<BookRs> getAll(Pageable pageable) {
return PageRs.from(repository.findAll(pageable), BookRs::from);
}
@Transactional(readOnly = true)
public BookRs get(Long id) {
final BookEntity entity = getEntity(id);
// Инициализируем lazy-коллекцию
if (entity.getGenreBooks() != null) {
entity.getGenreBooks().size();
}
return BookRs.from(entity);
}
@Transactional
public BookRs create(BookRq dto) {
BookEntity entity = new BookEntity(
dto.title(),
dto.author(),
dto.price(),
dto.description(),
null, // genre будет установлен через связь
dto.image());
entity = repository.save(entity);
return BookRs.from(entity);
}
@Transactional
public BookRs update(Long id, BookRq dto) {
BookEntity entity = getEntity(id);
entity.setTitle(dto.title());
entity.setAuthor(dto.author());
entity.setPrice(dto.price());
entity.setDescription(dto.description());
entity.setImage(dto.image());
entity = repository.save(entity);
return BookRs.from(entity);
}
@Transactional
public BookRs delete(Long id) {
final BookEntity entity = getEntity(id);
repository.delete(entity);
return BookRs.from(entity);
}
@Transactional(readOnly = true)
public List<BookGenreRs> getBookGenres(Long id) {
return BookGenreRs.fromList(getEntity(id).getGenreBooks());
}
@Transactional
public BookGenreRs addGenre(Long bookId, BookGenreRq dto) {
final BookEntity book = getEntity(bookId);
final GenreEntity genre = genreService.getEntity(dto.genreId());
// Проверяем, существует ли уже такая связь
if (genreBookRepository.findOneByBookIdAndGenreId(book.getId(), genre.getId()).isPresent()) {
throw new AlreadyExistsException(GenreBookEntity.class, book.getId(), genre.getId());
}
final LocalDate date = LocalDate.parse(dto.date());
final GenreBookEntity genreBook = new GenreBookEntity(genre, book, date);
// Сохраняем связь напрямую в репозиторий
genreBookRepository.save(genreBook);
return BookGenreRs.from(genreBook);
}
@Transactional
public BookGenreRs updateGenre(Long bookId, Long genreId, BookGenreUpdateRq dto) {
final BookEntity book = getEntity(bookId);
final GenreEntity genre = genreService.getEntity(genreId);
final GenreBookEntity genreBook = genreBookRepository
.findOneByBookIdAndGenreId(book.getId(), genre.getId())
.orElseThrow(() -> new NotFoundException(GenreBookEntity.class, book.getId(), genre.getId()));
genreBook.setDate(LocalDate.parse(dto.date()));
genreBookRepository.save(genreBook);
return BookGenreRs.from(genreBook);
}
@Transactional
public BookGenreRs deleteGenre(Long bookId, Long genreId) {
final BookEntity book = getEntity(bookId);
final GenreEntity genre = genreService.getEntity(genreId);
final GenreBookEntity genreBook = genreBookRepository
.findOneByBookIdAndGenreId(book.getId(), genre.getId())
.orElseThrow(() -> new NotFoundException(GenreBookEntity.class, book.getId(), genre.getId()));
// Удаляем связь напрямую из репозитория
genreBookRepository.delete(genreBook);
return BookGenreRs.from(genreBook);
}
// ===== НОВЫЕ МЕТОДЫ ДЛЯ ПАГИНАЦИИ И ФИЛЬТРАЦИИ =====
@Transactional(readOnly = true)
public PageRs<BookRs> getBooksByGenre(Long genreId, Pageable pageable) {
// Проверяем, существует ли жанр
genreService.getEntity(genreId);
// Получаем книги по жанру с пагинацией
return PageRs.from(repository.findByGenreId(genreId, pageable), BookRs::from);
}
@Transactional(readOnly = true)
public PageRs<BookRs> searchBooks(String query, Pageable pageable) {
if (query == null || query.trim().isEmpty()) {
// Если запрос пустой, возвращаем все книги
return PageRs.from(repository.findAll(pageable), BookRs::from);
}
// Ищем книги по запросу
return PageRs.from(repository.searchBooks(query.trim(), pageable), BookRs::from);
}
@Transactional(readOnly = true)
public List<BookRs> getBooksByGenreWithoutPagination(Long genreId) {
// Проверяем, существует ли жанр
genreService.getEntity(genreId);
// Получаем книги по жанру без пагинации
List<BookEntity> books = repository.findBooksByGenreId(genreId);
// Инициализируем lazy-коллекции
books.forEach(book -> {
if (book.getGenreBooks() != null) {
book.getGenreBooks().size();
}
});
return BookRs.fromList(books);
}
}

View File

@@ -0,0 +1,96 @@
package com.example.server.service;
import java.util.List;
import java.util.Optional;
import com.example.server.api.PageRs;
import com.example.server.api.book.BookRs;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import com.example.server.api.cartItem.CartItemRq;
import com.example.server.api.cartItem.CartItemRs;
import com.example.server.entity.BookEntity;
import com.example.server.entity.CartItemEntity;
import com.example.server.error.NotFoundException;
import com.example.server.repository.CartItemRepository;
@Service
public class CartItemService {
private final CartItemRepository repository;
private final BookService bookService;
public CartItemService(CartItemRepository repository, BookService bookService) {
this.repository = repository;
this.bookService = bookService;
}
@Transactional(propagation = Propagation.MANDATORY)
public CartItemEntity getEntity(Long id) {
return repository.findById(id)
.orElseThrow(() -> new NotFoundException(CartItemEntity.class, id));
}
@Transactional(readOnly = true)
public List<CartItemRs> getAll() {
return CartItemRs.fromList(repository.findAll());
}
@Transactional(readOnly = true)
public PageRs<CartItemRs> getAll(Pageable pageable) {
return PageRs.from(repository.findAll(pageable), CartItemRs::from);
}
@Transactional(readOnly = true)
public CartItemRs get(Long id) {
final CartItemEntity entity = getEntity(id);
return CartItemRs.from(entity);
}
@Transactional(readOnly = true)
public Optional<CartItemRs> getByBookId(Long bookId) {
return repository.findByBookId(bookId)
.stream()
.findFirst()
.map(CartItemRs::from);
}
@Transactional
public CartItemRs create(CartItemRq dto) {
final BookEntity book = bookService.getEntity(dto.bookId());
CartItemEntity entity = new CartItemEntity(book, dto.quantity());
entity = repository.save(entity);
return CartItemRs.from(entity);
}
@Transactional
public CartItemRs update(Long id, CartItemRq dto) {
CartItemEntity entity = getEntity(id);
entity.setBook(bookService.getEntity(dto.bookId()));
entity.setQuantity(dto.quantity());
entity = repository.save(entity);
return CartItemRs.from(entity);
}
@Transactional
public CartItemRs updateQuantity(Long id, int quantity) {
CartItemEntity entity = getEntity(id);
entity.setQuantity(quantity);
entity = repository.save(entity);
return CartItemRs.from(entity);
}
@Transactional
public CartItemRs delete(Long id) {
final CartItemEntity entity = getEntity(id);
repository.delete(entity);
return CartItemRs.from(entity);
}
@Transactional
public void deleteAll() {
repository.deleteAll();
}
}

View File

@@ -0,0 +1,89 @@
package com.example.server.service;
import com.example.server.api.PageRs;
import com.example.server.api.cartItem.CartItemRs;
import com.example.server.api.genre.GenreRq;
import com.example.server.api.genre.GenreRs;
import com.example.server.entity.GenreEntity;
import java.util.List;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import com.example.server.api.genre.GenreStatsRs;
import com.example.server.error.AlreadyExistsException;
import com.example.server.error.NotFoundException;
import com.example.server.repository.GenreRepository;
@Service
public class GenreService {
private final GenreRepository repository;
public GenreService(GenreRepository repository) {
this.repository = repository;
}
private void checkName(String name) {
repository.findOneByNameIgnoreCase(name).ifPresent(val -> {
throw new AlreadyExistsException(GenreEntity.class, name);
});
}
@Transactional(propagation = Propagation.MANDATORY)
public GenreEntity getEntity(Long id) {
return repository.findById(id)
.orElseThrow(() -> new NotFoundException(GenreEntity.class, id));
}
@Transactional(readOnly = true)
public List<GenreRs> getAll() {
return GenreRs.fromList(repository.findAll());
}
@Transactional(readOnly = true)
public PageRs<GenreRs> getAll(Pageable pageable) {
return PageRs.from(repository.findAll(pageable), GenreRs::from);
}
@Transactional(readOnly = true)
public GenreRs get(Long id) {
final GenreEntity entity = getEntity(id);
return GenreRs.from(entity);
}
@Transactional
public GenreRs create(GenreRq dto) {
checkName(dto.name());
GenreEntity entity = new GenreEntity(dto.name());
entity = repository.save(entity);
return GenreRs.from(entity);
}
@Transactional
public GenreRs update(Long id, GenreRq dto) {
checkName(dto.name());
GenreEntity entity = getEntity(id);
entity.setName(dto.name());
entity = repository.save(entity);
return GenreRs.from(entity);
}
@Transactional
public GenreRs delete(Long id) {
final GenreEntity entity = getEntity(id);
repository.delete(entity);
return GenreRs.from(entity);
}
@Transactional(readOnly = true)
public List<GenreStatsRs> getAllGenresStats() {
return GenreStatsRs.fromList(repository.getAllGenresStatistics());
}
@Transactional(readOnly = true)
public GenreStatsRs getGenreStats(Long id) {
return GenreStatsRs.from(repository.getGenreStatistics(id));
}
}

View File

@@ -0,0 +1,36 @@
# DEV профиль - H2 база данных
spring:
datasource:
url: jdbc:h2:file:./data/bookstore;DB_CLOSE_DELAY=-1;AUTO_SERVER=TRUE
username: sa
password: sa
driver-class-name: org.h2.Driver
h2:
console:
enabled: true
path: /h2-console
settings:
web-allow-others: true
jpa:
hibernate:
ddl-auto: validate # ИЛИ none
show-sql: true
properties:
hibernate:
format_sql: true
dialect: org.hibernate.dialect.H2Dialect
jdbc:
batch_size: 20
order_inserts: true
order_updates: true
liquibase:
enabled: true
drop-first: false
change-log: classpath:db/master.yml
contexts: dev
logging:
level:
com.example.server: DEBUG
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql: TRACE

View File

@@ -0,0 +1,18 @@
# FRONT профиль - настройки для фронтенда
spring:
web:
resources:
static-locations:
- classpath:/static/
- classpath:/public/
- classpath:/resources/
- classpath:/META-INF/resources/
- classpath:/images/
cache:
period: 0
server:
compression:
enabled: true
mime-types: text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json
min-response-size: 1024

View File

@@ -0,0 +1,32 @@
# PROD профиль - PostgreSQL база данных
spring:
datasource:
url: jdbc:postgresql://localhost:5432/bookstore
username: postgres
password: postgres
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: 10
minimum-idle: 5
connection-timeout: 30000
jpa:
hibernate:
ddl-auto: validate # ИЛИ none - но не create, create-drop, update
show-sql: false
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
jdbc:
batch_size: 20
order_inserts: true
order_updates: true
liquibase:
enabled: true
drop-first: false
change-log: classpath:db/master.yml
contexts: prod
logging:
level:
com.example.server: INFO
org.springframework.web: WARN

View File

@@ -0,0 +1,28 @@
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
# Available levels: TRACE, DEBUG, INFO, WARN, ERROR, FATAL, OFF
logging.level.com.example.server=DEBUG
spring.datasource.url=jdbc:h2:file:./data
spring.datasource.username=sa
spring.datasource.password=sa
spring.datasource.driver-class-name=org.h2.Driver
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create
# spring.jpa.show-sql=true
# spring.jpa.properties.hibernate.format_sql=true
spring.h2.console.enabled=true

View File

@@ -0,0 +1,52 @@
spring:
main:
banner-mode: off
application:
name: bookstore
profiles:
active: prod
jpa:
hibernate:
ddl-auto: validate
open-in-view: false
properties:
hibernate:
dialect: org.hibernate.dialect.H2Dialect
liquibase:
enabled: true
drop-first: false
change-log: classpath:db/master.yml
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB
web:
resources:
static-locations: classpath:/static/
cache:
period: 3600
chain:
strategy:
content:
enabled: true
paths: /**
server:
port: 8080
error:
include-message: always
include-binding-errors: always
springdoc:
api-docs:
path: /api-docs
swagger-ui:
path: /swagger-ui.html
operations-sorter: method
tags-sorter: alpha
logging:
level:
com.example.server: DEBUG
org.springframework.web: INFO
org.hibernate.SQL: WARN

View File

@@ -0,0 +1,156 @@
databaseChangeLog:
- changeSet:
id: 0001-create-sequences
author: bookstore
preConditions:
- onFail: MARK_RAN
- not:
- sequenceExists:
sequenceName: hibernate_sequence
changes:
- createSequence:
sequenceName: hibernate_sequence
startValue: 1
incrementBy: 50
- changeSet:
id: 0002-create-genres-table
author: bookstore
preConditions:
- onFail: MARK_RAN
- not:
- tableExists:
tableName: genres
changes:
- createTable:
tableName: genres
columns:
- column:
name: id
type: bigint
constraints:
primaryKey: true
nullable: false
- column:
name: name
type: varchar(100)
constraints:
nullable: false
unique: true
- changeSet:
id: 0003-create-books-table
author: bookstore
preConditions:
- onFail: MARK_RAN
- not:
- tableExists:
tableName: books
changes:
- createTable:
tableName: books
columns:
- column:
name: id
type: bigint
constraints:
primaryKey: true
nullable: false
- column:
name: title
type: varchar(255)
constraints:
nullable: false
- column:
name: author
type: varchar(255)
constraints:
nullable: false
- column:
name: price
type: int
constraints:
nullable: false
- column:
name: description
type: clob
- column:
name: image
type: varchar(500)
- column:
name: created_at
type: timestamp
defaultValueComputed: current_timestamp
- column:
name: updated_at
type: timestamp
defaultValueComputed: current_timestamp
- changeSet:
id: 0004-create-cart-items-table
author: bookstore
preConditions:
- onFail: MARK_RAN
- not:
- tableExists:
tableName: cartitems
changes:
- createTable:
tableName: cartitems
columns:
- column:
name: id
type: bigint
constraints:
primaryKey: true
nullable: false
- column:
name: book_id
type: bigint
constraints:
nullable: false
foreignKeyName: fk_cartitems_book
references: books(id)
- column:
name: quantity
type: int
constraints:
nullable: false
- column:
name: created_at
type: timestamp
defaultValueComputed: current_timestamp
- changeSet:
id: 0005-create-genre-book-table
author: bookstore
preConditions:
- onFail: MARK_RAN
- not:
- tableExists:
tableName: genre_book
changes:
- createTable:
tableName: genre_book
columns:
- column:
name: genre_id
type: bigint
constraints:
primaryKey: true
nullable: false
foreignKeyName: fk_genre_book_genre
references: genres(id)
- column:
name: book_id
type: bigint
constraints:
primaryKey: true
nullable: false
foreignKeyName: fk_genre_book_book
references: books(id)
- column:
name: date
type: date
constraints:
nullable: false

View File

@@ -0,0 +1,178 @@
databaseChangeLog:
- changeSet:
id: 0006-populate-genres
author: bookstore
preConditions:
- onFail: MARK_RAN
- and:
- tableExists:
tableName: genres
- sqlCheck:
expectedResult: "0"
sql: "SELECT COUNT(*) FROM genres WHERE id IN (1, 2, 3, 4)"
changes:
- insert:
tableName: genres
columns:
- column:
name: id
value: 1
- column:
name: name
value: "Роман"
- insert:
tableName: genres
columns:
- column:
name: id
value: 2
- column:
name: name
value: "Фантастика"
- insert:
tableName: genres
columns:
- column:
name: id
value: 3
- column:
name: name
value: "Ужасы"
- insert:
tableName: genres
columns:
- column:
name: id
value: 4
- column:
name: name
value: "Детектив"
- changeSet:
id: 0007-populate-books
author: bookstore
preConditions:
- onFail: MARK_RAN
- and:
- tableExists:
tableName: books
- sqlCheck:
expectedResult: "0"
sql: "SELECT COUNT(*) FROM books WHERE id IN (1, 2, 3)"
changes:
- insert:
tableName: books
columns:
- column:
name: id
value: 1
- column:
name: title
value: "Основание"
- column:
name: author
value: "Айзек Азимов"
- column:
name: price
value: 700
- column:
name: description
value: "Сага о падении и возрождении галактической империи, основанная на научных принципах."
- column:
name: image
value: "images/foundation.jpg"
- insert:
tableName: books
columns:
- column:
name: id
value: 2
- column:
name: title
value: "Дюна"
- column:
name: author
value: "Фрэнк Герберт"
- column:
name: price
value: 900
- column:
name: description
value: "Эпическая история о борьбе за контроль над планетой Арракис, источником самого ценного вещества во вселенной."
- column:
name: image
value: "images/dune.jpg"
- insert:
tableName: books
columns:
- column:
name: id
value: 3
- column:
name: title
value: "Убийство в Восточном экспрессе"
- column:
name: author
value: "Агата Кристи"
- column:
name: price
value: 750
- column:
name: description
value: "Загадочное убийство на поезде, где каждый пассажир может быть подозреваемым."
- column:
name: image
value: "images/murder_on_the_orient_express.jpg"
- changeSet:
id: 0008-populate-genre-book
author: bookstore
preConditions:
- onFail: MARK_RAN
- and:
- tableExists:
tableName: genre_book
- sqlCheck:
expectedResult: "0"
sql: >
SELECT COUNT(*) FROM genre_book
WHERE (genre_id = 2 AND book_id = 1)
OR (genre_id = 2 AND book_id = 2)
OR (genre_id = 4 AND book_id = 3)
changes:
- insert:
tableName: genre_book
columns:
- column:
name: genre_id
value: 2
- column:
name: book_id
value: 1
- column:
name: date
value: "2024-01-15"
- insert:
tableName: genre_book
columns:
- column:
name: genre_id
value: 2
- column:
name: book_id
value: 2
- column:
name: date
value: "2024-01-16"
- insert:
tableName: genre_book
columns:
- column:
name: genre_id
value: 4
- column:
name: book_id
value: 3
- column:
name: date
value: "2024-01-17"

View File

@@ -0,0 +1,8 @@
databaseChangeLog:
- changeSet:
id: 1765522199393-1
author: денис (generated)
changes:
- dropSequence:
sequenceName: HIBERNATE_SEQUENCE

View File

@@ -0,0 +1,5 @@
databaseChangeLog:
- includeAll:
path: changelog/
relativeToChangelogFile: true
errorIfMissingOrEmpty: false

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

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