diff --git a/app/backend/src/main/java/com/app/gamereview/controller/GameController.java b/app/backend/src/main/java/com/app/gamereview/controller/GameController.java index 7ef480c0..7562152d 100644 --- a/app/backend/src/main/java/com/app/gamereview/controller/GameController.java +++ b/app/backend/src/main/java/com/app/gamereview/controller/GameController.java @@ -1,23 +1,24 @@ package com.app.gamereview.controller; import com.app.gamereview.dto.request.game.CreateGameRequestDto; -import com.app.gamereview.dto.request.tag.AddGameTagRequestDto; +import com.app.gamereview.dto.request.game.GetGameListRequestDto; +import com.app.gamereview.dto.request.game.AddGameTagRequestDto; +import com.app.gamereview.dto.request.game.RemoveGameTagRequestDto; import com.app.gamereview.dto.response.game.GameDetailResponseDto; +import com.app.gamereview.dto.response.game.GetGameListResponseDto; import com.app.gamereview.dto.response.tag.AddGameTagResponseDto; import com.app.gamereview.dto.response.tag.GetAllTagsOfGameResponseDto; import com.app.gamereview.model.Game; +import com.app.gamereview.service.GameService; import com.app.gamereview.util.validation.annotation.AdminRequired; import com.app.gamereview.util.validation.annotation.AuthorizationRequired; import jakarta.validation.Valid; +import org.springdoc.core.annotations.ParameterObject; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; -import com.app.gamereview.dto.request.game.GetGameListRequestDto; -import com.app.gamereview.dto.response.game.GetGameListResponseDto; -import com.app.gamereview.service.GameService; - import java.util.List; @RestController @@ -33,12 +34,24 @@ public GameController(GameService gameService) { } @PostMapping("get-game-list") - public ResponseEntity> getGames(@RequestBody(required = false) GetGameListRequestDto filter) { + public ResponseEntity> getAllGames(@RequestBody(required = false) GetGameListRequestDto filter) { List gameList = gameService.getAllGames(filter); return ResponseEntity.ok().body(gameList); } + @GetMapping("get-game-list") + public ResponseEntity> getGames(@ParameterObject GetGameListRequestDto filter) { + List gameList = gameService.getGames(filter); + return ResponseEntity.ok(gameList); + } + + @GetMapping("game-by-name") + public ResponseEntity getGameByName(String name) { + GameDetailResponseDto game = gameService.getGameByName(name); + return ResponseEntity.ok(game); + } + @AuthorizationRequired @AdminRequired @PostMapping("/add-tag") @@ -48,6 +61,15 @@ public ResponseEntity addGameTag( return ResponseEntity.ok(response); } + @AuthorizationRequired + @AdminRequired + @DeleteMapping("/remove-tag") + public ResponseEntity removeGameTag( + @Valid @RequestBody RemoveGameTagRequestDto removeGameTagRequestDto) { + Boolean response = gameService.removeGameTag(removeGameTagRequestDto); + return ResponseEntity.ok(response); + } + @GetMapping("/get-all-tags") public ResponseEntity getAllTags(@RequestParam String gameId){ GetAllTagsOfGameResponseDto response = gameService.getGameTags(gameId); @@ -61,7 +83,8 @@ public ResponseEntity getGameDetail(@RequestParam String @AuthorizationRequired @PostMapping("/create") - public ResponseEntity createGame(@Valid @RequestBody CreateGameRequestDto createGameRequestDto, String Authorization) { + public ResponseEntity createGame(@Valid @RequestBody CreateGameRequestDto createGameRequestDto, + @RequestHeader String Authorization) { Game gameToCreate = gameService.createGame(createGameRequestDto); return ResponseEntity.ok(gameToCreate); } diff --git a/app/backend/src/main/java/com/app/gamereview/controller/GroupController.java b/app/backend/src/main/java/com/app/gamereview/controller/GroupController.java new file mode 100644 index 00000000..046d3dcb --- /dev/null +++ b/app/backend/src/main/java/com/app/gamereview/controller/GroupController.java @@ -0,0 +1,138 @@ +package com.app.gamereview.controller; + +import com.app.gamereview.dto.request.group.*; +import com.app.gamereview.dto.response.group.GetGroupResponseDto; +import com.app.gamereview.dto.response.tag.AddGroupTagResponseDto; +import com.app.gamereview.model.Group; +import com.app.gamereview.model.User; +import com.app.gamereview.service.GroupService; +import com.app.gamereview.service.ReviewService; +import com.app.gamereview.util.validation.annotation.AdminRequired; +import com.app.gamereview.util.validation.annotation.AuthorizationRequired; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/group") +@Validated +public class GroupController { + + @Value("${SECRET_KEY}") + private String secret_key = "${SECRET_KEY}"; + + private final GroupService groupService; + + @Autowired + public GroupController( + GroupService groupService + ) { + this.groupService = groupService; + } + + @GetMapping("/get-all") + public ResponseEntity> getReviews( + @ParameterObject GetAllGroupsFilterRequestDto filter) { + List groups = groupService.getAllGroups(filter); + return ResponseEntity.ok(groups); + } + + @GetMapping("/get") + public ResponseEntity getGroup(@RequestParam String id) { + GetGroupResponseDto group = groupService.getGroupById(id); + + return ResponseEntity.ok(group); + } + + @AuthorizationRequired + @PostMapping("/create") + public ResponseEntity createGroup(@Valid @RequestBody CreateGroupRequestDto createGroupRequestDto, + @RequestHeader String Authorization, HttpServletRequest request) { + User user = (User) request.getAttribute("authenticatedUser"); + Group groupToCreate = groupService.createGroup(createGroupRequestDto, user); + return ResponseEntity.ok(groupToCreate); + } + + @AuthorizationRequired + @PutMapping("/update") + public ResponseEntity editGroup(@RequestParam String id, + @Valid @RequestBody UpdateGroupRequestDto updateGroupRequestDto, + @RequestHeader String Authorization, HttpServletRequest request) { + Group updatedGroup = groupService.updateGroup(id,updateGroupRequestDto); + return ResponseEntity.ok(updatedGroup); + } + + @AuthorizationRequired + @DeleteMapping("/delete") + public ResponseEntity deleteGroup(String identifier, + @RequestHeader String Authorization, HttpServletRequest request) { + User user = (User) request.getAttribute("authenticatedUser"); + Boolean response = groupService.deleteGroup(identifier); + return ResponseEntity.ok(response); + } + + @AuthorizationRequired + @PostMapping("/add-tag") + public ResponseEntity addGroupTag( + @Valid @RequestBody AddGroupTagRequestDto addGroupTagRequestDto) { + AddGroupTagResponseDto response = groupService.addGroupTag(addGroupTagRequestDto); + return ResponseEntity.ok(response); + } + + @AuthorizationRequired + @DeleteMapping("/remove-tag") + public ResponseEntity removeGroupTag( + @Valid @RequestBody RemoveGroupTagRequestDto removeGroupTagRequestDto) { + Boolean response = groupService.removeGroupTag(removeGroupTagRequestDto); + return ResponseEntity.ok(response); + } + + @AuthorizationRequired + @PostMapping("/join") + public ResponseEntity joinGroup(@RequestParam String id, + @RequestHeader String Authorization, HttpServletRequest request) { + User user = (User) request.getAttribute("authenticatedUser"); + Boolean joined = groupService.joinGroup(id, user); + return ResponseEntity.ok(joined); + } + + @AuthorizationRequired + @PostMapping("/leave") + public ResponseEntity leaveGroup(@RequestParam String id, + @RequestHeader String Authorization, HttpServletRequest request) { + User user = (User) request.getAttribute("authenticatedUser"); + Boolean leave = groupService.leaveGroup(id, user); + return ResponseEntity.ok(leave); + } + + @AuthorizationRequired + @PutMapping("/ban-user") + public ResponseEntity banUser(@RequestParam String groupId, @RequestParam String userId, @RequestHeader String Authorization, HttpServletRequest request) { + User user = (User) request.getAttribute("authenticatedUser"); + Boolean result = groupService.banUser(groupId, userId, user); + return ResponseEntity.ok(result); + } + @AuthorizationRequired + @PutMapping("/add-moderator") + public ResponseEntity addModerator(@RequestParam String groupId, @RequestParam String userId, @RequestHeader String Authorization, HttpServletRequest request) { + User user = (User) request.getAttribute("authenticatedUser"); + Boolean result = groupService.addModerator(groupId, userId, user); + return ResponseEntity.ok(result); + } + + @AuthorizationRequired + @AdminRequired + @PutMapping("/remove-moderator") + public ResponseEntity removeModerator(@RequestParam String groupId, @RequestParam String userId, @RequestHeader String Authorization, HttpServletRequest request) { + Boolean result = groupService.removeModerator(groupId, userId); + return ResponseEntity.ok(result); + } +} diff --git a/app/backend/src/main/java/com/app/gamereview/controller/ImageController.java b/app/backend/src/main/java/com/app/gamereview/controller/ImageController.java index fd9eb324..acb7f58b 100644 --- a/app/backend/src/main/java/com/app/gamereview/controller/ImageController.java +++ b/app/backend/src/main/java/com/app/gamereview/controller/ImageController.java @@ -1,22 +1,29 @@ package com.app.gamereview.controller; +import com.app.gamereview.service.FileStorageService; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.FileSystemResource; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.*; import org.springframework.core.io.Resource; +import org.springframework.web.multipart.MultipartFile; import java.io.File; +import java.io.IOException; @Controller public class ImageController { + private final FileStorageService fileService; @Value("${image.base-directory}") private String imageBaseDirectory; + public ImageController(FileStorageService fileService) { + this.fileService = fileService; + } + @GetMapping("/api/{folder}/{fileName:.+}") public ResponseEntity serveImage(@PathVariable String folder, @PathVariable String fileName) { try { @@ -41,4 +48,9 @@ public ResponseEntity serveImage(@PathVariable String folder, @PathVar } } + @PostMapping("/api/image/upload") + public ResponseEntity uploadImage(@RequestParam String folder, @RequestPart MultipartFile image) throws IOException { + return ResponseEntity.ok(folder + "/" + fileService.storeFile(image, folder)); + } + } diff --git a/app/backend/src/main/java/com/app/gamereview/controller/PostController.java b/app/backend/src/main/java/com/app/gamereview/controller/PostController.java index 9181935d..6896500c 100644 --- a/app/backend/src/main/java/com/app/gamereview/controller/PostController.java +++ b/app/backend/src/main/java/com/app/gamereview/controller/PostController.java @@ -84,7 +84,8 @@ public ResponseEntity> getPostComments(@Request @AuthorizationRequired @PostMapping("/create") - public ResponseEntity createPost(@Valid @RequestBody CreatePostRequestDto post, @RequestHeader String Authorization, HttpServletRequest request) { + public ResponseEntity createPost(@Valid @RequestBody CreatePostRequestDto post, + @RequestHeader String Authorization, HttpServletRequest request) { User user = (User) request.getAttribute("authenticatedUser"); Post postToCreate = postService.createPost(post, user); return ResponseEntity.ok(postToCreate); diff --git a/app/backend/src/main/java/com/app/gamereview/controller/ReviewController.java b/app/backend/src/main/java/com/app/gamereview/controller/ReviewController.java index e64c8e1a..48b13009 100644 --- a/app/backend/src/main/java/com/app/gamereview/controller/ReviewController.java +++ b/app/backend/src/main/java/com/app/gamereview/controller/ReviewController.java @@ -7,17 +7,19 @@ import com.app.gamereview.model.Review; import com.app.gamereview.model.User; import com.app.gamereview.service.ReviewService; -import com.app.gamereview.util.validation.annotation.AdminRequired; import com.app.gamereview.util.validation.annotation.AuthorizationRequired; +import io.jsonwebtoken.*; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; -import org.apache.tomcat.util.http.parser.Authorization; import org.springdoc.core.annotations.ParameterObject; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import java.util.Date; import java.util.List; @RestController @@ -25,6 +27,9 @@ @Validated public class ReviewController { + @Value("${SECRET_KEY}") + private String secret_key = "${SECRET_KEY}"; + private final ReviewService reviewService; @Autowired @@ -36,8 +41,28 @@ public ReviewController( @GetMapping("/get-all") public ResponseEntity> getReviews( - @ParameterObject GetAllReviewsFilterRequestDto filter) { - List reviews = reviewService.getAllReviews(filter); + @ParameterObject GetAllReviewsFilterRequestDto filter, + @RequestHeader(name = HttpHeaders.AUTHORIZATION, required = false) String Authorization) { + + String email = ""; + try { + Claims claims = Jwts.parser().setSigningKey(secret_key).parseClaimsJws(String.valueOf(Authorization)).getBody(); + + Date expirationDate = claims.getExpiration(); + Date now = new Date(); + if (expirationDate.after(now)) { + email = claims.getSubject(); + } + } + catch (SignatureException | ExpiredJwtException | IllegalArgumentException | MalformedJwtException e) { + // SignatureException: Token signature is invalid + // ExpiredJwtException: Token has expired + // IllegalArgumentException: Token is not correctly formatted + // MalformedJwtException: Token is not correctly formatted (eg. empty string) + // In any of these cases, the token is considered invalid + } + + List reviews = reviewService.getAllReviews(filter, email); return ResponseEntity.ok(reviews); } diff --git a/app/backend/src/main/java/com/app/gamereview/dto/request/tag/AddGameTagRequestDto.java b/app/backend/src/main/java/com/app/gamereview/dto/request/game/AddGameTagRequestDto.java similarity index 94% rename from app/backend/src/main/java/com/app/gamereview/dto/request/tag/AddGameTagRequestDto.java rename to app/backend/src/main/java/com/app/gamereview/dto/request/game/AddGameTagRequestDto.java index 007e3617..d089090e 100644 --- a/app/backend/src/main/java/com/app/gamereview/dto/request/tag/AddGameTagRequestDto.java +++ b/app/backend/src/main/java/com/app/gamereview/dto/request/game/AddGameTagRequestDto.java @@ -1,4 +1,4 @@ -package com.app.gamereview.dto.request.tag; +package com.app.gamereview.dto.request.game; import jakarta.validation.constraints.NotEmpty; diff --git a/app/backend/src/main/java/com/app/gamereview/dto/request/game/GetGameListRequestDto.java b/app/backend/src/main/java/com/app/gamereview/dto/request/game/GetGameListRequestDto.java index fd60d506..e2f01307 100644 --- a/app/backend/src/main/java/com/app/gamereview/dto/request/game/GetGameListRequestDto.java +++ b/app/backend/src/main/java/com/app/gamereview/dto/request/game/GetGameListRequestDto.java @@ -11,7 +11,9 @@ @NoArgsConstructor public class GetGameListRequestDto { - private Boolean findDeleted; + private Boolean findDeleted = false; + + private String gameName; private List playerTypes; diff --git a/app/backend/src/main/java/com/app/gamereview/dto/request/game/RemoveGameTagRequestDto.java b/app/backend/src/main/java/com/app/gamereview/dto/request/game/RemoveGameTagRequestDto.java new file mode 100644 index 00000000..6a38b620 --- /dev/null +++ b/app/backend/src/main/java/com/app/gamereview/dto/request/game/RemoveGameTagRequestDto.java @@ -0,0 +1,26 @@ +package com.app.gamereview.dto.request.game; + + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Pattern; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class RemoveGameTagRequestDto { + + @NotEmpty(message = "Game Id field cannot be null or empty") + @Pattern(regexp = "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$", + message = "Game Id has Invalid Id (UUID) format") + private String gameId; + + @NotEmpty(message = "Tag Id field cannot be null or empty") + @Pattern(regexp = "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$", + message = "Tag Id has Invalid Id (UUID) format") + private String tagId; +} diff --git a/app/backend/src/main/java/com/app/gamereview/dto/request/group/AddGroupTagRequestDto.java b/app/backend/src/main/java/com/app/gamereview/dto/request/group/AddGroupTagRequestDto.java new file mode 100644 index 00000000..8009b2f4 --- /dev/null +++ b/app/backend/src/main/java/com/app/gamereview/dto/request/group/AddGroupTagRequestDto.java @@ -0,0 +1,26 @@ +package com.app.gamereview.dto.request.group; + + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Pattern; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class AddGroupTagRequestDto { + + @NotEmpty(message = "Game Id field cannot be null or empty") + @Pattern(regexp = "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$", + message = "Group Id has Invalid Id (UUID) format") + private String groupId; + + @NotEmpty(message = "Tag Id field cannot be null or empty") + @Pattern(regexp = "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$", + message = "Tag Id has Invalid Id (UUID) format") + private String tagId; +} diff --git a/app/backend/src/main/java/com/app/gamereview/dto/request/group/CreateGroupRequestDto.java b/app/backend/src/main/java/com/app/gamereview/dto/request/group/CreateGroupRequestDto.java new file mode 100644 index 00000000..7247588c --- /dev/null +++ b/app/backend/src/main/java/com/app/gamereview/dto/request/group/CreateGroupRequestDto.java @@ -0,0 +1,41 @@ +package com.app.gamereview.dto.request.group; + +import com.app.gamereview.enums.MembershipPolicy; +import com.app.gamereview.enums.TagType; +import com.app.gamereview.util.validation.annotation.ValidMemberPolicy; +import jakarta.validation.constraints.*; +import lombok.Getter; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; + +@Getter +@Setter +public class CreateGroupRequestDto { + + @NotEmpty(message = "Title field cannot be null or empty") + @Size(min = 3, message = "Title must be at least 3 characters long") + @Size(max = 25, message = "Title must be at most 25 characters long") + private String title; + + @NotNull(message = "Description field cannot be null") + @Size(max = 600, message = "Description must be at most 600 characters long") + private String description; + + @ValidMemberPolicy(allowedValues = {MembershipPolicy.PUBLIC, MembershipPolicy.PRIVATE}) + private String membershipPolicy; + + private List tags = new ArrayList<>(); // list of tag ids + + @NotEmpty(message = "Game Id must be provided") + @Pattern(regexp = "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$", + message = "Game has Invalid Id (UUID) format") + private String gameId; // id of related game + + @Positive(message = "Quota cannot be negative or zero") + private int quota; + + @NotNull(message = "Avatar only field cannot be null") + private Boolean avatarOnly = false; +} diff --git a/app/backend/src/main/java/com/app/gamereview/dto/request/group/GetAllGroupsFilterRequestDto.java b/app/backend/src/main/java/com/app/gamereview/dto/request/group/GetAllGroupsFilterRequestDto.java new file mode 100644 index 00000000..867ef22e --- /dev/null +++ b/app/backend/src/main/java/com/app/gamereview/dto/request/group/GetAllGroupsFilterRequestDto.java @@ -0,0 +1,29 @@ +package com.app.gamereview.dto.request.group; + +import com.app.gamereview.enums.SortDirection; +import com.app.gamereview.enums.SortType; +import com.app.gamereview.util.validation.annotation.ValidSortDirection; +import com.app.gamereview.util.validation.annotation.ValidSortType; +import lombok.Getter; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; + +@Getter +@Setter +public class GetAllGroupsFilterRequestDto { + private String title; + + private String membershipPolicy; + + private List tags = new ArrayList<>(); // list of tag ids + + private String gameName; // id of related game + + @ValidSortType(allowedValues = {SortType.QUOTA, SortType.CREATION_DATE}) + private String sortBy = SortType.CREATION_DATE.name(); + + @ValidSortDirection(allowedValues = {SortDirection.ASCENDING, SortDirection.DESCENDING}) + private String sortDirection = SortDirection.DESCENDING.name(); +} diff --git a/app/backend/src/main/java/com/app/gamereview/dto/request/group/RemoveGroupTagRequestDto.java b/app/backend/src/main/java/com/app/gamereview/dto/request/group/RemoveGroupTagRequestDto.java new file mode 100644 index 00000000..5c9d38e0 --- /dev/null +++ b/app/backend/src/main/java/com/app/gamereview/dto/request/group/RemoveGroupTagRequestDto.java @@ -0,0 +1,26 @@ +package com.app.gamereview.dto.request.group; + + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Pattern; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class RemoveGroupTagRequestDto { + + @NotEmpty(message = "Game Id field cannot be null or empty") + @Pattern(regexp = "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$", + message = "Group Id has Invalid Id (UUID) format") + private String groupId; + + @NotEmpty(message = "Tag Id field cannot be null or empty") + @Pattern(regexp = "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$", + message = "Tag Id has Invalid Id (UUID) format") + private String tagId; +} diff --git a/app/backend/src/main/java/com/app/gamereview/dto/request/group/UpdateGroupRequestDto.java b/app/backend/src/main/java/com/app/gamereview/dto/request/group/UpdateGroupRequestDto.java new file mode 100644 index 00000000..6f02b66c --- /dev/null +++ b/app/backend/src/main/java/com/app/gamereview/dto/request/group/UpdateGroupRequestDto.java @@ -0,0 +1,33 @@ +package com.app.gamereview.dto.request.group; + +import com.app.gamereview.enums.MembershipPolicy; +import com.app.gamereview.util.validation.annotation.ValidMemberPolicy; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class UpdateGroupRequestDto { + + @NotEmpty(message = "Title field cannot be null or empty") + @Size(min = 3, message = "Title must be at least 3 characters long") + @Size(max = 25, message = "Title must be at most 25 characters long") + private String title; + + @NotNull(message = "Description field cannot be null") + @Size(max = 600, message = "Description must be at most 600 characters long") + private String description; + + @ValidMemberPolicy(allowedValues = {MembershipPolicy.PUBLIC, MembershipPolicy.PRIVATE}) + private String membershipPolicy; + + @Positive(message = "Quota cannot be negative or zero") + private int quota; + + @NotNull(message = "Avatar only field cannot be null") + private Boolean avatarOnly; +} diff --git a/app/backend/src/main/java/com/app/gamereview/dto/request/post/GetPostListFilterRequestDto.java b/app/backend/src/main/java/com/app/gamereview/dto/request/post/GetPostListFilterRequestDto.java index 40492a08..3ffb7c89 100644 --- a/app/backend/src/main/java/com/app/gamereview/dto/request/post/GetPostListFilterRequestDto.java +++ b/app/backend/src/main/java/com/app/gamereview/dto/request/post/GetPostListFilterRequestDto.java @@ -28,7 +28,7 @@ public class GetPostListFilterRequestDto { message = "Forum has invalid Id (UUID) format") private String forum; - @ValidSortType(allowedValues = {SortType.CREATION_DATE, SortType.EDIT_DATE, SortType.OVERALL_VOTE, SortType.CREATION_DATE}) + @ValidSortType(allowedValues = {SortType.CREATION_DATE, SortType.EDIT_DATE, SortType.OVERALL_VOTE, SortType.VOTE_COUNT}) private String sortBy; @ValidSortDirection(allowedValues = {SortDirection.ASCENDING, SortDirection.DESCENDING}) diff --git a/app/backend/src/main/java/com/app/gamereview/dto/request/review/GetAllReviewsFilterRequestDto.java b/app/backend/src/main/java/com/app/gamereview/dto/request/review/GetAllReviewsFilterRequestDto.java index cdbb8535..d3d593e5 100644 --- a/app/backend/src/main/java/com/app/gamereview/dto/request/review/GetAllReviewsFilterRequestDto.java +++ b/app/backend/src/main/java/com/app/gamereview/dto/request/review/GetAllReviewsFilterRequestDto.java @@ -1,7 +1,9 @@ package com.app.gamereview.dto.request.review; import com.app.gamereview.enums.SortDirection; +import com.app.gamereview.enums.SortType; import com.app.gamereview.util.validation.annotation.ValidSortDirection; +import com.app.gamereview.util.validation.annotation.ValidSortType; import lombok.Getter; import lombok.Setter; @@ -14,6 +16,9 @@ public class GetAllReviewsFilterRequestDto { private Boolean withDeleted = false; + @ValidSortType(allowedValues = {SortType.OVERALL_VOTE, SortType.CREATION_DATE, SortType.VOTE_COUNT}) + private String sortBy = SortType.CREATION_DATE.name(); + @ValidSortDirection(allowedValues = {SortDirection.ASCENDING, SortDirection.DESCENDING}) private String sortDirection = SortDirection.DESCENDING.name(); } diff --git a/app/backend/src/main/java/com/app/gamereview/dto/request/tag/UpdateTagRequestDto.java b/app/backend/src/main/java/com/app/gamereview/dto/request/tag/UpdateTagRequestDto.java index ec8a5e71..6e50c362 100644 --- a/app/backend/src/main/java/com/app/gamereview/dto/request/tag/UpdateTagRequestDto.java +++ b/app/backend/src/main/java/com/app/gamereview/dto/request/tag/UpdateTagRequestDto.java @@ -17,7 +17,7 @@ public class UpdateTagRequestDto { @ValidTagType(allowedValues = {TagType.ART_STYLE, TagType.GENRE, TagType.DURATION, TagType.OTHER, TagType.MONETIZATION, TagType.PLATFORM, TagType.DEVELOPER, TagType.PLAYER_TYPE, TagType.POST, TagType.PRODUCTION}) - private TagType tagType; + private String tagType; @Pattern(regexp = "^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$", message = "Provided color must be in hexadecimal format, e.g. #FF4500") private String color; diff --git a/app/backend/src/main/java/com/app/gamereview/dto/response/group/GetGroupResponseDto.java b/app/backend/src/main/java/com/app/gamereview/dto/response/group/GetGroupResponseDto.java new file mode 100644 index 00000000..60c7eb28 --- /dev/null +++ b/app/backend/src/main/java/com/app/gamereview/dto/response/group/GetGroupResponseDto.java @@ -0,0 +1,40 @@ +package com.app.gamereview.dto.response.group; + +import com.app.gamereview.enums.MembershipPolicy; +import com.app.gamereview.model.Tag; +import lombok.Getter; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; + + +@Getter +@Setter +public class GetGroupResponseDto { + private String title; + + private String description; + + private MembershipPolicy membershipPolicy; + + private List tags = new ArrayList<>(); // list of tags + + private String gameId; // id of related game + + private String forumId; // id of the forum of the group + + private int quota; + + private List moderators = new ArrayList<>(); // userIds of the moderators + + private List members = new ArrayList<>(); // userIds of the members + + private List bannedMembers = new ArrayList<>(); // userIds of the banned members + + private Boolean avatarOnly; + + public void populateTag(Tag tag){ + this.tags.add(tag); + } +} diff --git a/app/backend/src/main/java/com/app/gamereview/dto/response/post/GetPostDetailResponseDto.java b/app/backend/src/main/java/com/app/gamereview/dto/response/post/GetPostDetailResponseDto.java index d9a93173..7886f2a9 100644 --- a/app/backend/src/main/java/com/app/gamereview/dto/response/post/GetPostDetailResponseDto.java +++ b/app/backend/src/main/java/com/app/gamereview/dto/response/post/GetPostDetailResponseDto.java @@ -1,5 +1,6 @@ package com.app.gamereview.dto.response.post; +import com.app.gamereview.model.Tag; import com.app.gamereview.model.User; import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; @@ -29,7 +30,7 @@ public class GetPostDetailResponseDto { private LocalDateTime lastEditedAt; - private List tags; + private List tags; private Boolean inappropriate; diff --git a/app/backend/src/main/java/com/app/gamereview/dto/response/post/GetPostListResponseDto.java b/app/backend/src/main/java/com/app/gamereview/dto/response/post/GetPostListResponseDto.java index 95687fcc..90365ae0 100644 --- a/app/backend/src/main/java/com/app/gamereview/dto/response/post/GetPostListResponseDto.java +++ b/app/backend/src/main/java/com/app/gamereview/dto/response/post/GetPostListResponseDto.java @@ -4,6 +4,7 @@ import java.util.List; import com.app.gamereview.enums.VoteChoice; +import com.app.gamereview.model.Tag; import com.app.gamereview.model.User; import lombok.AllArgsConstructor; import lombok.Data; @@ -32,7 +33,7 @@ public class GetPostListResponseDto { private Boolean isEdited; - private List tags; + private List tags; private boolean inappropriate; diff --git a/app/backend/src/main/java/com/app/gamereview/dto/response/review/GetAllReviewsResponseDto.java b/app/backend/src/main/java/com/app/gamereview/dto/response/review/GetAllReviewsResponseDto.java index 9717c54c..7d937f01 100644 --- a/app/backend/src/main/java/com/app/gamereview/dto/response/review/GetAllReviewsResponseDto.java +++ b/app/backend/src/main/java/com/app/gamereview/dto/response/review/GetAllReviewsResponseDto.java @@ -19,6 +19,8 @@ public class GetAllReviewsResponseDto { private String reviewedUser; // user username + private String requestedUserVote = null; // vote of requested (endpoint) user - null if didn't vote the review + private int overallVote; private int reportNum; diff --git a/app/backend/src/main/java/com/app/gamereview/dto/response/tag/AddGroupTagResponseDto.java b/app/backend/src/main/java/com/app/gamereview/dto/response/tag/AddGroupTagResponseDto.java new file mode 100644 index 00000000..725ee36d --- /dev/null +++ b/app/backend/src/main/java/com/app/gamereview/dto/response/tag/AddGroupTagResponseDto.java @@ -0,0 +1,13 @@ +package com.app.gamereview.dto.response.tag; + +import com.app.gamereview.model.Tag; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class AddGroupTagResponseDto { + private String groupId; + + private Tag addedTag; +} diff --git a/app/backend/src/main/java/com/app/gamereview/enums/MembershipPolicy.java b/app/backend/src/main/java/com/app/gamereview/enums/MembershipPolicy.java new file mode 100644 index 00000000..60dbc049 --- /dev/null +++ b/app/backend/src/main/java/com/app/gamereview/enums/MembershipPolicy.java @@ -0,0 +1,6 @@ +package com.app.gamereview.enums; + +public enum MembershipPolicy { + PUBLIC, + PRIVATE +} diff --git a/app/backend/src/main/java/com/app/gamereview/enums/SortType.java b/app/backend/src/main/java/com/app/gamereview/enums/SortType.java index d50354c0..762452ac 100644 --- a/app/backend/src/main/java/com/app/gamereview/enums/SortType.java +++ b/app/backend/src/main/java/com/app/gamereview/enums/SortType.java @@ -4,5 +4,6 @@ public enum SortType { CREATION_DATE, EDIT_DATE, OVERALL_VOTE, - VOTE_COUNT + VOTE_COUNT, + QUOTA } diff --git a/app/backend/src/main/java/com/app/gamereview/model/Game.java b/app/backend/src/main/java/com/app/gamereview/model/Game.java index 64bcc967..9f3ef6e9 100644 --- a/app/backend/src/main/java/com/app/gamereview/model/Game.java +++ b/app/backend/src/main/java/com/app/gamereview/model/Game.java @@ -86,6 +86,50 @@ public void addTag(Tag tag){ } } + public void removeTag(Tag tag){ + switch (tag.getTagType()){ + case PLAYER_TYPE: + playerTypes.remove(tag.getId()); + break; + case GENRE: + genre.remove(tag.getId()); + break; + case PRODUCTION: + production = null; + break; + case DURATION: + duration = null; + break; + case PLATFORM: + platforms.remove(tag.getId()); + break; + case ART_STYLE: + artStyles.remove(tag.getId()); + break; + case DEVELOPER: + developer = null; + break; + case OTHER: + otherTags.remove(tag.getId()); + break; + } + } + + public List getAllTags(){ + List allTags = new ArrayList<>(); + + allTags.addAll(playerTypes); + allTags.addAll(genre); + allTags.addAll(platforms); + allTags.addAll(artStyles); + allTags.addAll(otherTags); + allTags.add(production); + allTags.add(duration); + allTags.add(developer); + + return allTags; + } + public void addRating(float newRating){ overallRating = (overallRating * ratingCount + newRating) / (ratingCount + 1); ratingCount += 1; diff --git a/app/backend/src/main/java/com/app/gamereview/model/Group.java b/app/backend/src/main/java/com/app/gamereview/model/Group.java new file mode 100644 index 00000000..cfc39dcf --- /dev/null +++ b/app/backend/src/main/java/com/app/gamereview/model/Group.java @@ -0,0 +1,71 @@ +package com.app.gamereview.model; + +import com.app.gamereview.enums.MembershipPolicy; +import com.app.gamereview.model.common.BaseModel; +import lombok.Getter; +import lombok.Setter; +import org.springframework.data.annotation.TypeAlias; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.util.ArrayList; +import java.util.List; + +@Document(collection = "Group") +@TypeAlias("Group") +@Getter +@Setter +public class Group extends BaseModel { + private String title; + + private String description; + + private MembershipPolicy membershipPolicy; + + private List tags = new ArrayList<>(); // list of tag ids + + private String gameId; // id of related game + + private String forumId; // id of the forum of the group + + private int quota; + + private List moderators = new ArrayList<>(); // userIds of the moderators + + private List members = new ArrayList<>(); // userIds of the members + + private List bannedMembers = new ArrayList<>(); // userIds of the banned members + + private Boolean avatarOnly; + + public void addMember(String id){ + members.add(id); + } + + public void removeMember(String id){ + members.remove(id); + } + + public void addBannedUser(String id){ + members.remove(id); + bannedMembers.add(id); + } + + public void addModerator(String id){ + moderators.add(id); + if(!members.contains(id)){ + members.add(id); + } + } + public void removeModerator(String id){ + moderators.remove(id); + } + + + public void addTag(Tag tag){ + this.tags.add(tag.getId()); + } + + public void removeTag(Tag tag){ + this.tags.remove(tag.getId()); + } +} diff --git a/app/backend/src/main/java/com/app/gamereview/repository/GameRepository.java b/app/backend/src/main/java/com/app/gamereview/repository/GameRepository.java index 78155e62..6f96c935 100644 --- a/app/backend/src/main/java/com/app/gamereview/repository/GameRepository.java +++ b/app/backend/src/main/java/com/app/gamereview/repository/GameRepository.java @@ -9,4 +9,6 @@ public interface GameRepository extends MongoRepository { Optional findByGameNameAndIsDeletedFalse(String gameName); + Optional findByIdAndIsDeletedFalse(String id); + } diff --git a/app/backend/src/main/java/com/app/gamereview/repository/GroupRepository.java b/app/backend/src/main/java/com/app/gamereview/repository/GroupRepository.java new file mode 100644 index 00000000..e67b924d --- /dev/null +++ b/app/backend/src/main/java/com/app/gamereview/repository/GroupRepository.java @@ -0,0 +1,12 @@ +package com.app.gamereview.repository; + +import com.app.gamereview.model.Group; +import org.springframework.data.mongodb.repository.MongoRepository; + +import java.util.Optional; + +public interface GroupRepository extends MongoRepository { + Optional findByIdAndIsDeletedFalse(String id); + + Optional findByTitleAndIsDeletedFalse(String title); +} diff --git a/app/backend/src/main/java/com/app/gamereview/repository/VoteRepository.java b/app/backend/src/main/java/com/app/gamereview/repository/VoteRepository.java index cbb5c328..790b2a7e 100644 --- a/app/backend/src/main/java/com/app/gamereview/repository/VoteRepository.java +++ b/app/backend/src/main/java/com/app/gamereview/repository/VoteRepository.java @@ -8,4 +8,6 @@ public interface VoteRepository extends MongoRepository { Optional findByTypeIdAndVotedBy(String typeId, String votedBy); + + Optional findByVoteTypeAndTypeIdAndVotedBy(String voteType,String typeId, String votedBy); } diff --git a/app/backend/src/main/java/com/app/gamereview/service/CommentService.java b/app/backend/src/main/java/com/app/gamereview/service/CommentService.java index 737c2898..f59716b3 100644 --- a/app/backend/src/main/java/com/app/gamereview/service/CommentService.java +++ b/app/backend/src/main/java/com/app/gamereview/service/CommentService.java @@ -3,6 +3,7 @@ import com.app.gamereview.dto.request.comment.CreateCommentRequestDto; import com.app.gamereview.dto.request.comment.EditCommentRequestDto; import com.app.gamereview.dto.request.comment.ReplyCommentRequestDto; +import com.app.gamereview.enums.UserRole; import com.app.gamereview.exception.BadRequestException; import com.app.gamereview.exception.ResourceNotFoundException; import com.app.gamereview.model.*; @@ -66,8 +67,8 @@ public Comment editComment(String id, EditCommentRequestDto request, User user) throw new ResourceNotFoundException("The comment with the given id is not found."); } - if (!comment.get().getCommenter().equals(user.getId())) { - throw new BadRequestException("Only the user that created the comment can edit it."); + if (!(comment.get().getCommenter().equals(user.getId()) || UserRole.ADMIN.equals(user.getRole()))) { + throw new BadRequestException("Only the user that created the comment or admin can edit it."); } Comment editedComment = comment.get(); @@ -87,8 +88,8 @@ public Comment deleteComment(String id, User user) { throw new ResourceNotFoundException("The comment with the given id is not found."); } - if (!comment.get().getCommenter().equals(user.getId())) { - throw new BadRequestException("Only the user that created the comment can delete it."); + if (!(comment.get().getCommenter().equals(user.getId()) || UserRole.ADMIN.equals(user.getRole()))) { + throw new BadRequestException("Only the user that created the comment or admin can delete it."); } Comment commentToDelete = comment.get(); diff --git a/app/backend/src/main/java/com/app/gamereview/service/FileStorageService.java b/app/backend/src/main/java/com/app/gamereview/service/FileStorageService.java new file mode 100644 index 00000000..1d61766a --- /dev/null +++ b/app/backend/src/main/java/com/app/gamereview/service/FileStorageService.java @@ -0,0 +1,56 @@ +package com.app.gamereview.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Optional; +import java.util.UUID; + +@Service +public class FileStorageService { + + @Value("${image.base-directory}") + private Path baseLocation; + + public String storeFile(MultipartFile file, String folder) throws IOException { + // Check if the file is empty + if (file.isEmpty()) { + throw new IllegalArgumentException("Cannot store an empty file."); + } + + Path storageLocation = Path.of(baseLocation + "/" + folder); + // Ensure the target directory exists + Files.createDirectories(storageLocation); + + // Generate a random and unique filename + String originalFilename = Optional.ofNullable(file.getOriginalFilename()).orElse("default"); + String fileExtension = ""; + + // Check if the originalFilename contains a dot + int lastDotIndex = originalFilename.lastIndexOf('.'); + if (lastDotIndex > 0 && lastDotIndex < originalFilename.length() - 1) { + fileExtension = originalFilename.substring(lastDotIndex); + } + + String newFilename = UUID.randomUUID() + fileExtension; + + // Resolve the target file path + Path targetFilePath = storageLocation.resolve(newFilename); + + // Check if the filename is unique, regenerate if necessary + while (Files.exists(targetFilePath)) { + newFilename = UUID.randomUUID() + fileExtension; + targetFilePath = storageLocation.resolve(newFilename); + } + + // Copy the file to the target path, overwriting if it already exists + Files.copy(file.getInputStream(), targetFilePath, StandardCopyOption.REPLACE_EXISTING); + + return newFilename; + } +} diff --git a/app/backend/src/main/java/com/app/gamereview/service/GameService.java b/app/backend/src/main/java/com/app/gamereview/service/GameService.java index 25c72aed..770fd027 100644 --- a/app/backend/src/main/java/com/app/gamereview/service/GameService.java +++ b/app/backend/src/main/java/com/app/gamereview/service/GameService.java @@ -6,7 +6,8 @@ import java.util.stream.Collectors; import com.app.gamereview.dto.request.game.CreateGameRequestDto; -import com.app.gamereview.dto.request.tag.AddGameTagRequestDto; +import com.app.gamereview.dto.request.game.AddGameTagRequestDto; +import com.app.gamereview.dto.request.game.RemoveGameTagRequestDto; import com.app.gamereview.dto.response.tag.AddGameTagResponseDto; import com.app.gamereview.dto.response.tag.GetAllTagsOfGameResponseDto; import com.app.gamereview.enums.ForumType; @@ -53,6 +54,35 @@ public GameService( this.modelMapper = modelMapper; } + public List getGames(GetGameListRequestDto filter) { + Query query = new Query(); + if(filter != null) { + if (filter.getFindDeleted() == null || !filter.getFindDeleted()) { + query.addCriteria(Criteria.where("isDeleted").is(false)); + } + if(filter.getGameName() != null && !filter.getGameName().isBlank()){ + query.addCriteria(Criteria.where("gameName").is(filter.getGameName())); + } + if (filter.getPlayerTypes() != null && !filter.getPlayerTypes().isEmpty()) { + query.addCriteria(Criteria.where("playerTypes").in(filter.getPlayerTypes())); + } + if (filter.getGenre() != null && !filter.getGenre().isEmpty()) { + query.addCriteria(Criteria.where("genre").in(filter.getGenre())); + } + if (filter.getProduction() != null && !filter.getProduction().isBlank()) { + query.addCriteria(Criteria.where("production").is(filter.getProduction())); + } + if (filter.getPlatform() != null && !filter.getPlatform().isEmpty()) { + query.addCriteria(Criteria.where("platforms").in(filter.getPlatform())); + } + if (filter.getArtStyle() != null && !filter.getArtStyle().isEmpty()) { + query.addCriteria(Criteria.where("artStyles").in(filter.getArtStyle())); + } + } + + return mongoTemplate.find(query, Game.class); + } + public List getAllGames(GetGameListRequestDto filter) { Query query = new Query(); if(filter != null) { @@ -130,6 +160,11 @@ public AddGameTagResponseDto addGameTag(AddGameTagRequestDto request){ Game game = findGame.get(); Tag tag = findTag.get(); + + if(game.getAllTags().contains(tag.getId())){ + throw new BadRequestException("Tag is already added"); + } + game.addTag(tag); gameRepository.save(game); @@ -139,6 +174,32 @@ public AddGameTagResponseDto addGameTag(AddGameTagRequestDto request){ return response; } + public Boolean removeGameTag(RemoveGameTagRequestDto request){ + Optional findGame = gameRepository.findById(request.getGameId()); + + Optional findTag = tagRepository.findById(request.getTagId()); + + if(findGame.isEmpty() || findGame.get().getIsDeleted()){ + throw new ResourceNotFoundException("Game does not exist"); + } + + if(findTag.isEmpty() || findTag.get().getIsDeleted()){ + throw new ResourceNotFoundException("Tag does not exist"); + } + + Game game = findGame.get(); + Tag tag = findTag.get(); + + if(!game.getAllTags().contains(tag.getId())){ + return false; + } + + game.removeTag(tag); + gameRepository.save(game); + + return true; + } + public GameDetailResponseDto getGameDetail(String id){ Optional optionalGame = gameRepository.findById(id); if (optionalGame.isPresent()) { @@ -153,6 +214,19 @@ public GameDetailResponseDto getGameDetail(String id){ } } + public GameDetailResponseDto getGameByName(String name){ + Optional optionalGame = gameRepository.findByGameNameAndIsDeletedFalse(name); + if (optionalGame.isPresent()) { + Game game = optionalGame.get(); + GameDetailResponseDto response = new GameDetailResponseDto(); + response.setGame(game); + return response; + } + else { + throw new ResourceNotFoundException("Game not found"); + } + } + public Game createGame(CreateGameRequestDto request){ Optional sameName = gameRepository @@ -187,7 +261,7 @@ public Game createGame(CreateGameRequestDto request){ for (String genreId : request.getGenre()) { Optional genre = tagRepository.findByIdAndIsDeletedFalse(genreId); if (genre.isEmpty() || genre.get().getTagType() != TagType.GENRE) { - throw new ResourceNotFoundException("One of the givem genre is not found."); + throw new ResourceNotFoundException("One of the given genre is not found."); } } diff --git a/app/backend/src/main/java/com/app/gamereview/service/GroupService.java b/app/backend/src/main/java/com/app/gamereview/service/GroupService.java new file mode 100644 index 00000000..2401b2c6 --- /dev/null +++ b/app/backend/src/main/java/com/app/gamereview/service/GroupService.java @@ -0,0 +1,370 @@ +package com.app.gamereview.service; + +import com.app.gamereview.dto.request.group.*; +import com.app.gamereview.dto.request.review.CreateReviewRequestDto; +import com.app.gamereview.dto.response.group.GetGroupResponseDto; +import com.app.gamereview.dto.response.tag.AddGroupTagResponseDto; +import com.app.gamereview.enums.*; +import com.app.gamereview.exception.BadRequestException; +import com.app.gamereview.exception.ResourceNotFoundException; +import com.app.gamereview.model.*; +import com.app.gamereview.repository.*; +import com.app.gamereview.util.UtilExtensions; +import com.app.gamereview.util.validation.annotation.ValidMemberPolicy; +import org.modelmapper.ModelMapper; +import org.modelmapper.PropertyMap; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.stereotype.Service; + +import java.util.*; + +@Service +public class GroupService { + + private final GroupRepository groupRepository; + + private final GameRepository gameRepository; + + private final ForumRepository forumRepository; + + private final TagRepository tagRepository; + + private final MongoTemplate mongoTemplate; + + private final ModelMapper modelMapper; + + @Autowired + public GroupService( + GroupRepository groupRepository, + GameRepository gameRepository, + ForumRepository forumRepository, + TagRepository tagRepository, + MongoTemplate mongoTemplate, + ModelMapper modelMapper + ) { + this.groupRepository = groupRepository; + this.gameRepository = gameRepository; + this.forumRepository = forumRepository; + this.tagRepository = tagRepository; + this.mongoTemplate = mongoTemplate; + this.modelMapper = modelMapper; + + modelMapper.addMappings(new PropertyMap() { + @Override + protected void configure() { + map().setGameId(source.getGameId()); + skip().setId(null); // Exclude id from mapping + } + }); + + modelMapper.addMappings(new PropertyMap() { + @Override + protected void configure() { + skip().setTags(null); // Exclude id from mapping + } + }); + } + + public List getAllGroups(GetAllGroupsFilterRequestDto filter){ + Query query = new Query(); + + // search for title + if(filter.getTitle() != null && !filter.getTitle().isBlank()){ + String regexPattern = ".*" + filter.getTitle() + ".*"; + query.addCriteria(Criteria.where("title").regex(regexPattern, "i")); + } + if (filter.getMembershipPolicy() != null) { + query.addCriteria(Criteria.where("membershipPolicy").is(filter.getMembershipPolicy())); + } + if (filter.getTags() != null && !filter.getTags().isEmpty()) { + query.addCriteria(Criteria.where("tags").in(filter.getTags())); + } + if (filter.getGameName() != null && !filter.getGameName().isBlank()) { + String gameName = filter.getGameName(); + Optional game = gameRepository.findByGameNameAndIsDeletedFalse(gameName); + if(game.isEmpty()){ + return new ArrayList<>(); + } + query.addCriteria(Criteria.where("gameId").is(game.get().getId())); + } + if (filter.getSortBy() != null) { + Sort.Direction sortDirection = Sort.Direction.DESC; + if (filter.getSortDirection() != null) { + sortDirection = filter.getSortDirection().equals(SortDirection.ASCENDING.name()) ? Sort.Direction.ASC + : Sort.Direction.DESC; + } + if (filter.getSortBy().equals(SortType.CREATION_DATE.name())) { + query.with(Sort.by(sortDirection, "createdAt")); + } + else if (filter.getSortBy().equals(SortType.QUOTA.name())) { + query.with(Sort.by(sortDirection, "quota")); + } + } + + List filteredGroups = mongoTemplate.find(query,Group.class); + List responseDtos = new ArrayList<>(); + + for(Group group : filteredGroups){ + GetGroupResponseDto dto = modelMapper.map(group,GetGroupResponseDto.class); + + for(String tagId : group.getTags()){ + Optional tag = tagRepository.findById(tagId); + tag.ifPresent(dto::populateTag); + } + + responseDtos.add(dto); + } + + return responseDtos; + } + + public GetGroupResponseDto getGroupById(String groupId){ + Optional isGroupExists = groupRepository.findByIdAndIsDeletedFalse(groupId); + + if(isGroupExists.isEmpty()){ + throw new ResourceNotFoundException("Group not found"); + } + + Group group = isGroupExists.get(); + GetGroupResponseDto dto = modelMapper.map(group, GetGroupResponseDto.class); + for(String tagId : group.getTags()){ + Optional tag = tagRepository.findById(tagId); + tag.ifPresent(dto::populateTag); + } + + return dto; + } + + public Group createGroup(CreateGroupRequestDto request, User user){ + + Optional sameTitle = groupRepository.findByTitleAndIsDeletedFalse(request.getTitle()); + + if(sameTitle.isPresent()){ + throw new BadRequestException("Group with same title already exists, please pick a new title"); + } + + if(request.getTags() != null){ + for(String tagId : request.getTags()){ + Optional tag = tagRepository.findByIdAndIsDeletedFalse(tagId); + if(tag.isEmpty()){ + throw new ResourceNotFoundException("One of the added tag is not found"); + } + } + } + + if(request.getGameId() != null){ + Optional game = gameRepository.findByIdAndIsDeletedFalse(request.getGameId()); + if(game.isEmpty()){ + throw new ResourceNotFoundException("Game is not found"); + } + } + + Group groupToCreate = modelMapper.map(request, Group.class); + + Forum correspondingForum = new Forum(groupToCreate.getTitle(), ForumType.GROUP, + groupToCreate.getId(), new ArrayList<>(), new ArrayList<>()); + forumRepository.save(correspondingForum); + groupToCreate.setForumId(correspondingForum.getId()); + + List moderators = new ArrayList<>(); + moderators.add(user.getId()); + List members = new ArrayList<>(); + members.add(user.getId()); + + groupToCreate.setModerators(moderators); + groupToCreate.setMembers(members); + + return groupRepository.save(groupToCreate); + } + + public Boolean deleteGroup(String identifier){ + Optional foundGroup; + + if(UtilExtensions.isUUID(identifier)){ + foundGroup = groupRepository.findByIdAndIsDeletedFalse(identifier); + } + else{ + foundGroup = groupRepository.findByTitleAndIsDeletedFalse(identifier); + } + + if(foundGroup.isEmpty()){ + throw new ResourceNotFoundException("Group is not found"); + } + + Group groupToDelete = foundGroup.get(); + + groupToDelete.setIsDeleted(true); + groupRepository.save(groupToDelete); + return true; + } + + public Group updateGroup(String groupId, UpdateGroupRequestDto request){ + Optional foundGroup = groupRepository.findByIdAndIsDeletedFalse(groupId); + + if(foundGroup.isEmpty()){ + throw new ResourceNotFoundException("Group does not exist"); + } + + Group groupToUpdate = foundGroup.get(); + + if(groupToUpdate.getMembers().size() > request.getQuota()){ + throw new BadRequestException("Quota cannot be less than the current number of members in group"); + } + + // could also use a mapper (few values to assign hence this implementation is kind of handy) + groupToUpdate.setTitle(request.getTitle()); + groupToUpdate.setDescription(request.getDescription()); + groupToUpdate.setMembershipPolicy(MembershipPolicy.valueOf(request.getMembershipPolicy())); + groupToUpdate.setQuota(request.getQuota()); + groupToUpdate.setAvatarOnly(request.getAvatarOnly()); + groupRepository.save(groupToUpdate); + return groupToUpdate; + } + + public AddGroupTagResponseDto addGroupTag(AddGroupTagRequestDto request){ + Optional findGroup = groupRepository.findById(request.getGroupId()); + + Optional findTag = tagRepository.findById(request.getTagId()); + + if(findGroup.isEmpty() || findGroup.get().getIsDeleted()){ + throw new ResourceNotFoundException("Group does not exist"); + } + + if(findTag.isEmpty() || findTag.get().getIsDeleted()){ + throw new ResourceNotFoundException("Tag does not exist"); + } + + Group group = findGroup.get(); + Tag tag = findTag.get(); + + if(group.getTags().contains(tag.getId())){ + throw new BadRequestException("Tag is already added"); + } + + group.addTag(tag); + groupRepository.save(group); + + AddGroupTagResponseDto response = new AddGroupTagResponseDto(); + response.setGroupId(group.getId()); + response.setAddedTag(tag); + return response; + } + + public Boolean removeGroupTag(RemoveGroupTagRequestDto request){ + Optional findGroup = groupRepository.findById(request.getGroupId()); + + Optional findTag = tagRepository.findById(request.getTagId()); + + if(findGroup.isEmpty() || findGroup.get().getIsDeleted()){ + throw new ResourceNotFoundException("Group does not exist"); + } + + if(findTag.isEmpty() || findTag.get().getIsDeleted()){ + throw new ResourceNotFoundException("Tag does not exist"); + } + + Group group = findGroup.get(); + Tag tag = findTag.get(); + + if(!group.getTags().contains(tag.getId())){ + return false; + } + + group.removeTag(tag); + groupRepository.save(group); + + return true; + } + + public Boolean joinGroup(String groupId, User user){ + Optional isGroupExists = groupRepository.findByIdAndIsDeletedFalse(groupId); + + if(isGroupExists.isEmpty()){ + throw new ResourceNotFoundException("Group not found"); + } + + if(MembershipPolicy.PRIVATE.equals(isGroupExists.get().getMembershipPolicy())){ + throw new BadRequestException("You should send join request for this group"); + } + + if(isGroupExists.get().getQuota() <= isGroupExists.get().getMembers().size()){ + throw new BadRequestException("You cannot join, group is full"); + } + + isGroupExists.get().addMember(user.getId()); + groupRepository.save(isGroupExists.get()); + + return true; + } + + public Boolean leaveGroup(String groupId, User user){ + Optional isGroupExists = groupRepository.findByIdAndIsDeletedFalse(groupId); + + if(isGroupExists.isEmpty()){ + throw new ResourceNotFoundException("Group not found"); + } + + + isGroupExists.get().removeMember(user.getId()); + groupRepository.save(isGroupExists.get()); + + return true; + } + + public Boolean banUser(String groupId, String userId, User user) { + Optional group = groupRepository.findById(groupId); + + if (group.isEmpty() || group.get().getIsDeleted()) { + throw new ResourceNotFoundException("The group with the given id is not found."); + } + + if (group.get().getModerators().contains(user.getId()) && group.get().getModerators().contains(userId)) { + throw new BadRequestException("Moderators cannot ban moderators."); + } + + if (!(group.get().getModerators().contains(user.getId()) || UserRole.ADMIN.equals(user.getRole()))) { + throw new BadRequestException("Only the moderator or the admin can ban user."); + } + + group.get().addBannedUser(userId); + groupRepository.save(group.get()); + + return true; + } + + public Boolean addModerator(String groupId, String userId, User user) { + Optional group = groupRepository.findById(groupId); + + if (group.isEmpty() || group.get().getIsDeleted()) { + throw new ResourceNotFoundException("The group with the given id is not found."); + } + + if (!(group.get().getModerators().contains(user.getId()) || UserRole.ADMIN.equals(user.getRole()))) { + throw new BadRequestException("Only the moderator or the admin can add moderator."); + } + + group.get().addModerator(userId); + groupRepository.save(group.get()); + + return true; + } + + public Boolean removeModerator(String groupId, String userId) { + Optional group = groupRepository.findById(groupId); + + if (group.isEmpty() || group.get().getIsDeleted()) { + throw new ResourceNotFoundException("The group with the given id is not found."); + } + + group.get().removeModerator(userId); + groupRepository.save(group.get()); + + return true; + } + + +} diff --git a/app/backend/src/main/java/com/app/gamereview/service/PostService.java b/app/backend/src/main/java/com/app/gamereview/service/PostService.java index de32abb7..3fb79725 100644 --- a/app/backend/src/main/java/com/app/gamereview/service/PostService.java +++ b/app/backend/src/main/java/com/app/gamereview/service/PostService.java @@ -106,8 +106,16 @@ private GetPostListResponseDto mapToGetPostListResponseDto(Post post, String log VoteChoice userVoteChoice = userVote.map(Vote::getChoice).orElse(null); + List tags = new ArrayList<>(); + + // Fetch tags individually + for (String tagId : post.getTags()) { + Optional tag = tagRepository.findById(tagId); + tag.ifPresent(tags::add); + } + return new GetPostListResponseDto(post.getId(), post.getTitle(), post.getPostContent(), - posterObject, userVoteChoice, post.getLastEditedAt(), post.getCreatedAt(), isEdited, post.getTags(), + posterObject, userVoteChoice, post.getLastEditedAt(), post.getCreatedAt(), isEdited, tags, post.getInappropriate(), post.getOverallVote(), post.getVoteCount(), commentCount); } @@ -122,14 +130,23 @@ public GetPostDetailResponseDto getPostById(String id, User user) { if (forum.isPresent()) { List bannedUsers = forum.get().getBannedUsers(); - System.out.println(); - System.out.println(bannedUsers); if (bannedUsers.contains(user.getId())) { throw new ResourceNotFoundException("You cannot see the post because you are banned."); } } GetPostDetailResponseDto postDto = modelMapper.map(post, GetPostDetailResponseDto.class); + + List tags = new ArrayList<>(); + + // Fetch tags individually + for (String tagId : post.get().getTags()) { + Optional tag = tagRepository.findById(tagId); + tag.ifPresent(tags::add); + } + + postDto.setTags(tags); + Optional poster = userRepository.findByIdAndIsDeletedFalse(post.get().getPoster()); poster.ifPresent(postDto::setPoster); diff --git a/app/backend/src/main/java/com/app/gamereview/service/ReviewService.java b/app/backend/src/main/java/com/app/gamereview/service/ReviewService.java index b80da399..c2c0ca68 100644 --- a/app/backend/src/main/java/com/app/gamereview/service/ReviewService.java +++ b/app/backend/src/main/java/com/app/gamereview/service/ReviewService.java @@ -4,19 +4,24 @@ import com.app.gamereview.dto.request.review.GetAllReviewsFilterRequestDto; import com.app.gamereview.dto.request.review.UpdateReviewRequestDto; import com.app.gamereview.dto.response.review.GetAllReviewsResponseDto; +import com.app.gamereview.enums.SortDirection; +import com.app.gamereview.enums.SortType; import com.app.gamereview.enums.UserRole; import com.app.gamereview.exception.BadRequestException; import com.app.gamereview.exception.ResourceNotFoundException; import com.app.gamereview.model.Game; import com.app.gamereview.model.Review; import com.app.gamereview.model.User; +import com.app.gamereview.model.Vote; import com.app.gamereview.repository.GameRepository; import com.app.gamereview.repository.ReviewRepository; import com.app.gamereview.repository.UserRepository; +import com.app.gamereview.repository.VoteRepository; import com.mongodb.client.result.UpdateResult; import org.modelmapper.ModelMapper; import org.modelmapper.PropertyMap; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Sort; import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.data.mongodb.core.query.Query; @@ -31,6 +36,8 @@ public class ReviewService { private final GameRepository gameRepository; + private final VoteRepository voteRepository; + private final UserRepository userRepository; private final MongoTemplate mongoTemplate; @@ -41,12 +48,14 @@ public class ReviewService { public ReviewService( ReviewRepository reviewRepository, GameRepository gameRepository, + VoteRepository voteRepository, UserRepository userRepository, MongoTemplate mongoTemplate, ModelMapper modelMapper ) { this.reviewRepository = reviewRepository; this.gameRepository = gameRepository; + this.voteRepository = voteRepository; this.userRepository = userRepository; this.mongoTemplate = mongoTemplate; this.modelMapper = modelMapper; @@ -60,7 +69,10 @@ protected void configure() { }); } - public List getAllReviews(GetAllReviewsFilterRequestDto filter) { + public List getAllReviews(GetAllReviewsFilterRequestDto filter, String email) { + Optional loggedInUser = userRepository.findByEmailAndIsDeletedFalse(email); + String loggedInUserId = loggedInUser.map(User::getId).orElse(null); + Query query = new Query(); if (filter.getGameId() != null) { query.addCriteria(Criteria.where("gameId").is(filter.getGameId())); @@ -72,19 +84,37 @@ public List getAllReviews(GetAllReviewsFilterRequestDt query.addCriteria(Criteria.where("isDeleted").is(filter.getWithDeleted())); } - List filteredReviews = mongoTemplate.find(query, Review.class); - - if(filter.getSortDirection().equals("DESCENDING")){ - Collections.sort(filteredReviews, Comparator.comparing(Review::getCreatedAt).reversed()); - } - else{ - Collections.sort(filteredReviews, Comparator.comparing(Review::getCreatedAt)); + if (filter.getSortBy() != null) { + Sort.Direction sortDirection = Sort.Direction.DESC; + if (filter.getSortDirection() != null) { + sortDirection = filter.getSortDirection().equals(SortDirection.ASCENDING.name()) ? Sort.Direction.ASC + : Sort.Direction.DESC; + } + if (filter.getSortBy().equals(SortType.CREATION_DATE.name())) { + query.with(Sort.by(sortDirection, "createdAt")); + } + else if (filter.getSortBy().equals(SortType.OVERALL_VOTE.name())) { + query.with(Sort.by(sortDirection, "overallVote")); + } + else if (filter.getSortBy().equals(SortType.VOTE_COUNT.name())) { + query.with(Sort.by(sortDirection, "voteCount")); + } } + List filteredReviews = mongoTemplate.find(query, Review.class); + List reviewDtos = new ArrayList<>(); for(Review review : filteredReviews){ GetAllReviewsResponseDto reviewDto = modelMapper.map(review, GetAllReviewsResponseDto.class); + + Optional vote = voteRepository.findByVoteTypeAndTypeIdAndVotedBy("REVIEW", + review.getId(), loggedInUserId); + + if(vote.isPresent()){ + reviewDto.setRequestedUserVote(vote.get().getChoice().name()); + } + reviewDto.setReviewedUser(userRepository.findById(review.getReviewedBy()) .get().getUsername()); reviewDtos.add(reviewDto); diff --git a/app/backend/src/main/java/com/app/gamereview/util/UtilExtensions.java b/app/backend/src/main/java/com/app/gamereview/util/UtilExtensions.java new file mode 100644 index 00000000..152b2aca --- /dev/null +++ b/app/backend/src/main/java/com/app/gamereview/util/UtilExtensions.java @@ -0,0 +1,7 @@ +package com.app.gamereview.util; + +public class UtilExtensions { + public static boolean isUUID(String str) { + return str.matches("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"); + } +} diff --git a/app/backend/src/main/java/com/app/gamereview/util/validation/annotation/ValidMemberPolicy.java b/app/backend/src/main/java/com/app/gamereview/util/validation/annotation/ValidMemberPolicy.java new file mode 100644 index 00000000..e9e826ee --- /dev/null +++ b/app/backend/src/main/java/com/app/gamereview/util/validation/annotation/ValidMemberPolicy.java @@ -0,0 +1,21 @@ +package com.app.gamereview.util.validation.annotation; + +import com.app.gamereview.enums.MembershipPolicy; +import com.app.gamereview.util.validation.validator.MemberPolicyValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = MemberPolicyValidator.class) +public @interface ValidMemberPolicy { + MembershipPolicy[] allowedValues(); + String message() default "Invalid Membership Policy, allowed values are: {allowedValues}"; + Class[] groups() default {}; + Class[] payload() default {}; +} diff --git a/app/backend/src/main/java/com/app/gamereview/util/validation/validator/MemberPolicyValidator.java b/app/backend/src/main/java/com/app/gamereview/util/validation/validator/MemberPolicyValidator.java new file mode 100644 index 00000000..4933c370 --- /dev/null +++ b/app/backend/src/main/java/com/app/gamereview/util/validation/validator/MemberPolicyValidator.java @@ -0,0 +1,26 @@ +package com.app.gamereview.util.validation.validator; + + +import com.app.gamereview.enums.MembershipPolicy; +import com.app.gamereview.util.validation.annotation.ValidMemberPolicy; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class MemberPolicyValidator implements ConstraintValidator { + + + @Override + public boolean isValid(String providedType, ConstraintValidatorContext context) { + if (providedType == null || providedType.isEmpty()) { + return false; + } + + for (MembershipPolicy membershipPolicy : MembershipPolicy.values()) { + if (membershipPolicy.name().equals(providedType)) { + return true; + } + } + + return false; + } +} diff --git a/app/frontend/package.json b/app/frontend/package.json index a5da819b..09ebaea7 100644 --- a/app/frontend/package.json +++ b/app/frontend/package.json @@ -24,7 +24,9 @@ "react-query": "^3.39.3", "react-router-dom": "^6.17.0", "react-spinners": "^0.13.8", - "sass": "^1.69.4" + "sass": "^1.69.4", + "tw-to-css": "^0.0.12", + "usehooks-ts": "^2.9.1" }, "devDependencies": { "@types/react": "^18.2.15", diff --git a/app/frontend/pnpm-lock.yaml b/app/frontend/pnpm-lock.yaml index 88e32091..9c76348d 100644 --- a/app/frontend/pnpm-lock.yaml +++ b/app/frontend/pnpm-lock.yaml @@ -50,6 +50,12 @@ dependencies: sass: specifier: ^1.69.4 version: 1.69.4 + tw-to-css: + specifier: ^0.0.12 + version: 0.0.12 + usehooks-ts: + specifier: ^2.9.1 + version: 2.9.1(react-dom@18.2.0)(react@18.2.0) devDependencies: '@types/react': @@ -96,6 +102,11 @@ packages: engines: {node: '>=0.10.0'} dev: true + /@alloc/quick-lru@5.2.0: + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + dev: false + /@ampproject/remapping@2.2.1: resolution: {integrity: sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==} engines: {node: '>=6.0.0'} @@ -665,28 +676,23 @@ packages: '@jridgewell/set-array': 1.1.2 '@jridgewell/sourcemap-codec': 1.4.15 '@jridgewell/trace-mapping': 0.3.19 - dev: true /@jridgewell/resolve-uri@3.1.1: resolution: {integrity: sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==} engines: {node: '>=6.0.0'} - dev: true /@jridgewell/set-array@1.1.2: resolution: {integrity: sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==} engines: {node: '>=6.0.0'} - dev: true /@jridgewell/sourcemap-codec@1.4.15: resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} - dev: true /@jridgewell/trace-mapping@0.3.19: resolution: {integrity: sha512-kf37QtfW+Hwx/buWGMPcR60iF9ziHa6r/CZJIHbmcm4+0qrXiVdxegAH0F6yddEVQ7zdkjcGCgCzUu+BcbhQxw==} dependencies: '@jridgewell/resolve-uri': 3.1.1 '@jridgewell/sourcemap-codec': 1.4.15 - dev: true /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} @@ -694,12 +700,10 @@ packages: dependencies: '@nodelib/fs.stat': 2.0.5 run-parallel: 1.2.0 - dev: true /@nodelib/fs.stat@2.0.5: resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} engines: {node: '>= 8'} - dev: true /@nodelib/fs.walk@1.2.8: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} @@ -707,7 +711,6 @@ packages: dependencies: '@nodelib/fs.scandir': 2.1.5 fastq: 1.15.0 - dev: true /@popperjs/core@2.11.8: resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} @@ -1150,6 +1153,10 @@ packages: - moment dev: false + /any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + dev: false + /anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -1157,6 +1164,10 @@ packages: normalize-path: 3.0.0 picomatch: 2.3.1 + /arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + dev: false + /argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} dev: true @@ -1241,6 +1252,11 @@ packages: engines: {node: '>=6'} dev: true + /camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + dev: false + /caniuse-lite@1.0.30001549: resolution: {integrity: sha512-qRp48dPYSCYaP+KurZLhDYdVE+yEyht/3NlmcJgVQ2VMGt6JL36ndQ/7rgspdZsJuxDPFIo/OzBT2+GmIJ53BA==} dev: true @@ -1313,6 +1329,11 @@ packages: delayed-stream: 1.0.0 dev: false + /commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + dev: false + /compute-scroll-into-view@3.1.0: resolution: {integrity: sha512-rj8l8pD4bJ1nx+dAkMhV1xB5RuZEyVysfxJqB1pRchh1KVvwOv9b7CGB8ZfjTImVv2oF+sYMUkMZq6Na5Ftmbg==} dev: false @@ -1339,6 +1360,12 @@ packages: which: 2.0.2 dev: true + /cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + dev: false + /csstype@3.1.2: resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==} @@ -1377,6 +1404,10 @@ packages: resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} dev: false + /didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + dev: false + /dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -1384,6 +1415,10 @@ packages: path-type: 4.0.0 dev: true + /dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dev: false + /doctrine@3.0.0: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} @@ -1433,7 +1468,6 @@ packages: /escape-string-regexp@1.0.5: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} - dev: true /escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} @@ -1549,6 +1583,10 @@ packages: engines: {node: '>=0.10.0'} dev: true + /extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + dev: false + /fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} dev: true @@ -1562,7 +1600,6 @@ packages: glob-parent: 5.1.2 merge2: 1.4.1 micromatch: 4.0.5 - dev: true /fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -1576,7 +1613,6 @@ packages: resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==} dependencies: reusify: 1.0.4 - dev: true /file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} @@ -1641,6 +1677,10 @@ packages: requiresBuild: true optional: true + /function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + dev: false + /gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -1657,7 +1697,17 @@ packages: engines: {node: '>=10.13.0'} dependencies: is-glob: 4.0.3 - dev: true + + /glob@7.1.6: + resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==} + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + dev: false /glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} @@ -1707,6 +1757,13 @@ packages: engines: {node: '>=8'} dev: true + /hasown@2.0.0: + resolution: {integrity: sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==} + engines: {node: '>= 0.4'} + dependencies: + function-bind: 1.1.2 + dev: false + /ignore@5.2.4: resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==} engines: {node: '>= 4'} @@ -1743,6 +1800,12 @@ packages: dependencies: binary-extensions: 2.2.0 + /is-core-module@2.13.1: + resolution: {integrity: sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==} + dependencies: + hasown: 2.0.0 + dev: false + /is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1766,6 +1829,11 @@ packages: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} dev: true + /jiti@1.21.0: + resolution: {integrity: sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==} + hasBin: true + dev: false + /js-cookie@3.0.5: resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} engines: {node: '>=14'} @@ -1829,6 +1897,15 @@ packages: type-check: 0.4.0 dev: true + /lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + dev: false + + /lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + dev: false + /locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -1881,7 +1958,6 @@ packages: /merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - dev: true /micromatch@4.0.5: resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} @@ -1889,7 +1965,6 @@ packages: dependencies: braces: 3.0.2 picomatch: 2.3.1 - dev: true /microseconds@0.2.0: resolution: {integrity: sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA==} @@ -1921,6 +1996,14 @@ packages: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} dev: true + /mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + dev: false + /nano-time@1.0.0: resolution: {integrity: sha512-flnngywOoQ0lLQOTRNexn2gGSNuM9bKj9RZAWSzhQ+UJYaAFG9bac4DW9VHjUAzrOaIcajHybCTHe/bkvozQqA==} dependencies: @@ -1931,7 +2014,6 @@ packages: resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - dev: true /natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -1950,6 +2032,11 @@ packages: engines: {node: '>=0.10.0'} dev: false + /object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + dev: false + /oblivious-set@1.0.0: resolution: {integrity: sha512-z+pI07qxo4c2CulUHCDf9lcqDlMSo72N/4rLUpRXf6fu+q8vjt8y0xS+Tlf8NTJDdTXHbdeO1n3MlbctwEoXZw==} dev: false @@ -2006,6 +2093,10 @@ packages: engines: {node: '>=8'} dev: true + /path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + dev: false + /path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -2013,12 +2104,93 @@ packages: /picocolors@1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} - dev: true /picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} + /pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + dev: false + + /pirates@4.0.6: + resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} + engines: {node: '>= 6'} + dev: false + + /postcss-css-variables@0.18.0(postcss@8.4.31): + resolution: {integrity: sha512-lYS802gHbzn1GI+lXvy9MYIYDuGnl1WB4FTKoqMQqJ3Mab09A7a/1wZvGTkCEZJTM8mSbIyb1mJYn8f0aPye0Q==} + peerDependencies: + postcss: ^8.2.6 + dependencies: + balanced-match: 1.0.2 + escape-string-regexp: 1.0.5 + extend: 3.0.2 + postcss: 8.4.31 + dev: false + + /postcss-import@15.1.0(postcss@8.4.31): + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + dependencies: + postcss: 8.4.31 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.8 + dev: false + + /postcss-js@4.0.1(postcss@8.4.31): + resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + dependencies: + camelcase-css: 2.0.1 + postcss: 8.4.31 + dev: false + + /postcss-load-config@4.0.1(postcss@8.4.31): + resolution: {integrity: sha512-vEJIc8RdiBRu3oRAI0ymerOn+7rPuMvRXslTvZUKZonDHFIczxztIyJ1urxM1x9JXEikvpWWTUUqal5j/8QgvA==} + engines: {node: '>= 14'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + dependencies: + lilconfig: 2.1.0 + postcss: 8.4.31 + yaml: 2.3.4 + dev: false + + /postcss-nested@6.0.1(postcss@8.4.31): + resolution: {integrity: sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + dependencies: + postcss: 8.4.31 + postcss-selector-parser: 6.0.13 + dev: false + + /postcss-selector-parser@6.0.13: + resolution: {integrity: sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==} + engines: {node: '>=4'} + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + dev: false + + /postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + dev: false + /postcss@8.4.31: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} @@ -2026,7 +2198,6 @@ packages: nanoid: 3.3.6 picocolors: 1.0.0 source-map-js: 1.0.2 - dev: true /prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} @@ -2060,7 +2231,6 @@ packages: /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - dev: true /rc-cascader@3.18.1(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-M7Xr5Fs/E87ZGustfObtBYQjsvBCET0UX2JYXB2GmOP+2fsZgjaRGXK+CJBmmWXQ6o4OFinpBQBXG4wJOQ5MEg==} @@ -2718,6 +2888,12 @@ packages: react: 18.2.0 dev: false + /read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + dependencies: + pify: 2.3.0 + dev: false + /readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -2740,10 +2916,18 @@ packages: engines: {node: '>=4'} dev: true + /resolve@1.22.8: + resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} + hasBin: true + dependencies: + is-core-module: 2.13.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + dev: false + /reusify@1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - dev: true /rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} @@ -2763,7 +2947,6 @@ packages: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} dependencies: queue-microtask: 1.2.3 - dev: true /sass@1.69.4: resolution: {integrity: sha512-+qEreVhqAy8o++aQfCJwp0sklr2xyEzkm9Pp/Igu9wNPoe7EZEQ8X/MBvvXggI2ql607cxKg/RKOwDj6pp2XDA==} @@ -2839,6 +3022,20 @@ packages: resolution: {integrity: sha512-E87pIogpwUsUwXw7dNyU4QDjdgVMy52m+XEOPEKUn161cCzWjjhPSQhByfd1CcNvrOLnXQ6OnnZDwnJrz/Z4YQ==} dev: false + /sucrase@3.34.0: + resolution: {integrity: sha512-70/LQEZ07TEcxiU2dz51FKaE6hCTWC6vr7FOk3Gr0U60C3shtAN+H+BFr9XlYe5xqf3RA8nrc+VIwzCfnxuXJw==} + engines: {node: '>=8'} + hasBin: true + dependencies: + '@jridgewell/gen-mapping': 0.3.3 + commander: 4.1.1 + glob: 7.1.6 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.6 + ts-interface-checker: 0.1.13 + dev: false + /supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -2853,10 +3050,60 @@ packages: has-flag: 4.0.0 dev: true + /supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + dev: false + + /tailwindcss@3.3.2: + resolution: {integrity: sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w==} + engines: {node: '>=14.0.0'} + hasBin: true + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.5.3 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.1 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.0 + lilconfig: 2.1.0 + micromatch: 4.0.5 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.0.0 + postcss: 8.4.31 + postcss-import: 15.1.0(postcss@8.4.31) + postcss-js: 4.0.1(postcss@8.4.31) + postcss-load-config: 4.0.1(postcss@8.4.31) + postcss-nested: 6.0.1(postcss@8.4.31) + postcss-selector-parser: 6.0.13 + postcss-value-parser: 4.2.0 + resolve: 1.22.8 + sucrase: 3.34.0 + transitivePeerDependencies: + - ts-node + dev: false + /text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} dev: true + /thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + dependencies: + thenify: 3.3.1 + dev: false + + /thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + dependencies: + any-promise: 1.3.0 + dev: false + /throttle-debounce@5.0.0: resolution: {integrity: sha512-2iQTSgkkc1Zyk0MeVrt/3BvuOXYPl/R8Z0U2xxo9rjwNciaHDG3R+Lm6dh4EeUci49DanvBnuqI6jshoQQRGEg==} engines: {node: '>=12.22'} @@ -2890,6 +3137,21 @@ packages: typescript: 5.2.2 dev: true + /ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + dev: false + + /tw-to-css@0.0.12: + resolution: {integrity: sha512-rQAsQvOtV1lBkyCw+iypMygNHrShYAItES5r8fMsrhhaj5qrV2LkZyXc8ccEH+u5bFjHjQ9iuxe90I7Kykf6pw==} + engines: {node: '>=16.0.0'} + dependencies: + postcss: 8.4.31 + postcss-css-variables: 0.18.0(postcss@8.4.31) + tailwindcss: 3.3.2 + transitivePeerDependencies: + - ts-node + dev: false + /type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -2932,6 +3194,21 @@ packages: punycode: 2.3.0 dev: true + /usehooks-ts@2.9.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-2FAuSIGHlY+apM9FVlj8/oNhd+1y+Uwv5QNkMQz1oSfdHk4PXo1qoCw9I5M7j0vpH8CSWFJwXbVPeYDjLCx9PA==} + engines: {node: '>=16.15.0', npm: '>=8'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + dev: false + /vite@4.4.11(sass@1.69.4): resolution: {integrity: sha512-ksNZJlkcU9b0lBwAGZGGaZHCMqHsc8OpgtoYhsQ4/I2v5cnpmmmqe5pM4nv/4Hn6G/2GhTdj0DhZh2e+Er1q5A==} engines: {node: ^14.18.0 || >=16.0.0} @@ -2992,6 +3269,11 @@ packages: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} dev: true + /yaml@2.3.4: + resolution: {integrity: sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==} + engines: {node: '>= 14'} + dev: false + /yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} diff --git a/app/frontend/src/Components/Comment/Comment/Comment.module.scss b/app/frontend/src/Components/Comment/Comment/Comment.module.scss index 7cf144a4..8762ca84 100644 --- a/app/frontend/src/Components/Comment/Comment/Comment.module.scss +++ b/app/frontend/src/Components/Comment/Comment/Comment.module.scss @@ -1,48 +1,80 @@ @import "../../../colors"; .container { - position: relative; padding: 1em; background-color: $celadon; border-radius: 0.5em; - display: grid; + position: relative; + max-width: 70em; + word-break: break-word; gap: 0.5em; - grid-template-areas: - "v t" - "v m"; - grid-template-rows: 50px 25px; - grid-template-columns: 25px 1fr; + margin-bottom: 0.5em; + .edit { + position: absolute; + bottom: 0.005em; + right: 0.5em; + } + .commentButton{ + color: $celadon-light-30 + } + .text{ + font-size: 1.2em; + } .vote { - grid-area: v; - grid-template-rows: repeat(3, 1fr); justify-items: center; align-items: center; display: grid; color: $orange; + font-size: 1rem; + margin-right: 30px; + button { - all: unset; cursor: pointer; - color: $orange-dark-40; + background-color: $orange-dark-40; + color: white; + + &.active { + background-color: $orange; + } - .active { - color: $orange; + &:disabled { + opacity: 40%; } } } + .row { + display: flex; + flex-direction: row; + align-items: center; + position: relative; + margin-left: 10px; + } + .column{ + display: flex; + flex-direction: column; + align-items: center; + position: relative; + } .title { - font-size: 1.2em; - grid-area: t; + font-size: 1em; + font-weight: bold; } .meta { display: flex; - gap: 1em; + gap: 1.5em; font-size: 0.8em; opacity: 80%; - grid-area: m; + margin-left: 3.5em; + margin-top: 1em; } .delete { margin-left: 5em; } -} + .commentInput { + padding: 1em; + position: relative; + flex-direction: row; + } +} \ No newline at end of file diff --git a/app/frontend/src/Components/Comment/Comment/Comment.tsx b/app/frontend/src/Components/Comment/Comment/Comment.tsx index d2118dbb..2836aee9 100644 --- a/app/frontend/src/Components/Comment/Comment/Comment.tsx +++ b/app/frontend/src/Components/Comment/Comment/Comment.tsx @@ -1,25 +1,40 @@ import { formatDate } from "../../../Library/utils/formatDate"; import styles from "./Comment.module.scss"; -import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined } from "@ant-design/icons"; + +import { + ArrowDownOutlined, + ArrowUpOutlined, + CommentOutlined, + DeleteOutlined, + DownOutlined, + EditOutlined, + UpOutlined, +} from "@ant-design/icons"; + import { useVote } from "../../Hooks/useVote"; -import { Button } from "antd"; +import { Button, Form, Input } from "antd"; import { useAuth } from "../../Hooks/useAuth"; import { useMutation } from "react-query"; import { deleteComment } from "../../../Services/comment"; import { useQueryClient } from "react-query"; - +import clsx from "clsx"; +import { useState } from "react"; +import ReplyForm from "../ReplyForm/ReplyForm"; +import Reply from "../Reply/Reply"; +import { useNavigate } from "react-router-dom"; +import CommentForm from "../CommentForm/CommentForm"; +import CommentEditForm from "../CommentForm/CommentEditForm"; function Comment({ comment, postId }: { comment: any; postId: string }) { - const { upvote, downvote } = useVote({ voteType: "COMMENT", typeId: comment.id, invalidateKey: ["comments", postId], }); - const { user } = useAuth(); - + const { user, isLoggedIn } = useAuth(); + const navigate = useNavigate(); const queryClient = useQueryClient(); const { mutate: removeComment } = useMutation( (id: string) => deleteComment(id), @@ -34,37 +49,103 @@ function Comment({ comment, postId }: { comment: any; postId: string }) { }, } ); + const [isCommenting, setCommenting] = useState(false); + const [isEditing, setEditing] = useState(false); + const toggleCommenting = () => { + setCommenting(!isCommenting); + }; + const toggleEditing = () => { + setEditing(!isEditing); + }; return (
-
- -
{comment.overallVote}
- +
+
+
+ +
{comment.commentContent}
-
{comment.commentContent}
-
- {comment.commenter.username} - {comment.createdAt && formatDate(comment.createdAt)} - {user.username === comment.commenter.username && ( -
+
+
+ {comment.commenter.username} + {comment.createdAt && formatDate(comment.createdAt)} + +
)} + type="text" + ghost={true} + shape="circle" + size="small" + icon={} + onClick={() => { + removeComment(comment.id); + }} + /> +
+ )} + {user.id === comment.commenter.id && ( +
+ +
+ )} +
- + {isCommenting && ( +
+ {isLoggedIn && } + {comment.replies.map( + (reply: any) => + !reply.isDeleted && + )} +
+ )} + + {isEditing && ( +
+ {isLoggedIn && ( + + )} +
+ )}
); } diff --git a/app/frontend/src/Components/Comment/CommentForm/CommentEditForm.tsx b/app/frontend/src/Components/Comment/CommentForm/CommentEditForm.tsx new file mode 100644 index 00000000..97dccce5 --- /dev/null +++ b/app/frontend/src/Components/Comment/CommentForm/CommentEditForm.tsx @@ -0,0 +1,55 @@ +import { useParams } from "react-router-dom"; +import styles from "./CommentForm.module.scss"; +import { Button, Form, Input } from "antd"; +import { useMutation, useQueryClient } from "react-query"; +import { edit } from "../../../Services/comment"; + +function CommentEditForm({commentId, commentContent}:{commentId:string, commentContent:string}) { + const postId = useParams(); + const [form] = Form.useForm(); + const queryClient = useQueryClient(); + + const { mutate: editComment, isLoading } = useMutation( + ({ commentContent }: { commentContent: string }) => + edit({ id: commentId!, commentContent }), + { + onSuccess() { + queryClient.invalidateQueries(["comments", postId.postId]); + }, + onMutate(comment: any) { + queryClient.setQueryData(["comments", postId.postId], (prev: any) => { + return prev?.filter((comments: any) => comment.id !== comments.id); + }); + }, + } + ); + form.setFieldsValue({commentContent:commentContent}) + return ( +
+
+ + + + + + +
+
+ ); +} + +export default CommentEditForm; diff --git a/app/frontend/src/Components/Comment/CommentForm/CommentForm.module.scss b/app/frontend/src/Components/Comment/CommentForm/CommentForm.module.scss index 5d0b68a2..e51c3b2b 100644 --- a/app/frontend/src/Components/Comment/CommentForm/CommentForm.module.scss +++ b/app/frontend/src/Components/Comment/CommentForm/CommentForm.module.scss @@ -1,4 +1,10 @@ +@import "../../../colors"; + .container { - padding: 2.5em; - } - \ No newline at end of file + padding: 1em; + border-radius: 0.5em; + position: relative; + z-index: 1; +} + + diff --git a/app/frontend/src/Components/Comment/CommentForm/CommentForm.tsx b/app/frontend/src/Components/Comment/CommentForm/CommentForm.tsx index 4909d355..54ae8951 100644 --- a/app/frontend/src/Components/Comment/CommentForm/CommentForm.tsx +++ b/app/frontend/src/Components/Comment/CommentForm/CommentForm.tsx @@ -10,19 +10,17 @@ function CommentForm() { const queryClient = useQueryClient(); const { mutate: addComment, isLoading } = useMutation( - ({ commentContent }: { commentContent: string }) => - createComment({ post: postId.postId!, commentContent }), + ({ commentContent }: { commentContent: string }) => + createComment({ post: postId.postId!, commentContent }), { onSuccess() { queryClient.invalidateQueries(["comments", postId.postId]); - }, onMutate(comment: any) { queryClient.setQueryData(["comments", postId.postId], (prev: any) => { return prev?.filter((comments: any) => comment.id !== comments.id); - }) + }); }, - } ); @@ -34,12 +32,18 @@ function CommentForm() { rules={[{ required: true, message: "Please enter a comment" }]} > - - diff --git a/app/frontend/src/Components/Comment/Reply/Reply.module.scss b/app/frontend/src/Components/Comment/Reply/Reply.module.scss new file mode 100644 index 00000000..6ee22b5e --- /dev/null +++ b/app/frontend/src/Components/Comment/Reply/Reply.module.scss @@ -0,0 +1,55 @@ +@import "../../../colors"; + +.container { + position: relative; + padding: 1em; + background-color: $celadon-light-30; + border-radius: 0.5em; + display: grid; + gap: 0.5em; + grid-template-areas: + "v t" + "v m"; + grid-template-rows: 50px 25px; + grid-template-columns: 25px 1fr; + .vote { + justify-items: center; + align-items: center; + display: grid; + color: $orange; + font-size: 1rem; + margin-right: 30px; + + + button { + cursor: pointer; + background-color: $orange-dark-40; + color: white; + + &.active { + background-color: $orange; + } + + &:disabled { + opacity: 40%; + } + } + } + .edit { + position: absolute; + bottom: 0.5em; + right: 0.5em; + } + .title { + font-size: 1.2em; + grid-area: t; + } + .meta { + display: flex; + gap: 1em; + font-size: 0.8em; + opacity: 80%; + grid-area: m; + } + +} diff --git a/app/frontend/src/Components/Comment/Reply/Reply.tsx b/app/frontend/src/Components/Comment/Reply/Reply.tsx new file mode 100644 index 00000000..547309d8 --- /dev/null +++ b/app/frontend/src/Components/Comment/Reply/Reply.tsx @@ -0,0 +1,89 @@ +import { formatDate } from "../../../Library/utils/formatDate"; +import styles from "./reply.module.scss"; +import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, DownOutlined, EditOutlined, UpOutlined } from "@ant-design/icons"; +import { useVote } from "../../Hooks/useVote"; +import { Button } from "antd"; +import { useAuth } from "../../Hooks/useAuth"; +import { useMutation } from "react-query"; +import { deleteComment } from "../../../Services/comment"; +import { useQueryClient } from "react-query"; +import { useState } from "react"; +import CommentForm from "../CommentForm/CommentForm"; +import ReplyForm from "../ReplyForm/ReplyForm"; + + + +function Reply({ reply }: { reply: any}) { + + const { upvote, downvote } = useVote({ + voteType: "COMMENT", + typeId: reply.id, + invalidateKey: ["comments", reply.post], + }); + + const { user, isLoggedIn } = useAuth(); + + + const queryClient = useQueryClient(); + const { mutate: removereply } = useMutation( + (id: string) => deleteComment(id), + { + onSuccess() { + queryClient.invalidateQueries(["comments", reply.post]); + }, + onMutate(id: string) { + queryClient.setQueriesData(["comments", reply.post], (prev: any) => { + return prev?.filter((reply: any) => id !== reply.id); + }); + }, + } + ); + + + + + return ( +
+
+
+
{reply.commentContent}
+
+ {reply.commenter.username} + {reply.createdAt && formatDate(reply.createdAt)} + {user.username === reply.commenter.username && ( +
+
)} +
+ +
+ ); +} + +export default Reply; diff --git a/app/frontend/src/Components/Comment/ReplyForm/ReplyForm.module.scss b/app/frontend/src/Components/Comment/ReplyForm/ReplyForm.module.scss new file mode 100644 index 00000000..0fe215e5 --- /dev/null +++ b/app/frontend/src/Components/Comment/ReplyForm/ReplyForm.module.scss @@ -0,0 +1,6 @@ +.container { + margin-top: 2%; + position: relative; + z-index: 1; + } + \ No newline at end of file diff --git a/app/frontend/src/Components/Comment/ReplyForm/ReplyForm.tsx b/app/frontend/src/Components/Comment/ReplyForm/ReplyForm.tsx new file mode 100644 index 00000000..e348bab6 --- /dev/null +++ b/app/frontend/src/Components/Comment/ReplyForm/ReplyForm.tsx @@ -0,0 +1,55 @@ +import { useParams } from "react-router-dom"; +import styles from "./ReplyForm.module.scss"; +import { Button, Form, Input } from "antd"; +import { useMutation, useQueryClient } from "react-query"; +import { createReply } from "../../../Services/comment"; + +function ReplyForm({commentId}:{commentId:string}) { + const postId = useParams(); + const [form] = Form.useForm(); + const queryClient = useQueryClient(); + + const { mutate: addComment, isLoading } = useMutation( + ({ commentContent }: { commentContent: string }) => + createReply({ parentComment: commentId, commentContent }), + { + onSuccess() { + queryClient.invalidateQueries(["comments", postId.postId]); + + }, + onMutate(comment: any) { + queryClient.setQueryData(["comments", postId.postId], (prev: any) => { + return prev?.filter((comments: any) => comment.id !== comments.id); + }) + }, + + } + ); + + return ( +
+
+ + + + + + +
+
+ ); +} + +export default ReplyForm; diff --git a/app/frontend/src/Components/Forum/Forum.module.scss b/app/frontend/src/Components/Forum/Forum.module.scss index ca16e0e1..ba0a8ac0 100644 --- a/app/frontend/src/Components/Forum/Forum.module.scss +++ b/app/frontend/src/Components/Forum/Forum.module.scss @@ -17,5 +17,15 @@ flex-direction: column; align-items: stretch; gap: 1em; + .searchContainer { + display: flex; + justify-content: stretch; + align-items: stretch; + gap: 0.5em; + + & > span { + flex: 1 1 0; + } + } } } diff --git a/app/frontend/src/Components/Forum/Forum.tsx b/app/frontend/src/Components/Forum/Forum.tsx index ce1a5937..01dd00a9 100644 --- a/app/frontend/src/Components/Forum/Forum.tsx +++ b/app/frontend/src/Components/Forum/Forum.tsx @@ -1,11 +1,26 @@ -import { useNavigate, useParams } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import styles from "./Forum.module.scss"; import { useQuery } from "react-query"; import { getPostList } from "../../Services/forum"; -import { Button } from "antd"; -import { PlusCircleOutlined } from "@ant-design/icons"; +import { Button, Input, Select } from "antd"; +import { + FilterOutlined, + PlusCircleOutlined, + SearchOutlined, + SortAscendingOutlined, + SortDescendingOutlined, +} from "@ant-design/icons"; import { useAuth } from "../Hooks/useAuth"; import ForumPost from "./ForumPost/ForumPost"; +import { useState } from "react"; +import { useDebounce } from "usehooks-ts"; +import { getTags } from "../../Services/tags"; +const sortOptions = [ + { label: "Creation Date", value: "CREATION_DATE" }, + { label: "Edit Date", value: "EDIT_DATE" }, + { label: "Overall Vote", value: "OVERALL_VOTE" }, + { label: "Vote Count", value: "VOTE_COUNT" }, +]; function Forum({ forumId, @@ -15,8 +30,41 @@ function Forum({ redirect?: string; }) { const { isLoggedIn } = useAuth(); - const { data: posts, isLoading } = useQuery(["forum", forumId], () => - getPostList({ forum: forumId }) + const [filterTags, setFilterTags] = useState([]); + const [sortBy, setSortBy] = useState(sortOptions[0].value); + const [sortDir, setSortDir] = useState<"ASCENDING" | "DESCENDING">( + "DESCENDING" + ); + const [search, setSearch] = useState(""); + const searchDebounced = useDebounce(search, 500); + const searchString = + searchDebounced.length >= 3 ? searchDebounced : undefined; + const toggleSortDir = () => { + setSortDir((currentSortDir) => + currentSortDir === "ASCENDING" ? "DESCENDING" : "ASCENDING" + ); + }; + + const { data: posts } = useQuery( + ["forum", forumId, sortBy, sortDir, filterTags, searchString], + () => + getPostList({ + forum: forumId, + sortBy, + sortDirection: sortDir, + search: searchString, + tags: filterTags, + }) + ); + const { data: tagOptions } = useQuery( + ["tagOptions", "forumPost"], + async () => { + const data = await getTags({ tagType: "POST" }); + return data.map((item: { name: any; id: any }) => ({ + label: item.name, + value: item.id, + })); + } ); const navigate = useNavigate(); return ( @@ -33,8 +81,46 @@ function Forum({ )}
+
+ + } + value={search} + onChange={(e) => setSearch(e.target.value)} + /> + + +
{posts?.map((post: any) => ( - + ))}
diff --git a/app/frontend/src/Components/Forum/ForumPost/ForumPost.module.scss b/app/frontend/src/Components/Forum/ForumPost/ForumPost.module.scss index 27ec921a..4a01d591 100644 --- a/app/frontend/src/Components/Forum/ForumPost/ForumPost.module.scss +++ b/app/frontend/src/Components/Forum/ForumPost/ForumPost.module.scss @@ -2,55 +2,79 @@ .container { position: relative; - padding: 1em; background-color: $celadon; border-radius: 0.5em; display: grid; gap: 0.5em; + overflow: hidden; + color: $color-text; + grid-template-rows: 30px 55px 35px; + grid-template-columns: 60px 1fr; + grid-template-areas: + "v t" + "v c" + "v m"; .titleContainer { display: flex; flex-direction: row; - align-items: center; + align-items: end; gap: 0.5rem; - grid-template-areas: - "v t" - "v m"; - grid-template-rows: 50px 25px; - grid-template-columns: 25px 1fr; - .vote { - grid-area: v; - grid-template-rows: repeat(3, 1fr); - justify-items: center; - align-items: center; - display: grid; - color: $orange; + } + + .content { + grid-area: c; + overflow: hidden; + opacity: 80%; + padding-right: 2.5em; + } - button { - all: unset; - cursor: pointer; - color: $orange-dark-40; + .vote { + grid-area: v; + grid-template-rows: repeat(3, 1fr); + justify-items: center; + align-items: center; + display: grid; + color: white; + background-color: $orange-light-40; + button { + cursor: pointer; + background-color: $orange-dark-40; + color: white; - .active { - color: $orange; - } + &.active { + background-color: $orange; + } + + &:disabled { + opacity: 40%; } } - .title { - font-weight: bold; - font-size: 1.2em; - grid-area: t; - } - .meta { - display: flex; - gap: 1em; + } + .title { + font-weight: bold; + font-size: 1.2em; + grid-area: t; + } + .meta { + display: flex; + align-items: end; + gap: 1em; + padding: 0.5em; + + grid-area: m; + & > span { font-size: 0.8em; opacity: 80%; - grid-area: m; - } - .readMore { - position: absolute; - bottom: 0.5em; - right: 0.5em; } } -} + .readMore { + position: absolute; + bottom: 0.5em; + right: 0.5em; + } + .edit { + position: absolute; + top: 0.5em; + right: 0.5em; + } +} \ No newline at end of file diff --git a/app/frontend/src/Components/Forum/ForumPost/ForumPost.tsx b/app/frontend/src/Components/Forum/ForumPost/ForumPost.tsx index 6970e6ee..61c73e7f 100644 --- a/app/frontend/src/Components/Forum/ForumPost/ForumPost.tsx +++ b/app/frontend/src/Components/Forum/ForumPost/ForumPost.tsx @@ -1,15 +1,34 @@ import { Button } from "antd"; import { formatDate } from "../../../Library/utils/formatDate"; import styles from "./ForumPost.module.scss"; -import { DeleteFilled } from "@ant-design/icons"; +import { + DeleteFilled, + DownOutlined, + EditOutlined, + UpOutlined, + WarningOutlined, +} from "@ant-design/icons"; import { useMutation } from "react-query"; import { deletePost } from "../../../Services/forum"; import { useAuth } from "../../Hooks/useAuth"; -import { ArrowDownOutlined, ArrowUpOutlined } from "@ant-design/icons"; import { useVote } from "../../Hooks/useVote"; +import clsx from "clsx"; +import { truncateWithEllipsis } from "../../../Library/utils/truncate"; +import { useNavigate } from "react-router-dom"; +import TagRenderer from "../../TagRenderer/TagRenderer"; +import { twj } from "tw-to-css"; -function ForumPost({ post, forumId }: { post: any; forumId: string }) { - const { user } = useAuth(); +function ForumPost({ + post, + forumId, + redirect = "/", +}: { + post: any; + forumId: string; + redirect?: string; +}) { + const { user, isLoggedIn } = useAuth(); + const navigate = useNavigate(); const isAdmin = user?.role === "ADMIN"; const deletePostMutation = useMutation(deletePost, { @@ -28,34 +47,76 @@ function ForumPost({ post, forumId }: { post: any; forumId: string }) { const { upvote, downvote } = useVote({ voteType: "POST", typeId: post.id, - invalidateKey: ["forum", forumId], + invalidateKeys: [ + ["forum", forumId], + ["post", post.id], + ], }); return (
- + + +
{post.title}
{isAdmin && ( )} + + + +
+ +
+ {truncateWithEllipsis(post.postContent, 300)}
+
{post.poster.username} {post.createdAt && formatDate(post.createdAt)} +
- +
+ {user?.id === post.poster.id && ( +
+ +
+ )}
); } -export default ForumPost; +export default ForumPost; \ No newline at end of file diff --git a/app/frontend/src/Components/Game/Game.module.scss b/app/frontend/src/Components/Game/Game.module.scss index 54d7e15d..f2bc0a82 100644 --- a/app/frontend/src/Components/Game/Game.module.scss +++ b/app/frontend/src/Components/Game/Game.module.scss @@ -1,17 +1,19 @@ +@import "../../colors"; + +.container { + background-color: $violet-light-80; + border-radius: 0.5em; + overflow: hidden; +} + @media only screen and (max-width: 976px) { .container { - border: 1px solid black; - border-radius: 5px; - margin-bottom: 1rem; flex: 0 1 100%; } } @media only screen and (min-width: 976px) { .container { - border: 1px solid black; - border-radius: 5px; - margin-bottom: 1rem; flex: 0 1 calc(100% * (1 / 2) - 1rem); } } @@ -21,7 +23,7 @@ flex-direction: row; align-items: center; justify-content: center; - border-bottom: 1px solid black; + background-color: $violet; } .descriptionContainer { diff --git a/app/frontend/src/Components/GameDetails/Review/Review.module.scss b/app/frontend/src/Components/GameDetails/Review/Review.module.scss index e7830612..87dd513a 100644 --- a/app/frontend/src/Components/GameDetails/Review/Review.module.scss +++ b/app/frontend/src/Components/GameDetails/Review/Review.module.scss @@ -1,46 +1,5 @@ @import "../../../colors"; - -.review-input-container { - display: flex; - flex-direction: column; - width: 350px; - color: $color-text-light ; - background-color: $blue-green; - border-radius: 0.5em; - margin: 5px 5px 5px 5px; - overflow: hidden; - padding: 5px; - gap: 5px; - justify-content: center; - - .header { - display: flex; - flex-direction: column; - gap: 7px; - margin: 5px 0px 0px 10px; - font-size: 18px; - } - - .content-input { - background-color: $prussian-blue-dark-10 !important; - flex: 4; - width: 100%; - border-radius: 0.5em; - padding: 5px; - font-size: 13px; - } - - .button { - color: $celadon; - } -} - -.reviews-subpage-container { - display: flex; - flex-wrap: wrap; - padding: 10px; -} .review-container { display: flex; flex-shrink: 0; @@ -61,6 +20,12 @@ color: $color-text; width: 60px; justify-content: center; + + .voted { + background-color: $blue-green-light-30; + opacity: 70%; + } + } .review { @@ -74,8 +39,9 @@ flex: 1; .header { display: flex; - justify-content: flex-start; + justify-content: space-between; align-items: center; + align-self: stretch; gap: 100px; .user { white-space: nowrap; diff --git a/app/frontend/src/Components/GameDetails/Review/Review.tsx b/app/frontend/src/Components/GameDetails/Review/Review.tsx index 4f451076..afb67633 100644 --- a/app/frontend/src/Components/GameDetails/Review/Review.tsx +++ b/app/frontend/src/Components/GameDetails/Review/Review.tsx @@ -7,6 +7,7 @@ import { EditOutlined, StarFilled, UpOutlined, + WarningOutlined, } from "@ant-design/icons"; import TextArea from "antd/es/input/TextArea"; import { useState } from "react"; @@ -27,7 +28,7 @@ function Review({ review }: { review: any }) { const { upvote, downvote } = useVote({ voteType: "REVIEW", typeId: review.id, - invalidateKey: ["reviews", review.gameId, ""], + invalidateKey: ["reviews", review.gameId], }); const { mutate: removeReview } = useMutation( @@ -70,6 +71,7 @@ function Review({ review }: { review: any }) { shape="circle" icon={} onClick={upvote} + className={review.requestedUserVote === "UPVOTE" ? styles.voted : ""} /> {review.overallVote} +