Compare commits
9 Commits
a21e43a80e
...
lab_6
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c48ab6b8e | |||
| 946f801e2a | |||
| d78269303f | |||
| 67d3ee7916 | |||
| 49510433e4 | |||
| 34fe6a28d9 | |||
| 022f81af6a | |||
| fec98632f0 | |||
| f253354d62 |
BIN
.vs/slnx.sqlite
BIN
.vs/slnx.sqlite
Binary file not shown.
BIN
.vs/ипРома/v17/.wsuo
Normal file
BIN
.vs/ипРома/v17/.wsuo
Normal file
Binary file not shown.
12
.vs/ипРома/v17/DocumentLayout.backup.json
Normal file
12
.vs/ипРома/v17/DocumentLayout.backup.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"Version": 1,
|
||||
"WorkspaceRootPath": "C:\\Users\\busla\\Desktop\\\u0438\u043F\u0420\u043E\u043C\u0430\\",
|
||||
"Documents": [],
|
||||
"DocumentGroupContainers": [
|
||||
{
|
||||
"Orientation": 0,
|
||||
"VerticalTabListWidth": 256,
|
||||
"DocumentGroups": []
|
||||
}
|
||||
]
|
||||
}
|
||||
12
.vs/ипРома/v17/DocumentLayout.json
Normal file
12
.vs/ипРома/v17/DocumentLayout.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"Version": 1,
|
||||
"WorkspaceRootPath": "C:\\Users\\busla\\Desktop\\\u0438\u043F\u0420\u043E\u043C\u0430\\",
|
||||
"Documents": [],
|
||||
"DocumentGroupContainers": [
|
||||
{
|
||||
"Orientation": 0,
|
||||
"VerticalTabListWidth": 256,
|
||||
"DocumentGroups": []
|
||||
}
|
||||
]
|
||||
}
|
||||
35
backend/build.front.gradle
Normal file
35
backend/build.front.gradle
Normal file
@@ -0,0 +1,35 @@
|
||||
apply plugin: "com.github.node-gradle.node"
|
||||
|
||||
logger.quiet("Configure front builder")
|
||||
|
||||
ext {
|
||||
frontDir = file("${project.projectDir}/front")
|
||||
logger.quiet("Webapp dir is ${frontDir}")
|
||||
}
|
||||
|
||||
node {
|
||||
version = "22.17.1"
|
||||
npmVersion = "10.9.2"
|
||||
download = true
|
||||
}
|
||||
|
||||
tasks.register("frontDepsInstall", NpmTask) {
|
||||
group = "front"
|
||||
description = "Installs dependencies from package.json"
|
||||
logger.quiet(description)
|
||||
workingDir = frontDir
|
||||
args = ["install"]
|
||||
}
|
||||
|
||||
tasks.register("frontBuild", NpmTask) {
|
||||
group = "front"
|
||||
description = "Build frontend webapp"
|
||||
logger.quiet(description)
|
||||
workingDir = frontDir
|
||||
dependsOn frontDepsInstall
|
||||
args = ["run", "build"]
|
||||
}
|
||||
|
||||
if (frontDir.exists()) {
|
||||
processResources.finalizedBy frontBuild
|
||||
}
|
||||
@@ -2,7 +2,10 @@ plugins {
|
||||
id 'java'
|
||||
id 'org.springframework.boot' version '3.5.5'
|
||||
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.0'
|
||||
}
|
||||
|
||||
group = 'ru.ulstu.is'
|
||||
version = '0.0.1-SNAPSHOT'
|
||||
description = 'Movie API (Lab 1)'
|
||||
@@ -18,16 +21,98 @@ java {
|
||||
repositories { mavenCentral() }
|
||||
|
||||
ext {
|
||||
springdocVersion = '2.8.11'
|
||||
springProfiles = []
|
||||
|
||||
springdocVersion = '2.8.14'
|
||||
|
||||
springProfiles = []
|
||||
|
||||
if (project.hasProperty("front")) {
|
||||
springProfiles.add("front")
|
||||
}
|
||||
|
||||
if (project.hasProperty("prod")) {
|
||||
springProfiles.add("prod")
|
||||
} else {
|
||||
springProfiles.add("dev")
|
||||
}
|
||||
|
||||
currentProfiles = springProfiles.join(",")
|
||||
logger.quiet("Current profiles are: " + currentProfiles)
|
||||
}
|
||||
|
||||
liquibase {
|
||||
activities {
|
||||
dev {
|
||||
url 'jdbc:h2:file:./data/appdb'
|
||||
username 'sa'
|
||||
driver 'org.h2.Driver'
|
||||
changelogFile 'src/main/resources/db/changelog/db.changelog-master.yaml'
|
||||
}
|
||||
prod {
|
||||
url 'jdbc:postgresql://127.0.0.1:5432/internet_lab4'
|
||||
username 'postgres'
|
||||
password 'postgres'
|
||||
driver 'org.postgresql.Driver'
|
||||
changelogFile 'src/main/resources/db/changelog/db.changelog-master.yaml'
|
||||
}
|
||||
}
|
||||
runList = project.hasProperty('prod') ? 'prod' : 'dev'
|
||||
}
|
||||
|
||||
configurations {
|
||||
liquibaseRuntime {
|
||||
extendsFrom runtimeClasspath
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:${springdocVersion}"
|
||||
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'
|
||||
implementation "org.springframework.boot:spring-boot-starter-aop"
|
||||
implementation "org.springframework.boot:spring-boot-starter-security"
|
||||
implementation "org.thymeleaf.extras:thymeleaf-extras-springsecurity6"
|
||||
|
||||
runtimeOnly 'com.h2database:h2'
|
||||
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
|
||||
liquibaseRuntime 'org.liquibase:liquibase-core:4.30.0'
|
||||
liquibaseRuntime 'info.picocli:picocli:4.7.6'
|
||||
liquibaseRuntime 'com.h2database:h2:2.2.224'
|
||||
|
||||
if (springProfiles.contains("prod")) {
|
||||
runtimeOnly "org.postgresql:postgresql:42.7.4"
|
||||
} else {
|
||||
runtimeOnly "org.postgresql:postgresql:42.7.4"
|
||||
runtimeOnly "com.h2database:h2:2.2.224"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
tasks.named('test') {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
if (springProfiles.contains("front")) {
|
||||
apply from: "build.front.gradle"
|
||||
}
|
||||
|
||||
|
||||
bootRun {
|
||||
def currentArgs = ["--spring.profiles.active=" + currentProfiles]
|
||||
if (project.hasProperty("args")) {
|
||||
currentArgs.addAll(project.args.tokenize())
|
||||
}
|
||||
args currentArgs
|
||||
}
|
||||
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
|
||||
systemProperty "spring.profiles.active", currentProfiles
|
||||
}
|
||||
BIN
backend/data/appdb.mv.db
Normal file
BIN
backend/data/appdb.mv.db
Normal file
Binary file not shown.
8
backend/data/appdb.trace.db
Normal file
8
backend/data/appdb.trace.db
Normal file
@@ -0,0 +1,8 @@
|
||||
2025-11-28 11:36:35.062950+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-28 14:36:26.320793+04:00 jdbc[13]: exception
|
||||
org.h2.jdbc.JdbcSQLSyntaxErrorException: Синтаксическая ошибка в выражении SQL "[*]USERS SELECT * FROM USERS"; ожидалось "UPDATE"
|
||||
Syntax error in SQL statement "[*]USERS SELECT * FROM USERS"; expected "UPDATE"; SQL statement:
|
||||
USERS SELECT * FROM USERS [42001-224]
|
||||
6
backend/package-lock.json
generated
Normal file
6
backend/package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
@@ -1,35 +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 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); }
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void delete(@PathVariable Long id) { service.delete(id); }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package ru.ulstu.is.server.api;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import ru.ulstu.is.server.repository.DirectorRepository;
|
||||
import ru.ulstu.is.server.repository.GenreRepository;
|
||||
import ru.ulstu.is.server.repository.MovieRepository;
|
||||
import ru.ulstu.is.server.repository.SubscriptionRepository;
|
||||
import ru.ulstu.is.server.repository.UserRepository;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/1.0/reports")
|
||||
@Tag(name = "Reports", description = "Отчёты и агрегированные выборки")
|
||||
public class ReportController {
|
||||
|
||||
private final GenreRepository genreRepo;
|
||||
private final DirectorRepository directorRepo;
|
||||
private final UserRepository userRepo;
|
||||
private final SubscriptionRepository subscriptionRepo;
|
||||
private final MovieRepository movieRepo;
|
||||
|
||||
public ReportController(GenreRepository genreRepo,
|
||||
DirectorRepository directorRepo,
|
||||
UserRepository userRepo,
|
||||
SubscriptionRepository subscriptionRepo,
|
||||
MovieRepository movieRepo) {
|
||||
this.genreRepo = genreRepo;
|
||||
this.directorRepo = directorRepo;
|
||||
this.userRepo = userRepo;
|
||||
this.subscriptionRepo = subscriptionRepo;
|
||||
this.movieRepo = movieRepo;
|
||||
}
|
||||
|
||||
@GetMapping("/genres/stats")
|
||||
@Operation(summary = "Количество фильмов по жанрам")
|
||||
public List<GenreRepository.GenreStats> genreStats() {
|
||||
return genreRepo.getGenreStats();
|
||||
}
|
||||
|
||||
@GetMapping("/directors/top")
|
||||
@Operation(summary = "Топ режиссёров по числу фильмов")
|
||||
public List<DirectorRepository.DirectorStats> topDirectors(
|
||||
@RequestParam(defaultValue = "10") int limit) {
|
||||
return directorRepo.getDirectorStats();
|
||||
}
|
||||
|
||||
@GetMapping("/subscriptions/usage")
|
||||
@Operation(summary = "Использование подписок: тариф, цена, число пользователей")
|
||||
public List<SubscriptionRepository.SubscriptionUsage> subscriptionUsage() {
|
||||
return subscriptionRepo.usageStats();
|
||||
}
|
||||
|
||||
@GetMapping("/users/by-subscription")
|
||||
@Operation(summary = "Число пользователей по тарифам")
|
||||
public List<UserRepository.UsersBySubscription> usersBySubscription() {
|
||||
return userRepo.countUsersBySubscription();
|
||||
}
|
||||
|
||||
@GetMapping("/users/avg-age-by-subscription")
|
||||
@Operation(summary = "Средний возраст пользователей по тарифам")
|
||||
public List<UserRepository.AvgAgeBySubscription> avgAgeBySubscription() {
|
||||
return userRepo.avgAgeBySubscription();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package ru.ulstu.is.server.api;
|
||||
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
@Controller
|
||||
public class SpaController {
|
||||
|
||||
@GetMapping("/")
|
||||
public String index() {
|
||||
return "forward:/index.html";
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package ru.ulstu.is.server.configuration;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
public class MvcWebConfiguration implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addViewControllers(ViewControllerRegistry registry) {
|
||||
registry.addViewController("/login").setViewName("login");
|
||||
|
||||
registry.addRedirectViewController("/", "/movies");
|
||||
}
|
||||
}
|
||||
@@ -5,22 +5,57 @@ import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public class MovieRq {
|
||||
|
||||
@NotBlank
|
||||
private String title;
|
||||
private String image;
|
||||
private float grade;
|
||||
|
||||
@NotNull @Valid
|
||||
private GenreRefRq genre;
|
||||
@NotNull
|
||||
@Valid
|
||||
private GenreRefRq genre;
|
||||
|
||||
@NotNull @Valid
|
||||
private DirectorRefRq director;
|
||||
@NotNull
|
||||
@Valid
|
||||
private DirectorRefRq director;
|
||||
|
||||
public String getTitle() { return title; }
|
||||
public void setTitle(String title) { this.title = title; }
|
||||
public String getImage() { return image; }
|
||||
public void setImage(String image) { this.image = image; }
|
||||
public GenreRefRq getGenre() { return genre; }
|
||||
public void setGenre(GenreRefRq genre) { this.genre = genre; }
|
||||
public DirectorRefRq getDirector() { return director; }
|
||||
public void setDirector(DirectorRefRq director) { this.director = director; }
|
||||
}
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public String getImage() {
|
||||
return image;
|
||||
}
|
||||
|
||||
public void setImage(String image) {
|
||||
this.image = image;
|
||||
}
|
||||
|
||||
public float getGrade() {
|
||||
return grade;
|
||||
}
|
||||
|
||||
public void setGrade(float grade) {
|
||||
this.grade = grade;
|
||||
}
|
||||
|
||||
public GenreRefRq getGenre() {
|
||||
return genre;
|
||||
}
|
||||
|
||||
public void setGenre(GenreRefRq genre) {
|
||||
this.genre = genre;
|
||||
}
|
||||
|
||||
public DirectorRefRq getDirector() {
|
||||
return director;
|
||||
}
|
||||
|
||||
public void setDirector(DirectorRefRq director) {
|
||||
this.director = director;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,60 @@
|
||||
package ru.ulstu.is.server.dto;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public class MovieRs {
|
||||
|
||||
private Long id;
|
||||
private String title;
|
||||
private String image;
|
||||
private float grade;
|
||||
|
||||
private GenreRs genre;
|
||||
private DirectorRs director;
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
public String getTitle() { return title; }
|
||||
public void setTitle(String title) { this.title = title; }
|
||||
public String getImage() { return image; }
|
||||
public void setImage(String image) { this.image = image; }
|
||||
public GenreRs getGenre() { return genre; }
|
||||
public void setGenre(GenreRs genre) { this.genre = genre; }
|
||||
public DirectorRs getDirector() { return director; }
|
||||
public void setDirector(DirectorRs director) { this.director = director; }
|
||||
}
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public String getImage() {
|
||||
return image;
|
||||
}
|
||||
|
||||
public float getGrade() {
|
||||
return grade;
|
||||
}
|
||||
|
||||
public void setGrade(float grade) {
|
||||
this.grade = grade;
|
||||
}
|
||||
|
||||
public void setImage(String image) {
|
||||
this.image = image;
|
||||
}
|
||||
|
||||
public GenreRs getGenre() {
|
||||
return genre;
|
||||
}
|
||||
|
||||
public void setGenre(GenreRs genre) {
|
||||
this.genre = genre;
|
||||
}
|
||||
|
||||
public DirectorRs getDirector() {
|
||||
return director;
|
||||
}
|
||||
|
||||
public void setDirector(DirectorRs director) {
|
||||
this.director = director;
|
||||
}
|
||||
}
|
||||
|
||||
15
backend/src/main/java/ru/ulstu/is/server/dto/PageHelper.java
Normal file
15
backend/src/main/java/ru/ulstu/is/server/dto/PageHelper.java
Normal file
@@ -0,0 +1,15 @@
|
||||
package ru.ulstu.is.server.dto;
|
||||
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
|
||||
public final class PageHelper {
|
||||
|
||||
private PageHelper() {
|
||||
}
|
||||
|
||||
public static Pageable toPageable(int page, int size) {
|
||||
return PageRequest.of(page - 1, size, Sort.by("id"));
|
||||
}
|
||||
}
|
||||
34
backend/src/main/java/ru/ulstu/is/server/dto/PageRs.java
Normal file
34
backend/src/main/java/ru/ulstu/is/server/dto/PageRs.java
Normal file
@@ -0,0 +1,34 @@
|
||||
package ru.ulstu.is.server.dto;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
|
||||
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 static <D, E> PageRs<D> from(Page<E> page, Function<E, D> mapper) {
|
||||
return new PageRs<>(
|
||||
page.getContent().stream().map(mapper).toList(),
|
||||
page.getNumberOfElements(),
|
||||
page.getNumber() + 1,
|
||||
page.getSize(),
|
||||
page.getTotalPages(),
|
||||
page.getTotalElements(),
|
||||
page.isFirst(),
|
||||
page.isLast(),
|
||||
page.hasNext(),
|
||||
page.hasPrevious()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,43 @@
|
||||
package ru.ulstu.is.server.dto;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public class SubscriptionRq {
|
||||
|
||||
@NotBlank
|
||||
private String level;
|
||||
@NotNull @Min(0)
|
||||
@NotNull
|
||||
@Min(0)
|
||||
private Integer price;
|
||||
public String getLevel() { return level; }
|
||||
public void setLevel(String level) { this.level = level; }
|
||||
public Integer getPrice() { return price; }
|
||||
public void setPrice(Integer price) { this.price = price; }
|
||||
}
|
||||
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
private Integer discount;
|
||||
|
||||
public String getLevel() {
|
||||
return level;
|
||||
}
|
||||
|
||||
public void setLevel(String level) {
|
||||
this.level = level;
|
||||
}
|
||||
|
||||
public Integer getPrice() {
|
||||
return price;
|
||||
}
|
||||
|
||||
public void setPrice(Integer price) {
|
||||
this.price = price;
|
||||
}
|
||||
|
||||
public Integer getDiscount() {
|
||||
return discount;
|
||||
}
|
||||
|
||||
public void setDiscount(Integer discount) {
|
||||
this.discount = discount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,62 @@
|
||||
package ru.ulstu.is.server.dto;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public class SubscriptionRs {
|
||||
|
||||
private Long id;
|
||||
private String level;
|
||||
private Integer price;
|
||||
public SubscriptionRs() { }
|
||||
public SubscriptionRs(Long id, String level, Integer price) {
|
||||
this.id = id; this.level = level; this.price = price;
|
||||
private Integer discount;
|
||||
private Integer priceWithDiscount;
|
||||
|
||||
public SubscriptionRs() {
|
||||
}
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
public String getLevel() { return level; }
|
||||
public void setLevel(String level) { this.level = level; }
|
||||
public Integer getPrice() { return price; }
|
||||
public void setPrice(Integer price) { this.price = price; }
|
||||
}
|
||||
|
||||
public SubscriptionRs(Long id, String level, Integer price,
|
||||
Integer discount, Integer priceWithDiscount) {
|
||||
this.id = id;
|
||||
this.level = level;
|
||||
this.price = price;
|
||||
this.discount = discount;
|
||||
this.priceWithDiscount = priceWithDiscount;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getLevel() {
|
||||
return level;
|
||||
}
|
||||
|
||||
public void setLevel(String level) {
|
||||
this.level = level;
|
||||
}
|
||||
|
||||
public Integer getPrice() {
|
||||
return price;
|
||||
}
|
||||
|
||||
public void setPrice(Integer price) {
|
||||
this.price = price;
|
||||
}
|
||||
|
||||
public Integer getDiscount() {
|
||||
return discount;
|
||||
}
|
||||
|
||||
public void setDiscount(Integer discount) {
|
||||
this.discount = discount;
|
||||
}
|
||||
|
||||
public Integer getPriceWithDiscount() {
|
||||
return priceWithDiscount;
|
||||
}
|
||||
|
||||
public void setPriceWithDiscount(Integer priceWithDiscount) {
|
||||
this.priceWithDiscount = priceWithDiscount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package ru.ulstu.is.server.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
@MappedSuperclass
|
||||
public abstract class BaseEntity {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
public BaseEntity() { }
|
||||
|
||||
@@ -1,11 +1,33 @@
|
||||
package ru.ulstu.is.server.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Entity
|
||||
@Table(name = "directors")
|
||||
public class Director extends BaseEntity {
|
||||
@Column(nullable = false, unique = true)
|
||||
private String name;
|
||||
|
||||
public Director() { }
|
||||
public Director(Long id, String name) { super(id); this.name = name; }
|
||||
@OneToMany(mappedBy = "director", cascade = CascadeType.ALL, orphanRemoval = false)
|
||||
private List<Movie> movies = new ArrayList<>();
|
||||
|
||||
public Director() {}
|
||||
public Director(Long id, String name) { setId(id); this.name = name; }
|
||||
|
||||
public void addMovie(Movie m) {
|
||||
if (m == null) return;
|
||||
movies.add(m);
|
||||
m.setDirector(this);
|
||||
}
|
||||
public void removeMovie(Movie m) {
|
||||
if (m == null) return;
|
||||
movies.remove(m);
|
||||
if (m.getDirector() == this) m.setDirector(null);
|
||||
}
|
||||
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public List<Movie> getMovies() { return movies; }
|
||||
}
|
||||
|
||||
@@ -1,11 +1,33 @@
|
||||
package ru.ulstu.is.server.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Entity
|
||||
@Table(name = "genres")
|
||||
public class Genre extends BaseEntity {
|
||||
@Column(nullable = false, unique = true)
|
||||
private String name;
|
||||
|
||||
public Genre() { }
|
||||
public Genre(Long id, String name) { super(id); this.name = name; }
|
||||
@OneToMany(mappedBy = "genre", cascade = CascadeType.ALL, orphanRemoval = false)
|
||||
private List<Movie> movies = new ArrayList<>();
|
||||
|
||||
public Genre() {}
|
||||
public Genre(Long id, String name) { setId(id); this.name = name; }
|
||||
|
||||
public void addMovie(Movie m) {
|
||||
if (m == null) return;
|
||||
movies.add(m);
|
||||
m.setGenre(this);
|
||||
}
|
||||
public void removeMovie(Movie m) {
|
||||
if (m == null) return;
|
||||
movies.remove(m);
|
||||
if (m.getGenre() == this) m.setGenre(null);
|
||||
}
|
||||
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public List<Movie> getMovies() { return movies; }
|
||||
}
|
||||
|
||||
@@ -1,30 +1,84 @@
|
||||
package ru.ulstu.is.server.entity;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.Lob;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
@Entity
|
||||
@Table(name = "movies")
|
||||
public class Movie extends BaseEntity {
|
||||
|
||||
@Column(nullable = false)
|
||||
private String title;
|
||||
private String image;
|
||||
|
||||
@Lob
|
||||
@Column(columnDefinition = "CLOB")
|
||||
private String image;
|
||||
|
||||
@Column(nullable = false)
|
||||
private float grade;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "genre_id", nullable = false)
|
||||
private Genre genre;
|
||||
private Director director;
|
||||
|
||||
public Movie() { }
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "director_id", nullable = false)
|
||||
private Director director;
|
||||
|
||||
public Movie(Long id, String title, String image, Genre genre, Director director) {
|
||||
super(id);
|
||||
public Movie() {
|
||||
}
|
||||
|
||||
public Movie(Long id, String title, String image, float grade, Genre genre, Director director) {
|
||||
setId(id);
|
||||
this.title = title;
|
||||
this.image = image;
|
||||
this.grade = grade;
|
||||
this.genre = genre;
|
||||
this.director = director;
|
||||
}
|
||||
|
||||
public String getTitle() { return title; }
|
||||
public void setTitle(String title) { this.title = title; }
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public String getImage() { return image; }
|
||||
public void setImage(String image) { this.image = image; }
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public Genre getGenre() { return genre; }
|
||||
public void setGenre(Genre genre) { this.genre = genre; }
|
||||
public String getImage() {
|
||||
return image;
|
||||
}
|
||||
|
||||
public Director getDirector() { return director; }
|
||||
public void setDirector(Director director) { this.director = director; }
|
||||
public void setImage(String image) {
|
||||
this.image = image;
|
||||
}
|
||||
|
||||
public Genre getGenre() {
|
||||
return genre;
|
||||
}
|
||||
|
||||
public void setGenre(Genre genre) {
|
||||
this.genre = genre;
|
||||
}
|
||||
|
||||
public float getGrade() {
|
||||
return grade;
|
||||
}
|
||||
|
||||
public void setGrade(float grade) {
|
||||
this.grade = grade;
|
||||
}
|
||||
|
||||
public Director getDirector() {
|
||||
return director;
|
||||
}
|
||||
|
||||
public void setDirector(Director director) {
|
||||
this.director = director;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,81 @@
|
||||
package ru.ulstu.is.server.entity;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
@Entity
|
||||
@Table(name = "subscriptions")
|
||||
public class Subscription extends BaseEntity {
|
||||
|
||||
@Column(nullable = false, unique = true)
|
||||
private String level;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Integer price;
|
||||
|
||||
public Subscription() { }
|
||||
public Subscription(Long id, String level, Integer price) {
|
||||
super(id); this.level = level; this.price = price;
|
||||
@OneToMany(mappedBy = "subscription", orphanRemoval = false)
|
||||
private List<User> users = new ArrayList<>();
|
||||
|
||||
@Column(nullable = false)
|
||||
private Integer discount = 0;
|
||||
|
||||
public Subscription() {
|
||||
}
|
||||
|
||||
public String getLevel() { return level; }
|
||||
public void setLevel(String level) { this.level = level; }
|
||||
public Integer getPrice() { return price; }
|
||||
public void setPrice(Integer price) { this.price = price; }
|
||||
public Subscription(Long id, String level, Integer price) {
|
||||
super(id);
|
||||
this.level = level;
|
||||
this.price = price;
|
||||
}
|
||||
|
||||
public void addUser(User u) {
|
||||
if (u == null) {
|
||||
return;
|
||||
}
|
||||
users.add(u);
|
||||
u.setSubscription(this);
|
||||
}
|
||||
|
||||
public Integer getDiscount() {
|
||||
return discount;
|
||||
}
|
||||
|
||||
public void setDiscount(Integer discount) {
|
||||
this.discount = discount != null ? discount : 0;
|
||||
}
|
||||
|
||||
public void removeUser(User u) {
|
||||
if (u == null) {
|
||||
return;
|
||||
}
|
||||
users.remove(u);
|
||||
if (u.getSubscription() == this) {
|
||||
u.setSubscription(null);
|
||||
}
|
||||
}
|
||||
|
||||
public String getLevel() {
|
||||
return level;
|
||||
}
|
||||
|
||||
public void setLevel(String level) {
|
||||
this.level = level;
|
||||
}
|
||||
|
||||
public Integer getPrice() {
|
||||
return price;
|
||||
}
|
||||
|
||||
public void setPrice(Integer price) {
|
||||
this.price = price;
|
||||
}
|
||||
|
||||
public List<User> getUsers() {
|
||||
return users;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +1,178 @@
|
||||
package ru.ulstu.is.server.entity;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class User extends BaseEntity {
|
||||
private String name;
|
||||
private String login;
|
||||
private String password;
|
||||
private Boolean isAdmin;
|
||||
private Integer age;
|
||||
private String gender;
|
||||
private String avatar;
|
||||
private Subscription subscription;
|
||||
private String subscriptionUntil;
|
||||
private List<Long> watchlist = new ArrayList<>();
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.JoinTable;
|
||||
import jakarta.persistence.Lob;
|
||||
import jakarta.persistence.ManyToMany;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
|
||||
public User() { }
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
public class User extends BaseEntity {
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(nullable = false, unique = true)
|
||||
private String login;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String password;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Boolean isAdmin = false;
|
||||
|
||||
@Column
|
||||
private Integer age;
|
||||
|
||||
@Column
|
||||
private String gender;
|
||||
|
||||
@Lob
|
||||
@Column(columnDefinition = "CLOB")
|
||||
private String avatar;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "subscription_id")
|
||||
private Subscription subscription;
|
||||
|
||||
@Column(name = "subscription_until")
|
||||
private LocalDate subscriptionUntil;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "ROLE", nullable = false, length = 20)
|
||||
private UserRole role = UserRole.USER;
|
||||
|
||||
@ManyToMany
|
||||
@JoinTable(
|
||||
name = "user_watchlist",
|
||||
joinColumns = @JoinColumn(name = "user_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "movie_id")
|
||||
)
|
||||
private List<Movie> watchlist = new ArrayList<>();
|
||||
|
||||
public User() {
|
||||
}
|
||||
|
||||
public User(Long id, String name, String login, String password, Boolean isAdmin) {
|
||||
super(id);
|
||||
this.name = name; this.login = login; this.password = password; this.isAdmin = isAdmin;
|
||||
this.name = name;
|
||||
this.login = login;
|
||||
this.password = password;
|
||||
this.isAdmin = isAdmin;
|
||||
}
|
||||
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public void addToWatchlist(Movie m) {
|
||||
if (m == null) {
|
||||
return;
|
||||
}
|
||||
if (!watchlist.contains(m)) {
|
||||
watchlist.add(m);
|
||||
}
|
||||
}
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
public void removeFromWatchlist(Movie m) {
|
||||
if (m == null) {
|
||||
return;
|
||||
}
|
||||
watchlist.remove(m);
|
||||
}
|
||||
|
||||
public String getPassword() { return password; }
|
||||
public void setPassword(String password) { this.password = password; }
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public Boolean getIsAdmin() { return isAdmin; }
|
||||
public void setIsAdmin(Boolean isAdmin) { this.isAdmin = isAdmin; }
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public Integer getAge() { return age; }
|
||||
public void setAge(Integer age) { this.age = age; }
|
||||
public String getLogin() {
|
||||
return login;
|
||||
}
|
||||
|
||||
public String getGender() { return gender; }
|
||||
public void setGender(String gender) { this.gender = gender; }
|
||||
public void setLogin(String login) {
|
||||
this.login = login;
|
||||
}
|
||||
|
||||
public String getAvatar() { return avatar; }
|
||||
public void setAvatar(String avatar) { this.avatar = avatar; }
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public List<Long> getWatchlist() { return watchlist; }
|
||||
public void setWatchlist(List<Long> watchlist) { this.watchlist = watchlist; }
|
||||
public void setPassword(String password) {
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public Subscription getSubscription() { return subscription; }
|
||||
public void setSubscription(Subscription subscription) { this.subscription = subscription; }
|
||||
public Boolean getIsAdmin() {
|
||||
return isAdmin;
|
||||
}
|
||||
|
||||
public String getSubscriptionUntil() { return subscriptionUntil; }
|
||||
public void setSubscriptionUntil(String subscriptionUntil) { this.subscriptionUntil = subscriptionUntil; }
|
||||
public void setIsAdmin(Boolean isAdmin) {
|
||||
this.isAdmin = isAdmin;
|
||||
}
|
||||
|
||||
public Integer getAge() {
|
||||
return age;
|
||||
}
|
||||
|
||||
public void setAge(Integer age) {
|
||||
this.age = age;
|
||||
}
|
||||
|
||||
public String getGender() {
|
||||
return gender;
|
||||
}
|
||||
|
||||
public void setGender(String gender) {
|
||||
this.gender = gender;
|
||||
}
|
||||
|
||||
public String getAvatar() {
|
||||
return avatar;
|
||||
}
|
||||
|
||||
public void setAvatar(String avatar) {
|
||||
this.avatar = avatar;
|
||||
}
|
||||
|
||||
public Subscription getSubscription() {
|
||||
return subscription;
|
||||
}
|
||||
|
||||
public void setSubscription(Subscription subscription) {
|
||||
this.subscription = subscription;
|
||||
}
|
||||
|
||||
public LocalDate getSubscriptionUntil() {
|
||||
return subscriptionUntil;
|
||||
}
|
||||
|
||||
public void setSubscriptionUntil(LocalDate subscriptionUntil) {
|
||||
this.subscriptionUntil = subscriptionUntil;
|
||||
}
|
||||
|
||||
public List<Movie> getWatchlist() {
|
||||
return watchlist;
|
||||
}
|
||||
|
||||
public void setWatchlist(List<Movie> watchlist) {
|
||||
this.watchlist = watchlist != null ? watchlist : new ArrayList<>();
|
||||
}
|
||||
|
||||
public UserRole getRole() {
|
||||
return role;
|
||||
}
|
||||
|
||||
public void setRole(UserRole role) {
|
||||
this.role = role;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package ru.ulstu.is.server.entity;
|
||||
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
|
||||
public enum UserRole implements GrantedAuthority {
|
||||
ADMIN,
|
||||
USER;
|
||||
|
||||
@Override
|
||||
public String getAuthority() {
|
||||
return "ROLE_" + name();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import org.springframework.stereotype.Component;
|
||||
import ru.ulstu.is.server.dto.DirectorRq;
|
||||
import ru.ulstu.is.server.dto.DirectorRs;
|
||||
import ru.ulstu.is.server.entity.Director;
|
||||
import ru.ulstu.is.server.repository.SubscriptionRepository;
|
||||
|
||||
@Component
|
||||
public class DirectorMapper {
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
package ru.ulstu.is.server.mapper;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import ru.ulstu.is.server.dto.*;
|
||||
import ru.ulstu.is.server.entity.*;
|
||||
|
||||
import ru.ulstu.is.server.dto.DirectorRefRq;
|
||||
import ru.ulstu.is.server.dto.DirectorRs;
|
||||
import ru.ulstu.is.server.dto.GenreRefRq;
|
||||
import ru.ulstu.is.server.dto.GenreRs;
|
||||
import ru.ulstu.is.server.dto.MovieRq;
|
||||
import ru.ulstu.is.server.dto.MovieRs;
|
||||
import ru.ulstu.is.server.entity.Director;
|
||||
import ru.ulstu.is.server.entity.Genre;
|
||||
import ru.ulstu.is.server.entity.Movie;
|
||||
import ru.ulstu.is.server.repository.DirectorRepository;
|
||||
import ru.ulstu.is.server.repository.GenreRepository;
|
||||
|
||||
@Component
|
||||
public class MovieMapper {
|
||||
|
||||
private final GenreRepository genreRepo;
|
||||
private final DirectorRepository directorRepo;
|
||||
|
||||
@@ -19,6 +28,7 @@ public class MovieMapper {
|
||||
public Movie toEntity(MovieRq rq) {
|
||||
Movie e = new Movie();
|
||||
e.setTitle(rq.getTitle());
|
||||
e.setGrade(rq.getGrade());
|
||||
e.setImage(rq.getImage());
|
||||
|
||||
GenreRefRq gRef = rq.getGenre();
|
||||
@@ -35,34 +45,54 @@ public class MovieMapper {
|
||||
rs.setId(e.getId());
|
||||
rs.setTitle(e.getTitle());
|
||||
rs.setImage(e.getImage());
|
||||
rs.setGrade(e.getGrade());
|
||||
|
||||
Genre g = e.getGenre();
|
||||
if (g != null) rs.setGenre(new GenreRs(g.getId(), g.getName()));
|
||||
if (g != null) {
|
||||
rs.setGenre(new GenreRs(g.getId(), g.getName()));
|
||||
}
|
||||
|
||||
Director d = e.getDirector();
|
||||
if (d != null) rs.setDirector(new DirectorRs(d.getId(), d.getName()));
|
||||
if (d != null) {
|
||||
rs.setDirector(new DirectorRs(d.getId(), d.getName()));
|
||||
}
|
||||
|
||||
return rs;
|
||||
}
|
||||
|
||||
private Genre resolveGenre(GenreRefRq ref) {
|
||||
if (ref == null) return null;
|
||||
if (ref == null) {
|
||||
return null;
|
||||
}
|
||||
Long id = parseId(ref.getId());
|
||||
if (id != null) return genreRepo.findById(id).orElse(null);
|
||||
if (ref.getName() != null) return new Genre(null, ref.getName());
|
||||
if (id != null) {
|
||||
return genreRepo.findById(id).orElse(null);
|
||||
}
|
||||
if (ref.getName() != null) {
|
||||
return new Genre(null, ref.getName());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Director resolveDirector(DirectorRefRq ref) {
|
||||
if (ref == null) return null;
|
||||
if (ref == null) {
|
||||
return null;
|
||||
}
|
||||
Long id = parseId(ref.getId());
|
||||
if (id != null) return directorRepo.findById(id).orElse(null);
|
||||
if (ref.getName() != null) return new Director(null, ref.getName());
|
||||
if (id != null) {
|
||||
return directorRepo.findById(id).orElse(null);
|
||||
}
|
||||
if (ref.getName() != null) {
|
||||
return new Director(null, ref.getName());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Long parseId(String s) {
|
||||
try { return s == null ? null : Long.parseLong(s); }
|
||||
catch (NumberFormatException e) { return null; }
|
||||
try {
|
||||
return s == null ? null : Long.parseLong(s);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,46 @@
|
||||
package ru.ulstu.is.server.mapper;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import ru.ulstu.is.server.dto.SubscriptionRq;
|
||||
|
||||
import ru.ulstu.is.server.dto.SubscriptionRs;
|
||||
import ru.ulstu.is.server.dto.SubscriptionRq;
|
||||
import ru.ulstu.is.server.entity.Subscription;
|
||||
|
||||
@Component
|
||||
public class SubscriptionMapper {
|
||||
|
||||
public Subscription toEntity(SubscriptionRq rq) {
|
||||
Subscription s = new Subscription();
|
||||
s.setLevel(rq.getLevel());
|
||||
s.setPrice(rq.getPrice());
|
||||
s.setDiscount(rq.getDiscount() != null ? rq.getDiscount() : 0);
|
||||
return s;
|
||||
}
|
||||
|
||||
public void updateEntity(Subscription s, SubscriptionRq rq) {
|
||||
s.setLevel(rq.getLevel());
|
||||
s.setPrice(rq.getPrice());
|
||||
s.setDiscount(rq.getDiscount() != null ? rq.getDiscount() : 0);
|
||||
}
|
||||
|
||||
public SubscriptionRs toRs(Subscription s) {
|
||||
return new SubscriptionRs(s.getId(), s.getLevel(), s.getPrice());
|
||||
int price = s.getPrice() != null ? s.getPrice() : 0;
|
||||
int discount = s.getDiscount() != null ? s.getDiscount() : 0;
|
||||
if (discount < 0) {
|
||||
discount = 0;
|
||||
}
|
||||
if (discount > 100) {
|
||||
discount = 100;
|
||||
}
|
||||
|
||||
int priceWithDiscount = Math.round(price * (100 - discount) / 100.0f);
|
||||
|
||||
return new SubscriptionRs(
|
||||
s.getId(),
|
||||
s.getLevel(),
|
||||
price,
|
||||
discount,
|
||||
priceWithDiscount
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,77 +2,55 @@ package ru.ulstu.is.server.mapper;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import ru.ulstu.is.server.dto.*;
|
||||
import ru.ulstu.is.server.entity.Movie;
|
||||
import ru.ulstu.is.server.entity.Subscription;
|
||||
import ru.ulstu.is.server.entity.User;
|
||||
import ru.ulstu.is.server.repository.MovieRepository;
|
||||
import ru.ulstu.is.server.repository.SubscriptionRepository;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
public class UserMapper {
|
||||
private final SubscriptionRepository subscriptionRepository;
|
||||
private final MovieRepository movieRepository;
|
||||
|
||||
public UserMapper(SubscriptionRepository subscriptionRepository) {
|
||||
private static final DateTimeFormatter DF = DateTimeFormatter.ISO_DATE;
|
||||
|
||||
public UserMapper(SubscriptionRepository subscriptionRepository,
|
||||
MovieRepository movieRepository) {
|
||||
this.subscriptionRepository = subscriptionRepository;
|
||||
this.movieRepository = movieRepository;
|
||||
}
|
||||
|
||||
public User toEntity(UserRq rq) {
|
||||
User u = new User();
|
||||
u.setName(rq.getName());
|
||||
u.setLogin(rq.getLogin());
|
||||
u.setPassword(rq.getPassword());
|
||||
u.setIsAdmin(Boolean.TRUE.equals(rq.getIsAdmin()));
|
||||
u.setAge(rq.getAge());
|
||||
u.setGender(rq.getGender());
|
||||
u.setAvatar(rq.getAvatar());
|
||||
u.setSubscriptionUntil(rq.getSubscriptionUntil());
|
||||
u.setWatchlist(rq.getWatchlist() != null ? rq.getWatchlist() : new ArrayList<>());
|
||||
|
||||
SubscriptionRefRq ref = rq.getSubscription();
|
||||
if (ref != null) {
|
||||
Subscription sub = null;
|
||||
if (ref.getId() != null) {
|
||||
try {
|
||||
Long id = Long.parseLong(ref.getId());
|
||||
sub = subscriptionRepository.findById(id).orElse(null);
|
||||
} catch (NumberFormatException ignored) {}
|
||||
}
|
||||
if (sub == null && (ref.getLevel() != null || ref.getPrice() != null)) {
|
||||
sub = new Subscription(null, ref.getLevel(), ref.getPrice());
|
||||
sub = subscriptionRepository.save(sub);
|
||||
}
|
||||
u.setSubscription(sub);
|
||||
}
|
||||
updateEntity(u, rq);
|
||||
return u;
|
||||
}
|
||||
|
||||
public void updateEntity(User u, UserRq rq) {
|
||||
u.setName(rq.getName());
|
||||
u.setLogin(rq.getLogin());
|
||||
u.setPassword(rq.getPassword());
|
||||
u.setIsAdmin(Boolean.TRUE.equals(rq.getIsAdmin()));
|
||||
u.setAge(rq.getAge());
|
||||
u.setGender(rq.getGender());
|
||||
u.setAvatar(rq.getAvatar());
|
||||
u.setSubscriptionUntil(rq.getSubscriptionUntil());
|
||||
u.setWatchlist(rq.getWatchlist() != null ? rq.getWatchlist() : new ArrayList<>());
|
||||
if (rq.getName() != null) u.setName(rq.getName());
|
||||
if (rq.getLogin() != null) u.setLogin(rq.getLogin());
|
||||
if (rq.getPassword() != null) u.setPassword(rq.getPassword());
|
||||
if (rq.getIsAdmin() != null) u.setIsAdmin(rq.getIsAdmin());
|
||||
if (rq.getAge() != null) u.setAge(rq.getAge());
|
||||
if (rq.getGender() != null) u.setGender(rq.getGender());
|
||||
if (rq.getAvatar() != null) u.setAvatar(rq.getAvatar());
|
||||
|
||||
SubscriptionRefRq ref = rq.getSubscription();
|
||||
if (ref != null) {
|
||||
Subscription sub = null;
|
||||
if (ref.getId() != null) {
|
||||
try {
|
||||
Long id = Long.parseLong(ref.getId());
|
||||
sub = subscriptionRepository.findById(id).orElse(null);
|
||||
} catch (NumberFormatException ignored) {}
|
||||
}
|
||||
if (sub == null && (ref.getLevel() != null || ref.getPrice() != null)) {
|
||||
sub = new Subscription(null, ref.getLevel(), ref.getPrice());
|
||||
sub = subscriptionRepository.save(sub);
|
||||
}
|
||||
u.setSubscription(sub);
|
||||
} else {
|
||||
u.setSubscription(null);
|
||||
if (rq.getSubscriptionUntil() != null) {
|
||||
u.setSubscriptionUntil(parseDate(rq.getSubscriptionUntil()));
|
||||
}
|
||||
|
||||
if (rq.getSubscription() != null) {
|
||||
u.setSubscription(resolveSubscription(rq.getSubscription()));
|
||||
}
|
||||
|
||||
if (rq.getWatchlist() != null) {
|
||||
u.setWatchlist(resolveMovies(rq.getWatchlist()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,20 +59,62 @@ public class UserMapper {
|
||||
rs.setId(u.getId());
|
||||
rs.setName(u.getName());
|
||||
rs.setLogin(u.getLogin());
|
||||
rs.setIsAdmin(Boolean.TRUE.equals(u.getIsAdmin()));
|
||||
rs.setIsAdmin(u.getIsAdmin());
|
||||
rs.setAge(u.getAge());
|
||||
rs.setGender(u.getGender());
|
||||
rs.setAvatar(u.getAvatar());
|
||||
rs.setSubscriptionUntil(u.getSubscriptionUntil());
|
||||
rs.setWatchlist(u.getWatchlist());
|
||||
|
||||
|
||||
rs.setSubscriptionUntil(formatDate(u.getSubscriptionUntil()));
|
||||
|
||||
if (u.getSubscription() != null) {
|
||||
rs.setSubscription(new SubscriptionRs(
|
||||
u.getSubscription().getId(),
|
||||
u.getSubscription().getLevel(),
|
||||
u.getSubscription().getPrice()
|
||||
));
|
||||
Subscription s = u.getSubscription();
|
||||
SubscriptionRs srs = new SubscriptionRs();
|
||||
srs.setId(s.getId());
|
||||
srs.setLevel(s.getLevel());
|
||||
srs.setPrice(s.getPrice());
|
||||
rs.setSubscription(srs);
|
||||
}
|
||||
if (u.getWatchlist() != null) {
|
||||
rs.setWatchlist(u.getWatchlist().stream().map(Movie::getId).toList());
|
||||
} else {
|
||||
rs.setWatchlist(new ArrayList<>());
|
||||
}
|
||||
return rs;
|
||||
}
|
||||
|
||||
|
||||
private LocalDate parseDate(String s) {
|
||||
try { return s == null ? null : LocalDate.parse(s, DF); }
|
||||
catch (Exception ex) { return null; }
|
||||
}
|
||||
private String formatDate(LocalDate d) { return d == null ? null : DF.format(d); }
|
||||
|
||||
private Subscription resolveSubscription(SubscriptionRefRq ref) {
|
||||
if (ref == null) return null;
|
||||
|
||||
Long id = tryParseLong(ref.getId());
|
||||
if (id != null) {
|
||||
return subscriptionRepository.findById(id).orElse(null);
|
||||
}
|
||||
if (ref.getLevel() != null) {
|
||||
return subscriptionRepository.findByLevel(ref.getLevel())
|
||||
.orElseGet(() -> subscriptionRepository.save(new Subscription(null, ref.getLevel(), ref.getPrice())));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<Movie> resolveMovies(List<Long> ids) {
|
||||
List<Movie> list = new ArrayList<>();
|
||||
if (ids == null) return list;
|
||||
for (Long id : ids) {
|
||||
movieRepository.findById(id).ifPresent(list::add);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private Long tryParseLong(String s) {
|
||||
try { return s == null ? null : Long.parseLong(s); }
|
||||
catch (NumberFormatException e) { return null; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
package ru.ulstu.is.server.mvc;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
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.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import ru.ulstu.is.server.dto.DirectorRefRq;
|
||||
import ru.ulstu.is.server.dto.DirectorRs;
|
||||
import ru.ulstu.is.server.dto.GenreRefRq;
|
||||
import ru.ulstu.is.server.dto.GenreRs;
|
||||
import ru.ulstu.is.server.dto.MovieRq;
|
||||
import ru.ulstu.is.server.dto.MovieRs;
|
||||
import ru.ulstu.is.server.dto.PageHelper;
|
||||
import ru.ulstu.is.server.dto.PageRs;
|
||||
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;
|
||||
|
||||
@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,
|
||||
@RequestParam(required = false) Boolean exact,
|
||||
Model model
|
||||
) {
|
||||
int pageNumber = (page == null || page < 1) ? 1 : page;
|
||||
int pageSize = (size == null || size < 1) ? 12 : size;
|
||||
Boolean exactMatch = exact;
|
||||
|
||||
boolean filtersPassedInRequest
|
||||
= (title != null && !title.isBlank())
|
||||
|| genreId != null
|
||||
|| minGrade != null
|
||||
|| exact != 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();
|
||||
}
|
||||
if (movieSearchSession.getLastExactMatch() != null) {
|
||||
exactMatch = movieSearchSession.getLastExactMatch();
|
||||
}
|
||||
} else {
|
||||
movieSearchSession.setLastTitle(title);
|
||||
movieSearchSession.setLastGenreId(genreId);
|
||||
movieSearchSession.setLastMinGrade(minGrade);
|
||||
movieSearchSession.setLastExactMatch(exactMatch);
|
||||
}
|
||||
|
||||
movieSearchSession.setLastPageSize(pageSize);
|
||||
|
||||
boolean hasFilters
|
||||
= (title != null && !title.isBlank())
|
||||
|| genreId != null
|
||||
|| minGrade != null
|
||||
|| Boolean.TRUE.equals(exactMatch);
|
||||
|
||||
PageRs<MovieRs> pageRs;
|
||||
if (hasFilters) {
|
||||
pageRs = movieService.search(title, genreId, minGrade, exactMatch, 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);
|
||||
model.addAttribute("exactMatchFilter", Boolean.TRUE.equals(exactMatch));
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package ru.ulstu.is.server.mvc.admin;
|
||||
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/admin")
|
||||
public class AdminHomeController {
|
||||
|
||||
@GetMapping
|
||||
public String adminHome() {
|
||||
return "admin/index";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package ru.ulstu.is.server.mvc.admin;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import ru.ulstu.is.server.dto.DirectorRefRq;
|
||||
import ru.ulstu.is.server.dto.DirectorRs;
|
||||
import ru.ulstu.is.server.dto.GenreRefRq;
|
||||
import ru.ulstu.is.server.dto.GenreRs;
|
||||
import ru.ulstu.is.server.dto.MovieRq;
|
||||
import ru.ulstu.is.server.dto.MovieRs;
|
||||
import ru.ulstu.is.server.service.DirectorService;
|
||||
import ru.ulstu.is.server.service.GenreService;
|
||||
import ru.ulstu.is.server.service.MovieService;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/admin/movies")
|
||||
public class AdminMovieController {
|
||||
|
||||
private final MovieService movieService;
|
||||
private final GenreService genreService;
|
||||
private final DirectorService directorService;
|
||||
|
||||
public AdminMovieController(MovieService movieService,
|
||||
GenreService genreService,
|
||||
DirectorService directorService) {
|
||||
this.movieService = movieService;
|
||||
this.genreService = genreService;
|
||||
this.directorService = directorService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Список фильмов для админа
|
||||
*/
|
||||
@GetMapping
|
||||
public String list(Model model) {
|
||||
List<MovieRs> movies = movieService.getAll();
|
||||
model.addAttribute("movies", movies);
|
||||
return "admin/movies/list";
|
||||
}
|
||||
|
||||
/**
|
||||
* Форма создания нового фильма
|
||||
*/
|
||||
@GetMapping("/new")
|
||||
public String createForm(Model model) {
|
||||
MovieRq movie = new MovieRq();
|
||||
model.addAttribute("movie", movie);
|
||||
model.addAttribute("isEdit", false);
|
||||
prepareReferenceData(model);
|
||||
return "admin/movies/form";
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранение нового фильма
|
||||
*/
|
||||
@PostMapping
|
||||
public String create(@ModelAttribute("movie") @Valid MovieRq movieRq,
|
||||
BindingResult bindingResult,
|
||||
Model model) {
|
||||
if (bindingResult.hasErrors()) {
|
||||
model.addAttribute("isEdit", false);
|
||||
prepareReferenceData(model);
|
||||
return "admin/movies/form";
|
||||
}
|
||||
|
||||
movieService.create(movieRq);
|
||||
return "redirect:/admin/movies";
|
||||
}
|
||||
|
||||
/**
|
||||
* Форма редактирования фильма
|
||||
*/
|
||||
@GetMapping("/{id}")
|
||||
public String editForm(@PathVariable Long id, Model model) {
|
||||
MovieRs movieRs = movieService.get(id);
|
||||
MovieRq movieRq = toMovieRq(movieRs);
|
||||
|
||||
model.addAttribute("movie", movieRq);
|
||||
model.addAttribute("movieId", id);
|
||||
model.addAttribute("isEdit", true);
|
||||
prepareReferenceData(model);
|
||||
|
||||
return "admin/movies/form";
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление фильма
|
||||
*/
|
||||
@PostMapping("/{id}")
|
||||
public String update(@PathVariable Long id,
|
||||
@ModelAttribute("movie") @Valid MovieRq movieRq,
|
||||
BindingResult bindingResult,
|
||||
Model model) {
|
||||
if (bindingResult.hasErrors()) {
|
||||
model.addAttribute("movieId", id);
|
||||
model.addAttribute("isEdit", true);
|
||||
prepareReferenceData(model);
|
||||
return "admin/movies/form";
|
||||
}
|
||||
|
||||
movieService.update(id, movieRq);
|
||||
return "redirect:/admin/movies";
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаление фильма
|
||||
*/
|
||||
@PostMapping("/{id}/delete")
|
||||
public String delete(@PathVariable Long id) {
|
||||
movieService.delete(id);
|
||||
return "redirect:/admin/movies";
|
||||
}
|
||||
|
||||
/**
|
||||
* Подготовка списков жанров и режиссёров для select'ов в форме
|
||||
*/
|
||||
private void prepareReferenceData(Model model) {
|
||||
List<GenreRs> genres = genreService.getAll();
|
||||
List<DirectorRs> directors = directorService.getAll();
|
||||
model.addAttribute("genres", genres);
|
||||
model.addAttribute("directors", directors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Конвертация MovieRs (что вернул сервис) в MovieRq (что биндим к форме)
|
||||
*/
|
||||
private MovieRq toMovieRq(MovieRs rs) {
|
||||
MovieRq rq = new MovieRq();
|
||||
rq.setTitle(rs.getTitle());
|
||||
rq.setImage(rs.getImage());
|
||||
rq.setGrade(rs.getGrade());
|
||||
|
||||
if (rs.getGenre() != null) {
|
||||
GenreRefRq g = new GenreRefRq();
|
||||
g.setId(rs.getGenre().getId().toString());
|
||||
g.setName(rs.getGenre().getName());
|
||||
rq.setGenre(g);
|
||||
}
|
||||
|
||||
if (rs.getDirector() != null) {
|
||||
DirectorRefRq d = new DirectorRefRq();
|
||||
d.setId(rs.getDirector().getId().toString());
|
||||
d.setName(rs.getDirector().getName());
|
||||
rq.setDirector(d);
|
||||
}
|
||||
|
||||
return rq;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package ru.ulstu.is.server.mvc.admin;
|
||||
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import ru.ulstu.is.server.dto.SubscriptionRq;
|
||||
import ru.ulstu.is.server.service.SubscriptionService;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/admin/subscriptions")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public class AdminSubscriptionController {
|
||||
|
||||
private final SubscriptionService subscriptionService;
|
||||
|
||||
public AdminSubscriptionController(SubscriptionService subscriptionService) {
|
||||
this.subscriptionService = subscriptionService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public String list(Model model) {
|
||||
// можно брать либо сущности, либо Rs-дто — главное, чтобы в шаблоне совпадали поля
|
||||
model.addAttribute("subscriptions", subscriptionService.getAll());
|
||||
return "admin/subscriptions/list";
|
||||
}
|
||||
|
||||
@GetMapping("/new")
|
||||
public String createForm(Model model) {
|
||||
model.addAttribute("subscription", new SubscriptionRq());
|
||||
model.addAttribute("isEdit", false);
|
||||
return "admin/subscriptions/form";
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public String create(@Valid @ModelAttribute("subscription") SubscriptionRq rq,
|
||||
BindingResult bindingResult,
|
||||
Model model) {
|
||||
if (bindingResult.hasErrors()) {
|
||||
model.addAttribute("isEdit", false);
|
||||
return "admin/subscriptions/form";
|
||||
}
|
||||
subscriptionService.create(rq);
|
||||
return "redirect:/admin/subscriptions";
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public String editForm(@PathVariable Long id, Model model) {
|
||||
SubscriptionRq rq = subscriptionService.getSubscriptionRqById(id);
|
||||
model.addAttribute("subscription", rq);
|
||||
model.addAttribute("subscriptionId", id);
|
||||
model.addAttribute("isEdit", true);
|
||||
return "admin/subscriptions/form";
|
||||
}
|
||||
|
||||
@PostMapping("/{id}")
|
||||
public String update(@PathVariable Long id,
|
||||
@Valid @ModelAttribute("subscription") SubscriptionRq rq,
|
||||
BindingResult bindingResult,
|
||||
Model model) {
|
||||
if (bindingResult.hasErrors()) {
|
||||
model.addAttribute("subscriptionId", id);
|
||||
model.addAttribute("isEdit", true);
|
||||
return "admin/subscriptions/form";
|
||||
}
|
||||
subscriptionService.update(id, rq);
|
||||
return "redirect:/admin/subscriptions";
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/delete")
|
||||
public String delete(@PathVariable Long id) {
|
||||
subscriptionService.delete(id);
|
||||
return "redirect:/admin/subscriptions";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package ru.ulstu.is.server.mvc.auth;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import ru.ulstu.is.server.entity.User;
|
||||
import ru.ulstu.is.server.entity.UserRole;
|
||||
import ru.ulstu.is.server.repository.UserRepository;
|
||||
|
||||
@Controller
|
||||
@RequestMapping
|
||||
public class AuthMvcController {
|
||||
|
||||
private static final String ATTR_USER = "user";
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public AuthMvcController(UserRepository userRepository,
|
||||
PasswordEncoder passwordEncoder) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
@GetMapping("/signup")
|
||||
public String getSignup(Model model) {
|
||||
model.addAttribute(ATTR_USER, new SignupForm());
|
||||
return "signup";
|
||||
}
|
||||
|
||||
@PostMapping("/signup")
|
||||
public String postSignup(@ModelAttribute(ATTR_USER) @Valid SignupForm form,
|
||||
BindingResult bindingResult,
|
||||
Model model) {
|
||||
if (bindingResult.hasErrors()) {
|
||||
return "signup";
|
||||
}
|
||||
|
||||
if (userRepository.findByLoginIgnoreCase(form.getLogin()).isPresent()) {
|
||||
bindingResult.rejectValue("login", "signup.login.exists",
|
||||
"Пользователь с таким логином уже существует");
|
||||
return "signup";
|
||||
}
|
||||
|
||||
if (!Objects.equals(form.getPassword(), form.getPasswordConfirm())) {
|
||||
bindingResult.rejectValue("passwordConfirm", "signup.password.mismatch",
|
||||
"Пароли не совпадают");
|
||||
return "signup";
|
||||
}
|
||||
|
||||
User user = new User();
|
||||
user.setName(form.getName());
|
||||
user.setLogin(form.getLogin());
|
||||
user.setPassword(passwordEncoder.encode(form.getPassword()));
|
||||
user.setRole(UserRole.USER);
|
||||
try {
|
||||
user.getClass().getDeclaredField("isAdmin");
|
||||
user.getClass().getMethod("setIsAdmin", boolean.class).invoke(user, false);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
userRepository.save(user);
|
||||
|
||||
return "redirect:/login?signup";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package ru.ulstu.is.server.mvc.auth;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public class SignupForm {
|
||||
|
||||
@NotBlank
|
||||
@Size(min = 2, max = 50)
|
||||
private String name;
|
||||
|
||||
@NotBlank
|
||||
@Size(min = 3, max = 20)
|
||||
private String login;
|
||||
|
||||
@NotBlank
|
||||
@Size(min = 3, max = 60)
|
||||
private String password;
|
||||
|
||||
@NotBlank
|
||||
@Size(min = 3, max = 60)
|
||||
private String passwordConfirm;
|
||||
|
||||
public SignupForm() {
|
||||
}
|
||||
|
||||
public SignupForm(String name, String login, String password, String passwordConfirm) {
|
||||
this.name = name;
|
||||
this.login = login;
|
||||
this.password = password;
|
||||
this.passwordConfirm = passwordConfirm;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getLogin() {
|
||||
return login;
|
||||
}
|
||||
|
||||
public void setLogin(String login) {
|
||||
this.login = login;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public void setPassword(String password) {
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public String getPasswordConfirm() {
|
||||
return passwordConfirm;
|
||||
}
|
||||
|
||||
public void setPasswordConfirm(String passwordConfirm) {
|
||||
this.passwordConfirm = passwordConfirm;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package ru.ulstu.is.server.repository;
|
||||
|
||||
import ru.ulstu.is.server.entity.BaseEntity;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface CommonRepository<T extends BaseEntity> {
|
||||
List<T> findAll();
|
||||
Optional<T> findById(Long id);
|
||||
T save(T entity);
|
||||
void deleteById(Long id);
|
||||
void delete(T entity);
|
||||
long count();
|
||||
}
|
||||
@@ -1,5 +1,41 @@
|
||||
package ru.ulstu.is.server.repository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public class DirectorRepository extends MapRepository<ru.ulstu.is.server.entity.Director> { }
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
import ru.ulstu.is.server.entity.Director;
|
||||
|
||||
public interface DirectorRepository extends JpaRepository<Director, Long> {
|
||||
|
||||
Optional<Director> findByName(String name);
|
||||
|
||||
@Query("""
|
||||
select
|
||||
d.name as director,
|
||||
count(m.id) as moviesCount,
|
||||
avg(m.grade) as avgMovieGrade,
|
||||
max(m.grade) as maxMovieGrade,
|
||||
min(m.grade) as minMovieGrade
|
||||
from Director d
|
||||
left join d.movies m
|
||||
group by d.name
|
||||
order by moviesCount desc
|
||||
""")
|
||||
List<DirectorStats> getDirectorStats();
|
||||
|
||||
interface DirectorStats {
|
||||
|
||||
String getDirector();
|
||||
|
||||
long getMoviesCount();
|
||||
|
||||
float getAvgMovieGrade();
|
||||
|
||||
float getMaxMovieGrade();
|
||||
|
||||
float getMinMovieGrade();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,41 @@
|
||||
package ru.ulstu.is.server.repository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
import ru.ulstu.is.server.entity.Genre;
|
||||
|
||||
@Repository
|
||||
public class GenreRepository extends MapRepository<Genre> { }
|
||||
public interface GenreRepository extends JpaRepository<Genre, Long> {
|
||||
|
||||
Optional<Genre> findByName(String name);
|
||||
|
||||
@Query("""
|
||||
select
|
||||
g.name as genre,
|
||||
count(m.id) as moviesCount,
|
||||
avg(m.grade) as avgMovieGrade,
|
||||
max(m.grade) as maxMovieGrade,
|
||||
min(m.grade) as minMovieGrade
|
||||
from Genre g
|
||||
left join g.movies m
|
||||
group by g.name
|
||||
order by moviesCount desc
|
||||
""")
|
||||
List<GenreStats> getGenreStats();
|
||||
|
||||
interface GenreStats {
|
||||
|
||||
String getGenre();
|
||||
|
||||
long getMoviesCount();
|
||||
|
||||
float getAvgMovieGrade();
|
||||
|
||||
float getMaxMovieGrade();
|
||||
|
||||
float getMinMovieGrade();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
package ru.ulstu.is.server.repository;
|
||||
|
||||
import ru.ulstu.is.server.entity.BaseEntity;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentSkipListMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
public abstract class MapRepository<T extends BaseEntity> implements CommonRepository<T> {
|
||||
|
||||
protected final ConcurrentSkipListMap<Long, T> storage = new ConcurrentSkipListMap<>();
|
||||
protected final AtomicLong seq = new AtomicLong(0L);
|
||||
|
||||
@Override
|
||||
public List<T> findAll() {
|
||||
return new ArrayList<>(storage.values());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<T> findById(Long id) {
|
||||
return Optional.ofNullable(storage.get(id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public T save(T entity) {
|
||||
if (entity.getId() == null) {
|
||||
entity.setId(seq.incrementAndGet());
|
||||
}
|
||||
storage.put(entity.getId(), entity);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteById(Long id) {
|
||||
storage.remove(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(T entity) {
|
||||
if (entity != null && entity.getId() != null) {
|
||||
storage.remove(entity.getId());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public long count() {
|
||||
return storage.size();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,24 @@
|
||||
package ru.ulstu.is.server.repository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public class MovieRepository extends MapRepository<ru.ulstu.is.server.entity.Movie> { }
|
||||
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 ru.ulstu.is.server.entity.Movie;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface MovieRepository extends JpaRepository<Movie, Long> {
|
||||
Page<Movie> findAll(Pageable pageable);
|
||||
|
||||
List<Movie> findByGenre_Name(String name);
|
||||
List<Movie> findByDirector_Name(String name);
|
||||
|
||||
@Query("""
|
||||
select m.director.name as director, count(m.id) as moviesCount
|
||||
from Movie m group by m.director.name order by moviesCount desc
|
||||
""")
|
||||
List<TopDirectors> findTopDirectors();
|
||||
|
||||
interface TopDirectors { String getDirector(); long getMoviesCount(); }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
package ru.ulstu.is.server.repository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public class SubscriptionRepository extends MapRepository<ru.ulstu.is.server.entity.Subscription> { }
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import ru.ulstu.is.server.entity.Subscription;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface SubscriptionRepository extends JpaRepository<Subscription, Long> {
|
||||
Optional<Subscription> findByLevel(String level);
|
||||
|
||||
@Query("""
|
||||
select s.level as level, s.price as price, count(u.id) as usersCount
|
||||
from Subscription s left join s.users u
|
||||
group by s.level, s.price
|
||||
order by usersCount desc
|
||||
""")
|
||||
List<SubscriptionUsage> usageStats();
|
||||
|
||||
interface SubscriptionUsage {
|
||||
String getLevel();
|
||||
Integer getPrice();
|
||||
long getUsersCount();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,53 @@
|
||||
package ru.ulstu.is.server.repository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public class UserRepository extends MapRepository<ru.ulstu.is.server.entity.User> { }
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
import ru.ulstu.is.server.entity.User;
|
||||
|
||||
public interface UserRepository extends JpaRepository<User, Long> {
|
||||
|
||||
Optional<User> findByLogin(String login);
|
||||
|
||||
List<User> findByIsAdmin(boolean isAdmin);
|
||||
|
||||
List<User> findBySubscription_Level(String level);
|
||||
|
||||
Optional<User> findByLoginIgnoreCase(String login);
|
||||
|
||||
List<User> findByAgeGreaterThanEqual(Integer age);
|
||||
|
||||
@Query("""
|
||||
select s.level as level, count(u.id) as usersCount
|
||||
from User u join u.subscription s
|
||||
group by s.level
|
||||
order by usersCount desc
|
||||
""")
|
||||
List<UsersBySubscription> countUsersBySubscription();
|
||||
|
||||
interface UsersBySubscription {
|
||||
|
||||
String getLevel();
|
||||
|
||||
long getUsersCount();
|
||||
}
|
||||
|
||||
@Query("""
|
||||
select s.level as level, avg(u.age) as avgAge
|
||||
from User u join u.subscription s
|
||||
where u.age is not null
|
||||
group by s.level
|
||||
order by avgAge desc
|
||||
""")
|
||||
List<AvgAgeBySubscription> avgAgeBySubscription();
|
||||
|
||||
interface AvgAgeBySubscription {
|
||||
|
||||
String getLevel();
|
||||
|
||||
Double getAvgAge();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package ru.ulstu.is.server.security;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
@Configuration
|
||||
public class PasswordEncoderConfiguration {
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package ru.ulstu.is.server.security;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
@Configuration
|
||||
@EnableMethodSecurity
|
||||
public class SecurityMvcConfiguration {
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
|
||||
http.csrf(csrf -> csrf.disable());
|
||||
|
||||
http.cors(Customizer.withDefaults());
|
||||
|
||||
http.authorizeHttpRequests(req -> req
|
||||
.requestMatchers(
|
||||
"/css/**",
|
||||
"/js/**",
|
||||
"/images/**",
|
||||
"/webjars/**",
|
||||
"/icon.svg"
|
||||
).permitAll()
|
||||
.requestMatchers("/login", "/signup").permitAll()
|
||||
.requestMatchers("/admin/**").hasRole("ADMIN")
|
||||
.anyRequest().authenticated()
|
||||
);
|
||||
|
||||
http.formLogin(form -> form
|
||||
.loginPage("/login")
|
||||
.defaultSuccessUrl("/movies", true)
|
||||
.permitAll()
|
||||
);
|
||||
|
||||
http.logout(logout -> logout
|
||||
.logoutUrl("/logout")
|
||||
.logoutSuccessUrl("/login?logout")
|
||||
.deleteCookies("JSESSIONID")
|
||||
.permitAll()
|
||||
);
|
||||
|
||||
http.headers(headers -> headers
|
||||
.frameOptions(frame -> frame.sameOrigin())
|
||||
);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package ru.ulstu.is.server.security;
|
||||
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import ru.ulstu.is.server.entity.User;
|
||||
import ru.ulstu.is.server.repository.UserRepository;
|
||||
|
||||
@Service
|
||||
public class UserAuthService implements UserDetailsService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public UserAuthService(UserRepository userRepository) {
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||
User user = userRepository.findByLoginIgnoreCase(username)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
|
||||
|
||||
return new UserPrincipal(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package ru.ulstu.is.server.security;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
|
||||
import ru.ulstu.is.server.entity.User;
|
||||
import ru.ulstu.is.server.entity.UserRole;
|
||||
|
||||
public class UserPrincipal implements UserDetails {
|
||||
|
||||
private final Long id;
|
||||
private final String username;
|
||||
private final String password;
|
||||
private final Set<? extends GrantedAuthority> authorities;
|
||||
private final boolean active;
|
||||
|
||||
public UserPrincipal(User user) {
|
||||
this.id = user.getId();
|
||||
this.username = user.getLogin();
|
||||
this.password = user.getPassword();
|
||||
|
||||
UserRole role = user.getRole();
|
||||
if (role == null) {
|
||||
role = UserRole.USER;
|
||||
}
|
||||
this.authorities = Set.of(role);
|
||||
this.active = true;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
return authorities;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAccountNonExpired() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAccountNonLocked() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCredentialsNonExpired() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return active;
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,12 @@ import ru.ulstu.is.server.dto.DirectorRs;
|
||||
import ru.ulstu.is.server.entity.Director;
|
||||
import ru.ulstu.is.server.mapper.DirectorMapper;
|
||||
import ru.ulstu.is.server.repository.DirectorRepository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@Transactional
|
||||
public class DirectorService {
|
||||
private final DirectorRepository repo;
|
||||
private final DirectorMapper mapper;
|
||||
@@ -19,10 +21,12 @@ public class DirectorService {
|
||||
this.repo = repo; this.mapper = mapper;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<DirectorRs> getAll() {
|
||||
return repo.findAll().stream().map(mapper::toRs).toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public DirectorRs get(Long id) {
|
||||
return mapper.toRs(getEntity(id));
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@ import ru.ulstu.is.server.dto.GenreRs;
|
||||
import ru.ulstu.is.server.entity.Genre;
|
||||
import ru.ulstu.is.server.mapper.GenreMapper;
|
||||
import ru.ulstu.is.server.repository.GenreRepository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@Transactional
|
||||
public class GenreService {
|
||||
private final GenreRepository repo;
|
||||
private final GenreMapper mapper;
|
||||
@@ -19,10 +21,12 @@ public class GenreService {
|
||||
this.repo = repo; this.mapper = mapper;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<GenreRs> getAll() {
|
||||
return repo.findAll().stream().map(mapper::toRs).toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public GenreRs get(Long id) {
|
||||
return mapper.toRs(getEntity(id));
|
||||
}
|
||||
|
||||
@@ -1,55 +1,210 @@
|
||||
package ru.ulstu.is.server.service;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import ru.ulstu.is.server.api.NotFoundException;
|
||||
import ru.ulstu.is.server.dto.MovieRq;
|
||||
import ru.ulstu.is.server.dto.MovieRs;
|
||||
import ru.ulstu.is.server.entity.Movie;
|
||||
import ru.ulstu.is.server.mapper.MovieMapper;
|
||||
import ru.ulstu.is.server.repository.MovieRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import ru.ulstu.is.server.api.NotFoundException;
|
||||
import ru.ulstu.is.server.dto.DirectorRefRq;
|
||||
import ru.ulstu.is.server.dto.GenreRefRq;
|
||||
import ru.ulstu.is.server.dto.MovieRq;
|
||||
import ru.ulstu.is.server.dto.MovieRs;
|
||||
import ru.ulstu.is.server.entity.Director;
|
||||
import ru.ulstu.is.server.entity.Genre;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import ru.ulstu.is.server.dto.PageHelper;
|
||||
import ru.ulstu.is.server.dto.PageRs;
|
||||
import ru.ulstu.is.server.entity.Movie;
|
||||
import ru.ulstu.is.server.mapper.MovieMapper;
|
||||
import ru.ulstu.is.server.repository.DirectorRepository;
|
||||
import ru.ulstu.is.server.repository.GenreRepository;
|
||||
import ru.ulstu.is.server.repository.MovieRepository;
|
||||
|
||||
@Service
|
||||
@Transactional
|
||||
public class MovieService {
|
||||
private final MovieRepository repo;
|
||||
|
||||
private final MovieRepository movies;
|
||||
private final GenreRepository genres;
|
||||
private final DirectorRepository directors;
|
||||
private final MovieMapper mapper;
|
||||
|
||||
public MovieService(MovieRepository repo, MovieMapper mapper) {
|
||||
this.repo = repo; this.mapper = mapper;
|
||||
public MovieService(MovieRepository movies, GenreRepository genres,
|
||||
DirectorRepository directors, MovieMapper mapper) {
|
||||
this.movies = movies;
|
||||
this.genres = genres;
|
||||
this.directors = directors;
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<MovieRs> getAll() {
|
||||
return repo.findAll().stream().map(mapper::toRs).toList();
|
||||
return movies.findAll().stream().map(mapper::toRs).toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageRs<MovieRs> getPage(Pageable pageable) {
|
||||
return PageRs.from(movies.findAll(pageable), mapper::toRs);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageRs<MovieRs> search(String title,
|
||||
Long genreId,
|
||||
Float minGrade,
|
||||
Boolean exactMatch,
|
||||
int page,
|
||||
int size) {
|
||||
List<MovieRs> all = getAll();
|
||||
var stream = all.stream();
|
||||
|
||||
if (title != null && !title.isBlank()) {
|
||||
if (Boolean.TRUE.equals(exactMatch)) {
|
||||
String t = title;
|
||||
stream = stream.filter(m
|
||||
-> m.getTitle() != null && m.getTitle().equalsIgnoreCase(t)
|
||||
);
|
||||
} else {
|
||||
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) {
|
||||
return mapper.toRs(getEntity(id));
|
||||
Movie m = movies.findById(id).orElseThrow(() -> new NotFoundException(ru.ulstu.is.server.entity.Movie.class, "movie " + id + " not found"));
|
||||
return mapper.toRs(m);
|
||||
}
|
||||
|
||||
public MovieRs create(MovieRq rq) {
|
||||
Movie e = mapper.toEntity(rq);
|
||||
e = repo.save(e);
|
||||
return mapper.toRs(e);
|
||||
Movie e = new Movie();
|
||||
e.setTitle(rq.getTitle());
|
||||
e.setImage(rq.getImage());
|
||||
e.setGrade(rq.getGrade());
|
||||
|
||||
Genre g = resolveGenre(rq.getGenre());
|
||||
Director d = resolveDirector(rq.getDirector());
|
||||
e.setGenre(g);
|
||||
e.setDirector(d);
|
||||
|
||||
Movie saved = movies.save(e);
|
||||
return mapper.toRs(saved);
|
||||
}
|
||||
|
||||
public MovieRs update(Long id, MovieRq rq) {
|
||||
Movie existing = getEntity(id);
|
||||
existing.setTitle(rq.getTitle());
|
||||
existing.setImage(rq.getImage());
|
||||
Movie tmp = mapper.toEntity(rq);
|
||||
existing.setGenre(tmp.getGenre());
|
||||
existing.setDirector(tmp.getDirector());
|
||||
existing = repo.save(existing);
|
||||
return mapper.toRs(existing);
|
||||
Movie e = movies.findById(id).orElseThrow(() -> new NotFoundException(ru.ulstu.is.server.entity.Movie.class, "movie " + id + " not found"));
|
||||
e.setTitle(rq.getTitle());
|
||||
e.setImage(rq.getImage());
|
||||
e.setGrade(rq.getGrade());
|
||||
|
||||
if (rq.getGenre() != null) {
|
||||
e.setGenre(resolveGenre(rq.getGenre()));
|
||||
}
|
||||
if (rq.getDirector() != null) {
|
||||
e.setDirector(resolveDirector(rq.getDirector()));
|
||||
}
|
||||
|
||||
return mapper.toRs(movies.save(e));
|
||||
}
|
||||
|
||||
public void delete(Long id) {
|
||||
getEntity(id);
|
||||
repo.deleteById(id);
|
||||
movies.deleteById(id);
|
||||
}
|
||||
|
||||
Movie getEntity(Long id) {
|
||||
return repo.findById(id).orElseThrow(() -> new NotFoundException(Movie.class, String.valueOf(id)));
|
||||
private Genre resolveGenre(GenreRefRq ref) {
|
||||
if (ref == null) {
|
||||
return null;
|
||||
}
|
||||
if (ref.getId() != null) {
|
||||
Long id = tryParse(ref.getId());
|
||||
if (id != null) {
|
||||
return genres.findById(id).orElseThrow();
|
||||
}
|
||||
}
|
||||
if (ref.getName() != null) {
|
||||
return genres.findByName(ref.getName()).orElseGet(()
|
||||
-> genres.save(new Genre(null, ref.getName())));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Director resolveDirector(DirectorRefRq ref) {
|
||||
if (ref == null) {
|
||||
return null;
|
||||
}
|
||||
if (ref.getId() != null) {
|
||||
Long id = tryParse(ref.getId());
|
||||
if (id != null) {
|
||||
return directors.findById(id).orElseThrow();
|
||||
}
|
||||
}
|
||||
if (ref.getName() != null) {
|
||||
return directors.findByName(ref.getName()).orElseGet(()
|
||||
-> directors.save(new Director(null, ref.getName())));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Long tryParse(String s) {
|
||||
try {
|
||||
return s == null ? null : Long.parseLong(s);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,81 @@
|
||||
package ru.ulstu.is.server.service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import ru.ulstu.is.server.api.NotFoundException;
|
||||
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.entity.Subscription;
|
||||
import ru.ulstu.is.server.mapper.SubscriptionMapper;
|
||||
import ru.ulstu.is.server.repository.SubscriptionRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@Transactional
|
||||
public class SubscriptionService {
|
||||
|
||||
private final SubscriptionRepository repo;
|
||||
private final SubscriptionMapper mapper;
|
||||
|
||||
public SubscriptionService(SubscriptionRepository repo, SubscriptionMapper mapper) {
|
||||
this.repo = repo; this.mapper = mapper;
|
||||
this.repo = repo;
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<SubscriptionRs> getAll() {
|
||||
return repo.findAll().stream().map(mapper::toRs).toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public SubscriptionRs get(Long id) {
|
||||
return mapper.toRs(getEntity(id));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public SubscriptionRq getSubscriptionRqById(Long id) {
|
||||
Subscription s = getEntity(id);
|
||||
|
||||
SubscriptionRq rq = new SubscriptionRq();
|
||||
rq.setLevel(s.getLevel());
|
||||
rq.setPrice(s.getPrice());
|
||||
rq.setDiscount(s.getDiscount());
|
||||
|
||||
return rq;
|
||||
}
|
||||
|
||||
public SubscriptionRs create(SubscriptionRq rq) {
|
||||
Subscription s = mapper.toEntity(rq);
|
||||
s = repo.save(s);
|
||||
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);
|
||||
|
||||
@@ -7,10 +7,12 @@ import ru.ulstu.is.server.dto.UserRs;
|
||||
import ru.ulstu.is.server.entity.User;
|
||||
import ru.ulstu.is.server.mapper.UserMapper;
|
||||
import ru.ulstu.is.server.repository.UserRepository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@Transactional
|
||||
public class UserService {
|
||||
private final UserRepository repo;
|
||||
private final UserMapper mapper;
|
||||
@@ -19,10 +21,12 @@ public class UserService {
|
||||
this.repo = repo; this.mapper = mapper;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<UserRs> getAll() {
|
||||
return repo.findAll().stream().map(mapper::toRs).toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public UserRs get(Long id) {
|
||||
return mapper.toRs(getEntity(id));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
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;
|
||||
private Boolean lastExactMatch;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public Boolean getLastExactMatch() {
|
||||
return lastExactMatch;
|
||||
}
|
||||
|
||||
public void setLastExactMatch(Boolean lastExactMatch) {
|
||||
this.lastExactMatch = lastExactMatch;
|
||||
}
|
||||
}
|
||||
19
backend/src/main/resources/application-dev.yml
Normal file
19
backend/src/main/resources/application-dev.yml
Normal file
@@ -0,0 +1,19 @@
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:h2:file:./data/appdb
|
||||
username: sa
|
||||
password:
|
||||
driver-class-name: org.h2.Driver
|
||||
|
||||
h2:
|
||||
console:
|
||||
enabled: true
|
||||
|
||||
jpa:
|
||||
show-sql: true
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
logging:
|
||||
level:
|
||||
org.springdoc: WARN
|
||||
4
backend/src/main/resources/application-front.yml
Normal file
4
backend/src/main/resources/application-front.yml
Normal file
@@ -0,0 +1,4 @@
|
||||
spring:
|
||||
web:
|
||||
resources:
|
||||
add-mappings: true
|
||||
9
backend/src/main/resources/application-prod.yml
Normal file
9
backend/src/main/resources/application-prod.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:postgresql://127.0.0.1:5432/internet_lab4
|
||||
username: postgres
|
||||
password: 1234
|
||||
driver-class-name: org.postgresql.Driver
|
||||
|
||||
jpa:
|
||||
show-sql: false
|
||||
@@ -1,6 +0,0 @@
|
||||
spring.main.banner-mode=off
|
||||
spring.application.name=server
|
||||
server.port=8080
|
||||
|
||||
# logging
|
||||
logging.level.ru.ulstu.is.server=DEBUG
|
||||
19
backend/src/main/resources/application.yml
Normal file
19
backend/src/main/resources/application.yml
Normal file
@@ -0,0 +1,19 @@
|
||||
spring:
|
||||
liquibase:
|
||||
change-log: classpath:db/changelog/db.changelog-master.yaml
|
||||
application:
|
||||
name: server
|
||||
main:
|
||||
banner-mode: off
|
||||
profiles:
|
||||
active: dev
|
||||
jpa:
|
||||
open-in-view: false
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
servlet:
|
||||
session:
|
||||
tracking-modes: cookie
|
||||
@@ -0,0 +1,26 @@
|
||||
databaseChangeLog:
|
||||
- changeSet:
|
||||
id: add-review-table
|
||||
author: busla
|
||||
changes:
|
||||
- createTable:
|
||||
tableName: reviews
|
||||
columns:
|
||||
- column:
|
||||
name: id
|
||||
type: BIGINT
|
||||
autoIncrement: true
|
||||
constraints:
|
||||
primaryKey: true
|
||||
nullable: false
|
||||
- column:
|
||||
name: movie_id
|
||||
type: BIGINT
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: text
|
||||
type: VARCHAR(1000)
|
||||
- column:
|
||||
name: rating
|
||||
type: INT
|
||||
@@ -0,0 +1,14 @@
|
||||
databaseChangeLog:
|
||||
- changeSet:
|
||||
id: 4-add-subscription-discount
|
||||
author: busla
|
||||
changes:
|
||||
- addColumn:
|
||||
tableName: SUBSCRIPTIONS
|
||||
columns:
|
||||
- column:
|
||||
name: DISCOUNT
|
||||
type: INT
|
||||
defaultValueNumeric: 0
|
||||
constraints:
|
||||
nullable: false
|
||||
@@ -0,0 +1,14 @@
|
||||
databaseChangeLog:
|
||||
- changeSet:
|
||||
id: 7-add-user-role
|
||||
author: you
|
||||
changes:
|
||||
- addColumn:
|
||||
tableName: USERS
|
||||
columns:
|
||||
- column:
|
||||
name: ROLE
|
||||
type: VARCHAR(20)
|
||||
defaultValue: 'USER'
|
||||
constraints:
|
||||
nullable: false
|
||||
@@ -0,0 +1,334 @@
|
||||
databaseChangeLog:
|
||||
- changeSet:
|
||||
id: 1763112391251-1
|
||||
author: busla (generated)
|
||||
changes:
|
||||
- createTable:
|
||||
columns:
|
||||
- column:
|
||||
autoIncrement: true
|
||||
constraints:
|
||||
nullable: false
|
||||
primaryKey: true
|
||||
primaryKeyName: CONSTRAINT_6
|
||||
name: ID
|
||||
type: BIGINT
|
||||
- column:
|
||||
constraints:
|
||||
nullable: false
|
||||
name: NAME
|
||||
type: VARCHAR(255)
|
||||
tableName: DIRECTORS
|
||||
- changeSet:
|
||||
id: 1763112391251-2
|
||||
author: busla (generated)
|
||||
changes:
|
||||
- createTable:
|
||||
columns:
|
||||
- column:
|
||||
autoIncrement: true
|
||||
constraints:
|
||||
nullable: false
|
||||
primaryKey: true
|
||||
primaryKeyName: CONSTRAINT_7
|
||||
name: ID
|
||||
type: BIGINT
|
||||
- column:
|
||||
constraints:
|
||||
nullable: false
|
||||
name: NAME
|
||||
type: VARCHAR(255)
|
||||
tableName: GENRES
|
||||
- changeSet:
|
||||
id: 1763112391251-3
|
||||
author: busla (generated)
|
||||
changes:
|
||||
- createTable:
|
||||
columns:
|
||||
- column:
|
||||
autoIncrement: true
|
||||
constraints:
|
||||
nullable: false
|
||||
primaryKey: true
|
||||
primaryKeyName: CONSTRAINT_8
|
||||
name: ID
|
||||
type: BIGINT
|
||||
- column:
|
||||
constraints:
|
||||
nullable: false
|
||||
name: GRADE
|
||||
type: REAL
|
||||
- column:
|
||||
name: IMAGE
|
||||
type: CHARACTER LARGE OBJECT
|
||||
- column:
|
||||
constraints:
|
||||
nullable: false
|
||||
name: TITLE
|
||||
type: VARCHAR(255)
|
||||
- column:
|
||||
constraints:
|
||||
nullable: false
|
||||
name: DIRECTOR_ID
|
||||
type: BIGINT
|
||||
- column:
|
||||
constraints:
|
||||
nullable: false
|
||||
name: GENRE_ID
|
||||
type: BIGINT
|
||||
tableName: MOVIES
|
||||
- changeSet:
|
||||
id: 1763112391251-4
|
||||
author: busla (generated)
|
||||
changes:
|
||||
- createTable:
|
||||
columns:
|
||||
- column:
|
||||
autoIncrement: true
|
||||
constraints:
|
||||
nullable: false
|
||||
primaryKey: true
|
||||
primaryKeyName: CONSTRAINT_3
|
||||
name: ID
|
||||
type: BIGINT
|
||||
- column:
|
||||
constraints:
|
||||
nullable: false
|
||||
name: LEVEL
|
||||
type: VARCHAR(255)
|
||||
- column:
|
||||
constraints:
|
||||
nullable: false
|
||||
name: PRICE
|
||||
type: INT
|
||||
tableName: SUBSCRIPTIONS
|
||||
- changeSet:
|
||||
id: 1763112391251-5
|
||||
author: busla (generated)
|
||||
changes:
|
||||
- createTable:
|
||||
columns:
|
||||
- column:
|
||||
autoIncrement: true
|
||||
constraints:
|
||||
nullable: false
|
||||
primaryKey: true
|
||||
primaryKeyName: CONSTRAINT_4
|
||||
name: ID
|
||||
type: BIGINT
|
||||
- column:
|
||||
name: AGE
|
||||
type: INT
|
||||
- column:
|
||||
name: AVATAR
|
||||
type: CHARACTER LARGE OBJECT
|
||||
- column:
|
||||
name: GENDER
|
||||
type: VARCHAR(255)
|
||||
- column:
|
||||
constraints:
|
||||
nullable: false
|
||||
name: IS_ADMIN
|
||||
type: BOOLEAN
|
||||
- column:
|
||||
constraints:
|
||||
nullable: false
|
||||
name: LOGIN
|
||||
type: VARCHAR(255)
|
||||
- column:
|
||||
constraints:
|
||||
nullable: false
|
||||
name: NAME
|
||||
type: VARCHAR(255)
|
||||
- column:
|
||||
constraints:
|
||||
nullable: false
|
||||
name: PASSWORD
|
||||
type: VARCHAR(255)
|
||||
- column:
|
||||
name: SUBSCRIPTION_UNTIL
|
||||
type: date
|
||||
- column:
|
||||
name: SUBSCRIPTION_ID
|
||||
type: BIGINT
|
||||
tableName: USERS
|
||||
- changeSet:
|
||||
id: 1763112391251-6
|
||||
author: busla (generated)
|
||||
changes:
|
||||
- createTable:
|
||||
columns:
|
||||
- column:
|
||||
constraints:
|
||||
nullable: false
|
||||
name: USER_ID
|
||||
type: BIGINT
|
||||
- column:
|
||||
constraints:
|
||||
nullable: false
|
||||
name: MOVIE_ID
|
||||
type: BIGINT
|
||||
tableName: USER_WATCHLIST
|
||||
- changeSet:
|
||||
id: 1763112391251-7
|
||||
author: busla (generated)
|
||||
changes:
|
||||
- addUniqueConstraint:
|
||||
columnNames: LEVEL
|
||||
constraintName: UKAVRYNEIAOOG6H0RWOC9QAHFGU
|
||||
tableName: SUBSCRIPTIONS
|
||||
- changeSet:
|
||||
id: 1763112391251-8
|
||||
author: busla (generated)
|
||||
changes:
|
||||
- addUniqueConstraint:
|
||||
columnNames: LOGIN
|
||||
constraintName: UKOW0GAN20590JRB00UPG3VA2FN
|
||||
tableName: USERS
|
||||
- changeSet:
|
||||
id: 1763112391251-9
|
||||
author: busla (generated)
|
||||
changes:
|
||||
- addUniqueConstraint:
|
||||
columnNames: NAME
|
||||
constraintName: UKPE1A9WOIK1K97L87CIEGUYHH4
|
||||
tableName: GENRES
|
||||
- changeSet:
|
||||
id: 1763112391251-10
|
||||
author: busla (generated)
|
||||
changes:
|
||||
- addUniqueConstraint:
|
||||
columnNames: NAME
|
||||
constraintName: UKT6U48CMKTDMYIEUC2B6Y4UMN
|
||||
tableName: DIRECTORS
|
||||
- changeSet:
|
||||
id: 1763112391251-11
|
||||
author: busla (generated)
|
||||
changes:
|
||||
- createIndex:
|
||||
associatedWith: ''
|
||||
columns:
|
||||
- column:
|
||||
name: USER_ID
|
||||
indexName: FK1A2SF5HA20F8A3SQGO3N4H3W6_INDEX_5
|
||||
tableName: USER_WATCHLIST
|
||||
- changeSet:
|
||||
id: 1763112391251-12
|
||||
author: busla (generated)
|
||||
changes:
|
||||
- createIndex:
|
||||
associatedWith: ''
|
||||
columns:
|
||||
- column:
|
||||
name: DIRECTOR_ID
|
||||
indexName: FK5FT3U8K962BMJD8RN2MR77J8D_INDEX_8
|
||||
tableName: MOVIES
|
||||
- changeSet:
|
||||
id: 1763112391251-13
|
||||
author: busla (generated)
|
||||
changes:
|
||||
- createIndex:
|
||||
associatedWith: ''
|
||||
columns:
|
||||
- column:
|
||||
name: MOVIE_ID
|
||||
indexName: FK9FHBUF2RH3U2D0CITGJX86ID9_INDEX_5
|
||||
tableName: USER_WATCHLIST
|
||||
- changeSet:
|
||||
id: 1763112391251-14
|
||||
author: busla (generated)
|
||||
changes:
|
||||
- createIndex:
|
||||
associatedWith: ''
|
||||
columns:
|
||||
- column:
|
||||
name: SUBSCRIPTION_ID
|
||||
indexName: FKFWX079XWW5UYFBPI9U8GWAM34_INDEX_4
|
||||
tableName: USERS
|
||||
- changeSet:
|
||||
id: 1763112391251-15
|
||||
author: busla (generated)
|
||||
changes:
|
||||
- createIndex:
|
||||
associatedWith: ''
|
||||
columns:
|
||||
- column:
|
||||
name: GENRE_ID
|
||||
indexName: FKJP8FSY8A0KKMDI04I81V05C6A_INDEX_8
|
||||
tableName: MOVIES
|
||||
- changeSet:
|
||||
id: 1763112391251-16
|
||||
author: busla (generated)
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseColumnNames: USER_ID
|
||||
baseTableName: USER_WATCHLIST
|
||||
constraintName: FK1A2SF5HA20F8A3SQGO3N4H3W6
|
||||
deferrable: false
|
||||
initiallyDeferred: false
|
||||
onDelete: RESTRICT
|
||||
onUpdate: RESTRICT
|
||||
referencedColumnNames: ID
|
||||
referencedTableName: USERS
|
||||
validate: true
|
||||
- changeSet:
|
||||
id: 1763112391251-17
|
||||
author: busla (generated)
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseColumnNames: DIRECTOR_ID
|
||||
baseTableName: MOVIES
|
||||
constraintName: FK5FT3U8K962BMJD8RN2MR77J8D
|
||||
deferrable: false
|
||||
initiallyDeferred: false
|
||||
onDelete: RESTRICT
|
||||
onUpdate: RESTRICT
|
||||
referencedColumnNames: ID
|
||||
referencedTableName: DIRECTORS
|
||||
validate: true
|
||||
- changeSet:
|
||||
id: 1763112391251-18
|
||||
author: busla (generated)
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseColumnNames: MOVIE_ID
|
||||
baseTableName: USER_WATCHLIST
|
||||
constraintName: FK9FHBUF2RH3U2D0CITGJX86ID9
|
||||
deferrable: false
|
||||
initiallyDeferred: false
|
||||
onDelete: RESTRICT
|
||||
onUpdate: RESTRICT
|
||||
referencedColumnNames: ID
|
||||
referencedTableName: MOVIES
|
||||
validate: true
|
||||
- changeSet:
|
||||
id: 1763112391251-19
|
||||
author: busla (generated)
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseColumnNames: SUBSCRIPTION_ID
|
||||
baseTableName: USERS
|
||||
constraintName: FKFWX079XWW5UYFBPI9U8GWAM34
|
||||
deferrable: false
|
||||
initiallyDeferred: false
|
||||
onDelete: RESTRICT
|
||||
onUpdate: RESTRICT
|
||||
referencedColumnNames: ID
|
||||
referencedTableName: SUBSCRIPTIONS
|
||||
validate: true
|
||||
- changeSet:
|
||||
id: 1763112391251-20
|
||||
author: busla (generated)
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseColumnNames: GENRE_ID
|
||||
baseTableName: MOVIES
|
||||
constraintName: FKJP8FSY8A0KKMDI04I81V05C6A
|
||||
deferrable: false
|
||||
initiallyDeferred: false
|
||||
onDelete: RESTRICT
|
||||
onUpdate: RESTRICT
|
||||
referencedColumnNames: ID
|
||||
referencedTableName: GENRES
|
||||
validate: true
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
databaseChangeLog:
|
||||
- include:
|
||||
file: db.changelog-001-initial.yaml
|
||||
relativeToChangelogFile: true
|
||||
- include:
|
||||
file: changeset-3-add-review.yaml
|
||||
relativeToChangelogFile: true
|
||||
- include:
|
||||
file: changeset-4-add-subscription-discount.yaml
|
||||
relativeToChangelogFile: true
|
||||
- include:
|
||||
file: changeset-7-sec-users.yaml
|
||||
relativeToChangelogFile: true
|
||||
193
backend/src/main/resources/static/css/style.css
Normal file
193
backend/src/main/resources/static/css/style.css
Normal file
@@ -0,0 +1,193 @@
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #181818;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-nav .nav-link {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.card-title{
|
||||
color: white;
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-nav .nav-link:hover,
|
||||
.navbar-dark .navbar-nav .nav-link:focus {
|
||||
color: #FF0033;
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-nav .nav-link.active {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Стили для карточек фильмов */
|
||||
.movie-card {
|
||||
background-color: #282828;
|
||||
border: 1px solid #404040;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.movie-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 4px 15px rgba(255, 0, 51, 0.2);
|
||||
}
|
||||
|
||||
.movie-card img {
|
||||
width: 100%;
|
||||
height: 260px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Стили для таблиц */
|
||||
.table-dark {
|
||||
background-color: #282828;
|
||||
border-color: #404040;
|
||||
}
|
||||
|
||||
.table-dark th,
|
||||
.table-dark td {
|
||||
border-color: #404040;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Стили для форм */
|
||||
.form-control,
|
||||
.form-select {
|
||||
background-color: #333;
|
||||
border: 1px solid #555;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.form-control:focus,
|
||||
.form-select:focus {
|
||||
background-color: #3a3a3a;
|
||||
border-color: #FF0033;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 0 0.25rem rgba(255, 0, 51, 0.25);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
color: #e9ecef;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Стили для кнопок */
|
||||
.btn-primary {
|
||||
background-color: #FF0033;
|
||||
border-color: #FF0033;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #cc0029;
|
||||
border-color: #cc0029;
|
||||
}
|
||||
|
||||
.btn-outline-light:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Стили для пагинации */
|
||||
.page-link {
|
||||
background-color: #282828;
|
||||
border-color: #404040;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.page-link:hover {
|
||||
background-color: #333;
|
||||
border-color: #555;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.page-item.active .page-link {
|
||||
background-color: #FF0033;
|
||||
border-color: #FF0033;
|
||||
}
|
||||
|
||||
/* Стили для пользовательского фото */
|
||||
.user-photo {
|
||||
height: 130px;
|
||||
width: 130px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 3px solid #FF0033;
|
||||
}
|
||||
|
||||
/* Стили для выпадающего меню пользователя */
|
||||
.dropdown-menu-dark {
|
||||
background-color: #282828;
|
||||
border: 1px solid #404040;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background-color: #FF0033;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Стили для футера */
|
||||
footer {
|
||||
background-color: #202020;
|
||||
border-top: 1px solid #404040;
|
||||
}
|
||||
|
||||
/* Кастомные утилиты */
|
||||
.bg-custom-dark {
|
||||
background-color: #181818 !important;
|
||||
}
|
||||
|
||||
.bg-custom-gray {
|
||||
background-color: #282828 !important;
|
||||
}
|
||||
|
||||
.border-custom {
|
||||
border-color: #404040 !important;
|
||||
}
|
||||
|
||||
/* Адаптивность */
|
||||
@media (max-width: 768px) {
|
||||
.movie-card img {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.user-photo {
|
||||
height: 100px;
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.container {
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
.movie-card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-wrapper {
|
||||
min-height: calc(100vh - 160px); /* высота окна минус шапка+футер, можно подрегулировать */
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 60px; /* отступ сверху, чтобы не прилипало к меню */
|
||||
padding-bottom: 40px; /* немного воздуха над футером */
|
||||
}
|
||||
|
||||
/* Карточка формы входа/регистрации */
|
||||
.auth-card {
|
||||
background-color: #222;
|
||||
border-radius: 18px;
|
||||
border: 1px solid #333;
|
||||
color: #fff;
|
||||
box-shadow: 0 18px 45px rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
BIN
backend/src/main/resources/static/img/logo.png
Normal file
BIN
backend/src/main/resources/static/img/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 216 KiB |
33
backend/src/main/resources/templates/admin/index.html
Normal file
33
backend/src/main/resources/templates/admin/index.html
Normal file
@@ -0,0 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Админка</title>
|
||||
<link rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" th:href="@{/css/style.css}">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div th:insert="layout :: siteHeader"></div>
|
||||
|
||||
<div class="container mt-4">
|
||||
<h2 class="mb-4">Панель администратора</h2>
|
||||
|
||||
<div class="list-group">
|
||||
<a class="list-group-item list-group-item-action bg-custom-gray text-light"
|
||||
th:href="@{/admin/movies}">
|
||||
Управление фильмами
|
||||
</a>
|
||||
<a class="list-group-item list-group-item-action bg-custom-gray text-light"
|
||||
th:href="@{/admin/subscriptions}">
|
||||
Управление подписками
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div th:insert="layout :: siteFooter"></div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
102
backend/src/main/resources/templates/admin/movies/form.html
Normal file
102
backend/src/main/resources/templates/admin/movies/form.html
Normal file
@@ -0,0 +1,102 @@
|
||||
<!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="@{/admin/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} ? @{/admin/movies/{id}(id=${movieId})} : @{/admin/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>
|
||||
81
backend/src/main/resources/templates/admin/movies/list.html
Normal file
81
backend/src/main/resources/templates/admin/movies/list.html
Normal file
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:sec="https://www.thymeleaf.org/extras/spring-security"
|
||||
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="@{/admin/movies/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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>Жанр</th>
|
||||
<th>Режиссёр</th>
|
||||
<th>Оценка</th>
|
||||
<th class="text-end">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- ВАЖНО: тут именно movies, а не page.items -->
|
||||
<tr th:each="movie : ${movies}">
|
||||
<td th:text="${movie.id}">1</td>
|
||||
<td th:text="${movie.title}">Название</td>
|
||||
<td th:text="${movie.genre != null ? movie.genre.name : '—'}">Жанр</td>
|
||||
<td th:text="${movie.director != null ? movie.director.name : '—'}">Режиссёр</td>
|
||||
<td th:text="${movie.grade}">0.0</td>
|
||||
<td class="text-end">
|
||||
<a th:href="@{/admin/movies/{id}(id=${movie.id})}"
|
||||
class="btn btn-sm btn-outline-light me-1">
|
||||
Редактировать
|
||||
</a>
|
||||
|
||||
<form th:action="@{/admin/movies/{id}/delete(id=${movie.id})}"
|
||||
method="post"
|
||||
class="d-inline"
|
||||
onsubmit="return confirm('Удалить фильм?');">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||
Удалить
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr th:if="${#lists.isEmpty(movies)}">
|
||||
<td colspan="6" class="text-center py-4">
|
||||
Фильмы не найдены.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
@@ -0,0 +1,84 @@
|
||||
<!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="@{/admin/subscriptions}" 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="${subscription}"
|
||||
th:action="${isEdit} ? @{/admin/subscriptions/{id}(id=${subscriptionId})} : @{/admin/subscriptions}"
|
||||
method="post"
|
||||
class="row g-3">
|
||||
|
||||
<!-- Уровень -->
|
||||
<div class="col-12">
|
||||
<label class="form-label text-light">Уровень</label>
|
||||
<input type="text" th:field="*{level}" class="form-control"/>
|
||||
<div class="text-danger small"
|
||||
th:if="${#fields.hasErrors('level')}"
|
||||
th:errors="*{level}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Цена -->
|
||||
<div class="col-md-4">
|
||||
<label class="form-label text-light">Цена (₽)</label>
|
||||
<input type="number" min="0" th:field="*{price}" class="form-control"/>
|
||||
<div class="text-danger small"
|
||||
th:if="${#fields.hasErrors('price')}"
|
||||
th:errors="*{price}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Скидка -->
|
||||
<div class="col-md-4">
|
||||
<label class="form-label text-light">Скидка (%)</label>
|
||||
<input type="number" min="0" max="100" th:field="*{discount}" class="form-control"/>
|
||||
<div class="text-danger small"
|
||||
th:if="${#fields.hasErrors('discount')}"
|
||||
th:errors="*{discount}">
|
||||
</div>
|
||||
</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>
|
||||
@@ -0,0 +1,80 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:sec="https://www.thymeleaf.org/extras/spring-security"
|
||||
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="@{/admin/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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Уровень</th>
|
||||
<th>Цена, ₽</th>
|
||||
<th>Скидка, %</th>
|
||||
<th>Цена со скидкой, ₽</th>
|
||||
<th class="text-end">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="s : ${subscriptions}">
|
||||
<td th:text="${s.id}">1</td>
|
||||
<td th:text="${s.level}">Basic</td>
|
||||
<td th:text="${s.price}">0</td>
|
||||
<td th:text="${s.discount}">0</td>
|
||||
<td th:text="${s.priceWithDiscount}">0</td>
|
||||
<td class="text-end">
|
||||
<a th:href="@{/admin/subscriptions/{id}(id=${s.id})}"
|
||||
class="btn btn-sm btn-outline-light me-1">
|
||||
Редактировать
|
||||
</a>
|
||||
|
||||
<form th:action="@{/admin/subscriptions/{id}/delete(id=${s.id})}"
|
||||
method="post"
|
||||
class="d-inline"
|
||||
onsubmit="return confirm('Удалить подписку?');">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||
Удалить
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr th:if="${#lists.isEmpty(subscriptions)}">
|
||||
<td colspan="6" class="text-center py-4">
|
||||
Подписки не найдены.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
16
backend/src/main/resources/templates/error.html
Normal file
16
backend/src/main/resources/templates/error.html
Normal 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>
|
||||
102
backend/src/main/resources/templates/layout.html
Normal file
102
backend/src/main/resources/templates/layout.html
Normal file
@@ -0,0 +1,102 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru" xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:sec="https://www.thymeleaf.org/extras/spring-security">
|
||||
<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">
|
||||
|
||||
<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>
|
||||
<li class="nav-item" sec:authorize="hasRole('ADMIN')">
|
||||
<a class="nav-link" th:href="@{/admin}">Админка</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<ul class="navbar-nav ms-auto">
|
||||
|
||||
<!-- Если пользователь НЕ вошёл -->
|
||||
<li class="nav-item" sec:authorize="isAnonymous()">
|
||||
<a class="nav-link" th:href="@{/login}">Войти</a>
|
||||
</li>
|
||||
<li class="nav-item" sec:authorize="isAnonymous()">
|
||||
<a class="nav-link" th:href="@{/signup}">Регистрация</a>
|
||||
</li>
|
||||
|
||||
<!-- Если пользователь ВОШЁЛ -->
|
||||
<li class="nav-item dropdown" sec:authorize="isAuthenticated()">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="userMenu"
|
||||
role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
|
||||
<!-- Показываем имя -->
|
||||
<span sec:authentication="principal.username"></span>
|
||||
</a>
|
||||
|
||||
<ul class="dropdown-menu dropdown-menu-dark dropdown-menu-end">
|
||||
<li>
|
||||
<a class="dropdown-item" th:href="@{/user}">Профиль</a>
|
||||
</li>
|
||||
<li>
|
||||
<hr class="dropdown-divider"/>
|
||||
</li>
|
||||
<li>
|
||||
<form th:action="@{/logout}" method="post">
|
||||
<button class="dropdown-item" type="submit">Выйти</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
90
backend/src/main/resources/templates/login.html
Normal file
90
backend/src/main/resources/templates/login.html
Normal file
@@ -0,0 +1,90 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<title>Вход</title>
|
||||
|
||||
<link rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"/>
|
||||
|
||||
|
||||
<link rel="stylesheet" th:href="@{../css/style.css}">
|
||||
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div th:insert="layout :: siteHeader"></div>
|
||||
|
||||
<div class="auth-wrapper">
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-5">
|
||||
<div class="card auth-card shadow-lg">
|
||||
<div class="card-body p-4">
|
||||
<h3 class="mb-4 text-center">Вход в аккаунт</h3>
|
||||
|
||||
<div th:if="${param.error}" class="alert alert-danger py-2">
|
||||
Неверный логин или пароль.
|
||||
</div>
|
||||
<div th:if="${param.logout}" class="alert alert-success py-2">
|
||||
Вы вышли из аккаунта.
|
||||
</div>
|
||||
<div th:if="${param.signup}" class="alert alert-success py-2">
|
||||
Регистрация прошла успешно. Войдите в аккаунт.
|
||||
</div>
|
||||
|
||||
<form th:action="@{/login}" method="post">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Логин</label>
|
||||
<input type="text"
|
||||
name="username"
|
||||
class="form-control"
|
||||
placeholder="Введите логин"
|
||||
required
|
||||
autofocus/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Пароль</label>
|
||||
<input type="password"
|
||||
name="password"
|
||||
class="form-control"
|
||||
placeholder="Введите пароль"
|
||||
required/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
<input class="form-check-input"
|
||||
type="checkbox"
|
||||
name="remember-me"
|
||||
id="rememberMeCheck"/>
|
||||
<label class="form-check-label" for="rememberMeCheck">
|
||||
Запомнить меня
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button class="btn btn-primary" type="submit">Войти</button>
|
||||
</div>
|
||||
|
||||
<hr class="border-secondary my-3"/>
|
||||
|
||||
<div class="text-center small">
|
||||
Нет аккаунта?
|
||||
<a th:href="@{/signup}">Зарегистрироваться</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
101
backend/src/main/resources/templates/movies/form.html
Normal file
101
backend/src/main/resources/templates/movies/form.html
Normal 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>
|
||||
163
backend/src/main/resources/templates/movies/list.html
Normal file
163
backend/src/main/resources/templates/movies/list.html
Normal file
@@ -0,0 +1,163 @@
|
||||
<!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:replace="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>
|
||||
</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-2 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input"
|
||||
type="checkbox"
|
||||
name="exact"
|
||||
th:checked="${exactMatchFilter}">
|
||||
<label class="form-check-label text-light">
|
||||
Точный поиск
|
||||
</label>
|
||||
</div>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
108
backend/src/main/resources/templates/signup.html
Normal file
108
backend/src/main/resources/templates/signup.html
Normal file
@@ -0,0 +1,108 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<title>Регистрация</title>
|
||||
|
||||
<!-- Bootstrap, если не подключён в layout -->
|
||||
<link rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"/>
|
||||
|
||||
<link rel="stylesheet" th:href="@{/css/style.css}">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div th:insert="layout :: siteHeader"></div>
|
||||
|
||||
<div class="auth-wrapper">
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-5">
|
||||
<div class="card auth-card shadow-lg">
|
||||
<div class="card-body p-4">
|
||||
<h3 class="mb-4 text-center">Регистрация</h3>
|
||||
|
||||
<form th:action="@{/signup}" th:object="${user}" method="post">
|
||||
|
||||
<!-- Имя -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Имя</label>
|
||||
<input type="text"
|
||||
th:field="*{name}"
|
||||
class="form-control"
|
||||
placeholder="Как к вам обращаться?"/>
|
||||
<div class="text-danger small"
|
||||
th:if="${#fields.hasErrors('name')}"
|
||||
th:errors="*{name}"></div>
|
||||
</div>
|
||||
|
||||
<!-- Логин -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Логин</label>
|
||||
<input type="text"
|
||||
th:field="*{login}"
|
||||
class="form-control"
|
||||
placeholder="Придумайте логин"/>
|
||||
<div class="text-danger small"
|
||||
th:if="${#fields.hasErrors('login')}"
|
||||
th:errors="*{login}"></div>
|
||||
</div>
|
||||
|
||||
<!-- Пароль -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Пароль</label>
|
||||
<input type="password"
|
||||
th:field="*{password}"
|
||||
class="form-control"
|
||||
placeholder="Придумайте пароль"/>
|
||||
<div class="text-danger small"
|
||||
th:if="${#fields.hasErrors('password')}"
|
||||
th:errors="*{password}"></div>
|
||||
</div>
|
||||
|
||||
<!-- Подтверждение пароля -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Подтверждение пароля</label>
|
||||
<input type="password"
|
||||
th:field="*{passwordConfirm}"
|
||||
class="form-control"
|
||||
placeholder="Повторите пароль"/>
|
||||
<div class="text-danger small"
|
||||
th:if="${#fields.hasErrors('passwordConfirm')}"
|
||||
th:errors="*{passwordConfirm}"></div>
|
||||
</div>
|
||||
|
||||
<!-- Глобальные ошибки -->
|
||||
<div class="mb-2 text-danger small"
|
||||
th:if="${#fields.hasGlobalErrors()}">
|
||||
<div th:each="err : ${#fields.globalErrors()}"
|
||||
th:text="${err}"></div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Зарегистрироваться
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr class="border-secondary my-3"/>
|
||||
|
||||
<div class="text-center small">
|
||||
Уже есть аккаунт?
|
||||
<a th:href="@{/login}">Войти</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
106
backend/src/main/resources/templates/subscriptions/form.html
Normal file
106
backend/src/main/resources/templates/subscriptions/form.html
Normal file
@@ -0,0 +1,106 @@
|
||||
<!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="mb-3">
|
||||
<label class="form-label">Скидка (%)</label>
|
||||
<input type="number"
|
||||
min="0" max="100"
|
||||
th:field="*{discount}"
|
||||
class="form-control bg-secondary text-light border-0"
|
||||
placeholder="0"/>
|
||||
|
||||
<div class="text-danger mt-1"
|
||||
th:if="${#fields.hasErrors('discount')}"
|
||||
th:errors="*{discount}">
|
||||
</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>
|
||||
88
backend/src/main/resources/templates/subscriptions/list.html
Normal file
88
backend/src/main/resources/templates/subscriptions/list.html
Normal file
@@ -0,0 +1,88 @@
|
||||
<!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>
|
||||
</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>ID</th>
|
||||
<th>Уровень</th>
|
||||
<th>Цена, ₽</th>
|
||||
<th>Скидка, %</th>
|
||||
<th>Цена со скидкой, ₽</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 th:text="${sub.discount}">0</td>
|
||||
<td th:text="${sub.priceWithDiscount}">299</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>
|
||||
@@ -1,3 +0,0 @@
|
||||
logging.level.ru.ulstu.is.server=DEBUG
|
||||
|
||||
spring.datasource.url=jdbc:h2:mem:testdb
|
||||
10
backend/src/test/resources/application.yml
Normal file
10
backend/src/test/resources/application.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
spring:
|
||||
profiles:
|
||||
active: dev
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: create-drop
|
||||
|
||||
liquibase:
|
||||
enabled: false
|
||||
20303
front/package-lock.json
generated
20303
front/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,26 +1,40 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import axios from "axios";
|
||||
|
||||
export default function useMovies() {
|
||||
const [movies, setMovies] = useState([]);
|
||||
export default function useMovies(initialPage = 1, pageSize = 12) {
|
||||
const [page, setPage] = useState(initialPage);
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [reload, setReload] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
axios.get("http://localhost:8080/api/1.0/movies")
|
||||
.then(res => setMovies(res.data))
|
||||
.catch(setError)
|
||||
setLoading(true);
|
||||
axios
|
||||
.get("http://localhost:8080/api/1.0/movies/page", {
|
||||
params: { page, size: pageSize },
|
||||
})
|
||||
.then((res) => setData(res.data))
|
||||
.catch((err) => setError(err))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
}, [page, pageSize, reload]);
|
||||
|
||||
const remove = async (id) => {
|
||||
try {
|
||||
await axios.delete(`http://localhost:8080/api/1.0/movies/${id}`);
|
||||
setMovies(prev => prev.filter(m => m.id !== id));
|
||||
setReload((r) => r + 1);
|
||||
} catch (err) {
|
||||
console.error("Ошибка при удалении:", err);
|
||||
}
|
||||
};
|
||||
|
||||
return { movies, loading, error, remove };
|
||||
return {
|
||||
pageData: data,
|
||||
loading,
|
||||
error,
|
||||
page,
|
||||
setPage,
|
||||
pageSize,
|
||||
remove,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,56 @@
|
||||
import useMovies from "../hooks/useMovies";
|
||||
import MovieCard from "../components/MovieCard";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export default function Movies() {
|
||||
const { movies, loading, error, remove } = useMovies();
|
||||
const {
|
||||
pageData,
|
||||
loading,
|
||||
error,
|
||||
page,
|
||||
setPage,
|
||||
remove,
|
||||
} = useMovies(1, 12);
|
||||
|
||||
if (loading) return <p className="text-center py-5">Загрузка…</p>;
|
||||
if (error) return <p className="text-danger py-5 text-center">Ошибка: {error.message}</p>;
|
||||
if (loading) {
|
||||
return <p className="text-center py-5">Загрузка…</p>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<p className="text-danger py-5 text-center">
|
||||
Ошибка: {error.message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pageData || pageData.items.length === 0) {
|
||||
return (
|
||||
<div className="container py-4">
|
||||
<div className="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 className="h3 m-0">Каталог фильмов</h1>
|
||||
<Link to="/new" className="btn btn-success">
|
||||
+ Добавить
|
||||
</Link>
|
||||
</div>
|
||||
<p>Фильмы не найдены.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { items, totalPages, hasNext, hasPrevious } = pageData;
|
||||
|
||||
const goPrev = () => {
|
||||
if (hasPrevious && page > 1) {
|
||||
setPage(page - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const goNext = () => {
|
||||
if (hasNext && page < totalPages) {
|
||||
setPage(page + 1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container py-4">
|
||||
@@ -18,13 +62,36 @@ export default function Movies() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="row g-4">
|
||||
{movies.map((m) => (
|
||||
<div className="row g-4 mb-4">
|
||||
{items.map((m) => (
|
||||
<div className="col-6 col-md-4 col-lg-3" key={m.id}>
|
||||
<MovieCard movie={m} onDelete={remove} />
|
||||
<MovieCard movie={m} onDelete={() => remove(m.id)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="d-flex justify-content-center align-items-center gap-3">
|
||||
<button
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={goPrev}
|
||||
disabled={!hasPrevious}
|
||||
>
|
||||
← Назад
|
||||
</button>
|
||||
|
||||
<span>
|
||||
Страница {page} из {totalPages}
|
||||
</span>
|
||||
|
||||
<button
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={goNext}
|
||||
disabled={!hasNext}
|
||||
>
|
||||
Вперёд →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
// @ts-ignore
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/movies': 'http://localhost:3000',
|
||||
'/genres': 'http://localhost:3000',
|
||||
'/directors': 'http://localhost:3000',
|
||||
'/users': 'http://localhost:3000',
|
||||
'/subscriptions': 'http://localhost:3000',
|
||||
'/purchases': 'http://localhost:3000',
|
||||
},
|
||||
build: {
|
||||
sourcemap: true,
|
||||
emptyOutDir: true,
|
||||
outDir: "../build/resources/main/static",
|
||||
},
|
||||
});
|
||||
|
||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "ипРома",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
Reference in New Issue
Block a user