сделал лабу

This commit is contained in:
2025-11-14 23:32:45 +04:00
parent 49510433e4
commit 67d3ee7916
19 changed files with 1296 additions and 90 deletions

View File

@@ -72,6 +72,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.liquibase:liquibase-core:4.30.0'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
runtimeOnly 'com.h2database:h2'

Binary file not shown.

View File

@@ -1,12 +1,4 @@
2025-11-14 13:30:14.250845+04:00 jdbc[3]: exception
org.h2.jdbc.JdbcSQLSyntaxErrorException: Таблица "DATABASECHANGELOGLOCK" не найдена
Table "DATABASECHANGELOGLOCK" not found; SQL statement:
SELECT COUNT(*) FROM PUBLIC.DATABASECHANGELOGLOCK [42102-224]
2025-11-14 14:12:48.415553+04:00 jdbc[3]: exception
org.h2.jdbc.JdbcSQLSyntaxErrorException: Таблица "DIRECTORS" уже существует
Table "DIRECTORS" already exists; SQL statement:
CREATE TABLE PUBLIC.DIRECTORS (ID BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, NAME VARCHAR(255) NOT NULL, CONSTRAINT CONSTRAINT_6 PRIMARY KEY (ID)) [42101-224]
2025-11-14 14:17:18.481717+04:00 jdbc[3]: exception
2025-11-14 20:25:48.248270+04:00 jdbc[3]: exception
org.h2.jdbc.JdbcSQLSyntaxErrorException: Таблица "DATABASECHANGELOGLOCK" не найдена
Table "DATABASECHANGELOGLOCK" not found; SQL statement:
SELECT COUNT(*) FROM PUBLIC.DATABASECHANGELOGLOCK [42102-224]

View File

@@ -1,47 +0,0 @@
package ru.ulstu.is.server.api;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import ru.ulstu.is.server.dto.MovieRq;
import ru.ulstu.is.server.dto.MovieRs;
import ru.ulstu.is.server.service.MovieService;
import jakarta.validation.constraints.Min;
import org.springframework.data.domain.Pageable;
import ru.ulstu.is.server.dto.PageHelper;
import ru.ulstu.is.server.dto.PageRs;
import java.util.List;
@RestController
@RequestMapping("/api/1.0/movies")
public class MovieController {
private final MovieService service;
public MovieController(MovieService service) { this.service = service; }
@GetMapping
public List<MovieRs> all() { return service.getAll(); }
@GetMapping("/{id}")
public MovieRs one(@PathVariable Long id) { return service.get(id); }
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public MovieRs create(@RequestBody @Valid MovieRq rq) { return service.create(rq); }
@PutMapping("/{id}")
public MovieRs update(@PathVariable Long id, @RequestBody @Valid MovieRq rq) { return service.update(id, rq); }
@GetMapping("/page")
public PageRs<MovieRs> getPage(
@RequestParam(defaultValue = "1") @Min(1) int page,
@RequestParam(defaultValue = "12") @Min(1) int size) {
Pageable pageable = PageHelper.toPageable(page, size);
return service.getPage(pageable);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) { service.delete(id); }
}

View File

@@ -1,33 +0,0 @@
package ru.ulstu.is.server.api;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import ru.ulstu.is.server.dto.SubscriptionRq;
import ru.ulstu.is.server.dto.SubscriptionRs;
import ru.ulstu.is.server.service.SubscriptionService;
import java.util.List;
@RestController
@RequestMapping("/api/1.0/subscriptions")
public class SubscriptionController {
private final SubscriptionService service;
public SubscriptionController(SubscriptionService service) { this.service = service; }
@GetMapping public List<SubscriptionRs> all() { return service.getAll(); }
@GetMapping("/{id}") public SubscriptionRs one(@PathVariable Long id) { return service.get(id); }
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public SubscriptionRs create(@RequestBody @Valid SubscriptionRq rq) { return service.create(rq); }
@PutMapping("/{id}")
public SubscriptionRs update(@PathVariable Long id, @RequestBody @Valid SubscriptionRq rq) {
return service.update(id, rq);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) { service.delete(id); }
}

View File

@@ -0,0 +1,195 @@
package ru.ulstu.is.server.mvc;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import ru.ulstu.is.server.dto.*;
import ru.ulstu.is.server.service.DirectorService;
import ru.ulstu.is.server.service.GenreService;
import ru.ulstu.is.server.service.MovieService;
import ru.ulstu.is.server.session.MovieSearchSession;
import java.util.List;
@Controller
@RequestMapping("/movies")
public class MovieMvcController {
private final MovieService movieService;
private final GenreService genreService;
private final DirectorService directorService;
private final MovieSearchSession movieSearchSession;
public MovieMvcController(MovieService movieService,
GenreService genreService,
DirectorService directorService,
MovieSearchSession movieSearchSession) {
this.movieService = movieService;
this.genreService = genreService;
this.directorService = directorService;
this.movieSearchSession = movieSearchSession;
}
@GetMapping
public String listMovies(
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "size", required = false) Integer size,
@RequestParam(required = false) String title,
@RequestParam(required = false) Long genreId,
@RequestParam(required = false) Float minGrade,
Model model
) {
int pageNumber = (page == null || page < 1) ? 1 : page;
int pageSize = (size == null || size < 1) ? 12 : size;
boolean filtersPassedInRequest =
(title != null && !title.isBlank())
|| genreId != null
|| minGrade != null;
if (!filtersPassedInRequest) {
if (movieSearchSession.getLastTitle() != null) {
title = movieSearchSession.getLastTitle();
}
if (movieSearchSession.getLastGenreId() != null) {
genreId = movieSearchSession.getLastGenreId();
}
if (movieSearchSession.getLastMinGrade() != null) {
minGrade = movieSearchSession.getLastMinGrade();
}
if (movieSearchSession.getLastPageSize() != null) {
pageSize = movieSearchSession.getLastPageSize();
}
} else {
movieSearchSession.setLastTitle(title);
movieSearchSession.setLastGenreId(genreId);
movieSearchSession.setLastMinGrade(minGrade);
}
movieSearchSession.setLastPageSize(pageSize);
boolean hasFilters =
(title != null && !title.isBlank())
|| genreId != null
|| minGrade != null;
PageRs<MovieRs> pageRs;
if (hasFilters) {
pageRs = movieService.search(title, genreId, minGrade, pageNumber, pageSize);
} else {
Pageable pageable = PageHelper.toPageable(pageNumber, pageSize);
pageRs = movieService.getPage(pageable);
}
prepareReferenceData(model);
model.addAttribute("page", pageRs);
model.addAttribute("currentPage", pageRs.currentPage());
model.addAttribute("pageSize", pageRs.currentSize());
model.addAttribute("titleFilter", title);
model.addAttribute("genreIdFilter", genreId);
model.addAttribute("minGradeFilter", minGrade);
return "movies/list";
}
@GetMapping("/new")
public String showCreateForm(Model model) {
MovieRq movieRq = new MovieRq();
movieRq.setGenre(new GenreRefRq());
movieRq.setDirector(new DirectorRefRq());
prepareReferenceData(model);
model.addAttribute("movie", movieRq);
model.addAttribute("isEdit", false);
return "movies/form";
}
@PostMapping
public String createMovie(
@Valid @ModelAttribute("movie") MovieRq movieRq,
BindingResult bindingResult,
Model model
) {
if (bindingResult.hasErrors()) {
prepareReferenceData(model);
model.addAttribute("isEdit", false);
return "movies/form";
}
movieService.create(movieRq);
return "redirect:/movies";
}
@GetMapping("/{id}/edit")
public String showEditForm(@PathVariable Long id, Model model) {
MovieRs movie = movieService.get(id);
MovieRq movieRq = new MovieRq();
movieRq.setTitle(movie.getTitle());
movieRq.setImage(movie.getImage());
movieRq.setGrade(movie.getGrade());
GenreRefRq genreRef = new GenreRefRq();
if (movie.getGenre() != null) {
genreRef.setId(movie.getGenre().getId() != null
? movie.getGenre().getId().toString()
: null);
genreRef.setName(movie.getGenre().getName());
}
movieRq.setGenre(genreRef);
DirectorRefRq directorRef = new DirectorRefRq();
if (movie.getDirector() != null) {
directorRef.setId(movie.getDirector().getId() != null
? movie.getDirector().getId().toString()
: null);
directorRef.setName(movie.getDirector().getName());
}
movieRq.setDirector(directorRef);
prepareReferenceData(model);
model.addAttribute("movieId", movie.getId());
model.addAttribute("movie", movieRq);
model.addAttribute("isEdit", true);
return "movies/form";
}
@PostMapping("/{id}")
public String updateMovie(
@PathVariable Long id,
@Valid @ModelAttribute("movie") MovieRq movieRq,
BindingResult bindingResult,
Model model
) {
if (bindingResult.hasErrors()) {
prepareReferenceData(model);
model.addAttribute("movieId", id);
model.addAttribute("isEdit", true);
return "movies/form";
}
movieService.update(id, movieRq);
return "redirect:/movies";
}
@GetMapping("/{id}/delete")
public String deleteMovie(@PathVariable Long id) {
movieService.delete(id);
return "redirect:/movies";
}
private void prepareReferenceData(Model model) {
List<GenreRs> genres = genreService.getAll();
List<DirectorRs> directors = directorService.getAll();
model.addAttribute("genres", genres);
model.addAttribute("directors", directors);
}
}

View File

@@ -0,0 +1,100 @@
package ru.ulstu.is.server.mvc;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import ru.ulstu.is.server.dto.PageHelper;
import ru.ulstu.is.server.dto.PageRs;
import ru.ulstu.is.server.dto.SubscriptionRq;
import ru.ulstu.is.server.dto.SubscriptionRs;
import ru.ulstu.is.server.service.SubscriptionService;
@Controller
@RequestMapping("/subscriptions")
public class SubscriptionMvcController {
private final SubscriptionService subscriptionService;
public SubscriptionMvcController(SubscriptionService subscriptionService) {
this.subscriptionService = subscriptionService;
}
@GetMapping
public String listSubscriptions(
@RequestParam(defaultValue = "1") @Min(1) int page,
@RequestParam(defaultValue = "10") @Min(1) int size,
Model model
) {
Pageable pageable = PageHelper.toPageable(page, size);
PageRs<SubscriptionRs> pageRs = subscriptionService.getPage(pageable);
model.addAttribute("page", pageRs);
model.addAttribute("currentPage", pageRs.currentPage());
model.addAttribute("pageSize", pageRs.currentSize());
return "subscriptions/list";
}
@GetMapping("/new")
public String showCreateForm(Model model) {
model.addAttribute("subscription", new SubscriptionRq());
model.addAttribute("isEdit", false);
return "subscriptions/form";
}
@PostMapping
public String createSubscription(
@Valid @ModelAttribute("subscription") SubscriptionRq subscriptionRq,
BindingResult bindingResult,
Model model
) {
if (bindingResult.hasErrors()) {
model.addAttribute("isEdit", false);
return "subscriptions/form";
}
subscriptionService.create(subscriptionRq);
return "redirect:/subscriptions";
}
@GetMapping("/{id}/edit")
public String showEditForm(@PathVariable Long id, Model model) {
SubscriptionRs subscription = subscriptionService.get(id);
SubscriptionRq rq = new SubscriptionRq();
rq.setLevel(subscription.getLevel());
rq.setPrice(subscription.getPrice());
model.addAttribute("subscriptionId", subscription.getId());
model.addAttribute("subscription", rq);
model.addAttribute("isEdit", true);
return "subscriptions/form";
}
@PostMapping("/{id}")
public String updateSubscription(
@PathVariable Long id,
@Valid @ModelAttribute("subscription") SubscriptionRq subscriptionRq,
BindingResult bindingResult,
Model model
) {
if (bindingResult.hasErrors()) {
model.addAttribute("subscriptionId", id);
model.addAttribute("isEdit", true);
return "subscriptions/form";
}
subscriptionService.update(id, subscriptionRq);
return "redirect:/subscriptions";
}
@GetMapping("/{id}/delete")
public String deleteSubscription(@PathVariable Long id) {
subscriptionService.delete(id);
return "redirect:/subscriptions";
}
}

View File

@@ -49,6 +49,72 @@ public class MovieService {
return PageRs.from(movies.findAll(pageable), mapper::toRs);
}
@Transactional(readOnly = true)
public PageRs<MovieRs> search(String title, Long genreId, Float minGrade, int page, int size) {
List<MovieRs> all = getAll();
var stream = all.stream();
if (title != null && !title.isBlank()) {
String lower = title.toLowerCase();
stream = stream.filter(m ->
m.getTitle() != null && m.getTitle().toLowerCase().contains(lower)
);
}
if (genreId != null) {
stream = stream.filter(m ->
m.getGenre() != null
&& m.getGenre().getId() != null
&& m.getGenre().getId().equals(genreId)
);
}
if (minGrade != null) {
stream = stream.filter(m -> m.getGrade() >= minGrade);
}
List<MovieRs> filtered = stream.toList();
int totalItems = filtered.size();
if (size <= 0) {
size = 10;
}
int totalPages = totalItems == 0
? 1
: (int) Math.ceil(totalItems / (double) size);
int currentPage = page < 1 ? 1 : Math.min(page, totalPages);
int fromIndex = (currentPage - 1) * size;
if (fromIndex > totalItems) {
fromIndex = totalItems;
}
int toIndex = Math.min(fromIndex + size, totalItems);
List<MovieRs> pageItems = filtered.subList(fromIndex, toIndex);
boolean isFirst = currentPage == 1;
boolean isLast = currentPage >= totalPages;
boolean hasNext = currentPage < totalPages;
boolean hasPrevious = currentPage > 1;
return new PageRs<>(
pageItems,
pageItems.size(),
currentPage,
size,
totalPages,
totalItems,
isFirst,
isLast,
hasNext,
hasPrevious
);
}
@Transactional(readOnly = true)
public MovieRs get(Long id) {
Movie m = movies.findById(id).orElseThrow(() -> new NotFoundException(ru.ulstu.is.server.entity.Movie.class, "movie " + id + " not found"));

View File

@@ -8,6 +8,9 @@ import ru.ulstu.is.server.entity.Subscription;
import ru.ulstu.is.server.mapper.SubscriptionMapper;
import ru.ulstu.is.server.repository.SubscriptionRepository;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.data.domain.Pageable;
import ru.ulstu.is.server.dto.PageHelper;
import ru.ulstu.is.server.dto.PageRs;
import java.util.List;
@@ -37,6 +40,28 @@ public class SubscriptionService {
return mapper.toRs(s);
}
public PageRs<SubscriptionRs> getPage(Pageable pageable) {
var page = repo.findAll(pageable);
var items = page.getContent()
.stream()
.map(mapper::toRs)
.toList();
return new PageRs<>(
items,
page.getNumberOfElements(),
page.getNumber() + 1,
page.getSize(),
page.getTotalPages(),
page.getTotalElements(),
page.isFirst(),
page.isLast(),
page.hasNext(),
page.hasPrevious()
);
}
public SubscriptionRs update(Long id, SubscriptionRq rq) {
Subscription s = getEntity(id);
mapper.updateEntity(s, rq);

View File

@@ -0,0 +1,46 @@
package ru.ulstu.is.server.session;
import org.springframework.stereotype.Component;
import org.springframework.web.context.annotation.SessionScope;
@Component
@SessionScope
public class MovieSearchSession {
private String lastTitle;
private Long lastGenreId;
private Float lastMinGrade;
private Integer lastPageSize;
public String getLastTitle() {
return lastTitle;
}
public void setLastTitle(String lastTitle) {
this.lastTitle = lastTitle;
}
public Long getLastGenreId() {
return lastGenreId;
}
public void setLastGenreId(Long lastGenreId) {
this.lastGenreId = lastGenreId;
}
public Float getLastMinGrade() {
return lastMinGrade;
}
public void setLastMinGrade(Float lastMinGrade) {
this.lastMinGrade = lastMinGrade;
}
public Integer getLastPageSize() {
return lastPageSize;
}
public void setLastPageSize(Integer lastPageSize) {
this.lastPageSize = lastPageSize;
}
}

View File

@@ -16,4 +16,4 @@ spring:
format_sql: true
logging:
level:
org.springdoc: DEBUG
org.springdoc: WARN

View File

@@ -0,0 +1,314 @@
body {
font-family: Arial, sans-serif;
background-color: #181818;
color: #fff;
text-align: center;
}
nav a {
color: #fff;
margin: 10px;
text-decoration: none;
}
nav a:hover {
color: #FF0033;
}
.movie-list {
display: flex;
justify-content: space-around;
width: 500px;
flex-wrap: wrap;
margin: 0 auto;
}
.movie-list a {
color: #fff;
}
.movie-list img {
width: 150px;
height: auto;
}
table {
margin: auto;
background-color: #282828;
color: #fff;
}
.user_photo {
height: 130px;
width: 130px;
border-radius: 50px;
object-fit: cover;
}
#changeInfo {
font-size: 20px;
padding: 10px;
background-color: gray;
cursor: pointer;
}
#current_page {
color: red;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
background-color: #181818;
color: #fff;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 20px;
background-color: #202020;
}
.logo {
display: flex;
align-items: center;
}
.logo img {
height: 40px;
margin-right: 10px;
}
nav ul {
display: flex;
list-style: none;
}
nav ul li {
position: relative;
margin: 0 15px;
}
nav ul li a {
color: #fff;
text-decoration: none;
padding: 10px;
}
.dropdown-menu {
display: none;
position: absolute;
background-color: #333;
list-style: none;
top: 100%;
left: -40px;
min-width: 150px;
}
.dropdown-menu li {
padding: 5px 10px;
}
.dropdown-movies:hover .dropdown-menu {
display: block;
}
.user-actions a {
color: #fff;
text-decoration: none;
margin-left: 10px;
}
.content {
padding: 20px;
text-align: center;
}
footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 20px;
background-color: #202020;
}
.social-links img {
height: 30px;
margin: 0 5px;
}
html, body {
height: 100%;
margin: 0;
display: flex;
flex-direction: column;
}
main {
flex: 1;
}
footer {
background-color: #202020;
padding: 10px 20px;
text-align: center;
}
.form-container {
width: 300px;
margin: 50px auto;
padding: 20px;
background-color: #282828;
border-radius: 10px;
text-align: center;
}
.form-container h2 {
margin-bottom: 20px;
}
.form-container input {
width: 100%;
padding: 10px;
margin: 10px 0;
border: none;
border-radius: 5px;
}
.form-container button {
width: 100%;
padding: 10px;
background-color: red;
border: none;
color: white;
font-size: 16px;
border-radius: 5px;
cursor: pointer;
}
.form-container button:hover {
background-color: darkred;
}
.form-container a {
color: red;
text-decoration: none;
}
.user-menu {
position: relative;
display: inline-block;
}
.account-btn {
color: #fff;
text-decoration: none;
padding: 10px;
}
.user-menu:hover .dropdown {
display: block;
}
.dropdown {
display: none;
position: absolute;
background-color: #333;
list-style: none;
padding: 5px;
left: -22px;
margin: 0;
border-radius: 5px;
width: 120px;
}
.dropdown li {
padding: 8px;
text-align: center;
}
.dropdown li a {
color: #fff;
text-decoration: none;
display: block;
}
.dropdown li a:hover {
background-color: red;
}
@media (max-width: 1024px) {
header {
flex-direction: row;
justify-content: space-between;
padding: 10px;
}
nav ul {
flex-direction: row;
}
.movie-list {
max-width: 90%;
}
}
@media (max-width: 768px) {
header {
flex-direction: column;
text-align: center;
}
nav ul {
flex-direction: column;
padding: 10px;
}
.movie-list {
flex-direction: column;
align-items: center;
}
footer {
flex-direction: column;
text-align: center;
}
.user-menu {
display: block;
position: static;
background-color: transparent;
text-align: center;
}
.user-menu li {
display: block;
}
.dropdown:hover .user-menu {
display: block;
}
}
@media (max-width: 480px) {
.side-menu {
width: 200px;
}
.movie-list img {
width: 120px;
}
footer {
padding: 5px;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="ru">
<head>
<meta charset="UTF-8">
<title>Ошибка</title>
</head>
<body style="font-family: system-ui, sans-serif;">
<h1>Что-то пошло не так</h1>
<p><strong>Путь:</strong> <span th:text="${path}">/path</span></p>
<p><strong>Статус:</strong> <span th:text="${status}">500</span></p>
<p><strong>Ошибка:</strong> <span th:text="${error}">Error</span></p>
<p><strong>Сообщение:</strong> <span th:text="${message}">Message</span></p>
</body>
</html>

View File

@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="ru">
<head>
<meta charset="UTF-8">
<title>Онлайн-кинотеатр</title>
<!-- Bootstrap + иконки + твой CSS, как в фронте -->
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<link rel="stylesheet" th:href="@{/css/style.css}">
</head>
<body class="bg-dark text-light">
<!-- ====== HEADER ====== -->
<header th:fragment="siteHeader">
<nav class="navbar navbar-expand-lg navbar-dark bg-dark border-bottom border-secondary">
<div class="container">
<!-- Лого -->
<a class="navbar-brand d-flex align-items-center gap-2" th:href="@{/}">
<img th:src="@{/img/logo.png}" alt="Логотип" width="40"/>
<!-- Если хочешь можно убрать текст -->
<span class="fw-semibold">Cinema</span>
</a>
<button class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarNav"
aria-controls="navbarNav"
aria-expanded="false"
aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<!-- Левые ссылки -->
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" th:href="@{/}">Главная</a>
</li>
<li class="nav-item">
<a class="nav-link" th:href="@{/movies}">Фильмы</a>
</li>
<li class="nav-item">
<a class="nav-link" th:href="@{/subscriptions}">Подписки</a>
</li>
</ul>
<!-- Правая часть (пока статично, без useAuth) -->
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link" th:href="@{/login}">Войти</a>
</li>
<li class="nav-item">
<a class="nav-link" th:href="@{/register}">Регистрация</a>
</li>
<!-- потом можем сделать дропдаун "Профиль" по данным из сессии -->
</ul>
</div>
</div>
</nav>
</header>
<!-- ====== FOOTER ====== -->
<footer th:fragment="siteFooter" class="bg-dark text-light mt-4 py-3 border-top border-secondary">
<div class="container text-center small">
<p class="mb-1">Контакты: cinemaSupport@gmail.com</p>
<p class="mb-1">Адрес: г. Москва, ул. Киношная, д. 5</p>
<p class="mb-0">Часы работы: 10:00 22:00</p>
</div>
</footer>
<!-- Bootstrap JS (для бургер-меню и дропдаунов) -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,101 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="ru">
<head>
<meta charset="UTF-8">
<title th:text="${isEdit} ? 'Редактирование фильма' : 'Новый фильм'">Фильм</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<link rel="stylesheet" th:href="@{/css/style.css}">
</head>
<body class="bg-dark text-light">
<div th:insert="layout :: siteHeader"></div>
<main class="container my-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="mb-0"
th:text="${isEdit} ? 'Редактирование фильма' : 'Новый фильм'">
Фильм
</h1>
<a th:href="@{/movies}" class="btn btn-outline-light">
<i class="bi bi-arrow-left"></i>
Назад к списку
</a>
</div>
<div class="card bg-dark border-secondary shadow-sm">
<div class="card-body">
<form th:object="${movie}"
th:action="${isEdit} ? @{|/movies/${movieId}|} : @{/movies}"
method="post"
class="row g-3">
<!-- Название -->
<div class="col-12">
<label class="form-label text-light">Название</label>
<input type="text" th:field="*{title}" class="form-control"/>
<div class="text-danger small"
th:if="${#fields.hasErrors('title')}"
th:errors="*{title}">
</div>
</div>
<!-- URL постера -->
<div class="col-12">
<label class="form-label text-light">URL постера</label>
<input type="text" th:field="*{image}" class="form-control"/>
</div>
<!-- Оценка -->
<div class="col-md-3">
<label class="form-label text-light">Оценка</label>
<input type="number" step="0.1" th:field="*{grade}"
class="form-control"/>
</div>
<!-- Жанр -->
<div class="col-md-4">
<label class="form-label text-light">Жанр</label>
<select th:field="*{genre.id}" class="form-select">
<option value="">-- выберите жанр --</option>
<option th:each="g : ${genres}"
th:value="${g.id}"
th:text="${g.name}">
</option>
</select>
</div>
<!-- Режиссёр -->
<div class="col-md-5">
<label class="form-label text-light">Режиссёр</label>
<select th:field="*{director.id}" class="form-select">
<option value="">-- выберите режиссёра --</option>
<option th:each="d : ${directors}"
th:value="${d.id}"
th:text="${d.name}">
</option>
</select>
</div>
<!-- Кнопка -->
<div class="col-12 d-flex justify-content-end mt-3">
<button type="submit" class="btn btn-primary"
th:text="${isEdit} ? 'Сохранить' : 'Создать'">
Сохранить
</button>
</div>
</form>
</div>
</div>
</main>
<div th:insert="layout :: siteFooter"></div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,161 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="ru">
<head>
<meta charset="UTF-8">
<title>Фильмы</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<link rel="stylesheet" th:href="@{/css/style.css}">
</head>
<body class="bg-dark text-light">
<div th:insert="layout :: siteHeader"></div>
<main class="container my-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="mb-0">Фильмы</h1>
<a th:href="@{/movies/new}" class="btn btn-primary">
<i class="bi bi-plus-lg me-1"></i> Добавить фильм
</a>
</div>
<form class="card card-body mb-4 bg-secondary bg-opacity-10"
th:action="@{/movies}" method="get">
<div class="row g-3 align-items-end">
<div class="col-md-4">
<label class="form-label text-light">Название содержит</label>
<input type="text" name="title" class="form-control"
th:value="${titleFilter}">
</div>
<div class="col-md-3">
<label class="form-label text-light">Жанр</label>
<select name="genreId" class="form-select">
<option value="">-- любой --</option>
<option th:each="g : ${genres}"
th:value="${g.id}"
th:text="${g.name}"
th:selected="${genreIdFilter != null and g.id == genreIdFilter}">
</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label text-light">Мин. оценка</label>
<input type="number" step="0.1" name="minGrade"
class="form-control"
th:value="${minGradeFilter}">
</div>
<div class="col-md-2">
<label class="form-label text-light">Размер страницы</label>
<input type="number" min="1" name="size"
class="form-control"
th:value="${pageSize}">
<input type="hidden" name="page" value="1">
</div>
<div class="col-md-1 d-grid">
<button type="submit" class="btn btn-success">OK</button>
</div>
</div>
</form>
<div class="row g-4">
<div class="col-12 col-sm-6 col-md-4 col-lg-3"
th:each="movie : ${page.items}">
<div class="card h-100 bg-dark border-secondary shadow-sm">
<img th:if="${movie.image != null and !#strings.isEmpty(movie.image)}"
th:src="${movie.image}"
class="card-img-top"
alt="Постер"
style="object-fit: cover; height: 260px;">
<div class="card-body d-flex flex-column">
<h5 class="card-title" th:text="${movie.title}">Название</h5>
<p class="card-text text-secondary mb-1">
Жанр
<span th:text="${movie.genre != null ? movie.genre.name : 'Без жанра'}">
Жанр
</span>
</p>
<p class="card-text text-secondary mb-1">
Режиссёр
<span th:text="${movie.director != null ? movie.director.name : 'Без режиссёра'}">
Режиссёр
</span>
</p>
<p class="card-text text-warning mb-2">
<i class="bi bi-star-fill me-1"></i>
<span th:text="${movie.grade}">0</span>
</p>
<div class="mt-auto d-flex justify-content-between">
<a th:href="@{|/movies/${movie.id}/edit|}"
class="btn btn-sm btn-outline-light">
Редактировать
</a>
<a th:href="@{|/movies/${movie.id}/delete|}"
class="btn btn-sm btn-outline-danger"
onclick="return confirm('Удалить фильм?');">
Удалить
</a>
</div>
</div>
</div>
</div>
</div>
<nav class="mt-4">
<ul class="pagination justify-content-center">
<li class="page-item" th:classappend="${page.isFirst} ? ' disabled'">
<a class="page-link"
th:href="@{/movies(
page=${page.currentPage - 1},
size=${page.currentSize},
title=${titleFilter},
genreId=${genreIdFilter},
minGrade=${minGradeFilter}
)}">
Предыдущая
</a>
</li>
<li class="page-item"
th:each="p : ${#numbers.sequence(1, page.totalPages)}"
th:classappend="${p == page.currentPage} ? ' active'">
<a class="page-link"
th:text="${p}"
th:href="@{/movies(
page=${p},
size=${page.currentSize},
title=${titleFilter},
genreId=${genreIdFilter},
minGrade=${minGradeFilter}
)}">1</a>
</li>
<li class="page-item" th:classappend="${page.isLast} ? ' disabled'">
<a class="page-link"
th:href="@{/movies(
page=${page.currentPage + 1},
size=${page.currentSize},
title=${titleFilter},
genreId=${genreIdFilter},
minGrade=${minGradeFilter}
)}">
Следующая
</a>
</li>
</ul>
</nav>
</main>
<div th:insert="layout :: siteFooter"></div>
</body>
</html>

View File

@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="ru">
<head>
<meta charset="UTF-8">
<title th:text="${isEdit} ? 'Редактирование подписки' : 'Новая подписка'">Подписка</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<link rel="stylesheet" th:href="@{/css/style.css}">
</head>
<body class="bg-dark text-light">
<div th:insert="layout :: siteHeader"></div>
<main class="container my-4">
<!-- Заголовок -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="mb-0"
th:text="${isEdit} ? 'Редактирование подписки' : 'Новая подписка'">
Подписка
</h1>
<a th:href="@{/subscriptions}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Назад
</a>
</div>
<!-- Форма -->
<div class="card bg-dark border-secondary shadow-sm">
<div class="card-body">
<form th:object="${subscription}"
th:action="${isEdit} ? @{|/subscriptions/${subscriptionId}|} : @{/subscriptions}"
method="post">
<!-- Уровень -->
<div class="mb-3">
<label class="form-label">Уровень</label>
<input type="text"
th:field="*{level}"
class="form-control bg-secondary text-light border-0"/>
<div class="text-danger mt-1"
th:if="${#fields.hasErrors('level')}"
th:errors="*{level}">
</div>
</div>
<!-- Цена -->
<div class="mb-3">
<label class="form-label">Цена (₽)</label>
<input type="number"
min="0"
th:field="*{price}"
class="form-control bg-secondary text-light border-0"/>
<div class="text-danger mt-1"
th:if="${#fields.hasErrors('price')}"
th:errors="*{price}">
</div>
</div>
<!-- Кнопки -->
<div class="d-flex justify-content-end gap-2">
<a th:href="@{/subscriptions}"
class="btn btn-outline-light">
Отмена
</a>
<button type="submit"
class="btn btn-success"
th:text="${isEdit} ? 'Сохранить' : 'Создать'">
Сохранить
</button>
</div>
</form>
</div>
</div>
</main>
<div th:insert="layout :: siteFooter"></div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="ru">
<head>
<meta charset="UTF-8">
<title>Подписки</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<link rel="stylesheet" th:href="@{/css/style.css}">
</head>
<body class="bg-dark text-light">
<div th:insert="layout :: siteHeader"></div>
<main class="container my-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="mb-0">Подписки</h1>
<a th:href="@{/subscriptions/new}" class="btn btn-primary">
<i class="bi bi-plus-lg me-1"></i> Добавить подписку
</a>
</div>
<div class="card bg-dark border-secondary shadow-sm">
<div class="card-body p-0">
<table class="table table-dark table-hover mb-0 align-middle">
<thead>
<tr>
<th scope="col">ID</th>
<th scope="col">Уровень</th>
<th scope="col">Цена (₽)</th>
<th scope="col" class="text-end">Действия</th>
</tr>
</thead>
<tbody>
<tr th:each="sub : ${page.items}">
<td th:text="${sub.id}">1</td>
<td th:text="${sub.level}">Базовая</td>
<td th:text="${sub.price}">299</td>
<td class="text-end">
<a th:href="@{|/subscriptions/${sub.id}/edit|}"
class="btn btn-sm btn-outline-light me-2">
Редактировать
</a>
<a th:href="@{|/subscriptions/${sub.id}/delete|}"
class="btn btn-sm btn-outline-danger"
onclick="return confirm('Удалить подписку?');">
Удалить
</a>
</td>
</tr>
<tr th:if="${page.items.isEmpty()}">
<td colspan="4" class="text-center text-secondary py-4">
Подписок пока нет
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Пагинация -->
<nav class="mt-4">
<ul class="pagination justify-content-center">
<li class="page-item" th:classappend="${page.isFirst} ? ' disabled'">
<a class="page-link"
th:href="@{/subscriptions(page=${page.currentPage - 1}, size=${page.currentSize})}">
Предыдущая
</a>
</li>
<li class="page-item"
th:each="p : ${#numbers.sequence(1, page.totalPages)}"
th:classappend="${p == page.currentPage} ? ' active'">
<a class="page-link"
th:text="${p}"
th:href="@{/subscriptions(page=${p}, size=${page.currentSize})}">
1
</a>
</li>
<li class="page-item" th:classappend="${page.isLast} ? ' disabled'">
<a class="page-link"
th:href="@{/subscriptions(page=${page.currentPage + 1}, size=${page.currentSize})}">
Следующая
</a>
</li>
</ul>
</nav>
</main>
<div th:insert="layout :: siteFooter"></div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>