diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5133e6d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +tmp/* +sql/CreateBackendUser.sql diff --git a/backend/.gitignore b/backend/.gitignore index ef5b413..99a4769 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -22,3 +22,6 @@ src/main/resources/application-local.properties # Server Keystore src/main/resources/achievements-ssl-key.p12 + +# Program Data +storage/ \ No newline at end of file diff --git a/backend/src/main/java/achievements/Application.java b/backend/src/main/java/achievements/Application.java index b622eff..81dd0b3 100644 --- a/backend/src/main/java/achievements/Application.java +++ b/backend/src/main/java/achievements/Application.java @@ -1,20 +1,27 @@ package achievements; -import achievements.services.DbConnectionService; +import achievements.misc.DbConnection; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.context.annotation.Bean; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.web.client.RestTemplate; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; -@SpringBootApplication +import java.time.Duration; + +@SpringBootApplication(exclude = SecurityAutoConfiguration.class) +@EnableScheduling public class Application { public static void main(String[] args) { var context = SpringApplication.run(Application.class, args); // Verify the database connection succeeded - var db = context.getBean(DbConnectionService.class); + var db = context.getBean(DbConnection.class); if (db.getConnection() == null) { SpringApplication.exit(context, () -> 0); } @@ -25,10 +32,15 @@ public class Application { return new WebMvcConfigurer() { @Override public void addCorsMappings(CorsRegistry registry) { - registry - .addMapping("/*") - .allowedOrigins("*"); + registry + .addMapping("/**") + .allowedOrigins("*"); } }; } + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } } \ No newline at end of file diff --git a/backend/src/main/java/achievements/apis/PlatformAPI.java b/backend/src/main/java/achievements/apis/PlatformAPI.java new file mode 100644 index 0000000..f241a60 --- /dev/null +++ b/backend/src/main/java/achievements/apis/PlatformAPI.java @@ -0,0 +1,17 @@ +package achievements.apis; + +import achievements.data.APIResponse; +import org.springframework.web.client.RestTemplate; + +public abstract class PlatformAPI { + + protected int id; + protected RestTemplate rest; + + protected PlatformAPI(int id, RestTemplate rest) { + this.id = id; + this.rest = rest; + } + + public abstract APIResponse get(String userId); +} diff --git a/backend/src/main/java/achievements/apis/SteamAPI.java b/backend/src/main/java/achievements/apis/SteamAPI.java new file mode 100644 index 0000000..3bbe0d5 --- /dev/null +++ b/backend/src/main/java/achievements/apis/SteamAPI.java @@ -0,0 +1,113 @@ +package achievements.apis; + +import achievements.apis.steam.GetOwnedGameBody; +import achievements.apis.steam.GetPlayerAchievementsBody; +import achievements.apis.steam.GetSchemaForGameBody; +import achievements.data.APIResponse; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.FileInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Properties; + +public class SteamAPI extends PlatformAPI { + + private String apiKey; + + public SteamAPI(int id, RestTemplate rest) { + super(id, rest); + try { + var file = new FileInputStream("storage/apis/" + id + ".properties"); + var properties = new Properties(); + properties.load(file); + + apiKey = properties.getProperty("api-key"); + file.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + @Override + public APIResponse get(String userId) { + var headers = new HttpHeaders(); + headers.setAccept(Arrays.asList(MediaType.APPLICATION_JSON)); + var entity = new HttpEntity(headers); + var ownedGamesUrl = UriComponentsBuilder.fromHttpUrl("http://api.steampowered.com/IPlayerService/GetOwnedGames/v1/") + .queryParam("key", apiKey) + .queryParam("steamid", userId) + .queryParam("include_appinfo", true) + .queryParam("include_played_free_games", true) + .toUriString(); + + var gameSchemaBaseUrl = UriComponentsBuilder.fromHttpUrl("https://api.steampowered.com/ISteamUserStats/GetSchemaForGame/v2/") + .queryParam("key", apiKey); + var playerAchievementsBaseUrl = UriComponentsBuilder.fromHttpUrl("https://api.steampowered.com/ISteamUserStats/GetPlayerAchievements/v1/") + .queryParam("key", apiKey) + .queryParam("steamid", userId); + + var games = new ArrayList(); + try { + var ownedResponse = rest.exchange(ownedGamesUrl, HttpMethod.GET, entity, GetOwnedGameBody.class).getBody(); + for (var game : ownedResponse.getResponse().getGames()) { + var newGame = new APIResponse.Game(); + newGame.setPlatformGameId(Integer.toString(game.getAppid())); + newGame.setName(game.getName()); + // Technically this is not the advertised logo url, but it's used be steamcommunity.com + // and steamdb.info and it gives better aspect ratios and it means I don't need the random + // logo_url field + newGame.setThumbnail("https://cdn.cloudflare.steamstatic.com/steam/apps/" + game.getAppid() + "/header.jpg"); + newGame.setPlayed(game.getPlaytime_forever() > 0); + + var achievements = new HashMap(); + + var gameSchemaUrl = gameSchemaBaseUrl.cloneBuilder() + .queryParam("appid", game.getAppid()) + .toUriString(); + var playerAchievementsUrl = playerAchievementsBaseUrl.cloneBuilder() + .queryParam("appid", game.getAppid()) + .toUriString(); + + try { + var schemaResponse = rest.exchange(gameSchemaUrl, HttpMethod.GET, entity, GetSchemaForGameBody.class).getBody().getGame().getAvailableGameStats(); + if (schemaResponse != null && schemaResponse.getAchievements() != null) { + for (var schema : schemaResponse.getAchievements()) { + var achievement = new APIResponse.Game.Achievement(); + achievement.setName(schema.getDisplayName()); + achievement.setDescription(schema.getDescription()); + achievement.setStages(1); + achievement.setThumbnail(schema.getIcon()); + achievements.put(schema.getName(), achievement); + } + + var playerAchievementsResponse = rest.exchange(playerAchievementsUrl, HttpMethod.GET, entity, GetPlayerAchievementsBody.class).getBody().getPlayerstats().getAchievements(); + for (var achievement : playerAchievementsResponse) { + achievements.get(achievement.getApiname()).setProgress(achievement.getAchieved()); + } + + newGame.setAchievements(new ArrayList<>(achievements.values())); + if (newGame.getAchievements().size() > 0) { + games.add(newGame); + } + } + } catch (Exception e) { + System.err.println("Forbidden APPID: " + game.getAppid()); + e.printStackTrace(); + } + } + } catch (Exception e) { + e.printStackTrace(); + } + var response = new APIResponse(); + response.setGames(games); + return response; + } +} diff --git a/backend/src/main/java/achievements/apis/steam/GetOwnedGameBody.java b/backend/src/main/java/achievements/apis/steam/GetOwnedGameBody.java new file mode 100644 index 0000000..e34f7ed --- /dev/null +++ b/backend/src/main/java/achievements/apis/steam/GetOwnedGameBody.java @@ -0,0 +1,96 @@ +package achievements.apis.steam; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public class GetOwnedGameBody { + + public static class Response { + + public static class Game { + @JsonProperty("appid") + private int appid; + @JsonProperty("name") + private String name; + @JsonProperty("playtime_forever") + private int playtime_forever; + @JsonProperty("img_icon_url") + private String img_icon_url; + @JsonProperty("img_logo_url") + private String img_logo_url; + + public int getAppid() { + return appid; + } + + public void setAppid(int appid) { + this.appid = appid; + } + + public int getPlaytime_forever() { + return playtime_forever; + } + + public void setPlaytime_forever(int playtime_forever) { + this.playtime_forever = playtime_forever; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getImg_icon_url() { + return img_icon_url; + } + + public void setImg_icon_url(String img_icon_url) { + this.img_icon_url = img_icon_url; + } + + public String getImg_logo_url() { + return img_logo_url; + } + + public void setImg_logo_url(String img_logo_url) { + this.img_logo_url = img_logo_url; + } + } + + @JsonProperty("game_count") + private int game_count; + @JsonProperty("games") + private List games; + + public int getGame_count() { + return game_count; + } + + public void setGame_count(int game_count) { + this.game_count = game_count; + } + + public List getGames() { + return games; + } + + public void setGames(List games) { + this.games = games; + } + } + + @JsonProperty("response") + private Response response; + + public Response getResponse() { + return response; + } + + public void setResponse(Response response) { + this.response = response; + } +} diff --git a/backend/src/main/java/achievements/apis/steam/GetPlayerAchievementsBody.java b/backend/src/main/java/achievements/apis/steam/GetPlayerAchievementsBody.java new file mode 100644 index 0000000..c6c899b --- /dev/null +++ b/backend/src/main/java/achievements/apis/steam/GetPlayerAchievementsBody.java @@ -0,0 +1,54 @@ +package achievements.apis.steam; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public class GetPlayerAchievementsBody { + public static class PlayerStats { + public static class Achievement { + @JsonProperty("apiname") + private String apiname; + @JsonProperty("achieved") + private int achieved; + + public String getApiname() { + return apiname; + } + + public void setApiname(String apiname) { + this.apiname = apiname; + } + + public int getAchieved() { + return achieved; + } + + public void setAchieved(int achieved) { + this.achieved = achieved; + } + } + + @JsonProperty("achievements") + private List achievements; + + public List getAchievements() { + return achievements; + } + + public void setAchievements(List achievements) { + this.achievements = achievements; + } + } + + @JsonProperty("playerstats") + private PlayerStats playerstats; + + public PlayerStats getPlayerstats() { + return playerstats; + } + + public void setPlayerstats(PlayerStats playerstats) { + this.playerstats = playerstats; + } +} diff --git a/backend/src/main/java/achievements/apis/steam/GetSchemaForGameBody.java b/backend/src/main/java/achievements/apis/steam/GetSchemaForGameBody.java new file mode 100644 index 0000000..a4f1a4d --- /dev/null +++ b/backend/src/main/java/achievements/apis/steam/GetSchemaForGameBody.java @@ -0,0 +1,87 @@ +package achievements.apis.steam; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public class GetSchemaForGameBody { + public static class Game { + public static class GameStats { + public static class Achievement { + @JsonProperty("name") + private String name; + @JsonProperty("displayName") + private String displayName; + @JsonProperty("description") + private String description; + @JsonProperty("icon") + private String icon; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getIcon() { + return icon; + } + + public void setIcon(String icon) { + this.icon = icon; + } + } + + @JsonProperty("achievements") + private List achievements; + + public List getAchievements() { + return achievements; + } + + public void setAchievements(List achievements) { + this.achievements = achievements; + } + } + + @JsonProperty("availableGameStats") + private GameStats availableGameStats; + + public GameStats getAvailableGameStats() { + return availableGameStats; + } + + public void setAvailableGameStats(GameStats availableGameStats) { + this.availableGameStats = availableGameStats; + } + } + + @JsonProperty("game") + private Game game; + + public Game getGame() { + return game; + } + + public void setGame(Game game) { + this.game = game; + } +} diff --git a/backend/src/main/java/achievements/controllers/AchievementController.java b/backend/src/main/java/achievements/controllers/AchievementController.java new file mode 100644 index 0000000..c523d59 --- /dev/null +++ b/backend/src/main/java/achievements/controllers/AchievementController.java @@ -0,0 +1,60 @@ +package achievements.controllers; + +import achievements.data.APError; +import achievements.data.request.RateAchievement; +import achievements.services.ImageService; +import achievements.services.AchievementService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import javax.servlet.http.HttpServletResponse; + +@RestController +@RequestMapping("/achievement") +public class AchievementController { + + @Autowired + private AchievementService achievementService; + @Autowired + private ImageService imageService; + + @GetMapping(value = "/{achievement}", produces = "application/json") + public ResponseEntity getAchievement(@PathVariable("achievement") int achievementId) { + var achievement = achievementService.getAchievement(achievementId); + if (achievement == null) { + return ResponseEntity.badRequest().body(new APError(1, "Failed to get achievement")); + } else { + return ResponseEntity.ok(achievement); + } + } + + @GetMapping(value = "/{achievement}/image") + public void getProfilePicture(@PathVariable("achievement") int achievement, HttpServletResponse response) { + var icon = achievementService.getIcon(achievement); + imageService.send(icon, "achievement", response); + } + + @GetMapping(value = "/{achievement}/rating/{user}") + public ResponseEntity getRating(@PathVariable("achievement") int achievement, @PathVariable("user") int user) { + var rating = achievementService.getRating(achievement, user); + if (rating == null) { + return ResponseEntity.badRequest().body("{}"); + } else { + return ResponseEntity.ok(rating); + } + } + + @PostMapping(value = "/{achievement}/rating/{user}") + public ResponseEntity setRating(@PathVariable("achievement") int achievement, @PathVariable("user") int user, @RequestBody RateAchievement rating) { + var review = achievementService.setRating(achievement, user, rating); + if (review == null) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("{}"); + } else if (review.getSessionKey() == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(review); + } else { + return ResponseEntity.status(HttpStatus.CREATED).body("{}"); + } + } +} diff --git a/backend/src/main/java/achievements/controllers/AuthController.java b/backend/src/main/java/achievements/controllers/AuthController.java new file mode 100644 index 0000000..988f6ef --- /dev/null +++ b/backend/src/main/java/achievements/controllers/AuthController.java @@ -0,0 +1,87 @@ +package achievements.controllers; + +import achievements.data.APError; +import achievements.data.Session; +import achievements.data.User; +import achievements.services.AuthenticationService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/auth") +public class AuthController { + + @Autowired + private AuthenticationService authService; + + /** + * Acceptable codes + * 0 => Success + * 1 => Email already registered + * + * -1 => Unknown error + */ + @PostMapping(value = "/create_user", consumes = "application/json", produces = "application/json") + public ResponseEntity createUser(@RequestBody User user) { + var response = authService.createUser(user); + if (response.status == 0) { + return ResponseEntity.status(HttpStatus.CREATED).body(response.session); + } else if (response.status > 0) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new APError(response.status)); + } else { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new APError(response.status)); + } + } + + /** + * DO NOT RETURN CODE DIRECTLY! + * + * User should only ever recieve -1, 0, or 1. The specific authentication error should be hidden. + * + * Acceptable codes + * 0 => Success + * 1 => Unregistered email address + * 2 => Incorrect password + * + * -1 => Unknown error + */ + @PostMapping(value = "/login", consumes = "application/json", produces = "application/json") + public ResponseEntity login(@RequestBody User user) { + var response = authService.login(user); + if (response.status == 0) { + return ResponseEntity.ok(response.session); + } else if (response.status > 0) { + // Hardcoded 1 response code + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new APError(1)); + } else { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new APError(response.status)); + } + } + + @PostMapping(value = "/refresh", consumes = "application/json", produces = "application/json") + public ResponseEntity refresh(@RequestBody Session key) { + if (key.getId() == -1) { + if (authService.openAuth()) { + if (authService.refresh(key)) { + return ResponseEntity.ok(key); + } else { + return ResponseEntity.ok(authService.session().generate(-1, 0, true)); + } + } + } else if (authService.refresh(key)) { + return ResponseEntity.ok(key); + } + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("{}"); + } + + @PostMapping(value = "/logout", consumes = "application/json") + public ResponseEntity logout(@RequestBody Session session) { + authService.logout(session); + return ResponseEntity.ok("{}"); + } +} diff --git a/backend/src/main/java/achievements/controllers/Controller.java b/backend/src/main/java/achievements/controllers/Controller.java deleted file mode 100644 index 7595ba5..0000000 --- a/backend/src/main/java/achievements/controllers/Controller.java +++ /dev/null @@ -1,65 +0,0 @@ -package achievements.controllers; - -import achievements.data.Achievements; -import achievements.data.Games; -import achievements.data.InternalError; -import achievements.services.DbService; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import static org.springframework.web.bind.annotation.RequestMethod.GET; - -@RestController -public class Controller { - - @Autowired - private DbService db; - - public Controller() {} - - @RequestMapping(value = { "/achievements", "/achievements/{Name}" }, method = GET, produces = "application/json") - public ResponseEntity fetchAchievements(@PathVariable(value = "Name", required = false) String getName) { - var achievements = (Achievements) null; - if (getName == null) { - achievements = db.getAchievements("%"); - } else { - achievements = db.getAchievements(getName); - } - var mapper = new ObjectMapper(); - try { - if (achievements == null) { - return new ResponseEntity(mapper.writeValueAsString(new InternalError("Could not get achievements from database")), HttpStatus.INTERNAL_SERVER_ERROR); - } else { - return new ResponseEntity(mapper.writeValueAsString(achievements), HttpStatus.OK); - } - } catch (JsonProcessingException e) { - e.printStackTrace(); - return new ResponseEntity("{}", HttpStatus.INTERNAL_SERVER_ERROR); - } - } - - @RequestMapping(value = { "/games", "/games/{Name}" }, method = GET, produces = "application/json") - public ResponseEntity fetchGames(@PathVariable(value = "Name", required = false) String getName) { - var games = (Games) null; - if (getName == null) { - games = db.getGames("%"); - } else { - games = db.getGames(getName); - } - var mapper = new ObjectMapper(); - try { - if (games == null) { - return new ResponseEntity(mapper.writeValueAsString(new InternalError("Could not get games from database")), HttpStatus.INTERNAL_SERVER_ERROR); - } else { - return new ResponseEntity(mapper.writeValueAsString(games), HttpStatus.OK); - } - } catch (JsonProcessingException e) { - e.printStackTrace(); - return new ResponseEntity("{}", HttpStatus.INTERNAL_SERVER_ERROR); - } - } -} diff --git a/backend/src/main/java/achievements/controllers/GameController.java b/backend/src/main/java/achievements/controllers/GameController.java new file mode 100644 index 0000000..e8e7662 --- /dev/null +++ b/backend/src/main/java/achievements/controllers/GameController.java @@ -0,0 +1,27 @@ +package achievements.controllers; + +import achievements.services.ImageService; +import achievements.services.GameService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletResponse; + +@RestController +@RequestMapping("/game") +public class GameController { + + @Autowired + private GameService gameService; + @Autowired + private ImageService imageService; + + @GetMapping(value = "/{game}/image") + public void getProfilePicture(@PathVariable("game") int game, HttpServletResponse response) { + var icon = gameService.getIcon(game); + imageService.send(icon, "game", response); + } +} diff --git a/backend/src/main/java/achievements/controllers/ImportController.java b/backend/src/main/java/achievements/controllers/ImportController.java new file mode 100644 index 0000000..6e2b7d4 --- /dev/null +++ b/backend/src/main/java/achievements/controllers/ImportController.java @@ -0,0 +1,47 @@ +package achievements.controllers; + +import achievements.data.importing.ImportPlatform; +import achievements.data.importing.ImportUser; +import achievements.data.importing.ImportUserPlatform; +import achievements.services.ImportService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/import") +public class ImportController { + + @Autowired + private ImportService importService; + + @PostMapping(value = "/platform", consumes = "application/json", produces = "application/json") + public ResponseEntity createPlatform(@RequestBody ImportPlatform platform) { + var response = importService.importPlatform(platform); + if (response == 0) { + return ResponseEntity.ok("{}"); + } else { + return ResponseEntity.badRequest().body("{}"); + } + } + + @PostMapping(value = "/user", consumes = "application/json", produces = "application/json") + public ResponseEntity createUser(@RequestBody ImportUser user) { + var response = importService.importUser(user); + if (response == 0) { + return ResponseEntity.ok("{}"); + } else { + return ResponseEntity.badRequest().body("{}"); + } + } + + @PostMapping(value = "/user/platform", consumes = "application/json", produces = "application/json") + public ResponseEntity addUserToPlatform(@RequestBody ImportUserPlatform userPlatform) { + var response = importService.importUserPlatform(userPlatform); + if (response == 0) { + return ResponseEntity.ok("{}"); + } else { + return ResponseEntity.badRequest().body("{}"); + } + } +} diff --git a/backend/src/main/java/achievements/controllers/PlatformController.java b/backend/src/main/java/achievements/controllers/PlatformController.java new file mode 100644 index 0000000..f37a5c4 --- /dev/null +++ b/backend/src/main/java/achievements/controllers/PlatformController.java @@ -0,0 +1,27 @@ +package achievements.controllers; + +import achievements.services.ImageService; +import achievements.services.PlatformService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletResponse; + +@RestController +@RequestMapping("/platform") +public class PlatformController { + + @Autowired + private ImageService imageService; + @Autowired + private PlatformService platformService; + + @GetMapping(value = "/{platform}/image") + public void getIcon(@PathVariable("platform") int platform, HttpServletResponse response) { + var icon = platformService.getIcon(platform); + imageService.send(icon, "platform", response); + } +} diff --git a/backend/src/main/java/achievements/controllers/SearchController.java b/backend/src/main/java/achievements/controllers/SearchController.java new file mode 100644 index 0000000..5539435 --- /dev/null +++ b/backend/src/main/java/achievements/controllers/SearchController.java @@ -0,0 +1,48 @@ +package achievements.controllers; + +import achievements.data.request.SearchAchievements; +import achievements.data.request.SearchGames; +import achievements.data.request.SearchUsers; +import achievements.services.SearchService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class SearchController { + + @Autowired + private SearchService searchService; + + @PostMapping(value = "/achievements", consumes = "application/json", produces = "application/json") + public ResponseEntity searchAchievements(@RequestBody SearchAchievements searchAchievements) { + var achievements = searchService.searchAchievements(searchAchievements); + if (achievements != null) { + return ResponseEntity.ok(achievements); + } else { + return ResponseEntity.badRequest().body("[]"); + } + } + + @PostMapping(value = "/users", consumes = "application/json", produces = "application/json") + public ResponseEntity searchAchievements(@RequestBody SearchUsers searchUsers) { + var users = searchService.searchUsers(searchUsers); + if (users != null) { + return ResponseEntity.ok(users); + } else { + return ResponseEntity.badRequest().body("[]"); + } + } + + @PostMapping(value = "/games", consumes = "application/json", produces = "application/json") + public ResponseEntity searchAchievements(@RequestBody SearchGames searchGames) { + var users = searchService.searchGames(searchGames); + if (users != null) { + return ResponseEntity.ok(users); + } else { + return ResponseEntity.badRequest().body("[]"); + } + } +} diff --git a/backend/src/main/java/achievements/controllers/UserController.java b/backend/src/main/java/achievements/controllers/UserController.java new file mode 100644 index 0000000..84e060e --- /dev/null +++ b/backend/src/main/java/achievements/controllers/UserController.java @@ -0,0 +1,102 @@ +package achievements.controllers; + +import achievements.data.APError; +import achievements.data.APPostRequest; +import achievements.data.request.AddPlatform; +import achievements.data.request.RemovePlatform; +import achievements.data.request.SetUsername; +import achievements.services.ImageService; +import achievements.services.UserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletResponse; + +@RestController +@RequestMapping("/user") +public class UserController { + + @Autowired + private UserService userService; + + @Autowired + private ImageService imageService; + + @GetMapping(value = "/{user}", produces = "application/json") + public ResponseEntity getProfile(@PathVariable("user") int user) { + var profile = userService.getProfile(user); + if (profile == null) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new APError(1, "Failed to get user profile")); + } else { + return ResponseEntity.ok(profile); + } + } + + @PostMapping(value = "/{user}/username", consumes = "application/json", produces = "application/json") + public ResponseEntity setUsername(@PathVariable("user") int userId, @RequestBody SetUsername username) { + var name = userService.setUsername(userId, username); + if (name == 0) { + return ResponseEntity.status(HttpStatus.CREATED).body("{}"); + } + return ResponseEntity.badRequest().body("{}"); + } + + @GetMapping(value = "/{user}/image") + public void getProfilePicture(@PathVariable("user") int user, HttpServletResponse response) { + var profileImage = userService.getProfileImage(user); + imageService.send(profileImage, "user", response); + } + + @PostMapping(value = "/{user}/image", consumes = "multipart/form-data", produces = "application/json") + public ResponseEntity setProfilePicture(@PathVariable("user") int user, @RequestPart APPostRequest session, @RequestPart MultipartFile file) { + try { + var type = userService.setProfileImage(user, session.getKey(), file); + if ("not_an_image".equals(type)) { + return ResponseEntity.badRequest().body("{ \"code\": 1, \"message\": \"Not an image type\" }"); + } else if ("unsupported_type".equals(type)) { + return ResponseEntity.badRequest().body("{ \"code\": 1, \"message\": \"Unsupported file type\" }"); + } else if ("forbidden".equals(type)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body("{ \"code\": 2, \"message\": \"Invalid credentials\" }"); + } else if ("success".equals(type)) { + return ResponseEntity.status(HttpStatus.CREATED).body("{ \"code\": 0, \"message\": \"Success\" }"); + } + + } catch (Exception e) { + e.printStackTrace(); + } + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("{ \"code\": -1, \"message\": \"Unknown error\" }"); + } + + @PostMapping(value = "/{user}/platforms/add", consumes = "application/json", produces = "application/json") + public ResponseEntity addPlatformForUser(@PathVariable("user") int userId, @RequestBody AddPlatform request) { + var result = userService.addPlatform(userId, request, true); + if (result == 0) { + return ResponseEntity.status(HttpStatus.CREATED).body("{}"); + } else { + return ResponseEntity.badRequest().body("{}"); + } + } + + @PostMapping(value = "/{user}/platforms/remove", consumes = "application/json", produces = "application/json") + public ResponseEntity removePlatformForUser(@PathVariable("user") int userId, @RequestBody RemovePlatform request) { + var result = userService.removePlatform(userId, request); + if (result == 0) { + return ResponseEntity.status(HttpStatus.CREATED).body("{}"); + } else { + return ResponseEntity.badRequest().body("{}"); + } + } + + @GetMapping(value = "/{user}/noteworthy", produces = "application/json") + public ResponseEntity getNoteworthy(@PathVariable("user") int userId) { + var result = userService.getNoteworthy(userId); + if (result != null) { + return ResponseEntity.ok(result); + } else { + return ResponseEntity.badRequest().body("{}"); + } + } +} diff --git a/backend/src/main/java/achievements/data/APError.java b/backend/src/main/java/achievements/data/APError.java new file mode 100644 index 0000000..b6f8df2 --- /dev/null +++ b/backend/src/main/java/achievements/data/APError.java @@ -0,0 +1,36 @@ +package achievements.data; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class APError { + + @JsonProperty("code") + private int code; + @JsonProperty("message") + private String message; + + public APError(int code) { + this.code = code; + } + + public APError(int code, String message) { + this.code = code; + this.message = message; + } + + public int getCode() { + return code; + } + + public void setCode(int code) { + this.code = code; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/backend/src/main/java/achievements/data/APIResponse.java b/backend/src/main/java/achievements/data/APIResponse.java new file mode 100644 index 0000000..01136e8 --- /dev/null +++ b/backend/src/main/java/achievements/data/APIResponse.java @@ -0,0 +1,126 @@ +package achievements.data; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public class APIResponse { + + public static class Game { + + public static class Achievement { + @JsonProperty("name") + private String name; + @JsonProperty("description") + private String description; + @JsonProperty("stages") + private int stages; + @JsonProperty("progress") + private int progress; + @JsonProperty("thumbnail") + private String thumbnail; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public int getStages() { + return stages; + } + + public void setStages(int stages) { + this.stages = stages; + } + + public int getProgress() { + return progress; + } + + public void setProgress(int progress) { + this.progress = progress; + } + + public String getThumbnail() { + return thumbnail; + } + + public void setThumbnail(String thumbnail) { + this.thumbnail = thumbnail; + } + } + + @JsonProperty("platformGameId") + private String platformGameId; + @JsonProperty("name") + private String name; + @JsonProperty("played") + private boolean played; + @JsonProperty("thumbnail") + private String thumbnail; + @JsonProperty("achievements") + private List achievements; + + public String getPlatformGameId() { + return platformGameId; + } + + public void setPlatformGameId(String platformGameId) { + this.platformGameId = platformGameId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public boolean isPlayed() { + return played; + } + + public void setPlayed(boolean played) { + this.played = played; + } + + public String getThumbnail() { + return thumbnail; + } + + public void setThumbnail(String thumbnail) { + this.thumbnail = thumbnail; + } + + public List getAchievements() { + return achievements; + } + + public void setAchievements(List achievements) { + this.achievements = achievements; + } + } + + @JsonProperty("games") + private List games; + + public List getGames() { + return games; + } + + public void setGames(List games) { + this.games = games; + } +} diff --git a/backend/src/main/java/achievements/data/APPostRequest.java b/backend/src/main/java/achievements/data/APPostRequest.java new file mode 100644 index 0000000..fcf1b25 --- /dev/null +++ b/backend/src/main/java/achievements/data/APPostRequest.java @@ -0,0 +1,17 @@ +package achievements.data; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class APPostRequest { + + @JsonProperty("key") + private String key; + + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } +} diff --git a/backend/src/main/java/achievements/data/Achievements.java b/backend/src/main/java/achievements/data/Achievements.java deleted file mode 100644 index 4b2033f..0000000 --- a/backend/src/main/java/achievements/data/Achievements.java +++ /dev/null @@ -1,64 +0,0 @@ -package achievements.data; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.util.ArrayList; -import java.util.List; - -public class Achievements { - - public static class Achievement { - - @JsonProperty("name") - private String name; - @JsonProperty("description") - private String description; - @JsonProperty("stages") - private int stages; - - public Achievement(String name, String description, int stages) { - this.name = name; - this.description = description; - this.stages = stages; - } - - // Start Getters/Setters - public String getName() { return name; } - - public void setName(String name) { this.name = name; } - - public String getDescription() { return description; } - - public void setDescription(String description) { this.description = description; } - - public int getStages() { return stages; } - - public void setStages(int stages) { this.stages = stages; } - // End Getters/Setters - } - - @JsonProperty("gameID") - private int gameID; - @JsonProperty("gameName") - private String gameName; - @JsonProperty("achievements") - private List achievements; - - public Achievements() { achievements = new ArrayList(); } - - // Start Getters/Setters - public int getGameID() { return gameID; } - - public void setGameID(int gameID) { this.gameID = gameID; } - - public String getGameName() { return gameName; } - - public void setGameName(String gameName) { this.gameName = gameName; } - - public List getAchievements() { return achievements; } - - public void setAchievements(List achievements) { this.achievements = achievements; } - // End Getters/Setters - - public void addAchievement(Achievement achievement) { this.achievements.add(achievement); }; -} diff --git a/backend/src/main/java/achievements/data/Games.java b/backend/src/main/java/achievements/data/Games.java deleted file mode 100644 index 57ab9c6..0000000 --- a/backend/src/main/java/achievements/data/Games.java +++ /dev/null @@ -1,57 +0,0 @@ -package achievements.data; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.util.ArrayList; -import java.util.List; - -public class Games { - - public static class Game { - - @JsonProperty("ID") - private int id; - @JsonProperty("name") - private String name; - @JsonProperty("platforms") - private List platforms; - - public Game(int id, String name, String platform) { - this.id = id; - this.name = name; - this.platforms = new ArrayList<>(); - this.platforms.add(platform); - } - - // Start Getters/Setters - public int getId() { return id; } - - public void setId(int id) { this.id = id; } - - public String getName() { return name; } - - public void setName(String name) { this.name = name; } - - public List getPlatforms() { return platforms; } - - public void setPlatforms(List platforms) { this.platforms = platforms; } - - public void addToPlatforms(String platform) { this.platforms.add(platform); } - // End Getters/Setters - - } - - @JsonProperty("games") - private List games; - - public Games() { games = new ArrayList(); } - - // Start Getters/Setters - public List getGames() { return games; } - - public void setGames(List games) { this.games = games; } - // End Getters/Setters - - public void addGame(Game game) { this.games.add(game); } - -} diff --git a/backend/src/main/java/achievements/data/InternalError.java b/backend/src/main/java/achievements/data/InternalError.java deleted file mode 100644 index 16486a5..0000000 --- a/backend/src/main/java/achievements/data/InternalError.java +++ /dev/null @@ -1,21 +0,0 @@ -package achievements.data; - -import com.fasterxml.jackson.annotation.JsonProperty; - -public class InternalError { - - @JsonProperty - private String message; - - public InternalError(String message) { - this.message = message; - } - - public String getMessage() { - return message; - } - - public void setMessage(String message) { - this.message = message; - } -} diff --git a/backend/src/main/java/achievements/data/Profile.java b/backend/src/main/java/achievements/data/Profile.java new file mode 100644 index 0000000..2c02d38 --- /dev/null +++ b/backend/src/main/java/achievements/data/Profile.java @@ -0,0 +1,166 @@ +package achievements.data; + +import achievements.data.response.search.Achievement; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public class Profile { + + public static class Platform { + @JsonProperty + private int id; + @JsonProperty("name") + private String name; + @JsonProperty("connected") + private boolean connected; + + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public boolean getConnected() { + return connected; + } + + public void setConnected(boolean connected) { + this.connected = connected; + } + } + + public static class Rating { + @JsonProperty("achievementId") + private int achievementId; + @JsonProperty("name") + private String name; + @JsonProperty("difficulty") + private Float difficulty; + @JsonProperty("quality") + private Float quality; + @JsonProperty("review") + private String review; + + public int getAchievementId() { + return achievementId; + } + + public void setAchievementId(int achievementId) { + this.achievementId = achievementId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Float getDifficulty() { + return difficulty; + } + + public void setDifficulty(Float difficulty) { + this.difficulty = difficulty; + } + + public Float getQuality() { + return quality; + } + + public void setQuality(Float quality) { + this.quality = quality; + } + + public String getReview() { + return review; + } + + public void setReview(String review) { + this.review = review; + } + } + + @JsonProperty("username") + private String username; + @JsonProperty("completed") + private int completed; + @JsonProperty("average") + private Integer average; + @JsonProperty("perfect") + private int perfect; + @JsonProperty("noteworthy") + private List noteworthy; + @JsonProperty("platforms") + private List platforms; + @JsonProperty("ratings") + private List ratings; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public int getCompleted() { + return completed; + } + + public void setCompleted(int completed) { + this.completed = completed; + } + + public Integer getAverage() { + return average; + } + + public void setAverage(Integer average) { + this.average = average; + } + + public int getPerfect() { + return perfect; + } + + public void setPerfect(int perfect) { + this.perfect = perfect; + } + + public List getNoteworthy() { + return noteworthy; + } + + public void setNoteworthy(List noteworthy) { + this.noteworthy = noteworthy; + } + + public List getPlatforms() { + return platforms; + } + + public void setPlatforms(List platforms) { + this.platforms = platforms; + } + + public List getRatings() { + return ratings; + } + + public void setRatings(List ratings) { + this.ratings = ratings; + } +} diff --git a/backend/src/main/java/achievements/data/Session.java b/backend/src/main/java/achievements/data/Session.java new file mode 100644 index 0000000..a5c5e2f --- /dev/null +++ b/backend/src/main/java/achievements/data/Session.java @@ -0,0 +1,58 @@ +package achievements.data; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class Session { + + @JsonProperty("key") + private String key; + @JsonProperty("id") + private int id; + @JsonProperty("hue") + private int hue; + @JsonProperty("admin") + private boolean admin; + @JsonIgnore + private boolean used; + + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } + + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public int getHue() { + return hue; + } + + public void setHue(int hue) { + this.hue = hue; + } + + public boolean isAdmin() { + return admin; + } + + public void setAdmin(boolean admin) { + this.admin = admin; + } + + public boolean isUsed() { + return used; + } + + public void setUsed(boolean used) { + this.used = used; + } +} diff --git a/backend/src/main/java/achievements/data/User.java b/backend/src/main/java/achievements/data/User.java new file mode 100644 index 0000000..065de1d --- /dev/null +++ b/backend/src/main/java/achievements/data/User.java @@ -0,0 +1,37 @@ +package achievements.data; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class User { + + @JsonProperty("email") + public String email; + @JsonProperty("username") + public String username; + @JsonProperty("password") + public String password; + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } +} diff --git a/backend/src/main/java/achievements/data/importing/ImportPlatform.java b/backend/src/main/java/achievements/data/importing/ImportPlatform.java new file mode 100644 index 0000000..fa24903 --- /dev/null +++ b/backend/src/main/java/achievements/data/importing/ImportPlatform.java @@ -0,0 +1,37 @@ +package achievements.data.importing; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class ImportPlatform { + + @JsonProperty("userId") + private int userId; + @JsonProperty("sessionKey") + private String sessionKey; + @JsonProperty("name") + private String name; + + public int getUserId() { + return userId; + } + + public void setUserId(int userId) { + this.userId = userId; + } + + public String getSessionKey() { + return sessionKey; + } + + public void setSessionKey(String sessionKey) { + this.sessionKey = sessionKey; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} diff --git a/backend/src/main/java/achievements/data/importing/ImportUser.java b/backend/src/main/java/achievements/data/importing/ImportUser.java new file mode 100644 index 0000000..c46bedf --- /dev/null +++ b/backend/src/main/java/achievements/data/importing/ImportUser.java @@ -0,0 +1,38 @@ +package achievements.data.importing; + +import achievements.data.User; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class ImportUser extends User { + + @JsonProperty("userId") + private int userId; + @JsonProperty("sessionKey") + private String sessionKey; + @JsonProperty("admin") + private boolean admin; + + public int getUserId() { + return userId; + } + + public void setUserId(int userId) { + this.userId = userId; + } + + public String getSessionKey() { + return sessionKey; + } + + public void setSessionKey(String sessionKey) { + this.sessionKey = sessionKey; + } + + public boolean isAdmin() { + return admin; + } + + public void setAdmin(boolean admin) { + this.admin = admin; + } +} diff --git a/backend/src/main/java/achievements/data/importing/ImportUserPlatform.java b/backend/src/main/java/achievements/data/importing/ImportUserPlatform.java new file mode 100644 index 0000000..b43519a --- /dev/null +++ b/backend/src/main/java/achievements/data/importing/ImportUserPlatform.java @@ -0,0 +1,28 @@ +package achievements.data.importing; + +import achievements.data.request.AddPlatform; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class ImportUserPlatform extends AddPlatform { + + @JsonProperty("userId") + private int userId; + @JsonProperty("userEmail") + private String userEmail; + + public int getUserId() { + return userId; + } + + public void setUserId(int userId) { + this.userId = userId; + } + + public String getUserEmail() { + return userEmail; + } + + public void setUserEmail(String userEmail) { + this.userEmail = userEmail; + } +} diff --git a/backend/src/main/java/achievements/data/request/AddPlatform.java b/backend/src/main/java/achievements/data/request/AddPlatform.java new file mode 100644 index 0000000..7e8f352 --- /dev/null +++ b/backend/src/main/java/achievements/data/request/AddPlatform.java @@ -0,0 +1,36 @@ +package achievements.data.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class AddPlatform { + @JsonProperty("sessionKey") + private String sessionKey; + @JsonProperty("platformId") + private int platformId; + @JsonProperty("platformUserId") + private String platformUserId; + + public String getSessionKey() { + return sessionKey; + } + + public void setSessionKey(String sessionKey) { + this.sessionKey = sessionKey; + } + + public int getPlatformId() { + return platformId; + } + + public void setPlatformId(int platformId) { + this.platformId = platformId; + } + + public String getPlatformUserId() { + return platformUserId; + } + + public void setPlatformUserId(String platformUserId) { + this.platformUserId = platformUserId; + } +} diff --git a/backend/src/main/java/achievements/data/request/RateAchievement.java b/backend/src/main/java/achievements/data/request/RateAchievement.java new file mode 100644 index 0000000..d38cbf4 --- /dev/null +++ b/backend/src/main/java/achievements/data/request/RateAchievement.java @@ -0,0 +1,47 @@ +package achievements.data.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class RateAchievement { + + @JsonProperty("sessionKey") + private String sessionKey; + @JsonProperty("difficulty") + private Float difficulty; + @JsonProperty("quality") + private Float quality; + @JsonProperty("review") + private String review; + + public String getSessionKey() { + return sessionKey; + } + + public void setSessionKey(String sessionKey) { + this.sessionKey = sessionKey; + } + + public Float getDifficulty() { + return difficulty; + } + + public void setDifficulty(Float difficulty) { + this.difficulty = difficulty; + } + + public Float getQuality() { + return quality; + } + + public void setQuality(Float quality) { + this.quality = quality; + } + + public String getReview() { + return review; + } + + public void setReview(String review) { + this.review = review; + } +} diff --git a/backend/src/main/java/achievements/data/request/RemovePlatform.java b/backend/src/main/java/achievements/data/request/RemovePlatform.java new file mode 100644 index 0000000..ff6371e --- /dev/null +++ b/backend/src/main/java/achievements/data/request/RemovePlatform.java @@ -0,0 +1,26 @@ +package achievements.data.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class RemovePlatform { + @JsonProperty("sessionKey") + private String sessionKey; + @JsonProperty("platformId") + private int platformId; + + public String getSessionKey() { + return sessionKey; + } + + public void setSessionKey(String sessionKey) { + this.sessionKey = sessionKey; + } + + public int getPlatformId() { + return platformId; + } + + public void setPlatformId(int platformId) { + this.platformId = platformId; + } +} diff --git a/backend/src/main/java/achievements/data/request/SearchAchievements.java b/backend/src/main/java/achievements/data/request/SearchAchievements.java new file mode 100644 index 0000000..5dbd340 --- /dev/null +++ b/backend/src/main/java/achievements/data/request/SearchAchievements.java @@ -0,0 +1,117 @@ +package achievements.data.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class SearchAchievements { + + @JsonProperty("searchTerm") + private String searchTerm; + @JsonProperty("userId") + private Integer userId; + @JsonProperty("completed") + private boolean completed; + @JsonProperty("minCompletion") + private Float minCompletion; + @JsonProperty("maxCompletion") + private Float maxCompletion; + @JsonProperty("minDifficulty") + private Float minDifficulty; + @JsonProperty("maxDifficulty") + private Float maxDifficulty; + @JsonProperty("minQuality") + private Float minQuality; + @JsonProperty("maxQuality") + private Float maxQuality; + @JsonProperty("ordering") + private String ordering; + @JsonProperty("orderDirection") + private String orderDirection; + + public String getSearchTerm() { + return searchTerm; + } + + public void setSearchTerm(String searchTerm) { + this.searchTerm = searchTerm; + } + + public Integer getUserId() { + return userId; + } + + public void setUserId(Integer userId) { + this.userId = userId; + } + + public boolean isCompleted() { + return completed; + } + + public void setCompleted(boolean completed) { + this.completed = completed; + } + + public Float getMinCompletion() { + return minCompletion; + } + + public void setMinCompletion(Float minCompletion) { + this.minCompletion = minCompletion; + } + + public Float getMaxCompletion() { + return maxCompletion; + } + + public void setMaxCompletion(Float maxCompletion) { + this.maxCompletion = maxCompletion; + } + + public Float getMinDifficulty() { + return minDifficulty; + } + + public void setMinDifficulty(Float minDifficulty) { + this.minDifficulty = minDifficulty; + } + + public Float getMaxDifficulty() { + return maxDifficulty; + } + + public void setMaxDifficulty(Float maxDifficulty) { + this.maxDifficulty = maxDifficulty; + } + + public Float getMinQuality() { + return minQuality; + } + + public void setMinQuality(Float minQuality) { + this.minQuality = minQuality; + } + + public Float getMaxQuality() { + return maxQuality; + } + + public void setMaxQuality(Float maxQuality) { + this.maxQuality = maxQuality; + } + + public String getOrdering() { + return ordering; + } + + public void setOrdering(String ordering) { + this.ordering = ordering; + } + + public String getOrderDirection() { + return orderDirection; + } + + public void setOrderDirection(String orderDirection) { + this.orderDirection = orderDirection; + } +} diff --git a/backend/src/main/java/achievements/data/request/SearchGames.java b/backend/src/main/java/achievements/data/request/SearchGames.java new file mode 100644 index 0000000..b066b69 --- /dev/null +++ b/backend/src/main/java/achievements/data/request/SearchGames.java @@ -0,0 +1,117 @@ +package achievements.data.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class SearchGames { + + @JsonProperty("searchTerm") + private String searchTerm; + @JsonProperty("userId") + private Integer userId; + @JsonProperty("owned") + private boolean owned; + @JsonProperty("minAvgCompletion") + private Float minAvgCompletion; + @JsonProperty("maxAvgCompletion") + private Float maxAvgCompletion; + @JsonProperty("minNumOwners") + private Float minNumOwners; + @JsonProperty("maxNumOwners") + private Float maxNumOwners; + @JsonProperty("minNumPerfects") + private Float minNumPerfects; + @JsonProperty("maxNumPerfects") + private Float maxNumPerfects; + @JsonProperty("ordering") + private String ordering; + @JsonProperty("orderDirection") + private String orderDirection; + + public String getSearchTerm() { + return searchTerm; + } + + public void setSearchTerm(String searchTerm) { + this.searchTerm = searchTerm; + } + + public Integer getUserId() { + return userId; + } + + public void setUserId(Integer userId) { + this.userId = userId; + } + + public boolean isOwned() { + return owned; + } + + public void setOwned(boolean owned) { + this.owned = owned; + } + + public Float getMinAvgCompletion() { + return minAvgCompletion; + } + + public void setMinAvgCompletion(Float minAvgCompletion) { + this.minAvgCompletion = minAvgCompletion; + } + + public Float getMaxAvgCompletion() { + return maxAvgCompletion; + } + + public void setMaxAvgCompletion(Float maxAvgCompletion) { + this.maxAvgCompletion = maxAvgCompletion; + } + + public Float getMinNumOwners() { + return minNumOwners; + } + + public void setMinNumOwners(Float minNumOwners) { + this.minNumOwners = minNumOwners; + } + + public Float getMaxNumOwners() { + return maxNumOwners; + } + + public void setMaxNumOwners(Float maxNumOwners) { + this.maxNumOwners = maxNumOwners; + } + + public Float getMinNumPerfects() { + return minNumPerfects; + } + + public void setMinNumPerfects(Float minNumPerfects) { + this.minNumPerfects = minNumPerfects; + } + + public Float getMaxNumPerfects() { + return maxNumPerfects; + } + + public void setMaxNumPerfects(Float maxNumPerfects) { + this.maxNumPerfects = maxNumPerfects; + } + + public String getOrdering() { + return ordering; + } + + public void setOrdering(String ordering) { + this.ordering = ordering; + } + + public String getOrderDirection() { + return orderDirection; + } + + public void setOrderDirection(String orderDirection) { + this.orderDirection = orderDirection; + } +} diff --git a/backend/src/main/java/achievements/data/request/SearchUsers.java b/backend/src/main/java/achievements/data/request/SearchUsers.java new file mode 100644 index 0000000..bb11c82 --- /dev/null +++ b/backend/src/main/java/achievements/data/request/SearchUsers.java @@ -0,0 +1,97 @@ +package achievements.data.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class SearchUsers { + + @JsonProperty("searchTerm") + private String searchTerm; + @JsonProperty("minOwned") + private Float minOwned; + @JsonProperty("maxOwned") + private Float maxOwned; + @JsonProperty("minCompleted") + private Float minCompleted; + @JsonProperty("maxCompleted") + private Float maxCompleted; + @JsonProperty("minAvgCompletion") + private Float minAvgCompletion; + @JsonProperty("maxAvgCompletion") + private Float maxAvgCompletion; + @JsonProperty("ordering") + private String ordering; + @JsonProperty("orderDirection") + private String orderDirection; + + public String getSearchTerm() { + return searchTerm; + } + + public void setSearchTerm(String searchTerm) { + this.searchTerm = searchTerm; + } + + public Float getMinOwned() { + return minOwned; + } + + public void setMinOwned(Float minOwned) { + this.minOwned = minOwned; + } + + public Float getMaxOwned() { + return maxOwned; + } + + public void setMaxOwned(Float maxOwned) { + this.maxOwned = maxOwned; + } + + public Float getMinCompleted() { + return minCompleted; + } + + public void setMinCompleted(Float minCompleted) { + this.minCompleted = minCompleted; + } + + public Float getMaxCompleted() { + return maxCompleted; + } + + public void setMaxCompleted(Float maxCompleted) { + this.maxCompleted = maxCompleted; + } + + public Float getMinAvgCompletion() { + return minAvgCompletion; + } + + public void setMinAvgCompletion(Float minAvgCompletion) { + this.minAvgCompletion = minAvgCompletion; + } + + public Float getMaxAvgCompletion() { + return maxAvgCompletion; + } + + public void setMaxAvgCompletion(Float maxAvgCompletion) { + this.maxAvgCompletion = maxAvgCompletion; + } + + public String getOrdering() { + return ordering; + } + + public void setOrdering(String ordering) { + this.ordering = ordering; + } + + public String getOrderDirection() { + return orderDirection; + } + + public void setOrderDirection(String orderDirection) { + this.orderDirection = orderDirection; + } +} diff --git a/backend/src/main/java/achievements/data/request/SetUsername.java b/backend/src/main/java/achievements/data/request/SetUsername.java new file mode 100644 index 0000000..96274aa --- /dev/null +++ b/backend/src/main/java/achievements/data/request/SetUsername.java @@ -0,0 +1,36 @@ +package achievements.data.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class SetUsername { + @JsonProperty("sessionKey") + private String sessionKey; + @JsonProperty("userId") + private int userId; + @JsonProperty("username") + private String username; + + public String getSessionKey() { + return sessionKey; + } + + public void setSessionKey(String sessionKey) { + this.sessionKey = sessionKey; + } + + public int getUserId() { + return userId; + } + + public void setUserId(int userId) { + this.userId = userId; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } +} diff --git a/backend/src/main/java/achievements/data/response/search/Achievement.java b/backend/src/main/java/achievements/data/response/search/Achievement.java new file mode 100644 index 0000000..fa8969e --- /dev/null +++ b/backend/src/main/java/achievements/data/response/search/Achievement.java @@ -0,0 +1,134 @@ +package achievements.data.response.search; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public class Achievement { + + public static class Rating { + @JsonProperty("userId") + private int userId; + @JsonProperty("username") + private String username; + @JsonProperty("difficulty") + private Float difficulty; + @JsonProperty("quality") + private Float quality; + @JsonProperty("review") + private String review; + + public int getUserId() { + return userId; + } + + public void setUserId(int userId) { + this.userId = userId; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public Float getDifficulty() { + return difficulty; + } + + public void setDifficulty(Float difficulty) { + this.difficulty = difficulty; + } + + public Float getQuality() { + return quality; + } + + public void setQuality(Float quality) { + this.quality = quality; + } + + public String getReview() { + return review; + } + + public void setReview(String review) { + this.review = review; + } + } + + @JsonProperty("ID") + private int ID; + @JsonProperty("game") + private String game; + @JsonProperty("name") + private String name; + @JsonProperty("description") + private String description; + @JsonProperty("completion") + private Integer completion; + @JsonProperty("difficulty") + private Float difficulty; + @JsonProperty("quality") + private Float quality; + @JsonProperty("ratings") + private List ratings; + + public int getID() { + return ID; + } + + public void setID(int ID) { + this.ID = ID; + } + + public String getGame() { + return game; + } + + public void setGame(String game) { + this.game = game; + } + + public String getName() { return name; } + + public void setName(String name) { this.name = name; } + + public String getDescription() { return description; } + + public void setDescription(String description) { this.description = description; } + + public Integer getCompletion() { + return completion; + } + + public void setCompletion(Integer completion) { + this.completion = completion; + } + + public Float getDifficulty() { + return difficulty; + } + + public void setDifficulty(Float difficulty) { + this.difficulty = difficulty; + } + + public Float getQuality() { + return quality; + } + + public void setQuality(Float quality) { + this.quality = quality; + } + + public List getRatings() { + return ratings; + } + + public void setRatings(List ratings) { + this.ratings = ratings; + } +} diff --git a/backend/src/main/java/achievements/data/response/search/Game.java b/backend/src/main/java/achievements/data/response/search/Game.java new file mode 100644 index 0000000..c113d19 --- /dev/null +++ b/backend/src/main/java/achievements/data/response/search/Game.java @@ -0,0 +1,59 @@ +package achievements.data.response.search; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class Game { + + @JsonProperty("ID") + private int ID; + @JsonProperty("name") + private String name; + @JsonProperty("achievement_count") + private int achievement_count; + @JsonProperty("avg_completion") + private Integer avg_completion; + @JsonProperty("num_owners") + private int num_owners; + @JsonProperty("num_perfects") + private int num_perfects; + + public int getID() { return ID; } + + public void setID(int ID) { this.ID = ID; } + + public String getName() { return name; } + + public void setName(String name) { this.name = name; } + + public int getAchievement_count() { + return achievement_count; + } + + public void setAchievement_count(int achievement_count) { + this.achievement_count = achievement_count; + } + + public Integer getAvg_completion() { + return avg_completion; + } + + public void setAvg_completion(Integer avg_completion) { + this.avg_completion = avg_completion; + } + + public int getNum_owners() { + return num_owners; + } + + public void setNum_owners(int num_owners) { + this.num_owners = num_owners; + } + + public int getNum_perfects() { + return num_perfects; + } + + public void setNum_perfects(int num_perfects) { + this.num_perfects = num_perfects; + } +} diff --git a/backend/src/main/java/achievements/data/response/search/User.java b/backend/src/main/java/achievements/data/response/search/User.java new file mode 100644 index 0000000..d5bd718 --- /dev/null +++ b/backend/src/main/java/achievements/data/response/search/User.java @@ -0,0 +1,67 @@ +package achievements.data.response.search; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class User { + + @JsonProperty("ID") + private int ID; + @JsonProperty("username") + private String username; + @JsonProperty("game_count") + private int game_count; + @JsonProperty("achievement_count") + private int achievement_count; + @JsonProperty("avg_completion") + private Integer avg_completion; + @JsonProperty("perfect_games") + private int perfect_games; + + public int getID() { + return ID; + } + + public void setID(int ID) { + this.ID = ID; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public int getGame_count() { + return game_count; + } + + public void setGame_count(int game_count) { + this.game_count = game_count; + } + + public int getAchievement_count() { + return achievement_count; + } + + public void setAchievement_count(int achievement_count) { + this.achievement_count = achievement_count; + } + + public Integer getAvg_completion() { + return avg_completion; + } + + public void setAvg_completion(Integer avg_completion) { + this.avg_completion = avg_completion; + } + + public int getPerfect_games() { + return perfect_games; + } + + public void setPerfect_games(int perfect_games) { + this.perfect_games = perfect_games; + } +} diff --git a/backend/src/main/java/achievements/misc/APIList.java b/backend/src/main/java/achievements/misc/APIList.java new file mode 100644 index 0000000..99ea3d1 --- /dev/null +++ b/backend/src/main/java/achievements/misc/APIList.java @@ -0,0 +1,41 @@ +package achievements.misc; + +import achievements.apis.PlatformAPI; +import achievements.apis.SteamAPI; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import javax.annotation.PostConstruct; +import java.util.HashMap; +import java.util.Map; + +@Component +public class APIList { + + @Autowired + private RestTemplate rest; + + public final Map apis = new HashMap<>(); + + @PostConstruct + private void init() { + /*db = dbs.getConnection(); + try { + + var stmt = db.prepareCall("{call GetPlatforms()}"); + var results = stmt.executeQuery(); + + while (results.next()) { + var id = results.getInt("ID"); + + // Wanted to pull some skekery with dynamic class loading and external api jars, but...time is of the essence and I need to cut scope as much as possible + apis.put(id, new ????(id, rest)); + } + } catch (Exception e) { + e.printStackTrace(); + }*/ + + apis.put(0, new SteamAPI(0, rest)); + } +} diff --git a/backend/src/main/java/achievements/services/DbConnectionService.java b/backend/src/main/java/achievements/misc/DbConnection.java similarity index 88% rename from backend/src/main/java/achievements/services/DbConnectionService.java rename to backend/src/main/java/achievements/misc/DbConnection.java index 99ef0b1..1d7ea59 100644 --- a/backend/src/main/java/achievements/services/DbConnectionService.java +++ b/backend/src/main/java/achievements/misc/DbConnection.java @@ -1,56 +1,56 @@ -package achievements.services; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import javax.annotation.PostConstruct; -import javax.annotation.PreDestroy; -import java.sql.Connection; -import java.sql.SQLException; -import com.microsoft.sqlserver.jdbc.SQLServerDataSource; - -@Component -public class DbConnectionService { - - private Connection connection; - - @Value("${database.server}") - private String serverName; - @Value("${database.name}") - private String databaseName; - @Value("${database.user.name}") - private String username; - @Value("${database.user.password}") - private String password; - - public DbConnectionService() {} - - @PostConstruct - public void connect() { - try { - var dataSource = new SQLServerDataSource(); - dataSource.setServerName (serverName ); - dataSource.setDatabaseName(databaseName); - dataSource.setUser (username ); - dataSource.setPassword (password ); - connection = dataSource.getConnection(); - } catch (SQLException e) { - e.printStackTrace(); - } - } - - public Connection getConnection() { - return this.connection; - } - - @PreDestroy - public void disconnect() { - try { - if (connection != null) { - connection.close(); - } - } catch (SQLException e) { - e.printStackTrace(); - } - } -} +package achievements.misc; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.sql.Connection; +import java.sql.SQLException; +import com.microsoft.sqlserver.jdbc.SQLServerDataSource; + +@Component +public class DbConnection { + + private Connection connection; + + @Value("${database.server}") + private String serverName; + @Value("${database.name}") + private String databaseName; + @Value("${database.user.name}") + private String username; + @Value("${database.user.password}") + private String password; + + public DbConnection() {} + + @PostConstruct + public void connect() { + try { + var dataSource = new SQLServerDataSource(); + dataSource.setServerName (serverName ); + dataSource.setDatabaseName(databaseName); + dataSource.setUser (username ); + dataSource.setPassword (password ); + connection = dataSource.getConnection(); + } catch (SQLException e) { + e.printStackTrace(); + } + } + + public Connection getConnection() { + return this.connection; + } + + @PreDestroy + public void disconnect() { + try { + if (connection != null) { + connection.close(); + } + } catch (SQLException e) { + e.printStackTrace(); + } + } +} diff --git a/backend/src/main/java/achievements/misc/HashManager.java b/backend/src/main/java/achievements/misc/HashManager.java new file mode 100644 index 0000000..2e133c4 --- /dev/null +++ b/backend/src/main/java/achievements/misc/HashManager.java @@ -0,0 +1,70 @@ +package achievements.misc; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Random; + +public class HashManager { + + private static final Random RANDOM = new SecureRandom(); + + public static byte[] hash(byte[] salt, byte[] password) { + try { + var concat = new byte[salt.length + password.length]; + int i = 0; + for (; i < salt.length; ++i) { + concat[i] = salt[i]; + } + for (int j = 0; j < password.length; ++j) { + concat[i + j] = password[j]; + } + + var md = MessageDigest.getInstance("SHA-256"); + return md.digest(concat); + } catch (NoSuchAlgorithmException e) { + e.printStackTrace(); + } + return null; + } + + public static String encode(byte[] bytes) { + var chars = new char[bytes.length << 1]; + for (int i = 0; i < bytes.length; ++i) { + chars[(i << 1) ] = toHex(bytes[i] >> 0); + chars[(i << 1) + 1] = toHex(bytes[i] >> 4); + } + return new String(chars); + } + + public static byte[] decode(String data) { + var decoded = new byte[data.length() >> 1]; + for (int i = 0; i < data.length(); i += 2) { + int currentByte = + (fromHex(data.charAt(i )) ) | + (fromHex(data.charAt(i + 1)) << 4); + decoded[i >> 1] = (byte) (currentByte & 0xFF); + } + return decoded; + } + + public static byte[] generateBytes(int length) { + var bytes = new byte[length]; + RANDOM.nextBytes(bytes); + return bytes; + } + + public static char toHex(int halfByte) { + halfByte = halfByte & 0xF; + if (0 <= halfByte && halfByte <= 9 ) return (char) (halfByte + '0' ); + if (10 <= halfByte && halfByte <= 15) return (char) (halfByte + 'a' - 10); + return '0'; + } + + public static int fromHex(char c) { + if ('0' <= c && c <= '9') return c - '0'; + if ('A' <= c && c <= 'F') return c - 'A' + 10; + if ('a' <= c && c <= 'f') return c - 'a' + 10; + return 0; + } +} diff --git a/backend/src/main/java/achievements/misc/Password.java b/backend/src/main/java/achievements/misc/Password.java new file mode 100644 index 0000000..f96958d --- /dev/null +++ b/backend/src/main/java/achievements/misc/Password.java @@ -0,0 +1,33 @@ +package achievements.misc; + +public class Password { + + public final String salt; + public final String hash; + + private Password(String salt, String hash) { + this.salt = salt; + this.hash = hash; + } + + public static Password generate(String password) { + // Generate the salt + var salt = HashManager.generateBytes(16); // 128 bits + + return new Password( + HashManager.encode(salt), + HashManager.encode(HashManager.hash(salt, password.getBytes())) + ); + } + + public static boolean validate(String salt, String password, String hash) { + var srcHash = HashManager.hash(HashManager.decode(salt), password.getBytes()); + var targetHash = HashManager.decode(hash); + for (int i = 0; i < srcHash.length; ++i) { + if (srcHash[i] != targetHash[i]) { + return false; + } + } + return true; + } +} diff --git a/backend/src/main/java/achievements/misc/SessionManager.java b/backend/src/main/java/achievements/misc/SessionManager.java new file mode 100644 index 0000000..9f01c42 --- /dev/null +++ b/backend/src/main/java/achievements/misc/SessionManager.java @@ -0,0 +1,73 @@ +package achievements.misc; + +import achievements.data.Session; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.HashMap; + +@Component +public class SessionManager { + + private HashMap sessions; + + public SessionManager() { + sessions = new HashMap<>(); + } + + public Session generate(int user, int hue, boolean admin) { + var key = HashManager.encode(HashManager.generateBytes(16)); + var session = new Session(); + session.setKey(key); + session.setId(user); + session.setHue(hue); + session.setAdmin(admin); + sessions.put(key, session); + return session; + } + + public int getUser(String key) { + return sessions.get(key).getId(); + } + + public void remove(String key) { + sessions.remove(key); + } + + public boolean validate(int user, String key) { + var session = sessions.get(key); + return session != null && user == session.getId(); + } + + public boolean validateAdmin(int user, String key) { + var session = sessions.get(key); + return session != null && user == session.getId() && session.isAdmin(); + } + + public boolean refresh(String key) { + var foreign = sessions.get(key); + if (foreign != null) { + foreign.setUsed(true); + return true; + } else { + return false; + } + } + + // Clean up inactive sessions + @Scheduled(cron = "0 */30 * * * *") + public void clean() { + var remove = new ArrayList(); + sessions.forEach((key, session) -> { + if (!session.isUsed()) { + remove.add(session.getKey()); + } else { + session.setUsed(false); + } + }); + for (var session : remove) { + sessions.remove(session); + } + } +} diff --git a/backend/src/main/java/achievements/services/APIService.java b/backend/src/main/java/achievements/services/APIService.java new file mode 100644 index 0000000..14d1ed3 --- /dev/null +++ b/backend/src/main/java/achievements/services/APIService.java @@ -0,0 +1,114 @@ +package achievements.services; + +import achievements.misc.APIList; +import achievements.misc.DbConnection; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import javax.annotation.PostConstruct; +import java.io.File; +import java.io.FileOutputStream; +import java.sql.Connection; +import java.sql.Types; +import java.util.HashSet; + +@Service +public class APIService { + + @Autowired + private RestTemplate rest; + @Autowired + private APIList apis; + @Autowired + private DbConnection dbs; + private Connection db; + + @PostConstruct + private void init() { + db = dbs.getConnection(); + } + + private String getFileType(String imagePath) { + var path = imagePath.split("\\."); + return path[path.length - 1]; + } + + public int importUserPlatform(int userId, int platformId, String platformUserId) { + try { + var response = apis.apis.get(platformId).get(platformUserId); + + var addIfNotGame = db.prepareCall("{call AddIfNotGame(?, ?, ?)}"); + var addGameToPlatform = db.prepareCall("{call AddGameToPlatform(?, ?, ?)}"); + var addGameToUser = db.prepareCall("{call AddGameToUser(?, ?, ?)}"); + var addIfNotAchievement = db.prepareCall("{call AddIfNotAchievement(?, ?, ?, ?, ?, ?)}"); + var setAchievementProgressForUser = db.prepareCall("{call SetAchievementProgressForUser(?, ?, ?, ?)}"); + + addIfNotGame.registerOutParameter(3, Types.INTEGER); + addIfNotAchievement.registerOutParameter(6, Types.INTEGER); + + for (var game : response.getGames()) { + addIfNotGame.setString(1, game.getName()); + addIfNotGame.setString(2, getFileType(game.getThumbnail())); + addIfNotGame.execute(); + var gameId = addIfNotGame.getInt(3); + + addGameToPlatform.setInt(1, gameId); + addGameToPlatform.setInt(2, platformId); + addGameToPlatform.setString(3, game.getPlatformGameId()); + addGameToPlatform.execute(); + + var gameThumbnail = new File("storage/images/game/" + gameId + "." + getFileType(game.getThumbnail())); + if (!gameThumbnail.exists()) { + var bytes = rest.getForObject(game.getThumbnail(), byte[].class); + var stream = new FileOutputStream(gameThumbnail); + stream.write(bytes); + stream.close(); + } + + addGameToUser.setInt(1, gameId); + addGameToUser.setInt(2, userId); + addGameToUser.setInt(3, platformId); + addGameToUser.execute(); + + var set = new HashSet(); + for (var achievement : game.getAchievements()) { + addIfNotAchievement.setInt(1, gameId); + addIfNotAchievement.setString(2, achievement.getName()); + addIfNotAchievement.setString(3, achievement.getDescription()); + addIfNotAchievement.setInt(4, achievement.getStages()); + addIfNotAchievement.setString(5, getFileType(achievement.getThumbnail())); + addIfNotAchievement.execute(); + var achievementId = addIfNotAchievement.getInt(6); + set.add(achievementId); + + var achievementIcon = new File("storage/images/achievement/" + achievementId + "." + getFileType(achievement.getThumbnail())); + if (!achievementIcon.exists()) { + var bytes = rest.getForObject(achievement.getThumbnail(), byte[].class); + var stream = new FileOutputStream(achievementIcon); + stream.write(bytes); + stream.close(); + } + + if (game.isPlayed()) { + setAchievementProgressForUser.setInt(1, userId); + setAchievementProgressForUser.setInt(2, platformId); + setAchievementProgressForUser.setInt(3, achievementId); + setAchievementProgressForUser.setInt(4, achievement.getProgress()); + setAchievementProgressForUser.execute(); + } + } + } + + addIfNotGame.close(); + addGameToPlatform.close(); + addIfNotAchievement.close(); + setAchievementProgressForUser.close(); + + return 0; + } catch (Exception e) { + e.printStackTrace(); + } + return -1; + } +} diff --git a/backend/src/main/java/achievements/services/AchievementService.java b/backend/src/main/java/achievements/services/AchievementService.java new file mode 100644 index 0000000..06cb554 --- /dev/null +++ b/backend/src/main/java/achievements/services/AchievementService.java @@ -0,0 +1,146 @@ +package achievements.services; + +import achievements.data.request.RateAchievement; +import achievements.data.response.search.Achievement; +import achievements.misc.DbConnection; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import javax.annotation.PostConstruct; +import java.sql.Connection; +import java.sql.Types; +import java.util.ArrayList; + +@Service +public class AchievementService { + + @Autowired + private DbConnection dbs; + private Connection db; + + @Autowired + private ImageService imageService; + + @Autowired + private AuthenticationService authService; + + @PostConstruct + private void init() { + db = dbs.getConnection(); + } + + public String[] getIcon(int achievementId) { + try { + var stmt = db.prepareCall("{call GetAchievementIcon(?)}"); + return imageService.getImageType(stmt, achievementId); + } catch (Exception e) { + e.printStackTrace(); + } + return null; + } + + public Achievement getAchievement(int achievementId) { + try { + var stmt = db.prepareCall("{call GetAchievement(?)}"); + stmt.setInt(1, achievementId); + + var result = stmt.executeQuery(); + if (result.next()) { + var achievement = new Achievement(); + achievement.setID(result.getInt("ID")); + achievement.setName(result.getString("Name")); + achievement.setCompletion(result.getInt("Completion")); if (result.wasNull()) { achievement.setCompletion(null); } + achievement.setDescription(result.getString("Description")); + achievement.setDifficulty(result.getFloat("Difficulty")); if (result.wasNull()) { achievement.setDifficulty(null); } + achievement.setQuality(result.getFloat("Quality")); if (result.wasNull()) { achievement.setQuality(null); } + + stmt = db.prepareCall("{call GetRatingsForAchievement(?)}"); + stmt.setInt(1, achievementId); + + var ratings = new ArrayList(); + var results = stmt.executeQuery(); + while (results.next()) { + var rating = new Achievement.Rating(); + rating.setUserId(results.getInt("UserID")); + rating.setUsername(results.getString("Username")); + rating.setDifficulty(results.getFloat("Difficulty")); if (results.wasNull()) { rating.setDifficulty(null); } + rating.setQuality(results.getFloat("Quality")); if (results.wasNull()) { rating.setQuality(null); } + rating.setReview(results.getString("Description")); + ratings.add(rating); + } + achievement.setRatings(ratings); + return achievement; + } + } catch (Exception e) { + e.printStackTrace(); + } + return null; + } + + public RateAchievement getRating(int achievement, int user) { + try { + var stmt = db.prepareCall("{call HasProgress(?, ?, ?)}"); + stmt.setInt(1, user); + stmt.setInt(2, achievement); + stmt.registerOutParameter(3, Types.BOOLEAN); + + stmt.execute(); + if (stmt.getBoolean(3)) { + stmt = db.prepareCall("{call GetRating(?, ?)}"); + stmt.setInt(1, user); + stmt.setInt(2, achievement); + + var result = stmt.executeQuery(); + if (result.next()) { + var rating = new RateAchievement(); + rating.setDifficulty(result.getFloat("Difficulty")); if (result.wasNull()) { rating.setDifficulty(null); } + rating.setQuality(result.getFloat("Quality")); if (result.wasNull()) { rating.setQuality(null); } + rating.setReview(result.getString("Description")); + return rating; + } else { + return new RateAchievement(); + } + } else { + return null; + } + } catch (Exception e) { + e.printStackTrace(); + } + return null; + } + + public RateAchievement setRating(int achievementId, int userId, RateAchievement rateAchievement) { + if (authService.session().validate(userId, rateAchievement.getSessionKey())) { + try { + var stmt = db.prepareCall("{call SetRating(?, ?, ?, ?, ?)}"); + stmt.setInt(1, userId); + stmt.setInt(2, achievementId); + stmt.setFloat(3, rateAchievement.getDifficulty()); + stmt.setFloat(4, rateAchievement.getQuality()); + stmt.setString(5, rateAchievement.getReview()); + + stmt.execute(); + return rateAchievement; + } catch (Exception e) { + e.printStackTrace(); + } + } + try { + var stmt = db.prepareCall("{call GetRating(?, ?)}"); + stmt.setInt(1, userId); + stmt.setInt(2, achievementId); + + var result = stmt.executeQuery(); + if (result.next()) { + var rating = new RateAchievement(); + rating.setDifficulty(result.getFloat("Difficulty")); + rating.setQuality(result.getFloat("Quality")); + rating.setReview(result.getString("Review")); + return rating; + } + } catch (Exception e) { + e.printStackTrace(); + } + return null; + } +} diff --git a/backend/src/main/java/achievements/services/AuthenticationService.java b/backend/src/main/java/achievements/services/AuthenticationService.java new file mode 100644 index 0000000..e2387db --- /dev/null +++ b/backend/src/main/java/achievements/services/AuthenticationService.java @@ -0,0 +1,141 @@ +package achievements.services; + +import achievements.data.Session; +import achievements.data.User; +import achievements.misc.DbConnection; +import achievements.misc.Password; +import achievements.misc.SessionManager; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import javax.annotation.PostConstruct; +import java.sql.*; + +@Service +public class AuthenticationService { + + public static class LoginResponse { + public int status; + public Session session; + + public LoginResponse() { + this.status = 0; + } + + public LoginResponse(int status) { + this.status = status; + this.session = null; + } + + public LoginResponse(int status, Session session) { + this.status = status; + this.session = session; + } + } + + @Autowired + private DbConnection dbs; + private Connection db; + + @Autowired + private SessionManager session; + + @PostConstruct + private void init() { + db = dbs.getConnection(); + } + + public LoginResponse createUser(User user) { + if (!user.getEmail().matches(".+@\\w+\\.\\w+")) { + return new LoginResponse(2); + } + + try { + var statement = db.prepareCall("{? = call CreateUser(?, ?, ?, ?, ?, ?)}"); + statement.registerOutParameter(1, Types.INTEGER); + statement.setString(2, user.getEmail()); + statement.setString(3, user.getUsername()); + + var password = Password.generate(user.getPassword()); + statement.setString(4, password.salt); + statement.setString(5, password.hash); + + statement.registerOutParameter(6, Types.INTEGER); + statement.registerOutParameter(7, Types.INTEGER); + + statement.execute(); + var response = new LoginResponse( + statement.getInt(1), + session.generate( + statement.getInt(6), + statement.getInt(7), + false + ) + ); + statement.close(); + + return response; + } catch (SQLException e) { + e.printStackTrace(); + } + return new LoginResponse(-1); + } + + public LoginResponse login(User user) { + var response = new LoginResponse(-1); + try { + var statement = db.prepareCall("{? = call GetUserLogin(?)}"); + statement.registerOutParameter(1, Types.INTEGER); + statement.setString(2, user.email); + + statement.execute(); + if (statement.getInt(1) == 0) { + var result = statement.executeQuery(); + result.next(); + var salt = result.getString("Salt"); + var hash = result.getString("Password"); + if (Password.validate(salt, user.getPassword(), hash)) { + response = new LoginResponse( + 0, + session.generate( + result.getInt("ID"), + result.getInt("Hue"), + result.getBoolean("Admin") + ) + ); + } else { + response = new LoginResponse(2); + } + } else { + response = new LoginResponse(1); + } + statement.close(); + } catch (SQLException e) { + e.printStackTrace(); + } + return response; + } + + public boolean refresh(Session key) { return session.refresh(key.getKey()); } + + public boolean openAuth() { + try { + var stmt = db.prepareCall("{call HasUser(?)}"); + stmt.registerOutParameter(1, Types.BOOLEAN); + + stmt.execute(); + return !stmt.getBoolean(1); + } catch (SQLException e) { + e.printStackTrace(); + } + return false; + } + + public void logout(Session key) { + session.remove(key.getKey()); + } + + public SessionManager session() { + return session; + } +} diff --git a/backend/src/main/java/achievements/services/DbService.java b/backend/src/main/java/achievements/services/DbService.java deleted file mode 100644 index 20eca43..0000000 --- a/backend/src/main/java/achievements/services/DbService.java +++ /dev/null @@ -1,87 +0,0 @@ -package achievements.services; - -import achievements.data.Achievements; -import achievements.data.Games; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; - -import javax.annotation.PostConstruct; -import java.sql.*; - -@Service -public class DbService { - - @Autowired - private DbConnectionService dbs; - private Connection db; - - @PostConstruct - private void init() { db = dbs.getConnection(); } - - public Achievements getAchievements(String gameName) { - try { - // Create Query - CallableStatement stmt = db.prepareCall("{? = call GetAchievements(?)}"); - stmt.registerOutParameter(1, Types.INTEGER); - stmt.setString(2, gameName); - - // Read Result(s) - ResultSet results = stmt.executeQuery(); - var achievements = new Achievements(); - while (results.next()) { - // Add Result(s) to data class - int achievementGameID = results.getInt("GameID"); - String achievementGameName = results.getString("GameName"); - String achievementName = results.getString("Name"); - String achievementDescription = results.getString("Description"); - int achievementStages = results.getInt("Stages"); - // Checks if getting from specific game or all achievements - if (!gameName.equals("%")) { - achievements.setGameID(achievementGameID); - achievements.setGameName(achievementGameName); - } - achievements.addAchievement(new Achievements.Achievement(achievementName, achievementDescription, achievementStages)); - } - stmt.close(); - return achievements; - } catch (SQLException e) { - e.printStackTrace(); - return null; - } - } - - public Games getGames(String name) { - try { - // Create Query - CallableStatement stmt = db.prepareCall("{? = call GetGame(?)}"); - stmt.registerOutParameter(1, Types.INTEGER); - stmt.setString(2, name); - - // Read Result(s) - ResultSet results = stmt.executeQuery(); - var games = new Games(); - while (results.next()) { - // Add Result(s) to data class - int gameID = results.getInt("ID"); - String gameName = results.getString("Name"); - String gamePlatform = results.getString("PlatformName"); - if (!games.getGames().isEmpty()) { - var lastGame = games.getGames().get(games.getGames().size()-1); - if (lastGame.getId() == gameID) { - lastGame.addToPlatforms(gamePlatform); - } else { - games.addGame(new Games.Game(gameID,gameName,gamePlatform)); - } - } else { - games.addGame(new Games.Game(gameID,gameName,gamePlatform)); - } - - } - stmt.close(); - return games; - } catch (SQLException e) { - e.printStackTrace(); - return null; - } - } -} diff --git a/backend/src/main/java/achievements/services/GameService.java b/backend/src/main/java/achievements/services/GameService.java new file mode 100644 index 0000000..cd03c0d --- /dev/null +++ b/backend/src/main/java/achievements/services/GameService.java @@ -0,0 +1,34 @@ +package achievements.services; + +import achievements.misc.DbConnection; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import javax.annotation.PostConstruct; +import java.sql.Connection; + +@Service +public class GameService { + + @Autowired + private DbConnection dbs; + private Connection db; + + @Autowired + private ImageService imageService; + + @PostConstruct + private void init() { + db = dbs.getConnection(); + } + + public String[] getIcon(int gameId) { + try { + var stmt = db.prepareCall("{call GetGameIcon(?)}"); + return imageService.getImageType(stmt, gameId); + } catch (Exception e) { + e.printStackTrace(); + } + return null; + } +} diff --git a/backend/src/main/java/achievements/services/ImageService.java b/backend/src/main/java/achievements/services/ImageService.java new file mode 100644 index 0000000..2acf4a8 --- /dev/null +++ b/backend/src/main/java/achievements/services/ImageService.java @@ -0,0 +1,80 @@ +package achievements.services; + +import org.apache.tomcat.util.http.fileupload.IOUtils; +import org.springframework.stereotype.Service; + +import javax.servlet.http.HttpServletResponse; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.sql.CallableStatement; +import java.sql.SQLException; +import java.util.HashMap; + +@Service +public class ImageService { + public static final HashMap MIME_TO_EXT = new HashMap<>(); + public static final HashMap EXT_TO_MIME = new HashMap<>(); + static { + MIME_TO_EXT.put("apng", "apng"); + MIME_TO_EXT.put("avif", "avif"); + MIME_TO_EXT.put("gif", "gif" ); + MIME_TO_EXT.put("jpeg", "jpg" ); + MIME_TO_EXT.put("png", "png" ); + MIME_TO_EXT.put("svg+xml", "svg" ); + MIME_TO_EXT.put("webp", "webp"); + + EXT_TO_MIME.put("apng", "apng" ); + EXT_TO_MIME.put("avif", "avif" ); + EXT_TO_MIME.put("gif", "gif" ); + EXT_TO_MIME.put("jpg", "jpeg" ); + EXT_TO_MIME.put("png", "png" ); + EXT_TO_MIME.put("svg", "svg+xml"); + EXT_TO_MIME.put("webp", "webp" ); + } + + public String[] getImageType(CallableStatement stmt, int id) { + try { + stmt.setInt(1, id); + + var result = stmt.executeQuery(); + if (result.next()) { + var type = result.getString(1); + if (type != null) { + return new String[] { id + "." + type, EXT_TO_MIME.get(type) }; + } + } + } catch (SQLException e) { + e.printStackTrace(); + } catch (NumberFormatException e) { + e.printStackTrace(); + } + return null; + } + + public void send(String[] image, String type, HttpServletResponse response) { + var file = (File) null; + var mimeType = (String) null; + if (image == null) { + file = new File("storage/images/default/" + type + ".png"); + mimeType = "png"; + } else { + file = new File("storage/images/" + type + "/" + image[0]); + mimeType = image[1]; + } + try { + var stream = new FileInputStream(file); + IOUtils.copy(stream, response.getOutputStream()); + + response.setStatus(200); + response.setContentType("image/" + mimeType); + response.flushBuffer(); + stream.close(); + + return; + } catch (IOException e) { + e.printStackTrace(); + } + response.setStatus(500); + } +} diff --git a/backend/src/main/java/achievements/services/ImportService.java b/backend/src/main/java/achievements/services/ImportService.java new file mode 100644 index 0000000..f2bf8b7 --- /dev/null +++ b/backend/src/main/java/achievements/services/ImportService.java @@ -0,0 +1,81 @@ +package achievements.services; + +import achievements.data.importing.ImportPlatform; +import achievements.data.importing.ImportUser; +import achievements.data.importing.ImportUserPlatform; +import achievements.misc.DbConnection; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import javax.annotation.PostConstruct; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Types; + +@Service +public class ImportService { + + @Autowired + private DbConnection dbs; + private Connection db; + + @Autowired + private AuthenticationService authService; + @Autowired + private UserService userService; + + @PostConstruct + public void init() { + db = dbs.getConnection(); + } + + public int importPlatform(ImportPlatform platform) { + if (authService.session().validateAdmin(platform.getUserId(), platform.getSessionKey())) { + try { + var stmt = db.prepareCall("{call AddPlatform(?, ?)}"); + stmt.setString(1, platform.getName()); + stmt.registerOutParameter(2, Types.INTEGER); + + stmt.execute(); + return 0; + } catch (SQLException e) { + e.printStackTrace(); + } + } + return -1; + } + + public int importUser(ImportUser user) { + if (authService.session().validateAdmin(user.getUserId(), user.getSessionKey())) { + try { + var response = authService.createUser(user); + if (user.isAdmin()) { + var stmt = db.prepareCall("{call OpUser(?)}"); + stmt.setInt(1, response.session.getId()); + stmt.execute(); + } + + return 0; + } catch (SQLException e) { + e.printStackTrace(); + } + } + return -1; + } + + public int importUserPlatform(ImportUserPlatform userPlatform) { + if (authService.session().validateAdmin(userPlatform.getUserId(), userPlatform.getSessionKey())) { + try { + var stmt = db.prepareCall("{call GetIdFromEmail(?, ?)}"); + stmt.setString(1, userPlatform.getUserEmail()); + stmt.registerOutParameter(2, Types.INTEGER); + + stmt.execute(); + return userService.addPlatform(stmt.getInt(2), userPlatform, false); + } catch (Exception e) { + e.printStackTrace(); + } + } + return -1; + } +} diff --git a/backend/src/main/java/achievements/services/PlatformService.java b/backend/src/main/java/achievements/services/PlatformService.java new file mode 100644 index 0000000..ad1ad2e --- /dev/null +++ b/backend/src/main/java/achievements/services/PlatformService.java @@ -0,0 +1,34 @@ +package achievements.services; + +import achievements.misc.DbConnection; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import javax.annotation.PostConstruct; +import java.sql.Connection; + +@Service +public class PlatformService { + + @Autowired + private DbConnection dbs; + private Connection db; + + @Autowired + private ImageService imageService; + + @PostConstruct + private void init() { + db = dbs.getConnection(); + } + + public String[] getIcon(int platformId) { + try { + var stmt = db.prepareCall("{call GetPlatformIcon(?)}"); + return imageService.getImageType(stmt, platformId); + } catch (Exception e) { + e.printStackTrace(); + } + return null; + } +} diff --git a/backend/src/main/java/achievements/services/SearchService.java b/backend/src/main/java/achievements/services/SearchService.java new file mode 100644 index 0000000..aa3ec03 --- /dev/null +++ b/backend/src/main/java/achievements/services/SearchService.java @@ -0,0 +1,132 @@ +package achievements.services; + +import achievements.data.request.SearchGames; +import achievements.data.request.SearchUsers; +import achievements.data.response.search.Achievement; +import achievements.data.request.SearchAchievements; +import achievements.data.response.search.Game; +import achievements.data.response.search.User; +import achievements.misc.DbConnection; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import javax.annotation.PostConstruct; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.*; + +@Service +public class SearchService { + + @Autowired + private DbConnection dbs; + private Connection db; + + @PostConstruct + private void init() { + db = dbs.getConnection(); + } + + public List searchAchievements(SearchAchievements query) { + try { + var stmt = db.prepareCall("{call SearchAchievements(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)}"); + stmt.setString(1, query.getSearchTerm()); + stmt.setBoolean(3, query.isCompleted()); + if (query.getUserId() != null) { stmt.setInt (2, query.getUserId()); } else { stmt.setString(2, null); } + if (query.getMinCompletion() != null) { stmt.setFloat(4, query.getMinCompletion()); } else { stmt.setString(4, null); } + if (query.getMaxCompletion() != null) { stmt.setFloat(5, query.getMaxCompletion()); } else { stmt.setString(5, null); } + if (query.getMinDifficulty() != null) { stmt.setFloat(6, query.getMinDifficulty()); } else { stmt.setString(6, null); } + if (query.getMaxDifficulty() != null) { stmt.setFloat(7, query.getMaxDifficulty()); } else { stmt.setString(7, null); } + if (query.getMinQuality() != null) { stmt.setFloat(8, query.getMinQuality()); } else { stmt.setString(8, null); } + if (query.getMaxQuality() != null) { stmt.setFloat(9, query.getMaxQuality()); } else { stmt.setString(9, null); } + stmt.setString(10, query.getOrdering()); + stmt.setString(11, query.getOrderDirection()); + var results = stmt.executeQuery(); + + var achievements = new ArrayList(); + while (results.next()) { + var achievement = new Achievement(); + achievement.setID (results.getInt ("ID" )); + achievement.setGame (results.getString("Game" )); + achievement.setName (results.getString("Name" )); + achievement.setCompletion(results.getInt ("Completion")); if (results.wasNull()) { achievement.setCompletion(null); } + achievement.setDifficulty(results.getFloat ("Difficulty")); if (results.wasNull()) { achievement.setDifficulty(null); } + achievement.setQuality (results.getFloat ("Quality" )); if (results.wasNull()) { achievement.setQuality (null); } + achievements.add(achievement); + } + + return achievements; + } catch (SQLException e) { + e.printStackTrace(); + } + return null; + } + + public List searchUsers(SearchUsers query) { + try { + var stmt = db.prepareCall("{call SearchUsers(?, ?, ?, ?, ?, ?, ?, ?, ?)}"); + stmt.setString(1, query.getSearchTerm()); + if (query.getMinOwned() != null) { stmt.setFloat(2, query.getMinOwned()); } else { stmt.setString(2, null); } + if (query.getMaxOwned() != null) { stmt.setFloat(3, query.getMaxOwned()); } else { stmt.setString(3, null); } + if (query.getMinCompleted() != null) { stmt.setFloat(4, query.getMinCompleted()); } else { stmt.setString(4, null); } + if (query.getMaxCompleted() != null) { stmt.setFloat(5, query.getMaxCompleted()); } else { stmt.setString(5, null); } + if (query.getMinAvgCompletion() != null) { stmt.setFloat(6, query.getMinAvgCompletion()); } else { stmt.setString(6, null); } + if (query.getMaxAvgCompletion() != null) { stmt.setFloat(7, query.getMaxAvgCompletion()); } else { stmt.setString(7, null); } + stmt.setString(8, query.getOrdering()); + stmt.setString(9, query.getOrderDirection()); + var results = stmt.executeQuery(); + + var users = new ArrayList(); + while (results.next()) { + var user = new User(); + user.setID (results.getInt ("ID" )); + user.setUsername (results.getString("Username" )); + user.setGame_count (results.getInt ("GameCount" )); + user.setAchievement_count(results.getInt ("AchievementCount")); + user.setAvg_completion (results.getInt ("AvgCompletion" )); if (results.wasNull()) { user.setAvg_completion(null); } + user.setPerfect_games (results.getInt ("PerfectGames" )); + users.add(user); + } + + return users; + } catch (SQLException e) { + e.printStackTrace(); + } + return null; + } + + public List searchGames(SearchGames query) { + try { + var stmt = db.prepareCall("{call SearchGames(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)}"); + stmt.setString(1, query.getSearchTerm()); + stmt.setBoolean(3, query.isOwned()); + if (query.getUserId() != null) { stmt.setInt (2, query.getUserId()); } else { stmt.setString(2, null); } + if (query.getMinAvgCompletion() != null) { stmt.setFloat(4, query.getMinAvgCompletion()); } else { stmt.setString(4, null); } + if (query.getMaxAvgCompletion() != null) { stmt.setFloat(5, query.getMaxAvgCompletion()); } else { stmt.setString(5, null); } + if (query.getMinNumOwners() != null) { stmt.setFloat(6, query.getMinNumOwners()); } else { stmt.setString(6, null); } + if (query.getMaxNumOwners() != null) { stmt.setFloat(7, query.getMaxNumOwners()); } else { stmt.setString(7, null); } + if (query.getMinNumPerfects() != null) { stmt.setFloat(8, query.getMinNumPerfects()); } else { stmt.setString(8, null); } + if (query.getMaxNumPerfects() != null) { stmt.setFloat(9, query.getMaxNumPerfects()); } else { stmt.setString(9, null); } + stmt.setString(10, query.getOrdering()); + stmt.setString(11, query.getOrderDirection()); + var results = stmt.executeQuery(); + + var games = new ArrayList(); + while (results.next()) { + var game = new Game(); + game.setID (results.getInt ("ID" )); + game.setName (results.getString("Name" )); + game.setAchievement_count(results.getInt ("AchievementCount")); + game.setAvg_completion (results.getInt ("AvgCompletion" )); if (results.wasNull()) { game.setAvg_completion(null); } + game.setNum_owners (results.getInt ("NumOwners" )); + game.setNum_perfects (results.getInt ("NumPerfects" )); + games.add(game); + } + + return games; + } catch (SQLException e) { + e.printStackTrace(); + } + return null; + } +} diff --git a/backend/src/main/java/achievements/services/UserService.java b/backend/src/main/java/achievements/services/UserService.java new file mode 100644 index 0000000..56aa863 --- /dev/null +++ b/backend/src/main/java/achievements/services/UserService.java @@ -0,0 +1,256 @@ +package achievements.services; + +import achievements.data.Profile; +import achievements.data.request.AddPlatform; +import achievements.data.request.RemovePlatform; +import achievements.data.request.SetUsername; +import achievements.data.response.search.Achievement; +import achievements.misc.DbConnection; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.util.FileCopyUtils; +import org.springframework.web.multipart.MultipartFile; + +import javax.annotation.PostConstruct; +import java.io.File; +import java.io.FileOutputStream; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Types; +import java.util.ArrayList; +import java.util.List; + +import static achievements.services.ImageService.MIME_TO_EXT; + +@Service +public class UserService { + + @Autowired + private DbConnection dbs; + private Connection db; + + @Autowired + private AuthenticationService auth; + + @Autowired + private APIService apiService; + + @Autowired + private ImageService imageService; + + @PostConstruct + private void init() { + db = dbs.getConnection(); + } + + public Profile getProfile(int userId) { + try { + var profile = (Profile) null; + { + var stmt = db.prepareCall("{? = call GetUserNameAndStats(?, ?, ?, ?, ?)}"); + stmt.registerOutParameter(1, Types.INTEGER); + stmt.setInt(2, userId); + stmt.registerOutParameter(3, Types.VARCHAR); + stmt.registerOutParameter(4, Types.INTEGER); + stmt.registerOutParameter(5, Types.INTEGER); + stmt.registerOutParameter(6, Types.INTEGER); + + stmt.execute(); + if (stmt.getInt(1) == 0) { + profile = new Profile(); + profile.setUsername(stmt.getString(3)); + profile.setCompleted(stmt.getInt(4)); + var average = stmt.getString(5); + profile.setPerfect(stmt.getInt(6)); + + if (average != null) { + profile.setAverage(Integer.parseInt(average)); + } + } else { + return null; + } + } + + { + var stmt = db.prepareCall("{call GetUserPlatforms(?)}"); + stmt.setInt(1, userId); + + var results = stmt.executeQuery(); + var platforms = new ArrayList(); + while (results.next()) { + var platform = new Profile.Platform(); + platform.setId (results.getInt ("ID" )); + platform.setName (results.getString ("PlatformName")); + platform.setConnected(results.getBoolean("Connected" )); + platforms.add(platform); + } + profile.setPlatforms(platforms); + } + + { + var stmt = db.prepareCall("{call GetRatingsByUser(?)}"); + stmt.setInt(1, userId); + + var results = stmt.executeQuery(); + var ratings = new ArrayList(); + while (results.next()) { + var rating = new Profile.Rating(); + rating.setAchievementId(results.getInt("AchievementID")); + rating.setName(results.getString("Name")); + rating.setDifficulty(results.getFloat("Difficulty")); if (results.wasNull()) { rating.setDifficulty(null); } + rating.setQuality(results.getFloat("Quality")); if (results.wasNull()) { rating.setQuality(null); } + rating.setReview(results.getString("Description")); + ratings.add(rating); + } + profile.setRatings(ratings); + } + + return profile; + } catch (SQLException e) { + e.printStackTrace(); + } catch (NumberFormatException e) { + e.printStackTrace(); + } + return null; + } + + public int setUsername(int userId, SetUsername username) { + try { + if (auth.session().validate(userId, username.getSessionKey()) && username.getUsername().length() > 0 && username.getUsername().length() <= 32) { + var stmt = db.prepareCall("{call SetUsername(?, ?)}"); + stmt.setInt(1, userId); + stmt.setString(2, username.getUsername()); + + stmt.execute(); + return 0; + } + } catch (Exception e) { + e.printStackTrace(); + } + return -1; + } + + public String[] getProfileImage(int userId) { + try { + var stmt = db.prepareCall("{call GetUserImage(?)}"); + return imageService.getImageType(stmt, userId); + } catch (Exception e) { + e.printStackTrace(); + } + return null; + } + + public String setProfileImage(int userId, String sessionKey, MultipartFile file) { + try { + var type = file.getContentType(); + if (type.matches("image/.*")) { + type = type.substring(6); + type = MIME_TO_EXT.get(type); + if (!auth.session().validate(userId, sessionKey)) { + return "forbidden"; + } else if (type == null) { + return "unsupported_type"; + } else { + var stmt = db.prepareCall("{call SetUserImage(?, ?, ?)}"); + stmt.setInt(1, userId); + stmt.setString(2, type); + stmt.registerOutParameter(3, Types.VARCHAR); + + stmt.execute(); + var oldType = stmt.getString(3); + + // Delete old file + if (oldType != null && type != oldType) { + var oldFile = new File("storage/images/user/" + userId + "." + oldType); + if (oldFile.exists()) { + oldFile.delete(); + } + } + + // Save new file (will overwrite old if file type didn't change) + { + var image = new FileOutputStream("storage/images/user/" + userId + "." + type); + FileCopyUtils.copy(file.getInputStream(), image); + image.close(); + } + + return "success"; + } + } else { + return "not_an_image"; + } + } catch (Exception e) { + e.printStackTrace(); + } + return "unknown"; + } + + public int addPlatform(int userId, AddPlatform request, boolean validate) { + if (!validate || auth.session().validate(userId, request.getSessionKey())) { + try { + db.setAutoCommit(false); + try { + var stmt = db.prepareCall("{call AddUserToPlatform(?, ?, ?)}"); + stmt.setInt(1, userId); + stmt.setInt(2, request.getPlatformId()); + stmt.setString(3, request.getPlatformUserId()); + + stmt.execute(); + + int successful = apiService.importUserPlatform(userId, request.getPlatformId(), request.getPlatformUserId()); + + if (successful == 0) { + db.commit(); + db.setAutoCommit(true); + return 0; + } + } catch (Exception e) { + e.printStackTrace(); + } + db.rollback(); + db.setAutoCommit(true); + } catch(SQLException e){ + e.printStackTrace(); + } + } + return -1; + } + + public int removePlatform(int userId, RemovePlatform request) { + try { + if (auth.session().validate(userId, request.getSessionKey())) { + var stmt = db.prepareCall("{call RemoveUserFromPlatform(?, ?)}"); + stmt.setInt(1, userId); + stmt.setInt(2, request.getPlatformId()); + + stmt.execute(); + + return 0; + } + } catch (Exception e) { + e.printStackTrace(); + } + return -1; + } + + public List getNoteworthy(int userId) { + try { + var stmt = db.prepareCall("{call GetNoteworthyAchievementsForUser(?)}"); + stmt.setInt(1, userId); + + var results = stmt.executeQuery(); + var achievements = new ArrayList(); + while (results.next()) { + var achievement = new Achievement(); + achievement.setID(results.getInt("ID")); + achievement.setName(results.getString("Name")); + achievement.setCompletion(results.getInt("Completion")); + achievements.add(achievement); + } + return achievements; + } catch (Exception e) { + e.printStackTrace(); + } + return null; + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 589c0f1..16fd9b6 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -1,2 +1,8 @@ server.port = 4730 spring.application.name = Achievements Project +spring.jackson.default-property-inclusion=always + +server.session.cookie.secure = false + +spring.servlet.multipart.max-file-size = 10MB +spring.servlet.multipart.max-request-size = 10MB \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore index 8967c66..a9e3966 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,3 +1,6 @@ # Node files node_modules/ package-lock.json + +# Import Data +import.json diff --git a/frontend/config/base.json b/frontend/config/base.json index 0967ef4..1260d47 100644 --- a/frontend/config/base.json +++ b/frontend/config/base.json @@ -1 +1,5 @@ -{} +{ + "hosts": { + "backend": "https://localhost:4730" + } +} diff --git a/frontend/config/debug.json b/frontend/config/debug.json index 912143b..b5c0161 100644 --- a/frontend/config/debug.json +++ b/frontend/config/debug.json @@ -2,6 +2,9 @@ "extends": [ "config/base.json" ], + "hosts": { + "frontend": "http://localhost:8080" + }, "build": "debug", "port": 8080 } diff --git a/frontend/config/release.json b/frontend/config/release.json index d8c144f..1f6800c 100644 --- a/frontend/config/release.json +++ b/frontend/config/release.json @@ -2,6 +2,9 @@ "extends": [ "config/base.json" ], + "hosts": { + "frontend": "http://localhost" + }, "build": "release", "port": 80 } diff --git a/frontend/package.json b/frontend/package.json index 155262f..a026d25 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,17 +3,19 @@ "version": "1.0.0", "description": "Cross platform achievement tracker", "repository": "github:Gnarwhal/AchievementProject", - "main": "static_server.js", + "main": "server.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "debug": "node static_server.js config/debug.json", - "release": "node static_server.js config/release.json" + "debug": "node server.js config/debug.json", + "release": "node server.js config/release.json" }, "author": "", "license": "ISC", "dependencies": { "express": "^4.17.1", "morgan": "^1.10.0", - "xml2js": "^0.4.23" + "passport": "^0.4.1", + "passport-steam": "^1.0.15", + "promptly": "^3.2.0" } } diff --git a/frontend/server.js b/frontend/server.js new file mode 100644 index 0000000..ac88714 --- /dev/null +++ b/frontend/server.js @@ -0,0 +1,54 @@ +const fs = require('fs' ); +const path = require('path' ); + +const https = require('https' ); +const express = require('express' ); +const morgan = require('morgan' ); +const passport = require('passport'); +const SteamStrategy = require('passport-steam').Strategy; + +const promptly = require('promptly'); + +const config = require('./config.js').load(process.argv[2]); + +console.log(`Running server at '${config.hosts.frontend}'`); + +passport.use(new SteamStrategy({ + returnURL: `${config.hosts.frontend}/user/steam`, + realm: `${config.hosts.frontend}`, + profile: false, +})); + +const app = express(); +app.use("/", morgan("dev")); +app.use("/static", express.static("webpage/static")); +app.get("/login", (req, res) => res.sendFile(path.join(__dirname + "/webpage/login.html"))); +app.get("/", (req, res) => res.sendFile(path.join(__dirname + "/webpage/search_achievements.html"))); +app.get("/achievements", (req, res) => res.sendFile(path.join(__dirname + "/webpage/search_achievements.html"))); +app.get("/users", (req, res) => res.sendFile(path.join(__dirname + "/webpage/search_users.html"))); +app.get("/games", (req, res) => res.sendFile(path.join(__dirname + "/webpage/search_games.html"))); +app.get("/import", (req, res) => res.sendFile(path.join(__dirname + "/webpage/import.html"))); +app.get("/achievement/:id", (req, res) => res.sendFile(path.join(__dirname + "/webpage/achievement.html"))); +app.get("/user/:id", (req, res) => res.sendFile(path.join(__dirname + "/webpage/user.html"))); +app.get("/auth/steam", passport.authenticate('steam'), (req, res) => {}); + +// --- API Forward --- // + +app.use("/api/*", (req, res) => { + res.redirect(307, `${config.hosts.backend}/${req.params[0]}`) +}); + +// ------------------- // + +const server = app.listen(config.port); + +const prompt = input => { + if (/q(?:uit)?|exit/i.test(input)) { + server.close(); + } else { + promptly.prompt('') + .then(prompt); + } +}; + +prompt(); \ No newline at end of file diff --git a/frontend/static_server.js b/frontend/static_server.js deleted file mode 100644 index 75509c5..0000000 --- a/frontend/static_server.js +++ /dev/null @@ -1,16 +0,0 @@ -const express = require('express'); -const morgan = require('morgan' ); -const fs = require('fs' ); -const https = require('https' ); - -const config = require('./config.js').load(process.argv[2]); - -if (config.build === 'debug') { - process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = 0; -} - -const app = express(); -app.use("/", morgan("dev")); -app.use("/", express.static("webpage")); - -app.listen(config.port); \ No newline at end of file diff --git a/frontend/webpage/achievement.html b/frontend/webpage/achievement.html new file mode 100644 index 0000000..b33132a --- /dev/null +++ b/frontend/webpage/achievement.html @@ -0,0 +1,163 @@ + + + + + Achievements Project + + + + + + + +
+
+
+ +
+
+

Contemplating...

+ Loading Symbol +
+ +
+
+ + + + + \ No newline at end of file diff --git a/frontend/webpage/import.html b/frontend/webpage/import.html new file mode 100644 index 0000000..74e9cfb --- /dev/null +++ b/frontend/webpage/import.html @@ -0,0 +1,39 @@ + + + + + Achievements Project | Import + + + + + + +
+
+
+ +
+
+
+
+
+ Import Icon + Import Icon Hover +
+
+
+
+
+
+
+
+ + + + + + \ No newline at end of file diff --git a/frontend/webpage/index.html b/frontend/webpage/index.html deleted file mode 100644 index f5ccc0d..0000000 --- a/frontend/webpage/index.html +++ /dev/null @@ -1,35 +0,0 @@ - - - - - Achievements Project - - - - - -
- -
- - - - \ No newline at end of file diff --git a/frontend/webpage/login.html b/frontend/webpage/login.html new file mode 100644 index 0000000..1614fa9 --- /dev/null +++ b/frontend/webpage/login.html @@ -0,0 +1,51 @@ + + + + + Achievements Project | Login + + + + + + +
+
+
+ +
+
+
+
+
+

Login

+
+
+
+

Egg

+ + + + +
+ + +
+ +

WARNING!

+

The security of this project is questionable at best. Please refrain from using any truly sensitive data.

+
+
+
+
+
+
+ + + + + + \ No newline at end of file diff --git a/frontend/webpage/res/dummy_achievement.png b/frontend/webpage/res/dummy_achievement.png deleted file mode 100644 index 3441a23..0000000 Binary files a/frontend/webpage/res/dummy_achievement.png and /dev/null differ diff --git a/frontend/webpage/res/dummy_game.png b/frontend/webpage/res/dummy_game.png deleted file mode 100644 index e98587b..0000000 Binary files a/frontend/webpage/res/dummy_game.png and /dev/null differ diff --git a/frontend/webpage/res/psn.png b/frontend/webpage/res/psn.png deleted file mode 100644 index cc2a8b2..0000000 Binary files a/frontend/webpage/res/psn.png and /dev/null differ diff --git a/frontend/webpage/res/steam.png b/frontend/webpage/res/steam.png deleted file mode 100644 index 3098061..0000000 Binary files a/frontend/webpage/res/steam.png and /dev/null differ diff --git a/frontend/webpage/res/temp_pfp.png b/frontend/webpage/res/temp_pfp.png deleted file mode 100644 index a33bede..0000000 Binary files a/frontend/webpage/res/temp_pfp.png and /dev/null differ diff --git a/frontend/webpage/res/xbox.png b/frontend/webpage/res/xbox.png deleted file mode 100644 index 6f2730c..0000000 Binary files a/frontend/webpage/res/xbox.png and /dev/null differ diff --git a/frontend/webpage/scripts/index.js b/frontend/webpage/scripts/index.js deleted file mode 100644 index a9a1460..0000000 --- a/frontend/webpage/scripts/index.js +++ /dev/null @@ -1,95 +0,0 @@ -const expandTemplates = async () => { - template.apply("navbar").values([ - { section: "left" }, - { section: "right" } - ]); - template.apply("navbar-section-left").values([ - { item: "games", title: "Games" }, - { item: "achievements", title: "Achievements" } - ]); - template.apply("navbar-section-right").values([ - { item: "profile", title: "Profile" } - ]); - template.apply("content-body").values([ - { page: "games", title: "Games" }, - { page: "achievements", title: "Achievements" }, - { page: "profile", title: "Profile" } - ]); - template.apply("extern-games-page" ).values("games_page" ); - template.apply("extern-achievements-page").values("achievements_page"); - template.apply("extern-profile-page" ).values("profile_page" ); - template.apply("achievements-page-list" ).fetch("achievements", "https://localhost:4730/achievements"); - - await template.expand(); -}; - -let pages = null; -const loadPages = () => { - pages = document.querySelectorAll(".page"); -} - -const connectNavbar = () => { - const navItems = document.querySelectorAll(".navbar-item"); - - for (const item of navItems) { - item.addEventListener("click", (clickEvent) => { - const navItemPageId = item.dataset.pageName + "-page" - for (const page of pages) { - if (page.id === navItemPageId) { - page.style.display = "block"; - } else { - page.style.display = "none"; - } - } - }); - } -}; - -const connectProfile = () => { - const games = document.querySelector("#profile-games"); - const achievements = document.querySelector("#profile-achievements"); - - games.children[0].addEventListener("click", (clickEvent) => { - for (const page of pages) { - if (page.id === "games-page") { - page.style.display = "block"; - } else { - page.style.display = "none"; - } - } - }); - - achievements.children[0].addEventListener("click", (clickEvent) => { - for (page of pages) { - if (page.id === "achievements-page") { - page.style.display = "block"; - } else { - page.style.display = "none"; - } - } - }); -} - -const loadFilters = () => { - const filters = document.querySelectorAll(".list-page-filter"); - for (let filter of filters) { - filter.addEventListener("click", (clickEvent) => { - if (filter.classList.contains("selected")) { - filter.classList.remove("selected"); - } else { - filter.classList.add("selected"); - } - }); - } -} - -window.addEventListener("load", async (loadEvent) => { - await expandTemplates(); - - loadPages(); - - connectNavbar(); - connectProfile(); - - loadFilters(); -}); diff --git a/frontend/webpage/scripts/template.js b/frontend/webpage/scripts/template.js deleted file mode 100644 index 7e302ea..0000000 --- a/frontend/webpage/scripts/template.js +++ /dev/null @@ -1,198 +0,0 @@ -var template = template || {}; - -template.type = {}; -template.type._entryMap = new Map(); -template.type.register = (type, callback) => { - if (typeof type !== 'string') { - console.error(`'type' must be a string, recieved: `, type); - } else { - const TYPE_REGEX = /^(\w+)\s*(<\s*\?(?:\s*,\s*\?)*\s*>)?\s*$/; - const result = type.match(TYPE_REGEX); - if (result === null) { - console.error(`'${type}' is not a valid type id`); - } else { - if (result[2] === undefined) { - result[2] = 0; - } else { - result[2] = result[2].split(/\s*,\s*/).length; - } - const completeType = result[1] + ':' + result[2]; - if (template.type._entryMap.get(completeType) === undefined) { - template.type._entryMap.set(completeType, async function() { - await callback.apply(null, Array.from(arguments)); - }); - } else { - console.error(`${type} is already a registered template!`); - } - } - } -}; - -// Courtesy of https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping -function escapeRegExp(string) { - return string.replace(/[.*+?^${}()|\[\]\\]/g, '\\$&'); // $& means the whole matched string -} - -/* Intrinsic Templates */ - -// Basic - Simple search and replace -template.type.register('Basic', (element, map) => { - let html = element.innerHTML; - function applyObject(object, path) { - for (const key in object) { - const regexKey = escapeRegExp(path + key); - html = html.replace(new RegExp(`(?:(? { - return fetch(`templates/${name}.html.template`, { - method: 'GET', - mode: 'no-cors', - headers: { - 'Content-Type': 'text/plain' - } - }).then(response => response.text().then((data) => { - element.outerHTML = data; - })).catch(error => { - console.error(`failed to retrieve template '${name}': `, error); - }); -}); - -// List - Iterate over list and emit copy of child for each iteration -template.type.register('List', async (element, subtype, arrayMap) => { - let cumulative = ''; - const temp = document.createElement('template'); - for (const obj of arrayMap) { - temp.innerHTML = ``; - const child = temp.content.children[0]; - child.innerHTML = element.innerHTML; - const callback = template.type._entryMap.get(subtype.type); - if (callback === undefined) { - cumulative = ''; - console.error(`'${subtype.type}' is not a registered template type`); - } else { - await callback.apply(null, [ child, obj ]); - } - cumulative = cumulative + temp.innerHTML.trim(); - } - element.outerHTML = cumulative; -}); - -template._entryMap = new Map(); -template.apply = function(pattern, promise) { - if (typeof pattern !== 'string') { - console.error('pattern must be a string, received: ', pattern); - } else { - return new template.apply.applicators(pattern); - } -}; -template.apply.applicators = class { - constructor(pattern) { - this._pattern = pattern; - } - - _apply(asyncArgs) { - template._entryMap.set(RegExp('^' + this._pattern + '$'), asyncArgs); - } - - values(...args) { - this._apply(async () => Array.from(args)); - } - - promise(promise) { - let args = null; - promise = promise.then(data => args = [ data ]); - this._apply(async () => args || promise); - } - - fetch(dataProcessor, url, options) { - if (typeof dataProcessor === 'string') { - const path = dataProcessor; - dataProcessor = data => { - for (const id of path.split(/\./)) { - data = data[id]; - if (data === undefined) { - throw `invalid path '${path}'`; - } - } - - return data; - }; - }; - this.promise( - fetch(url, options || { method: 'GET', mode: 'cors' }) - .then(response => response.json()) - .then(data => dataProcessor(data)) - ); - } -}; - -(() => { - const parseType = (type) => { - let result = type.match(/^\s*(\w+)\s*(?:<(.*)>)?\s*$/); - let id = result[1]; - result = result[2] ? result[2].split(/\s*,\s*/).map(parseType) : []; - return { type: id + ':' + result.length, params: result }; - }; - - const findTemplates = (element) => - Array - .from(element.querySelectorAll('template')) - .filter(child => Boolean(child.dataset.template)) - .map(child => { - const data = child.dataset.template.split(/\s*:\s*/); - return { - id: data[0], - typeCapture: parseType(data[1] || 'Begin'), - element: child - }; - }); - - const expand = async (element) => { - let children = findTemplates(element); - let promises = []; - let parents = new Set(); - for (const child of children) { - for (const [pattern, argsCallback] of template._entryMap) { - await argsCallback().then(args => { - if (pattern.test(child.id)) { - const callback = template.type._entryMap.get(child.typeCapture.type); - if (typeof callback !== 'function') { - console.error(`'${child.typeCapture.type}' is not a registered template type`); - } else { - let params = Array.from(args) - for (const subtype of child.typeCapture.params) { - params.unshift(subtype); - } - params.unshift(child.element); - let parent = child.element.parentElement; - if (!parents.has(parent)) { - parents.add(parent); - } - promises.push(callback.apply(null, params)); - } - } - }).catch(error => { - console.error('failed to retrieve arguments: ', error); - }); - } - } - await Promise.all(promises); - promises = []; - for (const parent of parents) { - promises.push(expand(parent)); - } - await Promise.all(promises); - }; - - template.expand = async () => expand(document.children[0]); -})(); \ No newline at end of file diff --git a/frontend/webpage/search_achievements.html b/frontend/webpage/search_achievements.html new file mode 100644 index 0000000..cd93d68 --- /dev/null +++ b/frontend/webpage/search_achievements.html @@ -0,0 +1,144 @@ + + + + + Achievements Project + + + + + + + + +
+ +
+ + + + + + \ No newline at end of file diff --git a/frontend/webpage/search_games.html b/frontend/webpage/search_games.html new file mode 100644 index 0000000..2c5a634 --- /dev/null +++ b/frontend/webpage/search_games.html @@ -0,0 +1,144 @@ + + + + + Achievements Project + + + + + + + + +
+ +
+ + + + + + \ No newline at end of file diff --git a/frontend/webpage/search_users.html b/frontend/webpage/search_users.html new file mode 100644 index 0000000..59dd860 --- /dev/null +++ b/frontend/webpage/search_users.html @@ -0,0 +1,130 @@ + + + + + Achievements Project + + + + + + + + +
+ +
+ + + + + + \ No newline at end of file diff --git a/frontend/webpage/static/res/cancel-hover.svg b/frontend/webpage/static/res/cancel-hover.svg new file mode 100644 index 0000000..bae7ec4 --- /dev/null +++ b/frontend/webpage/static/res/cancel-hover.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/webpage/static/res/cancel.svg b/frontend/webpage/static/res/cancel.svg new file mode 100644 index 0000000..f344111 --- /dev/null +++ b/frontend/webpage/static/res/cancel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/webpage/static/res/completion.svg b/frontend/webpage/static/res/completion.svg new file mode 100644 index 0000000..218b4ba --- /dev/null +++ b/frontend/webpage/static/res/completion.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/webpage/static/res/dropdown-hover.svg b/frontend/webpage/static/res/dropdown-hover.svg new file mode 100644 index 0000000..2382e2b --- /dev/null +++ b/frontend/webpage/static/res/dropdown-hover.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/webpage/static/res/dropdown.svg b/frontend/webpage/static/res/dropdown.svg new file mode 100644 index 0000000..fed35a7 --- /dev/null +++ b/frontend/webpage/static/res/dropdown.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/webpage/static/res/edit-hover.svg b/frontend/webpage/static/res/edit-hover.svg new file mode 100644 index 0000000..63ab387 --- /dev/null +++ b/frontend/webpage/static/res/edit-hover.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/webpage/static/res/edit.svg b/frontend/webpage/static/res/edit.svg new file mode 100644 index 0000000..8c1299c --- /dev/null +++ b/frontend/webpage/static/res/edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/webpage/static/res/import-hover.svg b/frontend/webpage/static/res/import-hover.svg new file mode 100644 index 0000000..3333fcd --- /dev/null +++ b/frontend/webpage/static/res/import-hover.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/webpage/static/res/import.svg b/frontend/webpage/static/res/import.svg new file mode 100644 index 0000000..580503e --- /dev/null +++ b/frontend/webpage/static/res/import.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/webpage/static/res/loading.svg b/frontend/webpage/static/res/loading.svg new file mode 100644 index 0000000..8debb61 --- /dev/null +++ b/frontend/webpage/static/res/loading.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/webpage/static/res/save-hover.svg b/frontend/webpage/static/res/save-hover.svg new file mode 100644 index 0000000..0f23a7c --- /dev/null +++ b/frontend/webpage/static/res/save-hover.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/webpage/static/res/save.svg b/frontend/webpage/static/res/save.svg new file mode 100644 index 0000000..be0a911 --- /dev/null +++ b/frontend/webpage/static/res/save.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/webpage/static/res/upload-hover.svg b/frontend/webpage/static/res/upload-hover.svg new file mode 100644 index 0000000..6a91f83 --- /dev/null +++ b/frontend/webpage/static/res/upload-hover.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/webpage/static/res/upload-invalid.svg b/frontend/webpage/static/res/upload-invalid.svg new file mode 100644 index 0000000..2fe8002 --- /dev/null +++ b/frontend/webpage/static/res/upload-invalid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/webpage/static/res/upload.svg b/frontend/webpage/static/res/upload.svg new file mode 100644 index 0000000..cf41f16 --- /dev/null +++ b/frontend/webpage/static/res/upload.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/webpage/static/scripts/achievement.js b/frontend/webpage/static/scripts/achievement.js new file mode 100644 index 0000000..94367c3 --- /dev/null +++ b/frontend/webpage/static/scripts/achievement.js @@ -0,0 +1,167 @@ +let achievementId = window.location.pathname.split('/').pop(); +let isReturn = false; +let achievementData = null; +let myRating = {}; +const loadAchievement = () => { + if (myRating.invalid) { + document.querySelector("#achievement-rating").remove(); + } + + const description = document.querySelector("#achievement-description-text"); + if (description.textContent === '') { + description.remove(); + } + + // Canvasing + + const completionCanvas = document.querySelector("#achievement-completion-canvas"); + + const STROKE_WIDTH = 0.18; + const style = window.getComputedStyle(completionCanvas); + const context = completionCanvas.getContext('2d'); + + const drawCanvas = () => achievementData.then(data => { + const width = Number(style.getPropertyValue('width').slice(0, -2)); + const height = width; + + context.canvas.width = width; + context.canvas.height = height; + context.clearRect(0, 0, width, height); + context.strokeStyle = root.getProperty('--accent-value3'); + context.lineWidth = (width / 2) * STROKE_WIDTH; + context.beginPath(); + context.arc(width / 2, height / 2, (width / 2) * (1 - STROKE_WIDTH / 2), -0.5 * Math.PI, (-0.5 + (data.completion === null ? 0 : (data.completion / 100) * 2)) * Math.PI); + context.stroke(); + }); + + window.addEventListener('resize', drawCanvas); + drawCanvas(); + + if (!myRating.invalid) { + const saveReview = document.querySelector("#rating-save-stack"); + + const myDifficulty = document.querySelector("#achievement-difficulty-rating-text"); + const myQuality = document.querySelector("#achievement-quality-rating-text"); + const myReview = document.querySelector("#achievement-review-rating-text"); + + const reviewInput = () => { + saveReview.style.display = 'block'; + } + myDifficulty.addEventListener('input', reviewInput); + myQuality.addEventListener('input', reviewInput); + myReview.addEventListener('input', reviewInput); + + const saveInputOnEnter = (keyEvent) => { + if (keyEvent.key === 'Enter') { + saveReview.click(); + } + } + myDifficulty.addEventListener('keydown', saveInputOnEnter); + myQuality.addEventListener('keydown', saveInputOnEnter); + + saveReview.addEventListener('click', (clickEvent) => { + let successful = true; + const difficulty = Number(myDifficulty.value); + const quality = Number(myQuality.value ); + if ((Number.isNaN(difficulty) && myDifficulty.value !== '') || difficulty < 0 || difficulty > 10) { + myDifficulty.style.backgroundColor = 'var(--error)'; + successful = false; + } + if ((Number.isNaN(quality) && myQuality.value !== '') || quality < 0 || quality > 10) { + myQuality.style.backgroundColor = 'var(--error)'; + successful = false; + } + if (successful) { + myDifficulty.style.backgroundColor = 'var(--foreground)'; + myQuality.style.backgroundColor = 'var(--foreground)'; + saveReview.style.display = 'none'; + fetch(`/api/achievement/${achievementId}/rating/${session.id}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + sessionKey: session.key, + difficulty: difficulty, + quality: quality, + review: myReview.value + }) + }) + .then(response => { + if (response.status === 401) { + responese.json().then(data => { + myDifficulty.value = data.difficulty ? data.difficulty : ''; + myQuality.value = data.quality ? data.quality : ''; + myReview.value = data.review ? data.review : ''; + }); + } + }); + } + }); + } + + { + const ratings = document.querySelectorAll(".list-page-entry.rating"); + for (const rating of ratings) { + rating.addEventListener("click", (clickEvent) => { + window.location.href = `/user/${rating.dataset.id}`; + }); + } + } +} + +const expandTemplates = async () => { + await commonTemplates(); + if (session.key) { + myRating = await fetch(`/api/achievement/${achievementId}/rating/${session.id}`, { method: 'GET' }) + .then(response => { + if (response.status !== 200) { + return { invalid: true }; + } else { + return response.json(); + } + }); + } else { + myRating = { invalid: true }; + } + template.apply("achievement-page").promise(achievementData.then(data => ({ + id: achievementId, + name: data.name, + description: data.description ? data.description : '', + completion: data.completion === null ? "N/A" : `${data.completion}%`, + difficulty: data.difficulty === null ? "N/A" : `${data.difficulty} / 10`, + quality: data.quality === null ? "N/A" : `${data.quality} / 10`, + my_difficulty: myRating.difficulty ? myRating.difficulty : '', + my_quality: myRating.quality ? myRating.quality : '', + my_review: myRating.review ? myRating.review : '', + }))); + template.apply("rating-list").promise(achievementData.then(data => data.ratings.map(data => ({ + user_id: data.userId, + user_username: data.username, + user_difficulty: data.difficulty, + user_quality: data.quality, + user_review: data.review + })))); +} + +window.addEventListener("load", async (loadEvent) => { + await loadCommon(); + + var importing = document.querySelector("#importing"); + if (/\d+/.test(achievementId)) { + achievementId = Number(achievementId); + } else { + // Handle error + } + importing.remove(); + + achievementData = fetch(`/api/achievement/${achievementId}`, { method: 'GET' }) + .then(response => response.json()); + + await expandTemplates(); + await template.expand(); + + loadLazyImages(); + connectNavbar(); + loadAchievement(); +}); \ No newline at end of file diff --git a/frontend/webpage/static/scripts/common.js b/frontend/webpage/static/scripts/common.js new file mode 100644 index 0000000..190059e --- /dev/null +++ b/frontend/webpage/static/scripts/common.js @@ -0,0 +1,120 @@ +let root = null; +const loadRoot = () => { + const rootElement = document.documentElement; + + root = {}; + root.getProperty = (name) => window.getComputedStyle(document.documentElement).getPropertyValue(name); + root.setProperty = (name, value) => { + rootElement.style.setProperty(name, value); + } +}; + +let session = { id: null }; +const clearSession = () => session = { id: null }; +const loadSession = async () => { + window.addEventListener('beforeunload', (beforeUnloadEvent) => { + window.sessionStorage.setItem('session', JSON.stringify(session)); + }); + + session = JSON.parse(window.sessionStorage.getItem('session')) || { id: -1 }; + if (session.hue) { + root.setProperty('--accent-hue', session.hue); + } + + if (session.id !== null) { + await fetch(`/api/auth/refresh`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ key: session.key, id: session.id }) + }) + .then(async response => ({ status: response.status, data: await response.json() })) + .then(response => { + if (response.status !== 200 && window.location.pathname !== "/login") { + session.id = null; + session.key = null; + if (session.id !== -1) { + window.location.href = "/login"; + } + } else { + session.key = response.data.key; + session.id = response.data.id; + if (session.id === -1 && window.location.pathname !== '/import') { + window.location.href = '/import'; + } + } + }); + } +}; + +const authenticate = (obj) => { + obj.sessionKey = session.key; + obj.userId = session.id; + return obj; +} + +const loadCommon = async () => { + loadRoot(); + await loadSession(); +} + +const commonTemplates = async () => { + template.apply("navbar").values([ + { section: "left" }, + { section: "right" } + ]); + template.apply("navbar-section-left").values([ + { item: "achievements", title: "Achievements" }, + { item: "users", title: "Users" }, + { item: "games", title: "Games" }, + { item: "import", title: "Import" } + ]); + if (session.id !== -1 && session.id !== null) { + template.apply("navbar-section-right").values([ + { item: "profile", title: "Profile" }, + { item: "logout", title: "Logout" } + ]); + } else { + template.apply("navbar-section-right").values([ + { item: "login", title: "Login / Create Account" } + ]); + } +}; + +const loadLazyImages = () => { + const imgs = document.querySelectorAll(".lazy-img"); + for (const img of imgs) { + img.src = img.dataset.src; + } +} + +const connectNavbar = () => { + if (session.id !== -1) { + const navItems = document.querySelectorAll(".navbar-item"); + + if (!session.admin) { + document.querySelector("#navbar-item-import").remove(); + } + + for (const item of navItems) { + if (item.dataset.pageName === "logout") { + item.addEventListener("click", (clickEvent) => { + fetch(`/api/auth/logout`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ key: session.key }) + }); + clearSession(); + window.location.href = "/login"; + }); + } else if (item.dataset.pageName === "profile") { + item.addEventListener("click", (clickEvent) => window.location.href = `/user/${session.id}`); + } else { + item.addEventListener("click", (clickEvent) => window.location.href = `/${item.dataset.pageName}`); + } + } + } +}; \ No newline at end of file diff --git a/frontend/webpage/static/scripts/import.js b/frontend/webpage/static/scripts/import.js new file mode 100644 index 0000000..05c0eaf --- /dev/null +++ b/frontend/webpage/static/scripts/import.js @@ -0,0 +1,105 @@ +let consoleTop = true; +let importConsole = null; +const appendLine = (line) => { + const template = document.createElement("template"); + template.innerHTML = `

${line}

` + importConsole.appendChild(template.content.firstElementChild); + consoleTop = false; +}; + +const loadConsole = () => { + importConsole = document.querySelector("#import-console"); + + const dropzone = document.querySelector("#import-dropzone"); + const uploadWrapper = document.querySelector("#upload-wrapper"); + const upload = (dropEvent) => { + dropEvent.preventDefault(); + + dropzone.classList.remove('active'); + if (dropEvent.dataTransfer.files) { + const file = dropEvent.dataTransfer.files[0]; + if (file.type === 'application/json') { + importConsole.style.display = 'block'; + uploadWrapper.style.display = 'none'; + file.text().then(data => JSON.parse(data)).then(data => { + let uploads = Promise.resolve(); + for (let i = 0; i < data.platforms.length; ++i) { + const platform = data.platforms[i]; + uploads = uploads + .then(() => { + appendLine(`(${i + 1}/${data.platforms.length}) Creating platform: ${platform.name}`); + }).then(() => fetch( + '/api/import/platform', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(authenticate(platform)) + } + ) + ); + } + for (let i = 0; i < data.users.length; ++i) { + const user = data.users[i]; + const userPlatforms = user.platforms; + delete user.platforms; + uploads = uploads + .then(() => { + appendLine(`(${i + 1}/${data.users.length}) Creating user: ${user.username}`); + }).then(() => fetch( + '/api/import/user', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(authenticate(user)) + } + ) + ); + for (let j = 0; j < userPlatforms.length; ++j) { + const platform = userPlatforms[j]; + platform.userEmail = user.email; + uploads = uploads + .then(() => { + appendLine(`    (${j + 1}/${userPlatforms.length}) Importing platform data: ${data.platforms[platform.platformId].name}`); + }).then(() => fetch( + '/api/import/user/platform', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(authenticate(platform)) + } + ) + ); + } + } + uploads = uploads.then(() => { + if (session.id === -1) { + clearSession(); + window.location.href = '/login'; + } else { + importConsole.innerHTML = ''; + importConsole.style.display = 'none'; + uploadWrapper.style.display = 'block'; + } + }); + }); + } + } + }; + dropzone.addEventListener("drop", upload); + dropzone.addEventListener("dragover", (dragEvent) => { + dragEvent.preventDefault(); + }); +}; + +window.addEventListener("load", async (loadEvent) => { + await loadCommon(); + + await commonTemplates(); + await template.expand(); + + connectNavbar(); + loadConsole(); +}); \ No newline at end of file diff --git a/frontend/webpage/static/scripts/login.js b/frontend/webpage/static/scripts/login.js new file mode 100644 index 0000000..4752721 --- /dev/null +++ b/frontend/webpage/static/scripts/login.js @@ -0,0 +1,185 @@ +window.addEventListener("load", async (loadEvent) => { + await loadCommon(); + + if (session.key) { + window.location.href = '/'; + } + + const fields = { + email: document.querySelector("#email" ), + username: document.querySelector("#username"), + password: document.querySelector("#password"), + confirm: document.querySelector("#confirm" ) + }; + fields.email.focus(); + + const createUser = document.querySelector("#create-user-button"); + const login = document.querySelector("#login-button"); + const guest = document.querySelector("#guest-login-button"); + + const header = document.querySelector("#login-header-text"); + const error = document.querySelector("#error-message"); + + if (!session.key && session.id) { + error.style.display = "block"; + error.textContent = "You have been signed out due to inactivity"; + } + + const raiseError = (errorFields, message) => { + for (const key in fields) { + if (errorFields.includes(key)) { + fields[key].classList.add("error"); + } else { + fields[key].classList.remove("error"); + } + } + + error.style.display = "block"; + error.textContent = message; + } + + let frozen = false; + const freeze = () => { + frozen = true; + createUser.classList.add("disabled"); + login.classList.add("disabled"); + guest.classList.add("disabled"); + }; + const unfreeze = () => { + frozen = false; + createUser.classList.remove("disabled"); + login.classList.remove("disabled"); + guest.classList.remove("disabled"); + }; + + const switchToCreateAction = (clickEvent) => { + if (!frozen) { + fields.username.style.display = "block"; + fields.confirm.style.display = "block"; + header.textContent = "Create Account"; + + createUser.removeEventListener("click", switchToCreateAction); + createUser.addEventListener("click", createUserAction); + + login.removeEventListener("click", loginAction); + login.addEventListener("click", switchToLoginAction); + + activeAction = createUserAction; + } + }; + const createUserAction = (clickEvent) => { + if (!frozen) { + if (fields.email.value === '') { + raiseError([ "email" ], "Email cannot be empty"); + } else if (fields.username.value === '') { + raiseError([ "username" ], "Username cannot be empty"); + } else if (fields.password.value !== fields.confirm.value) { + raiseError([ "password", "confirm" ], "Password fields did not match"); + } else if (fields.password.value === '') { + raiseError([ "password", "confirm" ], "Password cannot be empty"); + } else { + freeze(); + fetch(`/api/auth/create_user`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ email: fields.email.value, username: fields.username.value, password: fields.password.value }) + }) + .then(async response => ({ status: response.status, data: await response.json() })) + .then(response => { + const data = response.data; + if (response.status === 201) { + session = data; + window.location.href = `/user/${session.id}`; + } else if (response.status === 500) { + raiseError([], "Internal server error :("); + } else { + if (data.code === 1) { + raiseError([ "email" ], "A user with that email is already registered"); + fields.email.value = ''; + } else if (data.code === 2) { + raiseError([ "email" ], "Invalid email address"); + fields.email.value = ''; + } else { + raiseError([ "email" ], "Server is bad :p"); + fields.email.value = ''; + } + } + }) + .catch(error => { + console.error(error); + raiseError([], "Server error :("); + }).then(unfreeze); + } + } + }; + createUser.addEventListener("click", switchToCreateAction); + + const switchToLoginAction = (clickEvent) => { + if (!frozen) { + fields.username.style.display = "none"; + fields.confirm.style.display = "none"; + header.textContent = "Login"; + + createUser.removeEventListener("click", createUserAction); + createUser.addEventListener("click", switchToCreateAction); + + login.removeEventListener("click", switchToLoginAction); + login.addEventListener("click", loginAction); + + activeAction = loginAction; + } + }; + const loginAction = (clickEvent) => { + if (!frozen) { + if (fields.email.value === '') { + raiseError([ "email" ], "Email cannot be empty"); + } else if (fields.password.value === '') { + raiseError([ "password" ], "Password cannot be empty"); + } else { + freeze(); + fetch(`/api/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ email: fields.email.value, password: fields.password.value }) + }) + .then(async response => ({ status: response.status, data: await response.json() })) + .then(response => { + const data = response.data; + if (response.status === 200) { + session = data; + window.location.href = "/"; + } else if (response.status === 500) { + raiseError([], "Internal server error :("); + } else { + raiseError([ "email", "password" ], "Email or password is incorrect"); + fields.password.value = ''; + } + }) + .catch(error => { + console.error(error); + raiseError([], "Unknown error :("); + }).then(unfreeze); + } + } + }; + login.addEventListener("click", loginAction); + + guest.addEventListener("click", (clickEvent) => { + if (!frozen) { + window.location.href = '/'; + } + }); + + let activeAction = loginAction; + for (const field in fields) { + fields[field].addEventListener("keydown", (keyEvent) => { + if (keyEvent.key === "Enter") { + activeAction(); + } + }) + } +}); \ No newline at end of file diff --git a/frontend/webpage/static/scripts/search.js b/frontend/webpage/static/scripts/search.js new file mode 100644 index 0000000..c2cac2a --- /dev/null +++ b/frontend/webpage/static/scripts/search.js @@ -0,0 +1,26 @@ +const expandTemplates = async () => { + await commonTemplates(); +} + +const loadFilters = () => { + const filtersButton = document.querySelector("#filter-dropdown-stack"); + const filtersSection = document.querySelector("#list-page-filters-flex"); + + filtersButton.addEventListener("click", (clickEvent) => { + filtersButton.classList.toggle("active"); + filtersSection.classList.toggle("active"); + }); + + const filterCheckboxes = document.querySelectorAll(".list-page-filter-checkbox"); + for (const checkbox of filterCheckboxes) { + checkbox.parentElement.addEventListener("click", (clickEvent) => { + checkbox.parentElement.classList.toggle("selected"); + }) + } +} + +const loadCommonSearch = async () => { + await loadCommon(); + + await expandTemplates(); +}; \ No newline at end of file diff --git a/frontend/webpage/static/scripts/search_achievements.js b/frontend/webpage/static/scripts/search_achievements.js new file mode 100644 index 0000000..89bbb7e --- /dev/null +++ b/frontend/webpage/static/scripts/search_achievements.js @@ -0,0 +1,141 @@ +let templateList = null; +let templateText = null; +const saveTemplate = () => { + const templateElement = document.querySelector("#achievement-list-template"); + templateList = templateElement.parentElement; + templateText = templateElement.outerHTML; + templateElement.remove(); +}; + +const loadAchievementSearch = () => { + const loading = document.querySelector("#loading-results"); + + const personal = document.querySelector("#personal-filters"); + if (!session) { + personal.style.display = 'none'; + } + + const searchButton = document.querySelector("#achievement-search-button"); + const searchField = document.querySelector("#achievement-search-field" ); + + const completed = document.querySelector("#completed-filter"); + const minCompletion = document.querySelector("#min-completion-filter"); + const maxCompletion = document.querySelector("#max-completion-filter"); + const minDifficulty = document.querySelector("#min-difficulty-filter"); + const maxDifficulty = document.querySelector("#max-difficulty-filter"); + const minQuality = document.querySelector("#min-quality-filter" ); + const maxQuality = document.querySelector("#max-quality-filter" ); + + let ordering = 'name'; + let direction = true; + let canSearch = true; + const loadList = async () => { + if (canSearch) { + canSearch = false; + + const body = { + searchTerm: searchField.value, + userId: completed.classList.contains('selected') ? session.id : null, + completed: completed.classList.contains('selected') ? true : null, + minCompletion: minCompletion.value === '' ? null : Number(minCompletion.value), + maxCompletion: maxCompletion.value === '' ? null : Number(maxCompletion.value), + minDifficulty: minDifficulty.value === '' ? null : Number(minDifficulty.value), + maxDifficulty: maxDifficulty.value === '' ? null : Number(maxDifficulty.value), + minQuality: minQuality.value === '' ? null : Number(minQuality.value ), + maxQuality: maxQuality.value === '' ? null : Number(maxQuality.value ), + ordering: ordering, + orderDirection: direction ? 'ASC' : 'DESC', + }; + let successful = true; + if (Number.isNaN(body.minCompletion)) { successful = false; minCompletion.style.backgroundColor = 'var(--error)'; } else { minCompletion.style.backgroundColor = 'var(--foreground)'; } + if (Number.isNaN(body.maxCompletion)) { successful = false; maxCompletion.style.backgroundColor = 'var(--error)'; } else { maxCompletion.style.backgroundColor = 'var(--foreground)'; } + if (Number.isNaN(body.minDifficulty)) { successful = false; minDifficulty.style.backgroundColor = 'var(--error)'; } else { minDifficulty.style.backgroundColor = 'var(--foreground)'; } + if (Number.isNaN(body.maxDifficulty)) { successful = false; maxDifficulty.style.backgroundColor = 'var(--error)'; } else { maxDifficulty.style.backgroundColor = 'var(--foreground)'; } + if (Number.isNaN(body.minQuality )) { successful = false; minQuality.style.backgroundColor = 'var(--error)'; } else { minQuality.style.backgroundColor = 'var(--foreground)'; } + if (Number.isNaN(body.maxQuality )) { successful = false; maxQuality.style.backgroundColor = 'var(--error)'; } else { maxQuality.style.backgroundColor = 'var(--foreground)'; } + + if (!successful) { + canSearch = true; + return; + } + + for (const entry of templateList.querySelectorAll(".list-page-entry")) { + entry.remove(); + } + templateList.innerHTML += templateText; + loading.style.display = 'block'; + + const data = fetch("/api/achievements", { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + }) + .then(response => response.json()) + + template.clear(); + template.apply('achievements-page-list').promise(data.then(data => data.map(item => ({ + achievement_id: item.ID, + achievement_name: item.name, + game_name: item.game, + completion: item.completion == null ? 'N/A' : `${item.completion}%`, + difficulty: item.difficulty == null ? 'N/A' : `${item.difficulty} / 10`, + quality: item.quality == null ? 'N/A' : `${item.quality} / 10` + })))); + await template.expand(); + data.then(data => { + loading.style.display = 'none'; + canSearch = true; + loadLazyImages(); + + const entries = document.querySelectorAll(".list-page-entry.achievement"); + for (const entry of entries) { + entry.addEventListener("click", (clickEvent) => { + window.location.href = `/achievement/${entry.dataset.id}`; + }); + } + }); + + const headers = { + game: document.querySelector(".list-page-header > .achievement-game-name" ), + name: document.querySelector(".list-page-header > .achievement-name" ), + completion: document.querySelector(".list-page-header > .achievement-completion"), + difficulty: document.querySelector(".list-page-header > .achievement-difficulty"), + quality: document.querySelector(".list-page-header > .achievement-quality" ), + } + for (const header in headers) { + headers[header].addEventListener("click", (clickEvent) => { + if (ordering === header) { + direction = !direction; + } else { + ordering = header; + direction = true; + } + loadList(); + }); + } + } + }; + + searchButton.addEventListener("click", loadList); + searchField.addEventListener("keydown", (keyEvent) => { + if (keyEvent.key === 'Enter') { + loadList(); + } + }); + + loadList(); +}; + +window.addEventListener("load", async (loadEvent) => { + await loadCommonSearch(); + + saveTemplate(); + await template.expand(); + + connectNavbar(); + loadFilters(); + + await loadAchievementSearch(); +}); \ No newline at end of file diff --git a/frontend/webpage/static/scripts/search_games.js b/frontend/webpage/static/scripts/search_games.js new file mode 100644 index 0000000..23c229b --- /dev/null +++ b/frontend/webpage/static/scripts/search_games.js @@ -0,0 +1,134 @@ +let templateList = null; +let templateText = null; +const saveTemplate = () => { + const templateElement = document.querySelector("#game-list-template"); + templateList = templateElement.parentElement; + templateText = templateElement.outerHTML; + templateElement.remove(); +}; + +const loadGameSearch = () => { + const loading = document.querySelector("#loading-results"); + + const personal = document.querySelector("#personal-filters"); + if (!session) { + personal.style.display = 'none'; + } + + const searchButton = document.querySelector("#game-search-button"); + const searchField = document.querySelector("#game-search-field" ); + + const owned = document.querySelector("#owned-filter" ); + const minAvgCompletion = document.querySelector("#min-avg-completion-filter"); + const maxAvgCompletion = document.querySelector("#max-avg-completion-filter"); + const minNumOwners = document.querySelector("#min-num-owners-filter" ); + const maxNumOwners = document.querySelector("#max-num-owners-filter" ); + const minNumPerfects = document.querySelector("#min-num-perfects-filter" ); + const maxNumPerfects = document.querySelector("#max-num-perfects-filter" ); + + let ordering = 'name'; + let direction = true; + let canSearch = true; + const loadList = async () => { + if (canSearch) { + canSearch = false; + + const body = { + searchTerm: searchField.value, + userId: owned.classList.contains('selected') ? session.id : null, + owned: owned.classList.contains('selected') ? true : null, + minAvgCompletion: minAvgCompletion.value === '' ? null : Number(minAvgCompletion.value), + maxAvgCompletion: maxAvgCompletion.value === '' ? null : Number(maxAvgCompletion.value), + minNumOwners: minNumOwners.value === '' ? null : Number(minNumOwners.value ), + maxNumOwners: maxNumOwners.value === '' ? null : Number(maxNumOwners.value ), + minNumPerfects: minNumPerfects.value === '' ? null : Number(minNumPerfects.value ), + maxNumPerfects: maxNumPerfects.value === '' ? null : Number(maxNumPerfects.value ), + ordering: ordering, + orderDirection: direction ? 'ASC' : 'DESC', + }; + let successful = true; + if (Number.isNaN(body.minAvgCompletion)) { successful = false; minAvgCompletion.style.backgroundColor = 'var(--error)'; } else { minAvgCompletion.style.backgroundColor = 'var(--foreground)'; } + if (Number.isNaN(body.maxAvgCompletion)) { successful = false; maxAvgCompletion.style.backgroundColor = 'var(--error)'; } else { maxAvgCompletion.style.backgroundColor = 'var(--foreground)'; } + if (Number.isNaN(body.minNumOwners)) { successful = false; minNumOwners.style.backgroundColor = 'var(--error)'; } else { minNumOwners.style.backgroundColor = 'var(--foreground)'; } + if (Number.isNaN(body.maxNumOwners)) { successful = false; maxNumOwners.style.backgroundColor = 'var(--error)'; } else { maxNumOwners.style.backgroundColor = 'var(--foreground)'; } + if (Number.isNaN(body.minNumPerfects)) { successful = false; minNumPerfects.style.backgroundColor = 'var(--error)'; } else { minNumPerfects.style.backgroundColor = 'var(--foreground)'; } + if (Number.isNaN(body.maxNumPerfects)) { successful = false; maxNumPerfects.style.backgroundColor = 'var(--error)'; } else { maxNumPerfects.style.backgroundColor = 'var(--foreground)'; } + + if (!successful) { + canSearch = true; + return; + } + + for (const entry of templateList.querySelectorAll(".list-page-entry")) { + entry.remove(); + } + templateList.innerHTML += templateText; + loading.style.display = 'block'; + + const data = fetch("/api/games", { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + }) + .then(response => response.json()) + + template.clear(); + template.apply('games-page-list').promise(data.then(data => data.map(item => ({ + game_id: item.ID, + game_name: item.name, + achievement_count: item.achievement_count, + avg_completion: item.avg_completion == null ? 'N/A' : `${item.avg_completion}%`, + num_owners: item.num_owners, + num_perfects: item.num_perfects, + })))); + await template.expand(); + data.then(data => { + loading.style.display = 'none'; + canSearch = true; + loadLazyImages(); + }); + + const headers = { + game: document.querySelector(".list-page-header > .game-name" ), + achievement_count: document.querySelector(".list-page-header > .game-achievement-count"), + avg_completion: document.querySelector(".list-page-header > .game-avg-completion" ), + num_owners: document.querySelector(".list-page-header > .game-num-owners" ), + num_perfects: document.querySelector(".list-page-header > .game-num-perfects" ), + } + for (const header in headers) { + headers[header].addEventListener("click", (clickEvent) => { + if (ordering === header) { + direction = !direction; + } else { + ordering = header; + direction = true; + } + loadList(); + }); + } + } + }; + + searchButton.addEventListener("click", loadList); + searchField.addEventListener("keydown", (keyEvent) => { + if (keyEvent.key === 'Enter') { + loadList(); + } + }); + + loadList(); +}; + +window.addEventListener("load", async (loadEvent) => { + await loadCommonSearch(); + + saveTemplate(); + await template.expand(); + + connectNavbar(); + loadFilters(); + + await loadGameSearch(); +}); \ No newline at end of file diff --git a/frontend/webpage/static/scripts/search_users.js b/frontend/webpage/static/scripts/search_users.js new file mode 100644 index 0000000..4150e42 --- /dev/null +++ b/frontend/webpage/static/scripts/search_users.js @@ -0,0 +1,132 @@ +let templateList = null; +let templateText = null; +const saveTemplate = () => { + const templateElement = document.querySelector("#user-list-template"); + templateList = templateElement.parentElement; + templateText = templateElement.outerHTML; + templateElement.remove(); +}; + +const loadUserSearch = () => { + const loading = document.querySelector("#loading-results"); + + + const searchButton = document.querySelector("#user-search-button"); + const searchField = document.querySelector("#user-search-field" ); + + const minOwned = document.querySelector("#min-owned-filter" ); + const maxOwned = document.querySelector("#max-owned-filter" ); + const minCompleted = document.querySelector("#min-completed-filter" ); + const maxCompleted = document.querySelector("#max-completed-filter" ); + const minAvgCompletion = document.querySelector("#min-avg-completion-filter"); + const maxAvgCompletion = document.querySelector("#max-avg-completion-filter"); + + let ordering = 'name'; + let direction = true; + let canSearch = true; + const loadList = async () => { + if (canSearch) { + canSearch = false; + + const body = { + searchTerm: searchField.value, + minOwned: minOwned.value === '' ? null : Number(minOwned.value ), + maxOwned: maxOwned.value === '' ? null : Number(maxOwned.value ), + minCompleted: minCompleted.value === '' ? null : Number(minCompleted.value ), + maxCompleted: maxCompleted.value === '' ? null : Number(maxCompleted.value ), + minAvgCompletion: minAvgCompletion.value === '' ? null : Number(minAvgCompletion.value), + maxAvgCompletion: maxAvgCompletion.value === '' ? null : Number(maxAvgCompletion.value), + }; + let successful = true; + if (Number.isNaN(body.minOwned )) { successful = false; minOwned.style.backgroundColor = 'var(--error)'; } else { minOwned.style.backgroundColor = 'var(--foreground)'; } + if (Number.isNaN(body.maxOwned )) { successful = false; maxOwned.style.backgroundColor = 'var(--error)'; } else { maxOwned.style.backgroundColor = 'var(--foreground)'; } + if (Number.isNaN(body.minCompleted )) { successful = false; minCompleted.style.backgroundColor = 'var(--error)'; } else { minCompleted.style.backgroundColor = 'var(--foreground)'; } + if (Number.isNaN(body.maxCompleted )) { successful = false; maxCompleted.style.backgroundColor = 'var(--error)'; } else { maxCompleted.style.backgroundColor = 'var(--foreground)'; } + if (Number.isNaN(body.minAvgCompletion)) { successful = false; minAvgCompletion.style.backgroundColor = 'var(--error)'; } else { minAvgCompletion.style.backgroundColor = 'var(--foreground)'; } + if (Number.isNaN(body.maxAvgCompletion)) { successful = false; maxAvgCompletion.style.backgroundColor = 'var(--error)'; } else { maxAvgCompletion.style.backgroundColor = 'var(--foreground)'; } + + if (!successful) { + canSearch = true; + return; + } + + for (const entry of templateList.querySelectorAll(".list-page-entry")) { + entry.remove(); + } + templateList.innerHTML += templateText; + loading.style.display = 'block'; + + const data = fetch("/api/users", { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + }) + .then(response => response.json()) + + template.clear(); + template.apply('user-page-list').promise(data.then(data => data.map(item => ({ + user_id: item.id, + username: item.username, + game_count: item.game_count, + achievement_count: item.achievement_count, + avg_completion: item.avg_completion == null ? 'N/A' : `${item.avg_completion}%`, + perfect_games: item.perfect_games + })))); + await template.expand(); + data.then(data => { + loading.style.display = 'none'; + canSearch = true; + loadLazyImages(); + + const entries = document.querySelectorAll(".list-page-entry.user"); + for (const entry of entries) { + entry.addEventListener("click", (clickEvent) => { + window.location.href = `/user/${entry.dataset.id}`; + }); + } + }); + + const headers = { + username: document.querySelector(".list-page-header > .user-username" ), + game_count: document.querySelector(".list-page-header > .user-game-count" ), + achievement_count: document.querySelector(".list-page-header > .user-achievement-count"), + avg_completion: document.querySelector(".list-page-header > .user-avg-completion" ), + perfect_games: document.querySelector(".list-page-header > .user-perfect-games" ), + } + for (const header in headers) { + headers[header].addEventListener("click", (clickEvent) => { + if (ordering === header) { + direction = !direction; + } else { + ordering = header; + direction = true; + } + loadList(); + }); + } + } + }; + + searchButton.addEventListener("click", loadList); + searchField.addEventListener("keydown", (keyEvent) => { + if (keyEvent.key === 'Enter') { + loadList(); + } + }); + + loadList(); +}; + +window.addEventListener("load", async (loadEvent) => { + await loadCommonSearch(); + + saveTemplate(); + await template.expand(); + + connectNavbar(); + loadFilters(); + + await loadUserSearch(); +}); \ No newline at end of file diff --git a/frontend/webpage/static/scripts/template.js b/frontend/webpage/static/scripts/template.js new file mode 100644 index 0000000..cb3541f --- /dev/null +++ b/frontend/webpage/static/scripts/template.js @@ -0,0 +1,201 @@ +var template = template || {}; + +(() => { + templateTypeEntryMap = new Map(); + template.register = (type, callback) => { + if (typeof type !== 'string') { + console.error(`'type' must be a string, recieved: `, type); + } else { + const TYPE_REGEX = /^(\w+)\s*(<\s*\?(?:\s*,\s*\?)*\s*>)?\s*$/; + const result = type.match(TYPE_REGEX); + if (result === null) { + console.error(`'${type}' is not a valid type id`); + } else { + if (result[2] === undefined) { + result[2] = 0; + } else { + result[2] = result[2].split(/\s*,\s*/).length; + } + const completeType = result[1] + ':' + result[2]; + if (templateTypeEntryMap.get(completeType) === undefined) { + templateTypeEntryMap.set(completeType, async function() { + await callback.apply(null, Array.from(arguments)); + }); + } else { + console.error(`${type} is already a registered template!`); + } + } + } + }; + + // Courtesy of https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping + function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|\[\]\\]/g, '\\$&'); // $& means the whole matched string + } + + /* Intrinsic Templates */ + + // Basic - Simple search and replace + template.register('Basic', (element, map) => { + let html = element.innerHTML; + function applyObject(object, path) { + for (const key in object) { + const regexKey = escapeRegExp(path + key); + html = html.replace(new RegExp(`(?:(? { + return fetch(`templates/${name}.html.template`, { + method: 'GET', + mode: 'no-cors', + headers: { + 'Content-Type': 'text/plain' + } + }).then(response => response.text().then((data) => { + element.outerHTML = data; + })).catch(error => { + console.error(`failed to retrieve template '${name}': `, error); + }); + }); + + // List - Iterate over list and emit copy of child for each iteration + template.register('List', async (element, subtype, arrayMap) => { + let cumulative = ''; + const temp = document.createElement('template'); + for (const obj of arrayMap) { + temp.innerHTML = ``; + const child = temp.content.children[0]; + child.innerHTML = element.innerHTML; + const callback = templateTypeEntryMap.get(subtype.type); + if (callback === undefined) { + cumulative = ''; + console.error(`'${subtype.type}' is not a registered template`); + } else { + await callback.apply(null, [ child, obj ]); + } + cumulative = cumulative + temp.innerHTML.trim(); + } + element.outerHTML = cumulative; + }); + + templateEntryMap = new Map(); + template.apply = function(pattern, promise) { + if (typeof pattern !== 'string') { + console.error('pattern must be a string, received: ', pattern); + } else { + return new template.apply.applicators(pattern); + } + }; + template.apply.applicators = class { + constructor(pattern) { + this._pattern = pattern; + } + + _apply(asyncArgs) { + templateEntryMap.set(RegExp('^' + this._pattern + '$'), asyncArgs); + } + + values(...args) { + this._apply(async () => Array.from(args)); + } + + promise(promise) { + let args = null; + promise = promise.then(data => args = [ data ]); + this._apply(async () => args || promise); + } + + fetch(dataProcessor, url, options) { + if (typeof dataProcessor === 'string') { + const path = dataProcessor; + dataProcessor = data => { + for (const id of path.split(/\./)) { + data = data[id]; + if (data === undefined) { + throw `invalid path '${path}'`; + } + } + + return data; + }; + }; + this.promise( + fetch(url, options || { method: 'GET', mode: 'cors' }) + .then(response => response.json()) + .then(data => dataProcessor(data)) + ); + } + }; + + template.clear = () => { + templateEntryMap.clear(); + } + + const parseType = (type) => { + let result = type.match(/^\s*(\w+)\s*(?:<(.*)>)?\s*$/); + let id = result[1]; + result = result[2] ? result[2].split(/\s*,\s*/).map(parseType) : []; + return { type: id + ':' + result.length, params: result }; + }; + + const findTemplates = (element) => + Array + .from(element.querySelectorAll('template')) + .filter(child => Boolean(child.dataset.template)) + .map(child => { + const data = child.dataset.template.split(/\s*:\s*/); + return { + id: data[0], + typeCapture: parseType(data[1] || 'Basic'), + element: child + }; + }); + + const expand = async (element) => { + let children = findTemplates(element); + let promises = []; + let parents = new Set(); + for (const child of children) { + for (const [pattern, argsCallback] of templateEntryMap) { + await argsCallback().then(args => { + if (pattern.test(child.id)) { + const callback = templateTypeEntryMap.get(child.typeCapture.type); + if (typeof callback !== 'function') { + console.error(`'${child.typeCapture.type}' is not a registered template`); + } else { + let params = Array.from(args) + for (const subtype of child.typeCapture.params) { + params.unshift(subtype); + } + params.unshift(child.element); + let parent = child.element.parentElement; + if (!parents.has(parent)) { + parents.add(parent); + } + promises.push(callback.apply(null, params)); + } + } + }).catch(error => { + console.error('failed to retrieve arguments: ', error); + }); + } + } + await Promise.all(promises); + promises = []; + for (const parent of parents) { + promises.push(expand(parent)); + } + await Promise.all(promises); + }; + + template.expand = async () => expand(document.children[0]); +})(); \ No newline at end of file diff --git a/frontend/webpage/static/scripts/user.js b/frontend/webpage/static/scripts/user.js new file mode 100644 index 0000000..d62500d --- /dev/null +++ b/frontend/webpage/static/scripts/user.js @@ -0,0 +1,339 @@ +let profileId = window.location.pathname.split('/').pop(); +let isReturn = false; +let profileData = null; +const loadProfile = () => { + const lists = document.querySelectorAll(".profile-list"); + const checkLists = () => { + for (const list of lists) { + let found = false; + const entries = list.querySelectorAll(".profile-entry"); + for (const entry of entries) { + if (window.getComputedStyle(entry).getPropertyValue('display') !== 'none') { + found = true; + break; + } + } + if (!found) { + list.style.display = 'none'; + } else { + list.style.display = 'block'; + } + } + } + checkLists(); + + { + const validImageFile = (type) => { + return type === "image/apng" + || type === "image/avif" + || type === "image/gif" + || type === "image/jpeg" + || type === "image/png" + || type === "image/svg+xml" + || type === "image/webp" + } + + const editProfileButton = document.querySelector("#info-edit-stack"); + const saveProfileButton = document.querySelector("#info-save-stack"); + + const usernameText = document.querySelector("#profile-info-username-text"); + const usernameField = document.querySelector("#profile-info-username-field"); + + const finalizeName = () => { + if (usernameField.value !== '') { + fetch(`/api/user/${profileId}/username`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: `{ "sessionKey": "${session.key}", "username": "${usernameField.value}" }` + }).then(response => { + if (response.status === 201) { + usernameText.textContent = usernameField.value.substring(0, 32); + } + }); + } + }; + + usernameField.addEventListener("input", (inputEvent) => { + if (usernameField.value.length > 32) { + usernameField.value = usernameField.value.substring(0, 32); + } + }); + usernameField.addEventListener("keydown", (keyEvent) => { + if (keyEvent.key === "Enter") { + saveProfileButton.click(); + } + }) + + const pfp = document.querySelector("#profile-info-pfp-img"); + const pfpStack = document.querySelector("#profile-info-pfp"); + const upload = document.querySelector("#profile-info-pfp-upload"); + const uploadHover = document.querySelector("#profile-info-pfp-upload-hover"); + const uploadInvalid = document.querySelector("#profile-info-pfp-upload-invalid"); + + const togglePlatformEdit = (clickEvent) => { + editProfileButton.classList.toggle("active"); + saveProfileButton.classList.toggle("active"); + usernameText.classList.toggle("active"); + usernameField.classList.toggle("active"); + upload.classList.toggle("active"); + uploadHover.classList.toggle("active"); + uploadInvalid.classList.toggle("active"); + pfpStack.classList.remove("hover"); + pfpStack.classList.remove("invalid"); + }; + editProfileButton.addEventListener("click", togglePlatformEdit); + editProfileButton.addEventListener("click", () => { + usernameField.value = usernameText.textContent; + }); + saveProfileButton.addEventListener("click", togglePlatformEdit); + saveProfileButton.addEventListener("click", finalizeName); + + pfpStack.addEventListener("drop", (dropEvent) => { + if (upload.classList.contains("active")) { + dropEvent.preventDefault(); + pfpStack.classList.remove("hover"); + pfpStack.classList.remove("invalid"); + if (dropEvent.dataTransfer.files) { + const file = dropEvent.dataTransfer.files[0]; + if (validImageFile(file.type)) { + const data = new FormData(); + data.append('session', new Blob([`{ "key": "${session.key}" }`], { type: `application/json` })); + data.append('file', file); + + fetch(`/api/user/${profileId}/image`, { + method: 'POST', + body: data + }).then(response => { + if (upload.classList.contains("active")) { + if (response.status === 201) { + pfp.src = `/api/user/${profileId}/image?time=${Date.now()}`; + } else { + pfpStack.classList.add("invalid"); + } + } + }); + return; + } + } + pfpStack.classList.add("invalid"); + } + }); + pfpStack.addEventListener("dragover", (dragEvent) => { + if (upload.classList.contains("active")) { + dragEvent.preventDefault(); + } + }); + pfpStack.addEventListener("dragenter", (dragEvent) => { + if (upload.classList.contains("active")) { + pfpStack.classList.remove("hover"); + pfpStack.classList.remove("invalid"); + if (dragEvent.dataTransfer.types.includes("application/x-moz-file")) { + pfpStack.classList.add("hover"); + } else { + pfpStack.classList.add("invalid"); + } + } + }); + pfpStack.addEventListener("dragleave", (dragEvent) => { + if (upload.classList.contains("active")) { + pfpStack.classList.remove("hover"); + pfpStack.classList.remove("invalid"); + } + }); + } + + { + const editPlatformsButton = document.querySelector("#platform-edit-stack"); + const savePlatformsButton = document.querySelector("#platform-save-stack"); + const platforms = document.querySelectorAll("#profile-platforms .profile-entry"); + + const togglePlatformEdit = (clickEvent) => { + editPlatformsButton.classList.toggle("active"); + savePlatformsButton.classList.toggle("active"); + for (const platform of platforms) { + platform.classList.toggle("editing"); + } + checkLists(); + }; + editPlatformsButton.addEventListener("click", togglePlatformEdit); + savePlatformsButton.addEventListener("click", togglePlatformEdit); + + const steamButtons = [ + document.querySelector("#add-steam"), + document.querySelector("#platform-0"), + ]; + + let allowSteamImport = true; + steamButtons[0].addEventListener("click", (clickEvent) => { + if (allowSteamImport) { + window.location.href = "/auth/steam"; + } + }); + steamButtons[1].addEventListener("click", (clickEvent) => { + fetch(`/api/user/${profileId}/platforms/remove`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ sessionKey: session.key, platformId: 0 }) + }).then(() => { + allowSteamImport = true; + }); + allowSteamImport = false; + steamButtons[1].parentElement.classList.remove("connected"); + }); + + if (isReturn) { + editPlatformsButton.click(); + } + } + + // Canvasing + + const completionCanvas = document.querySelector("#profile-completion-canvas"); + + const STROKE_WIDTH = 0.18; + const style = window.getComputedStyle(completionCanvas); + const context = completionCanvas.getContext('2d'); + + const drawCanvas = () => profileData.then(data => { + const width = Number(style.getPropertyValue('width').slice(0, -2)); + const height = width; + + context.canvas.width = width; + context.canvas.height = height; + context.clearRect(0, 0, width, height); + context.strokeStyle = root.getProperty('--accent-value3'); + context.lineWidth = (width / 2) * STROKE_WIDTH; + context.beginPath(); + context.arc(width / 2, height / 2, (width / 2) * (1 - STROKE_WIDTH / 2), -0.5 * Math.PI, (-0.5 + (data.average === null ? 0 : (data.average / 100) * 2)) * Math.PI); + context.stroke(); + }); + + window.addEventListener('resize', drawCanvas); + drawCanvas(); + + if (profileId === session.id) { + document.querySelector("#profile-page").classList.add("self"); + } + + { + const noteworthy = document.querySelectorAll(".list-page-entry.achievement"); + for (const achievement of noteworthy) { + achievement.addEventListener("click", (clickEvent) => { + window.location.href = `/achievement/${achievement.dataset.id}`; + }); + } + } + + { + const ratings = document.querySelectorAll(".list-page-entry.rating"); + for (const rating of ratings) { + rating.addEventListener("click", (clickEvent) => { + window.location.href = `/achievement/${rating.dataset.id}`; + }); + } + } +} + +const expandTemplates = async () => { + await commonTemplates(); + template.apply("profile-page").promise(profileData.then(data => ({ + id: profileId, + username: data.username, + completed: data.completed, + average: data.average === null ? "N/A" : data.average + "%", + perfect: data.perfect, + }))); + template.apply("profile-noteworthy-list").promise( + fetch(`/api/user/${profileId}/noteworthy`, { + method: 'GET' + }) + .then(response => response.json()) + .then(data => data.map(data => ({ + achievement_id: data.ID, + achievement_name: data.name, + completion: data.completion + })) + ) + ); + template.apply("profile-platforms-list").promise(profileData.then(data => + data.platforms.map(platform => ({ + platform_id: platform.id, + img: `Steam Logo`, + name: platform.name, + connected: platform.connected ? "connected" : "", + add: + (platform.id === 0 ? `Add` : + (platform.id === 1 ? `

Coming soon...

` : + (platform.id === 2 ? `

Coming soon...

` : + ""))) + })) + )); + template.apply("rating-list").promise(profileData.then(data => data.ratings.map(data => ({ + achievement_id: data.achievementId, + rating_achievement: data.name, + rating_difficulty: data.difficulty, + rating_quality: data.quality, + rating_review: data.review + })))); +} + +window.addEventListener("load", async (loadEvent) => { + await loadCommon(); + + var importing = document.querySelector("#importing"); + if (!/\d+/.test(profileId)) { + isReturn = true; + const platform = profileId; + if (!session.key) { + window.location.href = "/404"; + } else { + profileId = session.lastProfile; + delete session.lastProfile; + } + + const importingText = importing.querySelector("#importing-text"); + importingText.textContent = `Importing from ${platform}...`; + importing.style.display = `flex`; + if (platform === 'steam') { + const query = new URLSearchParams(window.location.search); + + if (query.get('openid.mode') === 'cancel') { + + } else { + // Regex courtesy of https://github.com/liamcurry/passport-steam/blob/master/lib/passport-steam/strategy.js + var steamId = /^https?:\/\/steamcommunity\.com\/openid\/id\/(\d+)$/.exec(query.get('openid.claimed_id'))[1]; + await fetch(`/api/user/${profileId}/platforms/add`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ sessionKey: session.key, userId: profileId, platformId: 0, platformUserId: `${steamId}` }) + }); + } + } + + window.history.replaceState({}, '', `/profile/${profileId}`); + } else if (/\d+/.test(profileId)) { + profileId = Number(profileId); + if (session.key) { + session.lastProfile = profileId; + } + } else { + // Handle error + } + importing.remove(); + + profileData = fetch(`/api/user/${profileId}`, { method: 'GET' }) + .then(response => response.json()); + + await expandTemplates(); + await template.expand(); + + loadLazyImages(); + connectNavbar(); + loadProfile(); +}); \ No newline at end of file diff --git a/frontend/webpage/static/styles/achievement.css b/frontend/webpage/static/styles/achievement.css new file mode 100644 index 0000000..e062d46 --- /dev/null +++ b/frontend/webpage/static/styles/achievement.css @@ -0,0 +1,459 @@ +#achievement-page { + max-width: 1600px; +} + +#importing { + flex-direction: column; + align-items: center; + + display: none; +} + +#importing-text { + margin: 0; + height: 96px; + font-size: 64px; + line-height: 96px; + color: var(--foreground); +} + +#importing-loading { + height: 64px; + width: 64px; +} + +.achievement-list { + width: 100%; + height: max-content; + + border-radius: 8px; + + overflow: hidden; + + box-shadow: 0px 0px 8px 8px var(--shadow-color); +} + +.achievement-entry { + overflow: hidden; + + height: 64px; + + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + + background-color: var(--distinction); +} + +.achievement-entry-left { + height: 100%; + + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; +} + +.achievement-entry-right { + height: 100%; + + display: flex; + flex-direction: row; + justify-content: flex-end; + align-items: center; +} + +.achievement-entry-icon { + width: 64px; + + flex-grow: 0; +} + +.achievement-entry-text { + box-sizing: border-box; + + margin: 0; + padding: 0px 16px; + + height: 100%; + line-height: 64px; + + color: var(--foreground); + + font-size: 24px; + + border-top: 1px solid var(--background); + + flex-basis: max-content; + flex-grow: 0; +} + +.top > .achievement-entry-text { + border: 0; +} + +.achievement-entry-text.platform-name { + flex-grow: 1; +} + +#achievement-info { + height: max-content; +} + +#achievement-info-flex { + justify-content: flex-start; + align-items: flex-end; +} + +#achievement-name-text { + flex-grow: 0; + width: max-content; +} + +#achievement-icon-img { + flex-grow: 0; + margin-bottom: 0.25em; + margin-left: 32px; + + font-size: 32px; + width: 64px; + height: 64px; + object-fit: contain; + + background-color: var(--background-dark); + + position: relative; +} + +#achievement-description-text { + font-size: 24px; + color: var(--foreground); + background: var(--distinction); + margin: 0; + padding: 16px; +} + +#achievement-section-1 { + box-sizing: border-box; + + width: 100%; + height: max-content; + + display: flex; + flex-direction: row; + justify-content: center; + align-items: flex-start; +} + +#achievement-stats { + flex-basis: 0; + flex-grow: 1; + + display: flex; + flex-direction: row; + justify-content: center; +} + +#achievement-stats-numeric { + flex-grow: 1; + width: 100%; + max-width: 800px; + + display: flex; + flex-direction: row; + justify-content: space-between; +} + +#achievement-completion-stack { + flex-grow: 1; + height: max-content; + position: relative; +} + +#achievement-completion-background { + width: 100%; + display: block; +} + +#achievement-completion-canvas { + width: 100%; + height: 100%; + + position: absolute; + left: 0; + top: 0; +} + +#achievement-completion-text { + margin: 0; + + width: 100%; + height: 100%; + + color: var(--foreground); + font-size: 64px; + + position: absolute; + left: 0; + top: 0; + + display: flex; + justify-content: center; + align-items: center; +} + +#achievement-difficulty, +#achievement-quality { + flex-grow: 1; +} + +#achievement-difficulty-text { + margin: 0; + + height: 48px; + + color: var(--foreground); + + font-size: 48px; + line-height: 48px; + text-align: center; +} + +#achievement-quality-text { + margin: 0; + + height: 48px; + + color: var(--foreground); + + font-size: 48px; + line-height: 48px; + text-align: center; +} + +#achievement-rating { + flex-basis: 0; + flex-grow: 1; +} + +.achievement-save-stack { + display: none; +} + +.achievement-save-stack.active { + display: block; + width: max-content; + height: max-content; +} + +.achievement-save-stack:hover .achievement-save, +.achievement-save-hover { + display: none; +} + +.achievement-save, +.achievement-save-stack:hover .achievement-save-hover { + display: block; +} + +#achievement-rating-subsection { + background-color: var(--distinction); +} + +#achievement-rating-numeric { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +} + +.achievement-rating-text-flex { + display: flex; + flex-direction: row; + align-items: center; +} + +#achievement-difficulty-rating, +#achievement-quality-rating { + flex-grow: 1; +} + +.achievement-rating-text { + flex-basis: max-content; + flex-grow: 1; +} + +.achievement-rating-max-text { + flex-grow: 0; + + margin: 0; + margin-left: 16px; + font-size: 24px; + + color: var(--foreground); +} + +#achievement-difficulty-rating-text, +#achievement-quality-rating-text, +#achievement-review-rating-text { + box-sizing: border-box; + padding: 8px; + font-size: 20px; + color: var(--background); + border: 0; + outline: none; + + border-radius: 8px; +} + +#achievement-review-rating-text { + width: 100%; + height: 96px; + + resize: none; +} + + +#profile-section-2 { + box-sizing: border-box; + + width: 100%; + height: max-content; + + display: flex; + flex-direction: row; + justify-content: center; + align-items: flex-start; +} + +#profile-hardest { + flex-grow: 1; + height: 100%; +} + +#profile-platforms { + flex-grow: 1; +} + +#profile-platforms .profile-entry { + display: none; +} + +#profile-platforms .profile-entry-text { + color: var(--foreground); +} + +#profile-platforms .profile-entry.connected, +#profile-platforms .profile-entry.editing { + display: flex; +} + +#profile-page .profile-edit-stack, +#profile-page .profile-save-stack { + display: none; +} + +#profile-page.self .profile-edit-stack, +#profile-page.self .profile-save-stack.active { + display: block; + width: max-content; + height: max-content; +} + +#profile-page.self .profile-edit-stack.active, +#profile-page.self .profile-save-stack { + display: none; +} + +.profile-edit-stack:hover > .profile-edit-hover, +.profile-edit { + display: block; +} + +.profile-edit-stack:hover > .profile-edit, +.profile-edit-hover { + display: none; +} + +.profile-save-stack:hover > .profile-save-hover, +.profile-save { + display: block; +} + +.profile-save-stack:hover > .profile-save, +.profile-save-hover { + display: none; +} + +.profile-entry .platform-remove-stack, +.profile-entry .platform-add, +.profile-entry .platform-unsupported, +.profile-entry.connected.editing .platform-add { + border-top: 1px solid var(--background); + display: none; +} + +.profile-entry.connected.editing .platform-remove-stack { + box-sizing: border-box; + display: block; + height: 100%; + width: max-content; + flex-grow: 0; +} + +.platform-remove, .platform-remove-hover { + box-sizing: border-box; + padding: 12px; + height: 100%; +} + +.profile-entry.connected.editing .platform-remove-stack .platform-remove, +.profile-entry.connected.editing .platform-remove-stack:hover .platform-remove-hover { + display: block; +} + +.profile-entry.connected.editing .platform-remove-stack:hover .platform-remove, +.profile-entry.connected.editing .platform-remove-stack .platform-remove-hover { + display: none; +} + +.profile-entry .platform-add { + box-sizing: border-box; + height: 100%; + padding: 16px 8px; +} + +.profile-entry.editing .platform-add { + display: block; +} + +.profile-entry.editing .platform-unsupported { + box-sizing: border-box; + display: block; + margin: 0; + padding: 0% 2%; + line-height: 63px; + font-size: 24px; + color: var(--foreground-disabled); +} + +#profile-ratings { + flex-grow: 1; +} + +.list-page-entry.rating:hover { + background-color: var(--background-light); +} + +.list-page-entry-text.rating-username { flex-grow: 1.5; } +.list-page-entry-text.rating-difficulty { flex-grow: 1; } +.list-page-entry-text.rating-quality { flex-grow: 1; } +.list-page-entry-text.rating-review { flex-grow: 5; } + +.list-page-header > .list-page-entry-text.rating-username:hover, +.list-page-header > .list-page-entry-text.rating-difficulty:hover, +.list-page-header > .list-page-entry-text.rating-quality:hover, +.list-page-header > .list-page-entry-text.rating-review:hover { + background-color: var(--accent-value2); +} \ No newline at end of file diff --git a/frontend/webpage/static/styles/common.css b/frontend/webpage/static/styles/common.css new file mode 100644 index 0000000..1f72ac5 --- /dev/null +++ b/frontend/webpage/static/styles/common.css @@ -0,0 +1,592 @@ +:root { + --shadow-color: rgba(0, 0, 0, 0.5); +} + +html, body { + background-color: var(--background-dark); + + margin: 0; + border: 0; + padding: 0; + width: 100%; + height: 100%; + + font-family: sans-serif; +} + +input { + font-family: sans-serif; +} + +textarea { + font-family: sans-serif; +} + +#navbar { + z-index: 1; + + position: fixed; + + background-color: var(--accent-value2); + color: var(--foreground); + + width: 100%; + min-height: 76px; + height: 5%; + + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + box-shadow: 0px 0px 5px 10px var(--shadow-color); +} + +.navbar-section { + width: max-content; + height: 100%; + + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; +} + +.navbar-item { + box-sizing: border-box; + padding: 0px 20px; + + width: max-content; + height: 100%; + + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + + font-size: 24px; + + user-select: none; + + transition-property: background-color; + transition-duration: 0.15s; + + position: relative; +} + +.navbar-item:hover { + background-color: var(--accent-value3); +} + +@keyframes load { + from { transform: rotateZ(0deg ); } + to { transform: rotateZ(360deg); } +} + +.ap-loading { + animation: 1.5s cubic-bezier(0.4, 0.15, 0.6, 0.85) 0s infinite running load; +} + +.ap-button { + color: var(--foreground); + background-color: var(--accent-value2); + + border-radius: 4px; + + cursor: default; + + display: flex; + justify-content: center; + align-items: center; + + transition-property: background-color; + transition-duration: 0.15s; +} + +.ap-button:hover { + background-color: var(--accent-value3); +} + +.ap-button:active { + background-color: var(--accent-value1); +} + +.ap-button.disabled { + background-color: var(--accent-value1); + color: var(--accent-value0); +} + +.ap-button.disabled:hover { + background-color: var(--accent-value1); + color: var(--accent-value0); +} + +.ap-button.disabled:active { + background-color: var(--accent-value1); + color: var(--accent-value0); +} + +#content-body { + position: relative; + + top: max(76px, 5%); + + width: 100%; + height: calc(100% - max(76px, 5%)); + + overflow-y: auto; + + display: flex; + justify-content: center; +} + +.page { + box-sizing: border-box; + + padding: 32px; + + width: 100%; + height: max-content; + min-height: 100%; + + background-color: var(--background); + box-shadow: 0px 0px 5px 10px var(--shadow-color); +} + +.page-subsection { + box-sizing: border-box; + margin: 32px; + padding: 16px; + + background-color: var(--background-dark); + + border-radius: 8px; + + box-shadow: inset 0px 0px 8px 8px var(--shadow-color); +} + +.page-subsection-wrapper { + padding: 16px; +} + +.page-subsection-chunk { + box-shadow: 0px 0px 8px 8px var(--shadow-color); + + border-radius: 8px; + overflow: hidden; +} + +.page-header { + box-sizing: border-box; + + height: max-content; + + margin: 16px; +} + +.page-subheader { + margin-bottom: 16px; + + width: 100%; +} + +.page-subheader-flex { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; +} + +.page-header-text, +.page-subheader-text { + margin: 0 0 0.25em; + + color: var(--foreground); + + cursor: default; + + overflow: hidden; + text-overflow: ellipsis; + flex-grow: 1; +} + +.page-header-text.link, +.page-subheader-text.link { + transition-property: color; + transition-duration: 0.15s; +} + +.page-header-text.link:hover, +.page-subheader-text.link:hover { + color: var(--accent-value4); +} + +.page-header-text { + font-size: 48px; +} + +.page-subheader-text, +.page-subheader-icon { + font-size: 32px; +} + +.page-subheader-icon { + margin: 0 0 0.25em; + height: 32px; + padding: 0; +} + +.page-header-separator, +.page-subheader-separator { + width: 100%; + height: 3px; + + background-color: var(--accent-value3); +} + +#list-page-search-filters { + width: 100%; + height: max-content; +} + +#list-page-search-dropdown { + display: flex; + flex-direction: row; + align-items: center; +} + +#search-wrapper { + width: 100%; +} + +#list-page-search-pair { + flex-grow: 1; +} + +#filter-dropdown-wrapper { + box-sizing: border-box; + height: 84px; + width: 84px; +} + +#filter-dropdown-stack { + width: 100%; + height: 100%; + + position: relative; +} + +#filter-dropdown-stack.active { + transform: rotateZ(-90deg); +} + +#filter-dropdown-button { + position: absolute; + left: 0; + top: 0; + + height: 100%; + + display: block; +} + +#filter-dropdown-stack:hover > #filter-dropdown-button { + display: none; +} + +#filter-dropdown-button-hover { + position: absolute; + left: 0; + top: 0; + + height: 100%; + + display: none; +} + +#filter-dropdown-stack:hover > #filter-dropdown-button-hover { + display: block; +} + +#list-page-filters-flex { + display: none; + + width: 100%; + height: max-content; + + flex-direction: row; +} + +#list-page-filters-flex.active { + display: flex; +} + +.list-page-filter-section { + box-sizing: border-box; + + flex-basis: max-content; + flex-grow: 1; + + height: 100%; + + display: flex; + flex-direction: column; +} + +.list-page-filter-partition { + width: 20%; + max-width: 640px; +} + +.list-page-filter-chunk { + background-color: var(--distinction); + + width: 100%; + height: 100%; +} + +.list-page-filter { + box-sizing: border-box; + + width: 100%; + height: max-content; + + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; +} + +.list-page-filter-checkbox { + width: 28px; + height: 28px; + + background-color: var(--foreground); + + border: 3px solid var(--foreground); + border-radius: 8px; + + transition-property: background-color, border-color; + transition-duration: 0.15s; +} + +.list-page-filter:hover > .list-page-filter-checkbox { + background-color: var(--foreground); + border-color: var(--selected-accent1); +} + +.list-page-filter.selected > .list-page-filter-checkbox { + background-color: var(--selected-accent1); + border-color: var(--selected-accent1); +} + +.list-page-filter.selected:hover > .list-page-filter-checkbox { + background-color: var(--selected-accent0); + border-color: var(--selected-accent1); +} + +.list-page-filter-name, +.list-page-filter-label { + margin: 0; + padding: 16px; + + color: var(--foreground); + + font-size: 24px; + + user-select: none; +} + +.list-page-filter-label { + width: 40%; +} + +.list-page-filter-param { + padding: 4px; + width: 25%; + + font-size: 24px; + color: var(--background); + background-color: var(--foreground); + + border-radius: 8px; + + border: 0; + outline: none; +} + +#list-page-filters-background { + background-color: var(--distinction); +} + +.list-page-entry-text { + flex-basis: 0; +} + +.page.search { + max-width: 1720px; +} + +.list-page-search { + box-sizing: border-box; + + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; +} + +.list-page-search > label, +.list-page-search > input { + box-sizing: border-box; + padding: 12px 20px; + + color: var(--foreground); + + font-size: 24px; +} + +.list-page-search > label { + background-color: var(--accent-value2); +} + +.list-page-search > label:hover { + background-color: var(--accent-value3); +} + +.list-page-search > label:active { + background-color: var(--accent-value1); + + transition-property: background-color; + transition-duration: 0.15s; +} + +.list-page-search > input { + background-color: var(--distinction); + + border: 0; + + flex-grow: 1; + + outline: none; + + transition-property: background-color, color; + transition-duration: 0.075s; +} + +.list-page-search > input:focus { + background-color: var(--foreground); + + color: var(--background); +} + +.list-page-partitions { + box-sizing: border-box; + + width: 100%; + height: max-content; + + display: flex; + flex-direction: row; + justify-content: center; + align-items: flex-start; +} + + +.list-page-list-partition { + box-sizing: border-box; + + flex-grow: 1; +} + +.list-page-list { + border-radius: 8px; + + overflow: hidden; +} + +.list-page-header { + width: 100%; + height: 64px; + + background-color: var(--accent-value2); + + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + + color: var(--foreground); + font-size: 24px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.list-page-entry { + width: 100%; + height: 64px; + + background-color: var(--distinction); + + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + + color: var(--foreground); + font-size: 24px; + + transition-property: background-color; + transition-duration: 0.15s; +} + +.list-page-entry-icon { + width: 64px; + height: 64px; + + flex-grow: 0; +} + +.list-page-entry-text { + box-sizing: border-box; + + margin: 0; + padding: 0 12px; + height: 64px; + line-height: 64px; + + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + cursor: default; +} + +.list-page-entry > .list-page-entry-text { + border-top: 1px solid var(--background); + border-left: 1px solid var(--background); +} + +.list-page-header > .list-page-entry-text { + border-left: 1px solid var(--accent-value0); + + transition-property: background-color; + transition-duration: 0.15s; +} + +.list-page-header > .list-page-entry-text:hover { + background-color: var(--accent-value1); +} + +.list-page-header > .list-page-entry-text:active { + background-color: var(--accent-value0); +} + +#loading-results { + margin: 16px 0; + width: 100%; + height: 64px; + object-fit: contain; + + display: none; +} \ No newline at end of file diff --git a/frontend/webpage/static/styles/import.css b/frontend/webpage/static/styles/import.css new file mode 100644 index 0000000..98d69a1 --- /dev/null +++ b/frontend/webpage/static/styles/import.css @@ -0,0 +1,78 @@ +#import-page { + display: flex; + flex-direction: column; +} + +#import-dropzone { + flex-grow: 1; + + overflow-y: auto; +} + +#import-dropzone-wrapper { + box-sizing: border-box; + + width: 100%; + height: 100%; +} + +#upload-wrapper { + display: flex; + justify-content: center; + align-items: center; + + width: 100%; + height: 100%; +} + +#upload-icon-stack { + width: max-content; + height: max-content; + + position: relative; +} + +#import-icon-base, +#import-icon-hover { + width: 256px; + height: 256px; + display: block; +} + +#import-icon-hover { + position: absolute; + left: 0; + top: 0; +} + +#import-dropzone.active #import-icon-base, +#import-icon-hover { + visibility: hidden; +} + +#import-icon-base, +#import-dropzone.active #import-icon-hover { + visibility: visible; +} + +#import-console { + display: none; +} + +.console-entry { + box-sizing: border-box; + margin: 0; + padding-top: 0.25em; + height: max-content; + + color: var(--foreground); + font-size: 24px; + font-family: 'Ubuntu Mono', monospace; + white-space: nowrap; + text-overflow: ellipsis; + overflow-x: hidden; +} + +.console-entry.top { + padding-top: 0; +} \ No newline at end of file diff --git a/frontend/webpage/static/styles/login.css b/frontend/webpage/static/styles/login.css new file mode 100644 index 0000000..84c99d5 --- /dev/null +++ b/frontend/webpage/static/styles/login.css @@ -0,0 +1,125 @@ +:root { + --form-spacing: 48px; + + --element-spacing: 12px; +} + +#login-page { + display: block; + + max-width: 1280px; +} + +#login-flex { + display: flex; + flex-direction: row; + justify-content: center; +} + +#login-subsection { + width: 100%; +} + +#login-elements { + display: flex; + flex-direction: column; + align-items: center; +} + +#login-header { + box-sizing: border-box; + + width: 50%; + height: max-content; +} + +#login-form { + box-sizing: border-box; + + padding: 24px 0; + + width: 50%; + height: max-content; + + background-color: var(--distinction); + + border-radius: 8px; +} + +.form-row { + margin: var(--element-spacing) var(--form-spacing) 0; + border: 0; + padding: 0; + width: calc(100% - var(--form-spacing) * 2); + height: 40px; +} + +.form-row.top { + margin-top: 0; +} + +.form-row.multiline { + height: max-content; +} + +#error-message { + display: none; + + line-height: 40px; + + color: var(--error); + font-size: 20px; +} + +.login-field { + box-sizing: border-box; + padding: 0 var(--element-spacing); + font-size: 18px; + border-radius: 4px; + outline: none; +} + +.login-field.error { + background-color: var(--error); +} + +.ap-button.login { + height: 40px; + + font-size: 18px; +} + +#username, +#confirm { + display: none; +} + +#button-row { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +} + +#create-user-button, +#guest-login-button { + width: calc(50% - var(--element-spacing) / 2); +} + +#login-button { + width: calc(100% - var(--form-spacing * 2)) +} + +#warning { + color: var(--error); + font-size: 24px; + text-align: center; + line-height: 40px; +} + +#warning-message { + margin-top: 0; + color: var(--foreground); + font-size: 18px; + text-align: center; +} \ No newline at end of file diff --git a/frontend/webpage/static/styles/search.css b/frontend/webpage/static/styles/search.css new file mode 100644 index 0000000..e69de29 diff --git a/frontend/webpage/static/styles/search_achievements.css b/frontend/webpage/static/styles/search_achievements.css new file mode 100644 index 0000000..72f84d5 --- /dev/null +++ b/frontend/webpage/static/styles/search_achievements.css @@ -0,0 +1,9 @@ +.list-page-entry.achievement:hover { + background-color: var(--background-light); +} + +.list-page-entry-text.achievement-game-name { flex-grow: 1.75; } +.list-page-entry-text.achievement-name { flex-grow: 2; } +.list-page-entry-text.achievement-completion { flex-grow: 1; } +.list-page-entry-text.achievement-quality { flex-grow: 1; } +.list-page-entry-text.achievement-difficulty { flex-grow: 1; } \ No newline at end of file diff --git a/frontend/webpage/static/styles/search_games.css b/frontend/webpage/static/styles/search_games.css new file mode 100644 index 0000000..525cab5 --- /dev/null +++ b/frontend/webpage/static/styles/search_games.css @@ -0,0 +1,9 @@ +.list-page-entry-icon.game { + width: 137px; +} + +.list-page-entry-text.game-name { flex-grow: 2.5; } +.list-page-entry-text.game-achievement-count { flex-grow: 1; } +.list-page-entry-text.game-avg-completion { flex-grow: 1; } +.list-page-entry-text.game-num-owners { flex-grow: 1; } +.list-page-entry-text.game-num-perfects { flex-grow: 1; } \ No newline at end of file diff --git a/frontend/webpage/static/styles/search_users.css b/frontend/webpage/static/styles/search_users.css new file mode 100644 index 0000000..a1c116c --- /dev/null +++ b/frontend/webpage/static/styles/search_users.css @@ -0,0 +1,9 @@ +.list-page-entry.user:hover { + background-color: var(--background-light); +} + +.list-page-entry-text.user-username { flex-grow: 1.5; } +.list-page-entry-text.user-game-count { flex-grow: 1; } +.list-page-entry-text.user-achievement-count { flex-grow: 1; } +.list-page-entry-text.user-avg-completion { flex-grow: 1; } +.list-page-entry-text.user-perfect-games { flex-grow: 1; } \ No newline at end of file diff --git a/frontend/webpage/static/styles/theme.css b/frontend/webpage/static/styles/theme.css new file mode 100644 index 0000000..cc4b394 --- /dev/null +++ b/frontend/webpage/static/styles/theme.css @@ -0,0 +1,24 @@ +:root { + --background-dark: #111117; + --background: #22222A; + --background-light: #33333B; + --distinction: #44444C; + --foreground-disabled: #77777D; + --foreground-dark: #AAAAAA; + --foreground: #EEEEEE; + + --accent-hue: 0; + + --accent-value0: hsl(var(--accent-hue), 100%, 16%); + --accent-value1: hsl(var(--accent-hue), 100%, 25%); + --accent-value2: hsl(var(--accent-hue), 100%, 31%); + --accent-value3: hsl(var(--accent-hue), 73%, 47%); + --accent-value4: hsl(var(--accent-hue), 83%, 57%); + --accent-value5: hsl(var(--accent-hue), 93%, 72%); + --accent-pure: hsl(var(--accent-hue), 100%, 50%); + + --selected-accent0: #2266CC; + --selected-accent1: #3388FF; + + --error: #F95959; +} diff --git a/frontend/webpage/static/styles/user.css b/frontend/webpage/static/styles/user.css new file mode 100644 index 0000000..7f4e1c5 --- /dev/null +++ b/frontend/webpage/static/styles/user.css @@ -0,0 +1,448 @@ +#profile-page { + max-width: 1600px; +} + +#importing { + flex-direction: column; + align-items: center; + + display: none; +} + +#importing-text { + margin: 0; + height: 96px; + font-size: 64px; + line-height: 96px; + color: var(--foreground); +} + +#importing-loading { + height: 64px; + width: 64px; +} + +.profile-list { + width: 100%; + height: max-content; + + border-radius: 8px; + + overflow: hidden; + + box-shadow: 0px 0px 8px 8px var(--shadow-color); +} + +.profile-entry { + overflow: hidden; + + height: 64px; + + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + + background-color: var(--distinction); +} + +.profile-entry-left { + height: 100%; + + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; +} + +.profile-entry-right { + height: 100%; + + display: flex; + flex-direction: row; + justify-content: flex-end; + align-items: center; +} + +.profile-entry-icon { + width: 64px; + + flex-grow: 0; +} + +.profile-entry-text { + box-sizing: border-box; + + margin: 0; + padding: 0px 16px; + + height: 100%; + line-height: 64px; + + color: var(--foreground); + + font-size: 24px; + + border-top: 1px solid var(--background); + + flex-basis: max-content; + flex-grow: 0; +} + +.top > .profile-entry-text { + border: 0; +} + +.profile-entry-text.platform-name { + flex-grow: 1; +} + +#profile-section-1 { + box-sizing: border-box; + + width: 100%; + height: max-content; + + display: flex; + flex-direction: row; + justify-content: center; + align-items: flex-start; +} + +#profile-left { + widtH: 50%; + height: max-content; + + max-width: 512px; +} + +#profile-info-username-text.active, +#profile-info-username-field { + display: none; +} + +#profile-info-username-field.active { + display: block; +} + +#profile-info-username-field { + margin-right: 8px; + padding: 4px; + font-size: 24px; + color: var(--background); + background-color: var(--foreground); + + border-radius: 8px; + + border: 0; + outline: none; +} + +#profile-info-pfp-border { + box-sizing: border-box; + + padding: 24px; + background-color: var(--distinction); + + width: 100%; + height: max-content; + + box-shadow: 0px 0px 8px 8px var(--shadow-color); + + border-radius: 8px; +} + +#profile-info-pfp { + top: 0; + left: 0; + width: 100%; + height: 100%; + + border-radius: 8px; + + position: relative; +} + +#profile-info-pfp-img { + display: block; + width: 100%; + height: 100%; + border-radius: 8px; + object-fit: contain; + + background-color: var(--background-dark); + + position: absolute; +} + +#profile-info-pfp-vignette { + top: 0; + left: 0; + width: 100%; + height: 100%; + + box-shadow: inset 0px 0px 30px 10px var(--shadow-color); + + border-radius: 8px; + + position: absolute; + z-index: 1; +} + +#profile-info-pfp-upload, +#profile-info-pfp-upload-hover, +#profile-info-pfp-upload-invalid { + top: 0; + left: 0; + width: 100%; + + border-radius: 8px; + + background-color: var(--background-dark); + opacity: 0.8; + + display: block; + + visibility: hidden; +} + +#profile-info-pfp-upload { + position: relative; +} + +#profile-info-pfp-upload-hover, +#profile-info-pfp-upload-invalid { + position: absolute; +} + +#profile-info-pfp #profile-info-pfp-upload.active, +#profile-info-pfp.hover #profile-info-pfp-upload-hover.active, +#profile-info-pfp.invalid #profile-info-pfp-upload-invalid.active { + visibility: visible; +} + +#profile-info-pfp.hover #profile-info-pfp-upload.active, +#profile-info-pfp.invalid #profile-info-pfp-upload.active { + visibility: hidden; +} + +#profile-stats { + flex-grow: 1; + + display: flex; + flex-direction: row; +} + +#profile-stats-numeric { + flex-grow: 1; + max-width: 300px; + + display: flex; + flex-direction: column; + justify-content: space-between; +} + +#profile-completion-stack { + width: 100%; + height: max-content; + position: relative; +} + +#profile-completion-background { + width: 100%; + display: block; +} + +#profile-completion-canvas { + width: 100%; + height: 100%; + + position: absolute; + left: 0; + top: 0; +} + +#profile-completion-text { + margin: 0; + + width: 100%; + height: 100%; + + color: var(--foreground); + font-size: 64px; + + position: absolute; + left: 0; + top: 0; + + display: flex; + justify-content: center; + align-items: center; +} + +#profile-perfect-text { + margin: 0; + + height: 48px; + + color: var(--foreground); + + font-size: 48px; + line-height: 48px; + text-align: center; +} + +#profile-section-2 { + box-sizing: border-box; + + width: 100%; + height: max-content; + + display: flex; + flex-direction: row; + justify-content: center; + align-items: flex-start; +} + +#profile-hardest { + flex-grow: 1; + height: 100%; +} + +#profile-platforms .profile-entry { + display: none; +} + +#profile-platforms .profile-entry-text { + color: var(--foreground); +} + +#profile-platforms .profile-entry.connected, +#profile-platforms .profile-entry.editing { + display: flex; +} + +#profile-page .profile-edit-stack, +#profile-page .profile-save-stack { + display: none; +} + +#profile-page.self .profile-edit-stack, +#profile-page.self .profile-save-stack.active { + display: block; + width: max-content; + height: max-content; +} + +#profile-page.self .profile-edit-stack.active, +#profile-page.self .profile-save-stack { + display: none; +} + +.profile-edit-stack:hover > .profile-edit-hover, +.profile-edit { + display: block; +} + +.profile-edit-stack:hover > .profile-edit, +.profile-edit-hover { + display: none; +} + +.profile-save-stack:hover > .profile-save-hover, +.profile-save { + display: block; +} + +.profile-save-stack:hover > .profile-save, +.profile-save-hover { + display: none; +} + +.profile-entry .platform-remove-stack, +.profile-entry .platform-add, +.profile-entry .platform-unsupported, +.profile-entry.connected.editing .platform-add { + border-top: 1px solid var(--background); + display: none; +} + +.profile-entry.connected.editing .platform-remove-stack { + box-sizing: border-box; + display: block; + height: 100%; + width: max-content; + flex-grow: 0; +} + +.platform-remove, .platform-remove-hover { + box-sizing: border-box; + padding: 12px; + height: 100%; +} + +.profile-entry.connected.editing .platform-remove-stack .platform-remove, +.profile-entry.connected.editing .platform-remove-stack:hover .platform-remove-hover { + display: block; +} + +.profile-entry.connected.editing .platform-remove-stack:hover .platform-remove, +.profile-entry.connected.editing .platform-remove-stack .platform-remove-hover { + display: none; +} + +.profile-entry .platform-add { + box-sizing: border-box; + height: 100%; + padding: 16px 8px; +} + +.profile-entry.editing .platform-add { + display: block; +} + +.profile-entry.editing .platform-unsupported { + box-sizing: border-box; + display: block; + margin: 0; + padding: 0% 2%; + line-height: 63px; + font-size: 24px; + color: var(--foreground-disabled); +} + +#profile-ratings { + width: 100%; +} + +.list-page-entry.achievement:hover { + background-color: var(--background-light); +} + +.list-page-entry-text.achievement-name { flex-grow: 2; } +.list-page-entry-text.achievement-completion { flex-grow: 1; } +.list-page-entry-text.achievement-quality { flex-grow: 1; } +.list-page-entry-text.achievement-difficulty { flex-grow: 1; } + +.list-page-header > .list-page-entry-text.-aichevevement-name:hover, +.list-page-header > .list-page-entry-text.-aichevevement-completion:hover, +.list-page-header > .list-page-entry-text.-aichevevement-quality:hover, +.list-page-header > .list-page-entry-text.-aichevevement-difficulty:hover { + background-color: var(--accent-value2); +} + +.list-page-entry.rating:hover { + background-color: var(--background-light); +} + +.list-page-entry-text.rating-achievement { flex-grow: 2; } +.list-page-entry-text.rating-difficulty { flex-grow: 1; } +.list-page-entry-text.rating-quality { flex-grow: 1; } +.list-page-entry-text.rating-review { flex-grow: 5; } + +.list-page-header > .list-page-entry-text.rating-achievement:hover, +.list-page-header > .list-page-entry-text.rating-difficulty:hover, +.list-page-header > .list-page-entry-text.rating-quality:hover, +.list-page-header > .list-page-entry-text.rating-review:hover { + background-color: var(--accent-value2); +} \ No newline at end of file diff --git a/frontend/webpage/styles/index.css b/frontend/webpage/styles/index.css deleted file mode 100644 index a9fe898..0000000 --- a/frontend/webpage/styles/index.css +++ /dev/null @@ -1,707 +0,0 @@ -:root { - --background-dark: #111115; - --background: #22222A; - --foreground: #EEEEEE; - --distinction: #44444F; - - --accent-value0: #500000; - --accent-value1: #800000; - --accent-value2: #A00000; - --accent-value3: #D02020; - --accent-value4: #FA7575; - - --selected-accent0: #0066CC; - --selected-accent1: #3388FF; - - --navbar-background: var(--accent-value2); - --navbar-hover-background: var(--accent-value3); - --navbar-foreground: #EEEEEE; - - --header-color: var(--accent-value3); -} - -html, body { - background-color: var(--background-dark); - - margin: 0; - border: 0; - padding: 0; - width: 100%; - height: 100%; - - font-family: sans-serif; -} - -#navbar { - z-index: 1; - - position: fixed; - - background-color: var(--navbar-background); - color: var(--navbar-foreground); - - width: 100%; - min-height: 76px; - height: 5%; - - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - - box-shadow: 0px 0px 5px 10px rgba(0, 0, 0, 0.5); -} - -.navbar-section { - width: max-content; - height: 100%; - - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; -} - -.navbar-item { - box-sizing: border-box; - padding: 0px 20px; - - width: max-content; - height: 100%; - - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; - - font-size: 24px; - - user-select: none; - - transition-property: background-color; - transition-duration: 0.15s; - - position: relative; -} - -.navbar-item:hover { - background-color: var(--navbar-hover-background); -} - -#content-body { - position: relative; - - top: max(76px, 5%); - - width: 100%; - height: calc(100% - max(76px, 5%)); - - overflow-y: auto; - - display: flex; - justify-content: center; -} - -.page { - z-index: 0; - - box-sizing: border-box; - - padding: 0px 64px; - - width: 100%; - height: 100%; - - background-color: var(--background); - box-shadow: 0px 0px 5px 10px rgba(0, 0, 0, 0.5); - - overflow-y: auto; - - display: none; -} - -.page-header { - box-sizing: border-box; - - padding: 64px 0px; - - width: 100%; - height: max-content; -} - -.page-header-text { - width: max-content; - - margin: 0; - margin-bottom: 0.25em; - - color: var(--header-color); - - font-size: 64px; -} - -.page-header-separator { - width: 100%; - height: 3px; - - background-color: var(--foreground); -} - -.page-subheader-text { - width: max-content; - - margin: 0; - margin-bottom: 0.25em; - - color: var(--header-color); - - font-size: 48px; - - user-select: none; -} - -.page-subheader-separator { - width: 100%; - height: 3px; - - background-color: var(--foreground); - - transition-property: color; - transition-duration: 0.15s; -} - -.list-page-search { - box-sizing: border-box; - - padding: 32px 64px; - - width: 100%; - - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; -} - -.list-page-search > label { - box-sizing: border-box; - padding: 16px 24px; - - background-color: var(--accent-value2); - - color: var(--foreground); - - font-size: 32px; - - border-radius: 8px 0px 0px 8px; - - transition-property: background-color; - transition-duration: 0.15s; -} - -.list-page-search > label:hover { - background-color: var(--accent-value3); -} - -.list-page-search > label:active { - background-color: var(--accent-value1); -} - -.list-page-search > input { - box-sizing: border-box; - padding: 16px 24px; - - background-color: var(--distinction); - - color: var(--foreground); - - font-size: 32px; - - border: 0; - border-radius: 0px 8px 8px 0px; - - flex-grow: 1; - - outline: none; - - transition-property: background-color, color; - transition-duration: 0.075s; -} - -.list-page-search > input:focus { - background-color: var(--foreground); - color: var(--background); -} - -.list-page-partitions { - box-sizing: border-box; - - padding: 32px 64px; - - width: 100%; - height: max-content; - - display: flex; - flex-direction: row; - justify-content: center; - align-items: flex-start; -} - -.list-page-filter-partition { - width: 20%; - max-width: 640px; -} - -.list-page-filter { - margin-top: 16px; - - box-sizing: border-box; - - width: 100%; - height: max-content; - - display: flex; - flex-direction: row; - justify-content: flex-start; - align-items: center; -} - -.list-page-filter-checkbox { - width: 32px; - height: 32px; - - background-color: var(--background); - - border: 3px solid var(--distinction); - border-radius: 8px; - - transition-property: background-color, border-color; - transition-duration: 0.15s; -} - -.list-page-filter:hover > .list-page-filter-checkbox { - background-color: var(--background); - border-color: var(--selected-accent1); -} - -.list-page-filter.selected > .list-page-filter-checkbox { - background-color: var(--selected-accent1); - border-color: var(--selected-accent1); -} - -.list-page-filter.selected:hover > .list-page-filter-checkbox { - background-color: var(--selected-accent0); - border-color: var(--selected-accent1); -} - -.list-page-filter-name { - margin: 0; - padding: 16px; - - color: var(--foreground); - - font-size: 24px; - - user-select: none; -} - -.list-page-list-partition { - box-sizing: border-box; - - padding-left: 64px; - - flex-grow: 1; -} - -.list-page-list { - border-radius: 8px; - - overflow: hidden; - overflow-y: auto; -} - -.list-page-list-header { - width: 100%; - height: 64px; - - background-color: var(--accent-value2); - - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; - - color: var(--foreground); - font-size: 24px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.list-page-list-entry { - width: 100%; - height: 64px; - - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; - - color: var(--foreground); - font-size: 24px; - - border-bottom: 1px solid var(--distinction); -} - -#games-page { - max-width: 1920px; -} - -.game-list-page-entry-icon { - width: 64px; - height: 64px; - - flex-grow: 0; -} - -.game-list-page-entry-name { - box-sizing: border-box; - - margin: 0; - padding: 0 12px; - width: 0; - - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - - flex-grow: 1; - flex-basis: 0px; -} - -.game-list-page-entry-description { - box-sizing: border-box; - - margin: 0; - padding: 0 12px; - width: 0; - - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - - flex-grow: 2; - flex-basis: 0px; -} - -#achievements-page { - max-width: 1920px; -} - -.achievement-list-page-entry-icon { - width: 64px; - height: 64px; - - flex-grow: 0; -} - -.achievement-list-page-entry-name { - box-sizing: border-box; - - margin: 0; - padding: 0 12px; - width: 0; - - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - - flex-grow: 4; - flex-basis: 0px; -} - -.achievement-list-page-entry-description { - box-sizing: border-box; - - margin: 0; - padding: 0 12px; - width: 0; - - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - - flex-grow: 8; - flex-basis: 0px; -} - -.achievement-list-page-entry-stages { - box-sizing: border-box; - - margin: 0; - padding: 0 12px; - width: 0; - - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - - flex-grow: 1; - flex-basis: 0px; -} - -#profile-page { - max-width: 1920px; - - display: block; -} - -#profile-section-1 { - box-sizing: border-box; - - width: 100%; - height: max-content; - - display: flex; - flex-direction: row; - justify-content: center; - align-items: flex-start; -} - -#profile-info { - box-sizing: border-box; - - width: max-content; - height: max-content; - - max-width: 50%; - - padding: 0px 64px; - padding-bottom: 128px; -} - -#profile-info-pfp { - width: 100%; - max-width: 640px; -} - -#profile-info-name { - margin: 0; - - padding: 16px 0px; - - font-size: 42px; - - color: var(--header-color); -} - -#profile-platforms { - box-sizing: border-box; - - height: max-content; - - padding: 0px 64px; - - flex-grow: 1; -} - -.profile-platform-entry { - overflow: hidden; - - margin-top: 16px; - - height: 64px; - - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - - border: 3px solid var(--distinction); - border-radius: 12px; -} - -.profile-platform-entry.connected { - border: 3px solid var(--accent-value3); - - background-color: var(--accent-value2); -} - -.profile-platform-entry-left { - height: 100%; - - display: flex; - flex-direction: row; - justify-content: flex-start; - align-items: center; -} - -.profile-platform-entry-right { - height: 100%; - - display: flex; - flex-direction: row; - justify-content: flex-end; - align-items: center; -} - -.profile-platform-icon { - width: 64px; -} - -.profile-platform-name { - margin: 0; - padding: 0px 16px; - - color: var(--foreground); - - font-size: 24px; -} - -.profile-platform-connected { - display: none; - - margin: 0; - padding: 0px 16px; - - color: var(--foreground); - - font-size: 24px; -} - -.profile-platform-entry.connected .profile-platform-connected { - display: block; -} - -#profile-section-2 { - box-sizing: border-box; - - width: 100%; - height: max-content; - - display: flex; - flex-direction: row; - justify-content: center; - align-items: flex-start; -} - -#profile-games { - box-sizing: border-box; - padding: 0px 64px; - - width: 50%; - height: max-content; -} - -#profile-games > .page-subheader-text:hover { - color: var(--accent-value4); -} - -.profile-game-entry { - overflow: hidden; - - margin-top: 16px; - - height: 64px; - - display: flex; - flex-direction: row; - justify-content: flex-start; - align-items: center; - - border: 3px solid var(--distinction); - border-radius: 12px; -} - -.profile-game-entry-icon { - height: 64px; -} - -.profile-game-entry-name { - margin: 0; - padding: 0px 16px; - - color: var(--foreground); - - font-size: 24px; -} - -#profile-achievements { - box-sizing: border-box; - padding: 0px 64px; - - width: 50%; - height: max-content; -} - -#profile-achievements > .page-subheader-text:hover { - color: var(--accent-value4); -} - -.profile-achievement-entry { - overflow: hidden; - - margin-top: 16px; - - height: 64px; - - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - - border: 3px solid var(--distinction); - border-radius: 12px; -} - -.profile-achievement-entry.completed { - border: 3px solid var(--accent-value3); - - background-color: var(--accent-value2); -} - -.profile-achievement-entry-left { - height: 100%; - - display: flex; - flex-direction: row; - justify-content: flex-start; - align-items: center; -} - -.profile-achievement-entry-right { - height: 100%; - - display: flex; - flex-direction: row; - justify-content: flex-end; - align-items: center; -} - -.profile-achievement-entry-icon { - height: 64px; -} - -.profile-achievement-entry-name { - margin: 0; - padding: 0px 16px; - - color: var(--foreground); - - font-size: 24px; -} - -.profile-achievement-completed { - display: none; - - margin: 0; - padding: 0px 16px; - - color: var(--foreground); - - font-size: 24px; -} - -.profile-achievement-entry.completed .profile-achievement-completed { - display: block; -} - diff --git a/frontend/webpage/templates/achievements_page.html.template b/frontend/webpage/templates/achievements_page.html.template deleted file mode 100644 index b4fb2a8..0000000 --- a/frontend/webpage/templates/achievements_page.html.template +++ /dev/null @@ -1,40 +0,0 @@ - -
-
-

Filters

-
-
-
-

From Games Owned

-
-
-
-

In Progress

-
-
-
-

Completed

-
-
-
-
-
-

-

Name

-

Description

-

Stages

-
- -
-
-
diff --git a/frontend/webpage/templates/games_page.html.template b/frontend/webpage/templates/games_page.html.template deleted file mode 100644 index 1977414..0000000 --- a/frontend/webpage/templates/games_page.html.template +++ /dev/null @@ -1,53 +0,0 @@ - -
-
-

Filters

-
-
-
-

Games Owned

-
-
-
-
-
-

-

Name

-

Description

-
-
- Achievement Icon.png -

Latin

-

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

-
-
- Achievement Icon.png -

Latin

-

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

-
-
- Achievement Icon.png -

Latin

-

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

-
-
- Achievement Icon.png -

Latin

-

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

-
-
- Achievement Icon.png -

Latin

-

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

-
-
- Achievement Icon.png -

Latin

-

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

-
-
-
-
\ No newline at end of file diff --git a/frontend/webpage/templates/profile_page.html.template b/frontend/webpage/templates/profile_page.html.template deleted file mode 100644 index d2ce6a5..0000000 --- a/frontend/webpage/templates/profile_page.html.template +++ /dev/null @@ -1,135 +0,0 @@ -
-
- User's Profile Pictuer -

Jane Doe

-
-
-

Platforms

-
-
-
- Steam Logo -

Steam

-
-
-

Connected

-
-
-
-
- Xbox Logo -

Xbox Live

-
-
-

Connected

-
-
-
-
- PSN Logo -

PSN

-
-
-

Connected

-
-
-
-
-
-
-

Games

-
-
- Some Game Icon -

Latin

-
-
- Some Game Icon -

Latin

-
-
- Some Game Icon -

Latin

-
-
- Some Game Icon -

Latin

-
-
-
-

Achievements

-
-
-
- Some Achievement Icon -

Lorem Ipsum

-
-
-

Completed

-
-
-
-
- Some Achievement Icon -

Lorem Ipsum

-
-
-

Completed

-
-
-
-
- Some Achievement Icon -

Lorem Ipsum

-
-
-

Completed

-
-
-
-
- Some Achievement Icon -

Lorem Ipsum

-
-
-

Completed

-
-
-
-
- Some Achievement Icon -

Lorem Ipsum

-
-
-

Completed

-
-
-
-
- Some Achievement Icon -

Lorem Ipsum

-
-
-

Completed

-
-
-
-
- Some Achievement Icon -

Lorem Ipsum

-
-
-

Completed

-
-
-
-
- Some Achievement Icon -

Lorem Ipsum

-
-
-

Completed

-
-
-
-
\ No newline at end of file diff --git a/frontend/webpage/user.html b/frontend/webpage/user.html new file mode 100644 index 0000000..8d3caa8 --- /dev/null +++ b/frontend/webpage/user.html @@ -0,0 +1,178 @@ + + + + + Achievements Project + + + + + + + +
+
+
+ +
+
+

Contemplating...

+ Loading Symbol +
+ +
+
+ + + + + \ No newline at end of file diff --git a/sql/AuthProcs.sql b/sql/AuthProcs.sql new file mode 100644 index 0000000..c8717ac Binary files /dev/null and b/sql/AuthProcs.sql differ diff --git a/sql/CreateUserSP.sql b/sql/CreateUserSP.sql deleted file mode 100644 index 0af4edf..0000000 Binary files a/sql/CreateUserSP.sql and /dev/null differ diff --git a/sql/DataProcs.sql b/sql/DataProcs.sql new file mode 100644 index 0000000..a3d026f --- /dev/null +++ b/sql/DataProcs.sql @@ -0,0 +1,866 @@ +DELETE FROM [User] +DELETE FROM [Game] +DELETE FROM [Platform] +GO + +-------------- +-- HAS USER -- +-------------- + +CREATE PROCEDURE HasUser( + @result BIT OUTPUT +) +AS +SET @result = CASE WHEN EXISTS (SELECT * FROM [User]) THEN 1 ELSE 0 END +RETURN 0 +GO + +------------- +-- OP USER -- +------------- + +CREATE PROCEDURE OpUser( + @userId INT +) +AS +UPDATE [User] SET Admin = 1 WHERE @userId = [User].ID +RETURN 0 +GO + +----------------------- +-- GET ID FROM EMAIL -- +----------------------- + +CREATE PROCEDURE GetIdFromEmail( + @email VARCHAR(254), + @userId INT OUTPUT +) +AS +SELECT @userId = ID +FROM [User] +WHERE Email = @email +RETURN 0 +GO + +--------------------------------------- +-- GET USER NAME AND STATS PROCEDURE -- +--------------------------------------- + +CREATE PROCEDURE GetUserNameAndStats( + @userId INT, + @username VARCHAR(32) OUTPUT, + @completed INT OUTPUT, + @average INT OUTPUT, + @perfect INT OUTPUT +) +AS + +SELECT @username = Username +FROM [User] +WHERE ID = @userId + +IF @username IS NULL +BEGIN + PRINT 'No user with the specified ID was found' + RETURN 1 +END + +SELECT @completed = SUM(Completed) +FROM GameCompletionByUser +WHERE UserID = @userId + +SELECT @average = AVG((Completed * 100) / Total) +FROM GameCompletionByUser +WHERE UserID = @userId + +SELECT @perfect = COUNT(GameID) +FROM GameCompletionByUser +WHERE UserID = @userId AND Completed = Total + +RETURN 0 +GO + +SELECT * FROM [User] + +---------------------------------- +-- GET USER PLATFORMS PROCEDURE -- +---------------------------------- + +CREATE PROCEDURE GetUserPlatforms( + @userId INT +) +AS +IF NOT @userId IN (SELECT ID FROM [User]) +BEGIN + PRINT 'No user with the specified ID was found' + RETURN 1 +END +SELECT [Platform].ID, [PlatformName], (CASE WHEN UserID IS NOT NULL THEN 1 ELSE 0 END) AS Connected +FROM [Platform] +LEFT JOIN IsOn ON IsOn.PlatformID = [Platform].ID AND UserID = @userId +ORDER BY [Platform].ID +RETURN 0 +GO + +-------------------------------- +-- GET USER RATINGS PROCEDURE -- +-------------------------------- + +CREATE PROCEDURE GetUserRatings( + @userId INT +) +AS +IF NOT @userId IN (SELECT ID FROM [User]) +BEGIN + PRINT 'No user with the specified ID was found' + RETURN 1 +END +SELECT Game.Name AS GameName, Achievement.Name AS AchievementName, Quality, Difficulty, Rating.[Description] +FROM Rating +JOIN Achievement ON Achievement.ID = Rating.AchievementID +JOIN Game ON Game.ID = Achievement.GameID +WHERE UserID = @userId +RETURN 0 +GO + +------------------------------ +-- GET USER IMAGE PROCEDURE -- +------------------------------ + +CREATE PROCEDURE GetUserImage( + @userId INT +) +AS +IF NOT @userId IN (SELECT ID FROM [User]) +BEGIN + PRINT 'No user with the specified ID was found' + RETURN 1 +END +SELECT ProfileImage FROM [User] WHERE ID = @userId +RETURN 0 +GO + +------------------ +-- SET USERNAME -- +------------------ + +CREATE PROCEDURE SetUsername( + @userId INT, + @username VARCHAR(32) +) +AS +IF NOT @userId IN (SELECT ID FROM [User]) +BEGIN + PRINT 'No user with the specified ID was found' + RETURN 1 +END +UPDATE [User] SET Username = @username WHERE ID = @userId +RETURN 0 +GO + +------------------------------ +-- SET USER IMAGE PROCEDURE -- +------------------------------ + +CREATE PROCEDURE SetUserImage( + @userId INT, + @type ImageType, + @oldType ImageType OUTPUT +) +AS +IF NOT @userId IN (SELECT ID FROM [User]) +BEGIN + PRINT 'No user with the specified ID was found' + RETURN 1 +END +SELECT @oldType = ProfileImage FROM [User] WHERE ID = @userId +UPDATE [User] SET ProfileImage = @type WHERE ID = @userId +RETURN 0 +GO + +-------------------------- +-- ADD USER TO PLATFORM -- +-------------------------- + +CREATE PROCEDURE AddUserToPlatform( + @userId INT, + @platformId INT, + @platformUserID VARCHAR(32) +) +AS +IF NOT @userId IN (SELECT ID FROM [User]) +BEGIN + PRINT 'No user with the specified ID was found' + RETURN 1 +END +IF NOT @platformId IN (SELECT ID FROM [Platform]) +BEGIN + PRINT 'No platform with the specified ID was found' + RETURN 2 +END +IF EXISTS (SELECT * FROM IsOn WHERE UserID = @userId AND PlatformID = @platformId) +BEGIN + PRINT 'User already exists on specified platform' + RETURN 3 +END +INSERT INTO IsOn VALUES (@userId, @platformId, @platformUserId) +RETURN 0 +GO + +------------------------------- +-- REMOVE USER FROM PLATFORM -- +------------------------------- + +CREATE PROCEDURE RemoveUserFromPlatform( + @userId INT, + @platformId INT +) +AS +IF NOT @userId IN (SELECT ID FROM [User]) +BEGIN + PRINT 'No user with the specified ID was found' + RETURN 1 +END +IF NOT @platformId IN (SELECT ID FROM [Platform]) +BEGIN + PRINT 'No platform with the specified ID was found' + RETURN 2 +END +IF NOT EXISTS (SELECT UserID FROM IsOn WHERE UserID = @userId AND PlatformID = @platformId) +BEGIN + PRINT 'User does not exist on specified platform' + RETURN 3 +END +DELETE FROM IsOn WHERE UserID = @userId AND PlatformID = @platformId +DELETE FROM Progress WHERE UserID = @userId AND PlatformID = @platformId +DELETE FROM Owns WHERE UserID = @userId AND PlatformID = @platformId +RETURN 0 +GO + +------------------ +-- ADD PLATFORM -- +------------------ + +CREATE PROCEDURE AddPlatform( + @name VARCHAR(32), + @platformId INT OUTPUT +) +AS +IF @name IS NULL +BEGIN + PRINT 'Platform name cannot be null' + RETURN 1 +END +INSERT INTO [Platform] VALUES (@name, 'png') +SET @platformId = @@IDENTITY +RETURN 0 +GO + +--------------------- +-- REMOVE PLATFORM -- +--------------------- + +CREATE PROCEDURE RemovePlatform( + @platformId INT +) +AS +IF NOT @platformId IN (SELECT ID FROM [Platform]) +BEGIN + PRINT 'No platform with the specified ID was found' + RETURN 1 +END +IF @platformId IN (SELECT PlatformID FROM ExistsOn) +BEGIN + PRINT 'All games must be removed from the specified platform before it can be removed' + RETURN 2 +END +DELETE FROM [Platform] WHERE ID = @platformId +RETURN 0 +GO + +------------------- +-- GET PLATFORMS -- +------------------- + +CREATE PROCEDURE GetPlatforms +AS +SELECT ID, PlatformName FROM [Platform] +RETURN 0 +GO + +----------------------- +-- GET PLATFORM NAME -- +----------------------- + +CREATE PROCEDURE GetPlatformName( + @platformId INT, + @name VARCHAR(32) OUTPUT +) +AS +SELECT @name = PlatformName FROM [Platform] WHERE ID = @platformId +IF @name IS NULL +BEGIN + PRINT 'No platform with the specified ID was found' + RETURN 1 +END +RETURN 0 +GO + +----------------------- +-- GET PLATFORM ICON -- +----------------------- + +CREATE PROCEDURE GetPlatformIcon( + @platformId INT +) +AS +IF NOT @platformId IN (SELECT ID FROM [Platform]) +BEGIN + PRINT 'No platform with the specified ID was found' + RETURN 1 +END +SELECT Icon FROM [Platform] WHERE ID = @platformId +RETURN 0 +GO + +-------------- +-- ADD GAME -- +-------------- + +CREATE PROCEDURE AddGame( + @name VARCHAR(32), + @image ImageType, + @gameId INT OUTPUT +) +AS +IF @name IS NULL +BEGIN + PRINT 'Game name cannot be null' + RETURN 1 +END +IF @name IN (SELECT [Name] FROM Game) +BEGIN + PRINT 'Game with specified name already exists' + RETURN 2 +END +INSERT INTO Game VALUES (@name, @image) +SET @gameId = @@IDENTITY +RETURN 0 +GO + +--------------------- +-- ADD IF NOT GAME -- +--------------------- + +CREATE PROCEDURE AddIfNotGame( + @name VARCHAR(32), + @image VARCHAR(11), + @gameId INT OUTPUT +) +AS +IF @name IS NULL +BEGIN + PRINT 'Game name cannot be null' + RETURN 1 +END +-- Ideally game name wouldn't have to be unique, but I don't know of another way to sync games across platforms when they share no IDing system +IF NOT @name IN (SELECT [Name] FROM Game) +BEGIN + INSERT INTO Game VALUES (@name, @image) +END +SELECT @gameId = ID FROM Game WHERE [Name] = @name +RETURN 0 +GO + +----------------- +-- REMOVE GAME -- +----------------- + +CREATE PROCEDURE RemoveGame( + @gameId INT +) +AS +IF NOT @gameId IN (SELECT ID FROM Game) +BEGIN + PRINT 'No game with the specified ID was found' + RETURN 1 +END +DELETE FROM Game WHERE ID = @gameId +RETURN 0 +GO + +------------------- +-- GET GAME ICON -- +------------------- + +CREATE PROCEDURE GetGameIcon( + @gameId INT +) +AS +IF NOT @gameId IN (SELECT ID FROM [Game]) +BEGIN + PRINT 'No game with the specified ID was found' + RETURN 1 +END +SELECT Icon FROM [Game] WHERE ID = @gameId +RETURN 0 +GO + +---------------------- +-- ADD GAME TO USER -- +---------------------- + +CREATE PROCEDURE AddGameToUser( + @gameId INT, + @userId INT, + @platformId INT +) +AS +IF NOT @gameId IN (SELECT ID FROM Game) +BEGIN + PRINT 'No game with the specified ID was found' + RETURN 1 +END +IF NOT @userId IN (SELECT ID FROM [User]) +BEGIN + PRINT 'No user with the specified ID was found' + RETURN 2 +END +IF NOT @platformId IN (SELECT ID FROM [Platform]) +BEGIN + PRINT 'No platform with the specified ID was found' + RETURN 3 +END +IF NOT EXISTS (SELECT * FROM IsOn WHERE UserID = @userId AND PlatformID = @platformId) +BEGIN + PRINT 'User is not on specified platform' + RETURN 4 +END +IF EXISTS (SELECT * FROM Owns WHERE GameID = @gameId AND UserID = @userId AND PlatformID = @platformId) +BEGIN + PRINT 'Game is already owned by specified user on specified platform' + RETURN 5 +END +INSERT INTO Owns VALUES (@userId, @gameId, @platformId) +RETURN 0 +GO + +--------------------------- +-- REMOVE GAME FROM USER -- +--------------------------- + +CREATE PROCEDURE RemoveGameFromUser( + @gameId INT, + @userId INT, + @platformId INT +) +AS +IF NOT @gameId IN (SELECT ID FROM Game) +BEGIN + PRINT 'No game with the specified ID was found' + RETURN 1 +END +IF NOT @userId IN (SELECT ID FROM [User]) +BEGIN + PRINT 'No user with the specified ID was found' + RETURN 2 +END +IF NOT @platformId IN (SELECT ID FROM [Platform]) +BEGIN + PRINT 'No platform with the specified ID was found' + RETURN 3 +END +IF NOT EXISTS (SELECT * FROM Owns WHERE GameID = @gameId AND UserID = @userId AND PlatformID = @platformId) +BEGIN + PRINT 'Game is not owned by specified user on specified platform' + RETURN 4 +END +DELETE FROM Owns WHERE UserID = @userId AND GameID = @gameId AND PlatformID = @platformId +RETURN 0 +GO + +-------------------------- +-- ADD GAME TO PLATFORM -- +-------------------------- + +CREATE PROCEDURE AddGameToPlatform( + @gameId INT, + @platformId INT, + @platformGameId VARCHAR(32) +) +AS +IF NOT @gameId IN (SELECT ID FROM Game) +BEGIN + PRINT 'No game with the specified ID was found' + RETURN 1 +END +IF NOT @platformId IN (SELECT ID FROM [Platform]) +BEGIN + PRINT 'No platform with the specified ID was found' + RETURN 2 +END +IF EXISTS (SELECT * FROM ExistsOn WHERE GameID = @gameId AND PlatformID = @platformId) +BEGIN + PRINT 'Game already exists on specified platform' + RETURN 3 +END +INSERT INTO ExistsOn VALUES (@gameId, @platformId, @platformGameId) +RETURN 0 +GO + +------------------------------- +-- REMOVE GAME FROM PLATFORM -- +------------------------------- + +CREATE PROCEDURE RemoveGameFromPlatform( + @gameId INT, + @platformId INT +) +AS +IF NOT @gameId IN (SELECT ID FROM Game) +BEGIN + PRINT 'No game with the specified ID was found' + RETURN 1 +END +IF NOT @platformId IN (SELECT ID FROM [Platform]) +BEGIN + PRINT 'No platform with the specified ID was found' + RETURN 2 +END +IF NOT EXISTS (SELECT * FROM ExistsOn WHERE GameID = @gameId AND PlatformID = @platformId) +BEGIN + PRINT 'Game does not exist on specified platform' + RETURN 3 +END +DELETE FROM ExistsOn WHERE GameID = @gameId AND PlatformID = @platformId +RETURN 0 +GO + +--------------------- +-- ADD ACHIEVEMENT -- +--------------------- + +CREATE PROCEDURE AddAchievement( + @gameId INT, + @name VARCHAR(128), + @description VARCHAR(512), + @stages INT, + @image ImageType, + @achievementId INT OUTPUT +) +AS +IF NOT @gameId IN (SELECT ID FROM Game) +BEGIN + PRINT 'No game with the specified ID was found' + RETURN 1 +END +IF @name IS NULL +BEGIN + PRINT 'Achievement name cannot be null' + RETURN 2 +END +IF @stages IS NULL +BEGIN + PRINT 'Achievement stages cannot be null' + RETURN 3 +END +IF @name IN (SELECT [Name] FROM Achievement WHERE GameID = @gameId) +BEGIN + PRINT 'Achievement with specified name already exists for specified game' + RETURN 4 +END +INSERT INTO Achievement VALUES (@gameId, @name, @description, @stages, @image) +SET @achievementId = @@IDENTITY +RETURN 0 +GO + +---------------------------- +-- ADD IF NOT ACHIEVEMENT -- +---------------------------- + +CREATE PROCEDURE AddIfNotAchievement( + @gameId INT, + @name VARCHAR(128), + @description VARCHAR(512), + @stages INT, + @image VARCHAR(11), + @achievementId INT OUTPUT +) +AS +IF NOT @gameId IN (SELECT ID FROM Game) +BEGIN + PRINT 'No game with the specified ID was found' + RETURN 1 +END +IF @name IS NULL +BEGIN + PRINT 'Achievement name cannot be null' + RETURN 2 +END +IF @stages IS NULL +BEGIN + PRINT 'Achievement stages cannot be null' + RETURN 3 +END +IF NOT @name IN (SELECT [Name] FROM Achievement WHERE GameID = @gameId) +BEGIN + INSERT INTO Achievement VALUES (@gameId, @name, @description, @stages, @image) +END +SELECT @achievementId = ID FROM Achievement WHERE [Name] = @name AND GameID = @gameId +RETURN 0 +GO + +------------------------ +-- REMOVE ACHIEVEMENT -- +------------------------ + +CREATE PROCEDURE RemoveAchievement( + @achievementId INT +) +AS +IF NOT @achievementId IN (SELECT ID FROM Achievement) +BEGIN + PRINT 'No achievement with the specified ID was found' + RETURN 1 +END +DELETE FROM Achievement WHERE ID = @achievementId +RETURN 0 +GO + +-------------------------- +-- GET ACHIEVEMENT ICON -- +-------------------------- + +CREATE PROCEDURE GetAchievementIcon( + @achievementId INT +) +AS +IF NOT @achievementId IN (SELECT ID FROM Achievement) +BEGIN + PRINT 'No achievement with the specified ID was found' + RETURN 1 +END +SELECT Icon FROM Achievement WHERE ID = @achievementId +RETURN 0 +GO + +--------------------------------------- +-- SET ACHIEVEMENT PROGRESS FOR USER -- +--------------------------------------- + +CREATE PROCEDURE SetAchievementProgressForUser( + @userId INT, + @platformId INT, + @achievementId INT, + @progress INT +) +AS +IF NOT @userId IN (SELECT ID FROM [User]) +BEGIN + PRINT 'No user with the specified ID was found' + RETURN 1 +END +IF NOT @platformId IN (SELECT ID FROM [Platform]) +BEGIN + PRINT 'No platform with the specified ID was found' + RETURN 2 +END +IF NOT @achievementId IN (SELECT ID FROM Achievement) +BEGIN + PRINT 'No achievement with the specified ID was found' + RETURN 3 +END +IF EXISTS (SELECT * FROM Progress WHERE AchievementID = @achievementId AND UserID = @userId AND PlatformID = @platformId) +BEGIN + UPDATE Progress SET Progress = @progress WHERE AchievementID = @achievementId AND UserID = @userId AND PlatformID = @platformId +END +ELSE +BEGIN + INSERT INTO Progress VALUES (@userId, @platformId, @achievementId, @progress) +END +RETURN 0 +GO + +------------------------------------------ +-- GET NOTEWORTHY ACHIEVEMENTS FOR USER -- +------------------------------------------ + +CREATE PROCEDURE GetNoteworthyAchievementsForUser ( + @userId INT +) +AS +IF NOT @userId IN (SELECT ID FROM [User]) +BEGIN + PRINT 'No user with the specified ID was found' + RETURN 1 +END +SELECT TOP 5 Achievement.ID, Name, Completion +FROM Achievement +JOIN MaxProgress ON Achievement.ID = MaxProgress.AchievementID +JOIN AchievementCompletion AC ON AC.ID = Achievement.ID +WHERE UserID = @userId AND Progress = Stages +ORDER BY Completion ASC, NumberUsers DESC +RETURN 0 +GO + +--------------------- +-- GET ACHIEVEMENT -- +--------------------- + +CREATE PROCEDURE GetAchievement ( + @achievementId INT +) +AS +IF NOT @achievementId IN (SELECT ID FROM Achievement) +BEGIN + PRINT 'No achievement with the specified ID was found' + RETURN 1 +END +SELECT Achievement.ID, Name, Completion, Description, Difficulty, Quality +FROM Achievement +LEFT JOIN AchievementCompletion AC ON Achievement.ID = AC.ID +LEFT JOIN AchievementRatings AR ON Achievement.ID = AR.ID +WHERE Achievement.ID = @achievementId +RETURN 0 +GO + +--------------------------------- +-- GET RATINGS FOR ACHIEVEMENT -- +--------------------------------- + +CREATE PROCEDURE GetRatingsForAchievement( + @achievementId INT +) +AS +IF NOT @achievementId IN (SELECT ID FROM Achievement) +BEGIN + PRINT 'No achievement with the specified ID was found' + RETURN 1 +END +SELECT UserID, [Username], Difficulty, Quality, [Description] +FROM Rating +JOIN [User] ON [User].ID = Rating.UserID +WHERE AchievementID = @achievementId +RETURN 0 +GO + +------------------------- +-- GET RATINGS BY USER -- +------------------------- + +CREATE PROCEDURE GetRatingsByUser( + @userId INT +) +AS +IF NOT @userId IN (SELECT ID FROM [User]) +BEGIN + PRINT 'No user with the specified ID was found' + RETURN 1 +END +SELECT AchievementID, Achievement.[Name], Difficulty, Quality, Rating.[Description] +FROM Rating +JOIN Achievement ON Achievement.ID = Rating.UserID +WHERE UserID = @userId +RETURN 0 +GO + +EXEC GetRatingsByUser 0 + +------------------ +-- HAS PROGRESS -- +------------------ + +CREATE PROCEDURE HasProgress ( + @userId INT, + @achievementId INT, + @result BIT OUTPUT +) +AS +IF NOT @userId IN (SELECT ID FROM [User]) +BEGIN + PRINT 'No user with the specified ID was found' + RETURN 1 +END +IF NOT @achievementId IN (SELECT ID FROM Achievement) +BEGIN + PRINT 'No achievement with the specified ID was found' + RETURN 2 +END +SET @result = CASE WHEN EXISTS (SELECT * FROM Progress WHERE UserID = @userId AND AchievementID = @achievementId) THEN 1 ELSE 0 END +RETURN 0 +GO + +---------------- +-- GET RATING -- +---------------- + +CREATE PROCEDURE GetRating( + @userId INT, + @achievementId INT +) +AS +IF NOT @userId IN (SELECT ID FROM [User]) +BEGIN + PRINT 'No user with the specified ID was found' + RETURN 1 +END +IF NOT @achievementId IN (SELECT ID FROM Achievement) +BEGIN + PRINT 'No achievement with the specified ID was found' + RETURN 2 +END +SELECT Difficulty, Quality, [Description] +FROM Rating +WHERE UserID = @userId AND AchievementID = @achievementId +RETURN 0 +GO + +---------------- +-- SET RATING -- +---------------- + +CREATE PROCEDURE SetRating( + @userId INT, + @achievementId INT, + @difficulty FLOAT, + @quality FLOAT, + @review VARCHAR(1024) +) +AS +IF NOT @userId IN (SELECT ID FROM [User]) +BEGIN + PRINT 'No user with the specified ID was found' + RETURN 1 +END +IF NOT @achievementId IN (SELECT ID FROM Achievement) +BEGIN + PRINT 'No achievement with the specified ID was found' + RETURN 2 +END +IF NOT EXISTS (SELECT * FROM Progress WHERE UserID = @userId AND AchievementID = @achievementId) +BEGIN + PRINT 'User does not have progress on achievement' + RETURN 3 +END +IF @difficulty < 0 OR @difficulty > 10 +BEGIN + PRINT 'Difficult must be between 0 and 10' + RETURN 4 +END +IF @quality < 0 OR @quality > 10 +BEGIN + PRINT 'Quality must be between 0 and 10' + RETURN 5 +END +IF @quality IS NULL AND @quality IS NULL AND @review IS NULL + DELETE FROM Rating WHERE UserID = @userId AND AchievementID = @achievementId +ELSE IF EXISTS (SELECT * FROM Rating WHERE UserID = @userId AND AchievementID = @achievementId) + UPDATE Rating SET + Quality = @quality, + Difficulty = @difficulty, + [Description] = @review + WHERE UserID = @userId AND AchievementID = @achievementId +ELSE + INSERT INTO Rating VALUES (@userId, @achievementId, @quality, @difficulty, @review) +RETURN 0 +GO diff --git a/sql/Indexes.sql b/sql/Indexes.sql new file mode 100644 index 0000000..9723578 --- /dev/null +++ b/sql/Indexes.sql @@ -0,0 +1,21 @@ +----------------- +-- Email Index -- +----------------- + +CREATE NONCLUSTERED INDEX EmailIndex ON [User](Email) + +------------------ +-- Name Indexes -- +------------------ + +CREATE NONCLUSTERED INDEX UsernameIndex ON [User](Username) +CREATE NONCLUSTERED INDEX GameNameIndex ON Game(Name) +CREATE NONCLUSTERED INDEX AchievementNameIndex ON Achievement(Name) + +-------------------- +-- Rating Indexes -- +-------------------- + +CREATE NONCLUSTERED INDEX DifficultyIndex ON Rating(Difficulty) +CREATE NONCLUSTERED INDEX QualityIndex ON Rating(Quality) + diff --git a/sql/Mondo.sql b/sql/Mondo.sql new file mode 100644 index 0000000..e23f8aa --- /dev/null +++ b/sql/Mondo.sql @@ -0,0 +1,1339 @@ + --------------------- + --------------------- + -- -- +------ CREATE TABLES ------ + -- -- + --------------------- + --------------------- + +CREATE TYPE ImageType FROM VARCHAR(4) NULL +GO + +CREATE TABLE [User] ( + ID INT IDENTITY(0, 1) NOT NULL, + Email VARCHAR(254) NOT NULL, + Username VARCHAR(32) NOT NULL, + [Password] CHAR(64) NOT NULL, + [Salt] CHAR(32) NOT NULL, + Hue INT NOT NULL + CONSTRAINT HueDefault DEFAULT 0 + CONSTRAINT HueConstraint CHECK (0 <= Hue AND Hue <= 360), + ProfileImage ImageType, + [Admin] BIT NOT NULL + CONSTRAINT AdmivDefault DEFAULT 0, + Verified BIT NOT NULL + CONSTRAINT VerifiedDefault DEFAULT 0 + PRIMARY KEY(ID) +) + +CREATE TABLE [Platform] ( + ID INT IDENTITY(0, 1) NOT NULL, + PlatformName VARCHAR(32) NOT NULL, + Icon ImageType + PRIMARY KEY(ID) +) + +CREATE TABLE [Game] ( + ID INT IDENTITY(0, 1) NOT NULL, + Name VARCHAR(32) NOT NULL, + Icon ImageType + PRIMARY KEY(ID) +) + +CREATE TABLE [Achievement] ( + ID INT IDENTITY(0, 1) NOT NULL, + GameID INT NOT NULL, + Name VARCHAR(128) NOT NULL, + Description VARCHAR(512) NULL, + Stages INT NOT NULL, + Icon ImageType + PRIMARY KEY(ID) + FOREIGN KEY(GameID) REFERENCES [Game](ID) + ON UPDATE CASCADE + ON DELETE CASCADE +) + +CREATE TABLE [Owns] ( + UserID INT NOT NULL, + GameID INT NOT NULL, + PlatformID INT NOT NULL + PRIMARY KEY(UserID, GameID, PlatformID) + FOREIGN KEY(UserID) REFERENCES [User](ID) + ON UPDATE CASCADE + ON DELETE CASCADE, + FOREIGN KEY(GameID) REFERENCES [Game](ID) + ON UPDATE CASCADE + ON DELETE CASCADE, + FOREIGN KEY(PlatformID) REFERENCES [Platform](ID) + ON UPDATE CASCADE + ON DELETE CASCADE +) + +CREATE TABLE [Progress] ( + UserID INT NOT NULL, + PlatformID INT NOT NULL, + AchievementID INT NOT NULL, + Progress INT NOT NULL + PRIMARY KEY(UserID, PlatformID, AchievementID) + FOREIGN KEY(UserID) REFERENCES [User](ID) + ON UPDATE CASCADE + ON DELETE CASCADE, + FOREIGN KEY(PlatformID) REFERENCES [Platform](ID) + ON UPDATE CASCADE + ON DELETE CASCADE, + FOREIGN KEY(AchievementID) REFERENCES [Achievement](ID) + ON UPDATE CASCADE + ON DELETE CASCADE +) + +CREATE TABLE [IsOn] ( + UserID INT NOT NULL, + PlatformID INT NOT NULL, + PlatformUserID VARCHAR(32) NOT NULL + PRIMARY KEY(UserID, PlatformID) + FOREIGN KEY(UserID) REFERENCES [User](ID) + ON UPDATE CASCADE + ON DELETE CASCADE, + FOREIGN KEY(PlatformID) REFERENCES [Platform](ID) + ON UPDATE CASCADE + ON DELETE CASCADE +) + +CREATE TABLE [ExistsOn] ( + GameID INT NOT NULL, + PlatformID INT NOT NULL, + PlatformGameID VARCHAR(32) NOT NULL + PRIMARY KEY(GameID, PlatformID) + FOREIGN KEY(GameID) REFERENCES [Game](ID) + ON UPDATE CASCADE + ON DELETE CASCADE, + FOREIGN KEY(PlatformID) REFERENCES [Platform](ID) + ON UPDATE CASCADE +) + +CREATE TABLE [Rating] ( + UserID INT NOT NULL, + AchievementID INT NOT NULL, + Quality FLOAT NULL, + Difficulty FLOAT NULL, + [Description] VARCHAR(1024) NULL + PRIMARY KEY(UserID, AchievementID) + FOREIGN KEY(UserID) REFERENCES [User](ID) + ON UPDATE CASCADE + ON DELETE CASCADE, + FOREIGN KEY(AchievementID) REFERENCES [Achievement](ID) + ON UPDATE CASCADE + ON DELETE CASCADE +) + + -------------------- + -------------------- + -- -- +------ CREATE VIEWS ------ + -- -- + -------------------- + -------------------- + +-- The maximum progress a user has on an achievement across all platforms +CREATE VIEW MaxProgress +AS + SELECT UserID, AchievementID, MAX(Progress) AS Progress + FROM Progress + GROUP BY UserID, AchievementID +GO + +-- List of games and users with the number of completed achievements out of the total achievements the user has completed +CREATE VIEW GameCompletionByUser +AS + SELECT UserID, GameID, SUM(CASE WHEN Progress = Stages THEN 1 ELSE 0 END) AS Completed, COUNT(AchievementID) AS Total + FROM Achievement + JOIN MaxProgress ON AchievementID = Achievement.ID + GROUP BY UserID, GameID +GO + +-- List of achievements and the percentage of people who have completed it +CREATE VIEW AchievementCompletion +AS + SELECT Achievement.ID, (CASE WHEN COUNT(UserID) = 0 THEN NULL ELSE (SUM(CASE WHEN Progress = Stages THEN 1 ELSE 0 END) * 100 / COUNT(UserID)) END) AS Completion, COUNT(UserID) AS NumberUsers + FROM Achievement + LEFT JOIN MaxProgress ON AchievementID = Achievement.ID + GROUP BY Achievement.ID +GO + +-- List of achievements and their average quality and difficulty ratings filling with null as necessary +CREATE VIEW AchievementRatings +AS + SELECT Achievement.ID, AVG(Quality) AS Quality, AVG(Difficulty) AS Difficulty + FROM Achievement + LEFT JOIN Rating ON AchievementID = Achievement.ID + GROUP BY Achievement.ID +GO + +-- List of games owned by a user removing duplicate ownership if owned on multiple platforms +CREATE VIEW OwnsUnique +AS + SELECT UserID, GameID + FROM Owns + GROUP BY UserID, GameID +GO + + ---------------------- + ---------------------- + -- -- +------ CREATE INDICES ------ + -- -- + ---------------------- + ---------------------- + +----------------- +-- Email Index -- +----------------- + +CREATE NONCLUSTERED INDEX EmailIndex ON [User](Email) + +------------------ +-- Name Indexes -- +------------------ + +CREATE NONCLUSTERED INDEX UsernameIndex ON [User](Username) +CREATE NONCLUSTERED INDEX GameNameIndex ON Game(Name) +CREATE NONCLUSTERED INDEX AchievementNameIndex ON Achievement(Name) + +-------------------- +-- Rating Indexes -- +-------------------- + +CREATE NONCLUSTERED INDEX DifficultyIndex ON Rating(Difficulty) +CREATE NONCLUSTERED INDEX QualityIndex ON Rating(Quality) + + ------------------------- + ------------------------- + -- -- +------ CREATE PROCEDURES ------ + -- -- + ------------------------- + ------------------------- + +----------------------- +-- CREATE A NEW USER -- +----------------------- + +CREATE PROCEDURE [CreateUser] ( + @email VARCHAR(254), + @username VARCHAR(32), + @salt CHAR(32), + @password CHAR(64), + @ID INT OUTPUT, + @Hue INT OUTPUT +) AS +IF EXISTS (SELECT Email FROM [User] WHERE Email = @email) +BEGIN + PRINT 'Email is already registered with an account.' + RETURN 1 +END +IF @username IS NULL +BEGIN + PRINT 'Username cannot be null' + RETURN 2 +END +IF @password IS NULL +BEGIN + PRINT 'Password cannot be null' + RETURN 3 +END +IF @salt IS NULL +BEGIN + PRINT 'Password salt cannot be null' + RETURN 4 +END + +INSERT INTO [User](Email, Username, Salt, [Password]) VALUES (@email, @username, @salt, @password) +SET @ID = @@IDENTITY +SELECT @Hue = Hue FROM [User] WHERE ID = @ID + +RETURN 0 +GO + +------------------------- +-- GET USER LOGIN INFO -- +------------------------- + +CREATE PROCEDURE GetUserLogin( + @email VARCHAR(254) +) AS +IF NOT @email IN (SELECT Email FROM [User]) +BEGIN + PRINT 'No user exists with specified email' + RETURN 1 +END +SELECT Id, Salt, [Password], Hue, [Admin] FROM [User] WHERE Email = @email +RETURN 0 +GO + +DELETE FROM [User] +DELETE FROM [Game] +DELETE FROM [Platform] +GO + +-------------- +-- HAS USER -- +-------------- + +CREATE PROCEDURE HasUser( + @result BIT OUTPUT +) +AS +SET @result = CASE WHEN EXISTS (SELECT * FROM [User]) THEN 1 ELSE 0 END +RETURN 0 +GO + +------------- +-- OP USER -- +------------- + +CREATE PROCEDURE OpUser( + @userId INT +) +AS +UPDATE [User] SET Admin = 1 WHERE @userId = [User].ID +RETURN 0 +GO + +----------------------- +-- GET ID FROM EMAIL -- +----------------------- + +CREATE PROCEDURE GetIdFromEmail( + @email VARCHAR(254), + @userId INT OUTPUT +) +AS +SELECT @userId = ID +FROM [User] +WHERE Email = @email +RETURN 0 +GO + +--------------------------------------- +-- GET USER NAME AND STATS PROCEDURE -- +--------------------------------------- + +CREATE PROCEDURE GetUserNameAndStats( + @userId INT, + @username VARCHAR(32) OUTPUT, + @completed INT OUTPUT, + @average INT OUTPUT, + @perfect INT OUTPUT +) +AS + +SELECT @username = Username +FROM [User] +WHERE ID = @userId + +IF @username IS NULL +BEGIN + PRINT 'No user with the specified ID was found' + RETURN 1 +END + +SELECT @completed = SUM(Completed) +FROM GameCompletionByUser +WHERE UserID = @userId + +SELECT @average = AVG((Completed * 100) / Total) +FROM GameCompletionByUser +WHERE UserID = @userId + +SELECT @perfect = COUNT(GameID) +FROM GameCompletionByUser +WHERE UserID = @userId AND Completed = Total + +RETURN 0 +GO + +SELECT * FROM [User] + +---------------------------------- +-- GET USER PLATFORMS PROCEDURE -- +---------------------------------- + +CREATE PROCEDURE GetUserPlatforms( + @userId INT +) +AS +IF NOT @userId IN (SELECT ID FROM [User]) +BEGIN + PRINT 'No user with the specified ID was found' + RETURN 1 +END +SELECT [Platform].ID, [PlatformName], (CASE WHEN UserID IS NOT NULL THEN 1 ELSE 0 END) AS Connected +FROM [Platform] +LEFT JOIN IsOn ON IsOn.PlatformID = [Platform].ID AND UserID = @userId +ORDER BY [Platform].ID +RETURN 0 +GO + +-------------------------------- +-- GET USER RATINGS PROCEDURE -- +-------------------------------- + +CREATE PROCEDURE GetUserRatings( + @userId INT +) +AS +IF NOT @userId IN (SELECT ID FROM [User]) +BEGIN + PRINT 'No user with the specified ID was found' + RETURN 1 +END +SELECT Game.Name AS GameName, Achievement.Name AS AchievementName, Quality, Difficulty, Rating.[Description] +FROM Rating +JOIN Achievement ON Achievement.ID = Rating.AchievementID +JOIN Game ON Game.ID = Achievement.GameID +WHERE UserID = @userId +RETURN 0 +GO + +------------------------------ +-- GET USER IMAGE PROCEDURE -- +------------------------------ + +CREATE PROCEDURE GetUserImage( + @userId INT +) +AS +IF NOT @userId IN (SELECT ID FROM [User]) +BEGIN + PRINT 'No user with the specified ID was found' + RETURN 1 +END +SELECT ProfileImage FROM [User] WHERE ID = @userId +RETURN 0 +GO + +------------------ +-- SET USERNAME -- +------------------ + +CREATE PROCEDURE SetUsername( + @userId INT, + @username VARCHAR(32) +) +AS +IF NOT @userId IN (SELECT ID FROM [User]) +BEGIN + PRINT 'No user with the specified ID was found' + RETURN 1 +END +UPDATE [User] SET Username = @username WHERE ID = @userId +RETURN 0 +GO + +------------------------------ +-- SET USER IMAGE PROCEDURE -- +------------------------------ + +CREATE PROCEDURE SetUserImage( + @userId INT, + @type ImageType, + @oldType ImageType OUTPUT +) +AS +IF NOT @userId IN (SELECT ID FROM [User]) +BEGIN + PRINT 'No user with the specified ID was found' + RETURN 1 +END +SELECT @oldType = ProfileImage FROM [User] WHERE ID = @userId +UPDATE [User] SET ProfileImage = @type WHERE ID = @userId +RETURN 0 +GO + +-------------------------- +-- ADD USER TO PLATFORM -- +-------------------------- + +CREATE PROCEDURE AddUserToPlatform( + @userId INT, + @platformId INT, + @platformUserID VARCHAR(32) +) +AS +IF NOT @userId IN (SELECT ID FROM [User]) +BEGIN + PRINT 'No user with the specified ID was found' + RETURN 1 +END +IF NOT @platformId IN (SELECT ID FROM [Platform]) +BEGIN + PRINT 'No platform with the specified ID was found' + RETURN 2 +END +IF EXISTS (SELECT * FROM IsOn WHERE UserID = @userId AND PlatformID = @platformId) +BEGIN + PRINT 'User already exists on specified platform' + RETURN 3 +END +INSERT INTO IsOn VALUES (@userId, @platformId, @platformUserId) +RETURN 0 +GO + +------------------------------- +-- REMOVE USER FROM PLATFORM -- +------------------------------- + +CREATE PROCEDURE RemoveUserFromPlatform( + @userId INT, + @platformId INT +) +AS +IF NOT @userId IN (SELECT ID FROM [User]) +BEGIN + PRINT 'No user with the specified ID was found' + RETURN 1 +END +IF NOT @platformId IN (SELECT ID FROM [Platform]) +BEGIN + PRINT 'No platform with the specified ID was found' + RETURN 2 +END +IF NOT EXISTS (SELECT UserID FROM IsOn WHERE UserID = @userId AND PlatformID = @platformId) +BEGIN + PRINT 'User does not exist on specified platform' + RETURN 3 +END +DELETE FROM IsOn WHERE UserID = @userId AND PlatformID = @platformId +DELETE FROM Progress WHERE UserID = @userId AND PlatformID = @platformId +DELETE FROM Owns WHERE UserID = @userId AND PlatformID = @platformId +RETURN 0 +GO + +------------------ +-- ADD PLATFORM -- +------------------ + +CREATE PROCEDURE AddPlatform( + @name VARCHAR(32), + @platformId INT OUTPUT +) +AS +IF @name IS NULL +BEGIN + PRINT 'Platform name cannot be null' + RETURN 1 +END +INSERT INTO [Platform] VALUES (@name, 'png') +SET @platformId = @@IDENTITY +RETURN 0 +GO + +--------------------- +-- REMOVE PLATFORM -- +--------------------- + +CREATE PROCEDURE RemovePlatform( + @platformId INT +) +AS +IF NOT @platformId IN (SELECT ID FROM [Platform]) +BEGIN + PRINT 'No platform with the specified ID was found' + RETURN 1 +END +IF @platformId IN (SELECT PlatformID FROM ExistsOn) +BEGIN + PRINT 'All games must be removed from the specified platform before it can be removed' + RETURN 2 +END +DELETE FROM [Platform] WHERE ID = @platformId +RETURN 0 +GO + +------------------- +-- GET PLATFORMS -- +------------------- + +CREATE PROCEDURE GetPlatforms +AS +SELECT ID, PlatformName FROM [Platform] +RETURN 0 +GO + +----------------------- +-- GET PLATFORM NAME -- +----------------------- + +CREATE PROCEDURE GetPlatformName( + @platformId INT, + @name VARCHAR(32) OUTPUT +) +AS +SELECT @name = PlatformName FROM [Platform] WHERE ID = @platformId +IF @name IS NULL +BEGIN + PRINT 'No platform with the specified ID was found' + RETURN 1 +END +RETURN 0 +GO + +----------------------- +-- GET PLATFORM ICON -- +----------------------- + +CREATE PROCEDURE GetPlatformIcon( + @platformId INT +) +AS +IF NOT @platformId IN (SELECT ID FROM [Platform]) +BEGIN + PRINT 'No platform with the specified ID was found' + RETURN 1 +END +SELECT Icon FROM [Platform] WHERE ID = @platformId +RETURN 0 +GO + +-------------- +-- ADD GAME -- +-------------- + +CREATE PROCEDURE AddGame( + @name VARCHAR(32), + @image ImageType, + @gameId INT OUTPUT +) +AS +IF @name IS NULL +BEGIN + PRINT 'Game name cannot be null' + RETURN 1 +END +IF @name IN (SELECT [Name] FROM Game) +BEGIN + PRINT 'Game with specified name already exists' + RETURN 2 +END +INSERT INTO Game VALUES (@name, @image) +SET @gameId = @@IDENTITY +RETURN 0 +GO + +--------------------- +-- ADD IF NOT GAME -- +--------------------- + +CREATE PROCEDURE AddIfNotGame( + @name VARCHAR(32), + @image VARCHAR(11), + @gameId INT OUTPUT +) +AS +IF @name IS NULL +BEGIN + PRINT 'Game name cannot be null' + RETURN 1 +END +-- Ideally game name wouldn't have to be unique, but I don't know of another way to sync games across platforms when they share no IDing system +IF NOT @name IN (SELECT [Name] FROM Game) +BEGIN + INSERT INTO Game VALUES (@name, @image) +END +SELECT @gameId = ID FROM Game WHERE [Name] = @name +RETURN 0 +GO + +----------------- +-- REMOVE GAME -- +----------------- + +CREATE PROCEDURE RemoveGame( + @gameId INT +) +AS +IF NOT @gameId IN (SELECT ID FROM Game) +BEGIN + PRINT 'No game with the specified ID was found' + RETURN 1 +END +DELETE FROM Game WHERE ID = @gameId +RETURN 0 +GO + +------------------- +-- GET GAME ICON -- +------------------- + +CREATE PROCEDURE GetGameIcon( + @gameId INT +) +AS +IF NOT @gameId IN (SELECT ID FROM [Game]) +BEGIN + PRINT 'No game with the specified ID was found' + RETURN 1 +END +SELECT Icon FROM [Game] WHERE ID = @gameId +RETURN 0 +GO + +---------------------- +-- ADD GAME TO USER -- +---------------------- + +CREATE PROCEDURE AddGameToUser( + @gameId INT, + @userId INT, + @platformId INT +) +AS +IF NOT @gameId IN (SELECT ID FROM Game) +BEGIN + PRINT 'No game with the specified ID was found' + RETURN 1 +END +IF NOT @userId IN (SELECT ID FROM [User]) +BEGIN + PRINT 'No user with the specified ID was found' + RETURN 2 +END +IF NOT @platformId IN (SELECT ID FROM [Platform]) +BEGIN + PRINT 'No platform with the specified ID was found' + RETURN 3 +END +IF NOT EXISTS (SELECT * FROM IsOn WHERE UserID = @userId AND PlatformID = @platformId) +BEGIN + PRINT 'User is not on specified platform' + RETURN 4 +END +IF EXISTS (SELECT * FROM Owns WHERE GameID = @gameId AND UserID = @userId AND PlatformID = @platformId) +BEGIN + PRINT 'Game is already owned by specified user on specified platform' + RETURN 5 +END +INSERT INTO Owns VALUES (@userId, @gameId, @platformId) +RETURN 0 +GO + +--------------------------- +-- REMOVE GAME FROM USER -- +--------------------------- + +CREATE PROCEDURE RemoveGameFromUser( + @gameId INT, + @userId INT, + @platformId INT +) +AS +IF NOT @gameId IN (SELECT ID FROM Game) +BEGIN + PRINT 'No game with the specified ID was found' + RETURN 1 +END +IF NOT @userId IN (SELECT ID FROM [User]) +BEGIN + PRINT 'No user with the specified ID was found' + RETURN 2 +END +IF NOT @platformId IN (SELECT ID FROM [Platform]) +BEGIN + PRINT 'No platform with the specified ID was found' + RETURN 3 +END +IF NOT EXISTS (SELECT * FROM Owns WHERE GameID = @gameId AND UserID = @userId AND PlatformID = @platformId) +BEGIN + PRINT 'Game is not owned by specified user on specified platform' + RETURN 4 +END +DELETE FROM Owns WHERE UserID = @userId AND GameID = @gameId AND PlatformID = @platformId +RETURN 0 +GO + +-------------------------- +-- ADD GAME TO PLATFORM -- +-------------------------- + +CREATE PROCEDURE AddGameToPlatform( + @gameId INT, + @platformId INT, + @platformGameId VARCHAR(32) +) +AS +IF NOT @gameId IN (SELECT ID FROM Game) +BEGIN + PRINT 'No game with the specified ID was found' + RETURN 1 +END +IF NOT @platformId IN (SELECT ID FROM [Platform]) +BEGIN + PRINT 'No platform with the specified ID was found' + RETURN 2 +END +IF EXISTS (SELECT * FROM ExistsOn WHERE GameID = @gameId AND PlatformID = @platformId) +BEGIN + PRINT 'Game already exists on specified platform' + RETURN 3 +END +INSERT INTO ExistsOn VALUES (@gameId, @platformId, @platformGameId) +RETURN 0 +GO + +------------------------------- +-- REMOVE GAME FROM PLATFORM -- +------------------------------- + +CREATE PROCEDURE RemoveGameFromPlatform( + @gameId INT, + @platformId INT +) +AS +IF NOT @gameId IN (SELECT ID FROM Game) +BEGIN + PRINT 'No game with the specified ID was found' + RETURN 1 +END +IF NOT @platformId IN (SELECT ID FROM [Platform]) +BEGIN + PRINT 'No platform with the specified ID was found' + RETURN 2 +END +IF NOT EXISTS (SELECT * FROM ExistsOn WHERE GameID = @gameId AND PlatformID = @platformId) +BEGIN + PRINT 'Game does not exist on specified platform' + RETURN 3 +END +DELETE FROM ExistsOn WHERE GameID = @gameId AND PlatformID = @platformId +RETURN 0 +GO + +--------------------- +-- ADD ACHIEVEMENT -- +--------------------- + +CREATE PROCEDURE AddAchievement( + @gameId INT, + @name VARCHAR(128), + @description VARCHAR(512), + @stages INT, + @image ImageType, + @achievementId INT OUTPUT +) +AS +IF NOT @gameId IN (SELECT ID FROM Game) +BEGIN + PRINT 'No game with the specified ID was found' + RETURN 1 +END +IF @name IS NULL +BEGIN + PRINT 'Achievement name cannot be null' + RETURN 2 +END +IF @stages IS NULL +BEGIN + PRINT 'Achievement stages cannot be null' + RETURN 3 +END +IF @name IN (SELECT [Name] FROM Achievement WHERE GameID = @gameId) +BEGIN + PRINT 'Achievement with specified name already exists for specified game' + RETURN 4 +END +INSERT INTO Achievement VALUES (@gameId, @name, @description, @stages, @image) +SET @achievementId = @@IDENTITY +RETURN 0 +GO + +---------------------------- +-- ADD IF NOT ACHIEVEMENT -- +---------------------------- + +CREATE PROCEDURE AddIfNotAchievement( + @gameId INT, + @name VARCHAR(128), + @description VARCHAR(512), + @stages INT, + @image VARCHAR(11), + @achievementId INT OUTPUT +) +AS +IF NOT @gameId IN (SELECT ID FROM Game) +BEGIN + PRINT 'No game with the specified ID was found' + RETURN 1 +END +IF @name IS NULL +BEGIN + PRINT 'Achievement name cannot be null' + RETURN 2 +END +IF @stages IS NULL +BEGIN + PRINT 'Achievement stages cannot be null' + RETURN 3 +END +IF NOT @name IN (SELECT [Name] FROM Achievement WHERE GameID = @gameId) +BEGIN + INSERT INTO Achievement VALUES (@gameId, @name, @description, @stages, @image) +END +SELECT @achievementId = ID FROM Achievement WHERE [Name] = @name AND GameID = @gameId +RETURN 0 +GO + +------------------------ +-- REMOVE ACHIEVEMENT -- +------------------------ + +CREATE PROCEDURE RemoveAchievement( + @achievementId INT +) +AS +IF NOT @achievementId IN (SELECT ID FROM Achievement) +BEGIN + PRINT 'No achievement with the specified ID was found' + RETURN 1 +END +DELETE FROM Achievement WHERE ID = @achievementId +RETURN 0 +GO + +-------------------------- +-- GET ACHIEVEMENT ICON -- +-------------------------- + +CREATE PROCEDURE GetAchievementIcon( + @achievementId INT +) +AS +IF NOT @achievementId IN (SELECT ID FROM Achievement) +BEGIN + PRINT 'No achievement with the specified ID was found' + RETURN 1 +END +SELECT Icon FROM Achievement WHERE ID = @achievementId +RETURN 0 +GO + +--------------------------------------- +-- SET ACHIEVEMENT PROGRESS FOR USER -- +--------------------------------------- + +CREATE PROCEDURE SetAchievementProgressForUser( + @userId INT, + @platformId INT, + @achievementId INT, + @progress INT +) +AS +IF NOT @userId IN (SELECT ID FROM [User]) +BEGIN + PRINT 'No user with the specified ID was found' + RETURN 1 +END +IF NOT @platformId IN (SELECT ID FROM [Platform]) +BEGIN + PRINT 'No platform with the specified ID was found' + RETURN 2 +END +IF NOT @achievementId IN (SELECT ID FROM Achievement) +BEGIN + PRINT 'No achievement with the specified ID was found' + RETURN 3 +END +IF EXISTS (SELECT * FROM Progress WHERE AchievementID = @achievementId AND UserID = @userId AND PlatformID = @platformId) +BEGIN + UPDATE Progress SET Progress = @progress WHERE AchievementID = @achievementId AND UserID = @userId AND PlatformID = @platformId +END +ELSE +BEGIN + INSERT INTO Progress VALUES (@userId, @platformId, @achievementId, @progress) +END +RETURN 0 +GO + +------------------------------------------ +-- GET NOTEWORTHY ACHIEVEMENTS FOR USER -- +------------------------------------------ + +CREATE PROCEDURE GetNoteworthyAchievementsForUser ( + @userId INT +) +AS +IF NOT @userId IN (SELECT ID FROM [User]) +BEGIN + PRINT 'No user with the specified ID was found' + RETURN 1 +END +SELECT TOP 5 Achievement.ID, Name, Completion +FROM Achievement +JOIN MaxProgress ON Achievement.ID = MaxProgress.AchievementID +JOIN AchievementCompletion AC ON AC.ID = Achievement.ID +WHERE UserID = @userId AND Progress = Stages +ORDER BY Completion ASC, NumberUsers DESC +RETURN 0 +GO + +--------------------- +-- GET ACHIEVEMENT -- +--------------------- + +CREATE PROCEDURE GetAchievement ( + @achievementId INT +) +AS +IF NOT @achievementId IN (SELECT ID FROM Achievement) +BEGIN + PRINT 'No achievement with the specified ID was found' + RETURN 1 +END +SELECT Achievement.ID, Name, Completion, Description, Difficulty, Quality +FROM Achievement +LEFT JOIN AchievementCompletion AC ON Achievement.ID = AC.ID +LEFT JOIN AchievementRatings AR ON Achievement.ID = AR.ID +WHERE Achievement.ID = @achievementId +RETURN 0 +GO + +--------------------------------- +-- GET RATINGS FOR ACHIEVEMENT -- +--------------------------------- + +CREATE PROCEDURE GetRatingsForAchievement( + @achievementId INT +) +AS +IF NOT @achievementId IN (SELECT ID FROM Achievement) +BEGIN + PRINT 'No achievement with the specified ID was found' + RETURN 1 +END +SELECT UserID, [Username], Difficulty, Quality, [Description] +FROM Rating +JOIN [User] ON [User].ID = Rating.UserID +WHERE AchievementID = @achievementId +RETURN 0 +GO + +------------------------- +-- GET RATINGS BY USER -- +------------------------- + +CREATE PROCEDURE GetRatingsByUser( + @userId INT +) +AS +IF NOT @userId IN (SELECT ID FROM [User]) +BEGIN + PRINT 'No user with the specified ID was found' + RETURN 1 +END +SELECT AchievementID, Achievement.[Name], Difficulty, Quality, Rating.[Description] +FROM Rating +JOIN Achievement ON Achievement.ID = Rating.UserID +WHERE UserID = @userId +RETURN 0 +GO + +EXEC GetRatingsByUser 0 + +------------------ +-- HAS PROGRESS -- +------------------ + +CREATE PROCEDURE HasProgress ( + @userId INT, + @achievementId INT, + @result BIT OUTPUT +) +AS +IF NOT @userId IN (SELECT ID FROM [User]) +BEGIN + PRINT 'No user with the specified ID was found' + RETURN 1 +END +IF NOT @achievementId IN (SELECT ID FROM Achievement) +BEGIN + PRINT 'No achievement with the specified ID was found' + RETURN 2 +END +SET @result = CASE WHEN EXISTS (SELECT * FROM Progress WHERE UserID = @userId AND AchievementID = @achievementId) THEN 1 ELSE 0 END +RETURN 0 +GO + +---------------- +-- GET RATING -- +---------------- + +CREATE PROCEDURE GetRating( + @userId INT, + @achievementId INT +) +AS +IF NOT @userId IN (SELECT ID FROM [User]) +BEGIN + PRINT 'No user with the specified ID was found' + RETURN 1 +END +IF NOT @achievementId IN (SELECT ID FROM Achievement) +BEGIN + PRINT 'No achievement with the specified ID was found' + RETURN 2 +END +SELECT Difficulty, Quality, [Description] +FROM Rating +WHERE UserID = @userId AND AchievementID = @achievementId +RETURN 0 +GO + +---------------- +-- SET RATING -- +---------------- + +CREATE PROCEDURE SetRating( + @userId INT, + @achievementId INT, + @difficulty FLOAT, + @quality FLOAT, + @review VARCHAR(1024) +) +AS +IF NOT @userId IN (SELECT ID FROM [User]) +BEGIN + PRINT 'No user with the specified ID was found' + RETURN 1 +END +IF NOT @achievementId IN (SELECT ID FROM Achievement) +BEGIN + PRINT 'No achievement with the specified ID was found' + RETURN 2 +END +IF NOT EXISTS (SELECT * FROM Progress WHERE UserID = @userId AND AchievementID = @achievementId) +BEGIN + PRINT 'User does not have progress on achievement' + RETURN 3 +END +IF @difficulty < 0 OR @difficulty > 10 +BEGIN + PRINT 'Difficult must be between 0 and 10' + RETURN 4 +END +IF @quality < 0 OR @quality > 10 +BEGIN + PRINT 'Quality must be between 0 and 10' + RETURN 5 +END +IF @quality IS NULL AND @quality IS NULL AND @review IS NULL + DELETE FROM Rating WHERE UserID = @userId AND AchievementID = @achievementId +ELSE IF EXISTS (SELECT * FROM Rating WHERE UserID = @userId AND AchievementID = @achievementId) + UPDATE Rating SET + Quality = @quality, + Difficulty = @difficulty, + [Description] = @review + WHERE UserID = @userId AND AchievementID = @achievementId +ELSE + INSERT INTO Rating VALUES (@userId, @achievementId, @quality, @difficulty, @review) +RETURN 0 +GO + +------------------------- +-- SEARCH ACHIEVEMENTS -- +------------------------- + +CREATE PROCEDURE SearchAchievements( + @searchTerm VARCHAR(32), + @userId INT, + @completed BIT, + @minCompletion FLOAT, + @maxCompletion FLOAT, + @minDifficulty FLOAT, + @maxDifficulty FLOAT, + @minQuality FLOAT, + @maxQuality FLOAT, + @orderBy VARCHAR(16), + @orderDirection VARCHAR(4) +) +AS +IF @userId IS NULL AND @completed = 1 +BEGIN + PRINT 'Cannot search for completed achievements with no user specified' + RETURN 1 +END + +IF @completed IS NULL +SET @completed = 0 + +IF @searchTerm IS NULL OR @searchTerm = '' + SET @searchTerm = '%' +ELSE + SET @searchTerm = '%' + @searchTerm + '%' + +SELECT TOP 100 Achievement.ID, Game.[Name] AS Game, Achievement.[Name], Completion, Difficulty, Quality +FROM Achievement +JOIN Game ON Game.ID = GameID +JOIN AchievementCompletion AC ON AC.ID = Achievement.ID +JOIN AchievementRatings AR ON AR.ID = Achievement.ID +WHERE (Game.[Name] LIKE @searchTerm OR Achievement.[Name] LIKE @searchTerm) + AND (@completed <> 1 OR Achievement.ID IN (SELECT AchievementID FROM MaxProgress WHERE UserID = @userId AND Progress = Stages)) + AND (@minCompletion IS NULL OR @minCompletion <= Completion) + AND (@maxCompletion IS NULL OR @maxCompletion >= Completion) + AND (@minDifficulty IS NULL OR @minDifficulty <= Difficulty) + AND (@maxDifficulty IS NULL OR @maxDifficulty >= Difficulty) + AND (@minQuality IS NULL OR @minQuality <= Quality ) + AND (@maxQuality IS NULL OR @maxQuality >= Quality ) +ORDER BY + CASE WHEN @orderDirection = 'ASC' AND @orderBy = 'Game' THEN Game.[Name] ELSE NULL END ASC, + CASE WHEN @orderDirection = 'ASC' AND @orderBy = 'Name' THEN Achievement.[Name] ELSE NULL END ASC, + CASE WHEN @orderDirection = 'ASC' AND @orderBy = 'Completion' THEN Completion ELSE NULL END ASC, + CASE WHEN @orderDirection = 'ASC' AND @orderBy = 'Difficulty' THEN Difficulty ELSE NULL END ASC, + CASE WHEN @orderDirection = 'ASC' AND @orderBy = 'Quality' THEN Quality ELSE NULL END ASC, + CASE WHEN @orderDirection = 'DESC' AND @orderBy = 'Game' THEN Game.[Name] ELSE NULL END DESC, + CASE WHEN @orderDirection = 'DESC' AND @orderBy = 'Name' THEN Achievement.[Name] ELSE NULL END DESC, + CASE WHEN @orderDirection = 'DESC' AND @orderBy = 'Completion' THEN Completion ELSE NULL END DESC, + CASE WHEN @orderDirection = 'DESC' AND @orderBy = 'Difficulty' THEN Difficulty ELSE NULL END DESC, + CASE WHEN @orderDirection = 'DESC' AND @orderBy = 'Quality' THEN Quality ELSE NULL END DESC +RETURN 0 +GO + +------------------ +-- SEARCH USERS -- +------------------ + +CREATE PROCEDURE SearchUsers( + @searchTerm VARCHAR(32), + @minOwned INT, + @maxOwned INT, + @minCompleted INT, + @maxCompleted INT, + @minAvgCompletion INT, + @maxAvgCompletion INT, + @orderBy VARCHAR(16), + @orderDirection VARCHAR(4) +) +AS + +IF @searchTerm IS NULL OR @searchTerm = '' + SET @searchTerm = '%' +ELSE + SET @searchTerm = '%' + @searchTerm + '%' + +SELECT TOP 100 * +FROM ( + SELECT [User].ID, Username, ISNULL(GameCount, 0) AS GameCount, ISNULL(AchievementCount, 0) AS AchievementCount, AvgCompletion, ISNULL(PerfectGames, 0) AS PerfectGames + FROM [User] + LEFT JOIN ( + SELECT + UserID, + COUNT(GameID) AS GameCount, + SUM(Completed) AS AchievementCount, + AVG((Completed * 100) / Total) AS AvgCompletion, + SUM(CASE WHEN Completed = Total THEN 1 ELSE 0 END) AS PerfectGames + FROM GameCompletionByUser + GROUP BY UserID + ) AS Completion ON Completion.UserID = [User].ID +) AS Results +WHERE (Username LIKE @searchTerm) + AND (@minOwned IS NULL OR @minOwned <= GameCount ) + AND (@maxOwned IS NULL OR @maxOwned >= GameCount ) + AND (@minCompleted IS NULL OR @minCompleted <= AchievementCount) + AND (@maxCompleted IS NULL OR @maxCompleted >= AchievementCount) + AND (@minAvgCompletion IS NULL OR @minAvgCompletion <= AvgCompletion ) + AND (@maxAvgCompletion IS NULL OR @maxAvgCompletion >= AvgCompletion ) +ORDER BY + CASE WHEN @orderDirection = 'ASC' AND @orderBy = 'Username' THEN Username ELSE NULL END ASC, + CASE WHEN @orderDirection = 'ASC' AND @orderBy = 'GameCount' THEN GameCount ELSE NULL END ASC, + CASE WHEN @orderDirection = 'ASC' AND @orderBy = 'AchievementCount' THEN AchievementCount ELSE NULL END ASC, + CASE WHEN @orderDirection = 'ASC' AND @orderBy = 'AvgCompletion' THEN AvgCompletion ELSE NULL END ASC, + CASE WHEN @orderDirection = 'ASC' AND @orderBy = 'PerfectCount' THEN PerfectGames ELSE NULL END ASC, + CASE WHEN @orderDirection = 'DESC' AND @orderBy = 'Username' THEN Username ELSE NULL END DESC, + CASE WHEN @orderDirection = 'DESC' AND @orderBy = 'GameCount' THEN GameCount ELSE NULL END DESC, + CASE WHEN @orderDirection = 'DESC' AND @orderBy = 'AchievementCount' THEN AchievementCount ELSE NULL END DESC, + CASE WHEN @orderDirection = 'DESC' AND @orderBy = 'AvgCompletion' THEN AvgCompletion ELSE NULL END DESC, + CASE WHEN @orderDirection = 'DESC' AND @orderBy = 'PerfectCount' THEN PerfectGames ELSE NULL END DESC +RETURN 0 +GO +------------------ +-- SEARCH GAMES -- +------------------ + +CREATE PROCEDURE SearchGames( + @searchTerm VARCHAR(32), + @userId INT, + @owned BIT, + @minAvgCompletion INT, + @maxAvgCompletion INT, + @minNumOwners INT, + @maxNumOwners INT, + @minNumPerfects INT, + @maxNumPerfects INT, + @orderBy VARCHAR(16), + @orderDirection VARCHAR(4) +) +AS +IF @userId IS NULL AND @owned = 1 +BEGIN + PRINT 'Cannot search for owned games with no user specified' + RETURN 1 +END + +PRINT 'UserID, Owned' +PRINT @userId +PRINT @owned + +IF @owned IS NULL +SET @owned = 0 + +IF @searchTerm IS NULL OR @searchTerm = '' + SET @searchTerm = '%' +ELSE + SET @searchTerm = '%' + @searchTerm + '%' + +SELECT TOP 100 * +FROM ( + SELECT + Game.ID, + [Name], + AchievementCount, + AvgCompletion, + ISNULL(NumOwners, 0) AS NumOwners, + ISNULL(NumPerfects, 0) AS NumPerfects + FROM Game + LEFT JOIN ( + SELECT + GameID, + Total AS AchievementCount, + AVG((Completed * 100) / Total) AS AvgCompletion, + SUM(CASE WHEN Completed = Total THEN 1 ELSE 0 END) AS NumPerfects + FROM GameCompletionByUser + GROUP BY GameID, Total + ) AS Completion ON Completion.GameID = Game.ID + LEFT JOIN ( + SELECT GameID, COUNT(UserID) AS NumOwners + FROM OwnsUnique + GROUP BY GameID + ) AS Owners ON Owners.GameID = Game.ID +) AS Results +WHERE ([Name] LIKE @searchTerm) + AND (@owned <> 1 OR ID IN (SELECT GameID FROM OwnsUnique WHERE UserID = @userId)) + AND (@minAvgCompletion IS NULL OR @minAvgCompletion <= AvgCompletion) + AND (@maxAvgCompletion IS NULL OR @maxAvgCompletion >= AvgCompletion) + AND (@minNumOwners IS NULL OR @minNumOwners <= NumOwners ) + AND (@maxNumOwners IS NULL OR @maxNumOwners >= NumOwners ) + AND (@minNumPerfects IS NULL OR @minNumPerfects <= NumPerfects ) + AND (@maxNumPerfects IS NULL OR @maxNumPerfects >= NumPerfects ) +ORDER BY + CASE WHEN @orderDirection = 'ASC' AND @orderBy = 'Name' THEN [Name] ELSE NULL END ASC, + CASE WHEN @orderDirection = 'ASC' AND @orderBy = 'AchievementCount' THEN AchievementCount ELSE NULL END ASC, + CASE WHEN @orderDirection = 'ASC' AND @orderBy = 'AvgCompletion' THEN AvgCompletion ELSE NULL END ASC, + CASE WHEN @orderDirection = 'ASC' AND @orderBy = 'NumOwners' THEN NumOwners ELSE NULL END ASC, + CASE WHEN @orderDirection = 'ASC' AND @orderBy = 'NumPerfects' THEN NumPerfects ELSE NULL END ASC, + CASE WHEN @orderDirection = 'DESC' AND @orderBy = 'Name' THEN [Name] ELSE NULL END DESC, + CASE WHEN @orderDirection = 'DESC' AND @orderBy = 'AchievementCount' THEN AchievementCount ELSE NULL END DESC, + CASE WHEN @orderDirection = 'DESC' AND @orderBy = 'AvgCompletion' THEN AvgCompletion ELSE NULL END DESC, + CASE WHEN @orderDirection = 'DESC' AND @orderBy = 'NumOwners' THEN NumOwners ELSE NULL END DESC, + CASE WHEN @orderDirection = 'DESC' AND @orderBy = 'NumPerfects' THEN NumPerfects ELSE NULL END DESC +RETURN 0 +GO + +EXEC SearchGames '', 3, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL diff --git a/sql/SearchProcs.sql b/sql/SearchProcs.sql new file mode 100644 index 0000000..abe3a03 --- /dev/null +++ b/sql/SearchProcs.sql @@ -0,0 +1,200 @@ +------------------------- +-- SEARCH ACHIEVEMENTS -- +------------------------- + +CREATE PROCEDURE SearchAchievements( + @searchTerm VARCHAR(32), + @userId INT, + @completed BIT, + @minCompletion FLOAT, + @maxCompletion FLOAT, + @minDifficulty FLOAT, + @maxDifficulty FLOAT, + @minQuality FLOAT, + @maxQuality FLOAT, + @orderBy VARCHAR(16), + @orderDirection VARCHAR(4) +) +AS +IF @userId IS NULL AND @completed = 1 +BEGIN + PRINT 'Cannot search for completed achievements with no user specified' + RETURN 1 +END + +IF @completed IS NULL +SET @completed = 0 + +IF @searchTerm IS NULL OR @searchTerm = '' + SET @searchTerm = '%' +ELSE + SET @searchTerm = '%' + @searchTerm + '%' + +SELECT TOP 100 Achievement.ID, Game.[Name] AS Game, Achievement.[Name], Completion, Difficulty, Quality +FROM Achievement +JOIN Game ON Game.ID = GameID +JOIN AchievementCompletion AC ON AC.ID = Achievement.ID +JOIN AchievementRatings AR ON AR.ID = Achievement.ID +WHERE (Game.[Name] LIKE @searchTerm OR Achievement.[Name] LIKE @searchTerm) + AND (@completed <> 1 OR Achievement.ID IN (SELECT AchievementID FROM MaxProgress WHERE UserID = @userId AND Progress = Stages)) + AND (@minCompletion IS NULL OR @minCompletion <= Completion) + AND (@maxCompletion IS NULL OR @maxCompletion >= Completion) + AND (@minDifficulty IS NULL OR @minDifficulty <= Difficulty) + AND (@maxDifficulty IS NULL OR @maxDifficulty >= Difficulty) + AND (@minQuality IS NULL OR @minQuality <= Quality ) + AND (@maxQuality IS NULL OR @maxQuality >= Quality ) +ORDER BY + CASE WHEN @orderDirection = 'ASC' AND @orderBy = 'Game' THEN Game.[Name] ELSE NULL END ASC, + CASE WHEN @orderDirection = 'ASC' AND @orderBy = 'Name' THEN Achievement.[Name] ELSE NULL END ASC, + CASE WHEN @orderDirection = 'ASC' AND @orderBy = 'Completion' THEN Completion ELSE NULL END ASC, + CASE WHEN @orderDirection = 'ASC' AND @orderBy = 'Difficulty' THEN Difficulty ELSE NULL END ASC, + CASE WHEN @orderDirection = 'ASC' AND @orderBy = 'Quality' THEN Quality ELSE NULL END ASC, + CASE WHEN @orderDirection = 'DESC' AND @orderBy = 'Game' THEN Game.[Name] ELSE NULL END DESC, + CASE WHEN @orderDirection = 'DESC' AND @orderBy = 'Name' THEN Achievement.[Name] ELSE NULL END DESC, + CASE WHEN @orderDirection = 'DESC' AND @orderBy = 'Completion' THEN Completion ELSE NULL END DESC, + CASE WHEN @orderDirection = 'DESC' AND @orderBy = 'Difficulty' THEN Difficulty ELSE NULL END DESC, + CASE WHEN @orderDirection = 'DESC' AND @orderBy = 'Quality' THEN Quality ELSE NULL END DESC +RETURN 0 +GO + +------------------ +-- SEARCH USERS -- +------------------ + +CREATE PROCEDURE SearchUsers( + @searchTerm VARCHAR(32), + @minOwned INT, + @maxOwned INT, + @minCompleted INT, + @maxCompleted INT, + @minAvgCompletion INT, + @maxAvgCompletion INT, + @orderBy VARCHAR(16), + @orderDirection VARCHAR(4) +) +AS + +IF @searchTerm IS NULL OR @searchTerm = '' + SET @searchTerm = '%' +ELSE + SET @searchTerm = '%' + @searchTerm + '%' + +SELECT TOP 100 * +FROM ( + SELECT [User].ID, Username, ISNULL(GameCount, 0) AS GameCount, ISNULL(AchievementCount, 0) AS AchievementCount, AvgCompletion, ISNULL(PerfectGames, 0) AS PerfectGames + FROM [User] + LEFT JOIN ( + SELECT + UserID, + COUNT(GameID) AS GameCount, + SUM(Completed) AS AchievementCount, + AVG((Completed * 100) / Total) AS AvgCompletion, + SUM(CASE WHEN Completed = Total THEN 1 ELSE 0 END) AS PerfectGames + FROM GameCompletionByUser + GROUP BY UserID + ) AS Completion ON Completion.UserID = [User].ID +) AS Results +WHERE (Username LIKE @searchTerm) + AND (@minOwned IS NULL OR @minOwned <= GameCount ) + AND (@maxOwned IS NULL OR @maxOwned >= GameCount ) + AND (@minCompleted IS NULL OR @minCompleted <= AchievementCount) + AND (@maxCompleted IS NULL OR @maxCompleted >= AchievementCount) + AND (@minAvgCompletion IS NULL OR @minAvgCompletion <= AvgCompletion ) + AND (@maxAvgCompletion IS NULL OR @maxAvgCompletion >= AvgCompletion ) +ORDER BY + CASE WHEN @orderDirection = 'ASC' AND @orderBy = 'Username' THEN Username ELSE NULL END ASC, + CASE WHEN @orderDirection = 'ASC' AND @orderBy = 'GameCount' THEN GameCount ELSE NULL END ASC, + CASE WHEN @orderDirection = 'ASC' AND @orderBy = 'AchievementCount' THEN AchievementCount ELSE NULL END ASC, + CASE WHEN @orderDirection = 'ASC' AND @orderBy = 'AvgCompletion' THEN AvgCompletion ELSE NULL END ASC, + CASE WHEN @orderDirection = 'ASC' AND @orderBy = 'PerfectCount' THEN PerfectGames ELSE NULL END ASC, + CASE WHEN @orderDirection = 'DESC' AND @orderBy = 'Username' THEN Username ELSE NULL END DESC, + CASE WHEN @orderDirection = 'DESC' AND @orderBy = 'GameCount' THEN GameCount ELSE NULL END DESC, + CASE WHEN @orderDirection = 'DESC' AND @orderBy = 'AchievementCount' THEN AchievementCount ELSE NULL END DESC, + CASE WHEN @orderDirection = 'DESC' AND @orderBy = 'AvgCompletion' THEN AvgCompletion ELSE NULL END DESC, + CASE WHEN @orderDirection = 'DESC' AND @orderBy = 'PerfectCount' THEN PerfectGames ELSE NULL END DESC +RETURN 0 +GO +------------------ +-- SEARCH GAMES -- +------------------ + +CREATE PROCEDURE SearchGames( + @searchTerm VARCHAR(32), + @userId INT, + @owned BIT, + @minAvgCompletion INT, + @maxAvgCompletion INT, + @minNumOwners INT, + @maxNumOwners INT, + @minNumPerfects INT, + @maxNumPerfects INT, + @orderBy VARCHAR(16), + @orderDirection VARCHAR(4) +) +AS +IF @userId IS NULL AND @owned = 1 +BEGIN + PRINT 'Cannot search for owned games with no user specified' + RETURN 1 +END + +PRINT 'UserID, Owned' +PRINT @userId +PRINT @owned + +IF @owned IS NULL +SET @owned = 0 + +IF @searchTerm IS NULL OR @searchTerm = '' + SET @searchTerm = '%' +ELSE + SET @searchTerm = '%' + @searchTerm + '%' + +SELECT TOP 100 * +FROM ( + SELECT + Game.ID, + [Name], + AchievementCount, + AvgCompletion, + ISNULL(NumOwners, 0) AS NumOwners, + ISNULL(NumPerfects, 0) AS NumPerfects + FROM Game + LEFT JOIN ( + SELECT + GameID, + Total AS AchievementCount, + AVG((Completed * 100) / Total) AS AvgCompletion, + SUM(CASE WHEN Completed = Total THEN 1 ELSE 0 END) AS NumPerfects + FROM GameCompletionByUser + GROUP BY GameID, Total + ) AS Completion ON Completion.GameID = Game.ID + LEFT JOIN ( + SELECT GameID, COUNT(UserID) AS NumOwners + FROM OwnsUnique + GROUP BY GameID + ) AS Owners ON Owners.GameID = Game.ID +) AS Results +WHERE ([Name] LIKE @searchTerm) + AND (@owned <> 1 OR ID IN (SELECT GameID FROM OwnsUnique WHERE UserID = @userId)) + AND (@minAvgCompletion IS NULL OR @minAvgCompletion <= AvgCompletion) + AND (@maxAvgCompletion IS NULL OR @maxAvgCompletion >= AvgCompletion) + AND (@minNumOwners IS NULL OR @minNumOwners <= NumOwners ) + AND (@maxNumOwners IS NULL OR @maxNumOwners >= NumOwners ) + AND (@minNumPerfects IS NULL OR @minNumPerfects <= NumPerfects ) + AND (@maxNumPerfects IS NULL OR @maxNumPerfects >= NumPerfects ) +ORDER BY + CASE WHEN @orderDirection = 'ASC' AND @orderBy = 'Name' THEN [Name] ELSE NULL END ASC, + CASE WHEN @orderDirection = 'ASC' AND @orderBy = 'AchievementCount' THEN AchievementCount ELSE NULL END ASC, + CASE WHEN @orderDirection = 'ASC' AND @orderBy = 'AvgCompletion' THEN AvgCompletion ELSE NULL END ASC, + CASE WHEN @orderDirection = 'ASC' AND @orderBy = 'NumOwners' THEN NumOwners ELSE NULL END ASC, + CASE WHEN @orderDirection = 'ASC' AND @orderBy = 'NumPerfects' THEN NumPerfects ELSE NULL END ASC, + CASE WHEN @orderDirection = 'DESC' AND @orderBy = 'Name' THEN [Name] ELSE NULL END DESC, + CASE WHEN @orderDirection = 'DESC' AND @orderBy = 'AchievementCount' THEN AchievementCount ELSE NULL END DESC, + CASE WHEN @orderDirection = 'DESC' AND @orderBy = 'AvgCompletion' THEN AvgCompletion ELSE NULL END DESC, + CASE WHEN @orderDirection = 'DESC' AND @orderBy = 'NumOwners' THEN NumOwners ELSE NULL END DESC, + CASE WHEN @orderDirection = 'DESC' AND @orderBy = 'NumPerfects' THEN NumPerfects ELSE NULL END DESC +RETURN 0 +GO + +EXEC SearchGames '', 3, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL diff --git a/sql/CreateTables.sql b/sql/Tables.sql similarity index 83% rename from sql/CreateTables.sql rename to sql/Tables.sql index beb45bb..323bab7 100644 --- a/sql/CreateTables.sql +++ b/sql/Tables.sql @@ -27,24 +27,37 @@ ----------------------------- +CREATE TYPE ImageType FROM VARCHAR(4) NULL +GO + CREATE TABLE [User] ( ID INT IDENTITY(0, 1) NOT NULL, Email VARCHAR(254) NOT NULL, Username VARCHAR(32) NOT NULL, - [Password] CHAR(256) NOT NULL + [Password] CHAR(64) NOT NULL, + [Salt] CHAR(32) NOT NULL, + Hue INT NOT NULL + CONSTRAINT HueDefault DEFAULT 0 + CONSTRAINT HueConstraint CHECK (0 <= Hue AND Hue <= 360), + ProfileImage ImageType, + [Admin] BIT NOT NULL + CONSTRAINT AdmivDefault DEFAULT 0, + Verified BIT NOT NULL + CONSTRAINT VerifiedDefault DEFAULT 0 PRIMARY KEY(ID) ) CREATE TABLE [Platform] ( ID INT IDENTITY(0, 1) NOT NULL, - PlatformName VARCHAR(32) NOT NULL + PlatformName VARCHAR(32) NOT NULL, + Icon ImageType PRIMARY KEY(ID) ) CREATE TABLE [Game] ( ID INT IDENTITY(0, 1) NOT NULL, Name VARCHAR(32) NOT NULL, - Thumbnail VARCHAR(256) NULL + Icon ImageType PRIMARY KEY(ID) ) @@ -54,7 +67,7 @@ CREATE TABLE [Achievement] ( Name VARCHAR(128) NOT NULL, Description VARCHAR(512) NULL, Stages INT NOT NULL, - Thumbnail VARCHAR(256) NULL + Icon ImageType PRIMARY KEY(ID) FOREIGN KEY(GameID) REFERENCES [Game](ID) ON UPDATE CASCADE @@ -82,7 +95,7 @@ CREATE TABLE [Progress] ( PlatformID INT NOT NULL, AchievementID INT NOT NULL, Progress INT NOT NULL - PRIMARY KEY(UserID, AchievementID) + PRIMARY KEY(UserID, PlatformID, AchievementID) FOREIGN KEY(UserID) REFERENCES [User](ID) ON UPDATE CASCADE ON DELETE CASCADE, @@ -96,7 +109,8 @@ CREATE TABLE [Progress] ( CREATE TABLE [IsOn] ( UserID INT NOT NULL, - PlatformID INT NOT NULL + PlatformID INT NOT NULL, + PlatformUserID VARCHAR(32) NOT NULL PRIMARY KEY(UserID, PlatformID) FOREIGN KEY(UserID) REFERENCES [User](ID) ON UPDATE CASCADE @@ -109,7 +123,7 @@ CREATE TABLE [IsOn] ( CREATE TABLE [ExistsOn] ( GameID INT NOT NULL, PlatformID INT NOT NULL, - PlatformGameID INT NOT NULL + PlatformGameID VARCHAR(32) NOT NULL PRIMARY KEY(GameID, PlatformID) FOREIGN KEY(GameID) REFERENCES [Game](ID) ON UPDATE CASCADE diff --git a/sql/Views.sql b/sql/Views.sql new file mode 100644 index 0000000..17870ab --- /dev/null +++ b/sql/Views.sql @@ -0,0 +1,42 @@ +-- The maximum progress a user has on an achievement across all platforms +CREATE VIEW MaxProgress +AS + SELECT UserID, AchievementID, MAX(Progress) AS Progress + FROM Progress + GROUP BY UserID, AchievementID +GO + +-- List of games and users with the number of completed achievements out of the total achievements the user has completed +CREATE VIEW GameCompletionByUser +AS + SELECT UserID, GameID, SUM(CASE WHEN Progress = Stages THEN 1 ELSE 0 END) AS Completed, COUNT(AchievementID) AS Total + FROM Achievement + JOIN MaxProgress ON AchievementID = Achievement.ID + GROUP BY UserID, GameID +GO + +-- List of achievements and the percentage of people who have completed it +CREATE VIEW AchievementCompletion +AS + SELECT Achievement.ID, (CASE WHEN COUNT(UserID) = 0 THEN NULL ELSE (SUM(CASE WHEN Progress = Stages THEN 1 ELSE 0 END) * 100 / COUNT(UserID)) END) AS Completion, COUNT(UserID) AS NumberUsers + FROM Achievement + LEFT JOIN MaxProgress ON AchievementID = Achievement.ID + GROUP BY Achievement.ID +GO + +-- List of achievements and their average quality and difficulty ratings filling with null as necessary +CREATE VIEW AchievementRatings +AS + SELECT Achievement.ID, AVG(Quality) AS Quality, AVG(Difficulty) AS Difficulty + FROM Achievement + LEFT JOIN Rating ON AchievementID = Achievement.ID + GROUP BY Achievement.ID +GO + +-- List of games owned by a user removing duplicate ownership if owned on multiple platforms +CREATE VIEW OwnsUnique +AS + SELECT UserID, GameID + FROM Owns + GROUP BY UserID, GameID +GO