Merge pull request #11 from grimsi/gh-9_ImproveSortingAndFiltering

Improve Sorting and Filtering
This commit is contained in:
Simon
2022-08-15 17:10:28 +02:00
committed by GitHub
29 changed files with 594 additions and 192 deletions
+7 -1
View File
@@ -87,6 +87,11 @@
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<!-- File handling -->
<dependency>
@@ -144,8 +149,9 @@
<include>**/*.properties</include>
<include>**/*.yml</include>
<include>**/*.yaml</include>
<include>**/*.json</include>
<include>**/*.sql</include>
<include>**/*.txt</include>
<include>**/*.json</include>
<include>**/*.js</include>
<include>**/*.css</include>
<include>**/*.html</include>
@@ -35,6 +35,7 @@ public class SecurityConfiguration {
@Bean
protected SecurityFilterChain httpSecurity(HttpSecurity http) throws Exception {
http.csrf().disable();
http.headers().frameOptions().disable();
http.httpBasic(Customizer.withDefaults());
return http.build();
}
@@ -1,24 +0,0 @@
package de.grimsi.gameyfin.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.File;
import java.time.Instant;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GameDto {
private String name;
private String publisher;
private String slug;
private Instant releaseDate;
private List<File> files;
private Long fileSize;
}
@@ -1,9 +0,0 @@
package de.grimsi.gameyfin.dto;
import lombok.Data;
@Data
public class UsernamePasswordDto {
private String username;
private String password;
}
@@ -3,6 +3,7 @@ package de.grimsi.gameyfin.entities;
import lombok.*;
import org.hibernate.Hibernate;
import org.hibernate.annotations.CreationTimestamp;
import javax.persistence.*;
import java.time.Instant;
@@ -86,6 +87,9 @@ public class DetectedGame {
@Column(columnDefinition = "boolean default false")
private boolean confirmedMatch;
@CreationTimestamp
private Instant addedToLibrary;
@Override
public boolean equals(Object o) {
if (this == o) return true;
@@ -9,7 +9,9 @@ spring:
entity_copy_observer: allow
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: update
ddl-auto: none
flyway:
baseline-on-migrate: true
datasource:
username: gfadmin
password: gameyfin
@@ -0,0 +1,148 @@
-- Automatically generated by JPA
-- Hibernate sequence
create sequence HIBERNATE_SEQUENCE start with 1 increment by 1;
-- Detected Games
create table DETECTED_GAME
(
slug varchar(255) not null,
category varchar(255),
confirmed_match boolean default false,
cover_id varchar(255) not null,
critics_rating integer,
disk_size bigint not null,
lan_support boolean not null,
max_players integer not null,
offline_coop boolean not null,
online_coop boolean not null,
path varchar(255) not null,
release_date timestamp,
summary CLOB,
title varchar(255) not null,
total_rating integer,
user_rating integer,
primary key (slug)
);
-- Companies
create table COMPANY
(
slug varchar(255) not null,
logo_id varchar(255),
name varchar(255) not null,
primary key (slug)
);
-- Genres
create table GENRE
(
slug varchar(255) not null,
name varchar(255),
primary key (slug)
);
-- Themes
create table THEME
(
slug varchar(255) not null,
name varchar(255),
primary key (slug)
);
-- Keywords
create table KEYWORD
(
slug varchar(255) not null,
name varchar(255),
primary key (slug)
);
-- Player Perspectives
create table PLAYER_PERSPECTIVE
(
slug varchar(255) not null,
name varchar(255),
primary key (slug)
);
-- Unmappable files
create table UNMAPPABLE_FILE
(
id bigint not null,
path varchar(255),
primary key (id)
);
-- Game <-> Companies
create table DETECTED_GAME_COMPANIES
(
detected_game_slug varchar(255) not null,
companies_slug varchar(255) not null
);
alter table DETECTED_GAME_COMPANIES
add constraint companies_company_slug foreign key (companies_slug) references company;
alter table DETECTED_GAME_COMPANIES
add constraint companies_detected_game_slug foreign key (detected_game_slug) references detected_game;
-- Game <-> Genres
create table DETECTED_GAME_GENRES
(
detected_game_slug varchar(255) not null,
genres_slug varchar(255) not null
);
alter table DETECTED_GAME_GENRES
add constraint genres_genre_slug foreign key (genres_slug) references genre;
alter table DETECTED_GAME_GENRES
add constraint genres_detected_game_slug foreign key (detected_game_slug) references detected_game;
-- Game <-> Themes
create table DETECTED_GAME_THEMES
(
detected_game_slug varchar(255) not null,
themes_slug varchar(255) not null
);
alter table DETECTED_GAME_THEMES
add constraint themes_theme_slug foreign key (themes_slug) references theme;
alter table DETECTED_GAME_THEMES
add constraint themes_detected_game_slug foreign key (detected_game_slug) references detected_game;
-- Game <-> Keywords
create table DETECTED_GAME_KEYWORDS
(
detected_game_slug varchar(255) not null,
keywords_slug varchar(255) not null
);
alter table DETECTED_GAME_KEYWORDS
add constraint keywords_keyword_slug foreign key (keywords_slug) references keyword;
alter table DETECTED_GAME_KEYWORDS
add constraint keywords_detected_game_slug foreign key (detected_game_slug) references detected_game;
-- Game <-> Player Perspectives
create table DETECTED_GAME_PLAYER_PERSPECTIVES
(
detected_game_slug varchar(255) not null,
player_perspectives_slug varchar(255) not null
);
alter table DETECTED_GAME_PLAYER_PERSPECTIVES
add constraint player_perspectives_player_perspective_slug foreign key (player_perspectives_slug) references player_perspective;
alter table DETECTED_GAME_PLAYER_PERSPECTIVES
add constraint player_perspectives_detected_game_slug foreign key (detected_game_slug) references detected_game;
-- Game <-> Videos
create table DETECTED_GAME_VIDEO_IDS
(
detected_game_slug varchar(255) not null,
video_ids varchar(255)
);
alter table DETECTED_GAME_VIDEO_IDS
add constraint video_ids_detected_game_slug foreign key (detected_game_slug) references detected_game;
-- Game <-> Screenshots
create table DETECTED_GAME_SCREENSHOT_IDS
(
detected_game_slug varchar(255) not null,
screenshot_ids varchar(255)
);
alter table DETECTED_GAME_SCREENSHOT_IDS
add constraint screenshot_ids_detected_game_slug foreign key (detected_game_slug) references detected_game;
@@ -0,0 +1,4 @@
-- Add field "addedToLibrary" to the "DetectedGame" table with the default value of CURRENT_TIMESTAMP()
alter table DETECTED_GAME
add added_to_library timestamp not null default CURRENT_TIMESTAMP()
+6 -2
View File
@@ -49,6 +49,8 @@ import {MatListModule} from "@angular/material/list";
import {MatAutocompleteModule} from "@angular/material/autocomplete";
import { NgModelChangeDebouncedDirective } from './directives/ng-model-change-debounced.directive';
import { FooterComponent } from './components/footer/footer.component';
import {MatExpansionModule} from "@angular/material/expansion";
import {MatSelectModule} from "@angular/material/select";
@NgModule({
declarations: [
@@ -104,7 +106,9 @@ import { FooterComponent } from './components/footer/footer.component';
MatTableFilterModule,
MatDividerModule,
MatListModule,
MatAutocompleteModule
MatAutocompleteModule,
MatExpansionModule,
MatSelectModule
],
providers: [
{
@@ -119,7 +123,7 @@ import { FooterComponent } from './components/footer/footer.component';
},
{
provide: MAT_SNACK_BAR_DEFAULT_OPTIONS,
useValue: { panelClass: ['snackbar-dark'] },
useValue: { panelClass: ['formatted-snackbar'] },
}
],
bootstrap: [AppComponent]
@@ -1,9 +1,9 @@
@use 'sass:map';
@use '@angular/material' as mat;
@import '../../theme/default-theme';
@import 'src/app/themes/light-theme';
a {
$config: mat.get-color-config($custom-theme);
$config: mat.get-color-config($light-theme);
$primary-palette: map.get($config, 'primary');
color: mat.get-color-from-palette($primary-palette, 500);
}
@@ -5,7 +5,8 @@
<span class="spacer"></span>
<img class="logo" src="assets/Gameyfin_Logo_256px.png" alt="Gameyfin Logo">
<img *ngIf="document.body.style.colorScheme == 'dark'" class="logo" src="assets/Gameyfin_Logo_256px.png" alt="Gameyfin Logo">
<img *ngIf="document.body.style.colorScheme == 'light'" class="logo" src="assets/Gameyfin_Logo_256px_dark.png" alt="Gameyfin Logo">
<button mat-icon-button matTooltip="Reload library" (click)="reloadLibrary()" *ngIf="onLibraryScreen()">
<mat-icon>refresh</mat-icon>
@@ -1,9 +1,11 @@
import {Component} from '@angular/core';
import {Component, Inject} from '@angular/core';
import {LibraryService} from "../../services/library.service";
import {MatSnackBar} from '@angular/material/snack-bar';
import {timeInterval} from "rxjs";
import {Router} from "@angular/router";
import {GamesService} from "../../services/games.service";
import {ThemingService} from "../../services/theming.service";
import {DOCUMENT, Location} from '@angular/common';
@Component({
selector: 'app-header',
@@ -12,43 +14,15 @@ import {GamesService} from "../../services/games.service";
})
export class HeaderComponent {
darkmodeEnabled: boolean;
// Maybe bad practice? IDK, but I need to access the document from the template of this component
document: Document = document;
constructor(private libraryService: LibraryService,
private gameService: GamesService,
private themingService: ThemingService,
private snackBar: MatSnackBar,
private router: Router) {
if(this.getCookie("darkmode") !== null) {
this.darkmodeEnabled = this.getCookie("darkmode") === "true";
} else
if (window.matchMedia) {
this.darkmodeEnabled = window.matchMedia('(prefers-color-scheme: dark)').matches;
} else {
this.darkmodeEnabled = false;
}
this.setTheme();
}
toggleTheme(): void {
this.darkmodeEnabled = !this.darkmodeEnabled;
this.setTheme();
}
private setTheme(): void {
this.darkmodeEnabled ? this.setDarkmode() : this.setLightmode();
this.setCookie("darkmode", this.darkmodeEnabled);
}
private setDarkmode(): void {
document.body.style.background = "#303030";
document.body.style.color = "white";
}
private setLightmode(): void {
document.body.style.background = "white";
document.body.style.color = "black";
private router: Router,
private location: Location) {
}
scanLibrary(): void {
@@ -57,7 +31,7 @@ export class HeaderComponent {
// Refresh the current page "angular style"
this.router.navigate([this.router.url]).then(() =>
this.snackBar.open(`Library scan completed in ${Math.trunc(value.interval / 1000)} seconds.`, undefined, {duration: 5000})
)
)
},
error: error => this.snackBar.open(`Error while scanning library: ${error.error.message}`, undefined, {duration: 5000})
})
@@ -69,7 +43,7 @@ export class HeaderComponent {
}
goToLibraryScreen(): void {
this.router.navigate(['/']);
this.location.back();
}
goToLibraryManagementScreen(): void {
@@ -84,31 +58,7 @@ export class HeaderComponent {
return this.router.url === "/library-management";
}
private setCookie(name: string, value: any): void {
let d:Date = new Date();
document.cookie = `${name}=${value.toString()};`;
toggleTheme(): void {
this.themingService.toggleTheme();
}
private getCookie(name: string): string | null {
var dc = document.cookie;
var prefix = name + "=";
var begin = dc.indexOf("; " + prefix);
if (begin == -1) {
begin = dc.indexOf(prefix);
if (begin != 0) return null;
}
else
{
begin += 2;
var end = document.cookie.indexOf(";", begin);
if (end == -1) {
end = dc.length;
}
}
// because unescape has been deprecated, replaced with decodeURI
//return unescape(dc.substring(begin + prefix.length, end));
// @ts-ignore
return decodeURI(dc.substring(begin + prefix.length, end));
}
}
@@ -1,10 +1 @@
@use 'sass:map';
@use '@angular/material' as mat;
@import 'src/app/theme/default-theme';
@import 'src/app/components/library-overview/library-overview.component';
mat-tab-group {
$config: mat.get-color-config($custom-theme);
$background: map.get($config, background);
background: mat.get-color-from-palette($background, app-bar);
}
@@ -12,8 +12,8 @@ import {UnmappedFileDto} from "../../models/dtos/UnmappedFileDto";
export class LibraryManagementComponent implements OnInit {
loggedIn: boolean = false;
mappedGames!: DetectedGameDto[];
unmappedFiles!: UnmappedFileDto[];
mappedGames: DetectedGameDto[] = [];
unmappedFiles: UnmappedFileDto[] = [];
constructor(private gamesService: GamesService,
private libraryManagementService: LibraryManagementService) {
@@ -23,55 +23,99 @@
*ngIf="!this.loading && !this.gameLibraryIsEmpty">
<div fxFlex="10" fxHide fxShow.gt-md><!--SPACER--></div>
<div fxFlex.gt-md="0 1 15" fxLayout="column" fxLayoutGap="16px" fxLayoutAlign.lt-lg="start center" fxFlex.lt-lg="100" [ngClass.gt-md]="'sticky'">
<div fxFlex.gt-md="0 1 15" fxLayout="column" fxLayoutGap="16px" fxLayoutAlign.lt-lg="start center"
fxFlex.lt-lg="100" [ngClass.gt-md]="'sticky'">
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="6px">
<mat-icon matTooltip="Search for games by title">search</mat-icon>
<mat-form-field fxFlex="80" class="filter-category-content">
<input type="text" matInput [matAutocomplete]="librarySearchAutocomplete" [(ngModel)]="searchTerm" (ngModelChange)="filterGames()">
<mat-card fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="6px" class="mat-card-48">
<button mat-icon-button *ngIf="searchTerm.length > 0" matTooltip="Clear search input"
(click)="clearSearchTerm()">
<mat-icon>close</mat-icon>
</button>
<button mat-icon-button *ngIf="searchTerm.length === 0" matTooltip="Search for games by title">
<mat-icon>search</mat-icon>
</button>
<mat-form-field fxFlex>
<input type="text" matInput [matAutocomplete]="librarySearchAutocomplete" [(ngModel)]="searchTerm"
(ngModelChange)="refreshLibraryView()">
<mat-autocomplete #librarySearchAutocomplete="matAutocomplete">
<mat-option *ngFor="let game of games" [value]="game.title">
{{game.title}}
</mat-option>
</mat-autocomplete>
</mat-form-field>
</div>
</mat-card>
<div>
<div fxLayout="row" fxLayoutAlign="start start" fxLayoutGap="6px">
<h3 class="filter-category-title">Gamemodes</h3>
<mat-icon matTooltip="Filter may not work correctly, working on a fix" color="warn">error</mat-icon>
</div>
<div fxLayout="column" class="filter-category-content">
<mat-checkbox [(ngModel)]="offlineCoopFilterEnabled" (change)="filterGames()" color="primary">Offline Co-op
<mat-card fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="20px" class="mat-card-48">
<h3 class="filter-category-title" style="white-space: nowrap; padding-left: 6px">Sort by: </h3>
<mat-select [(value)]="selectedSortOption" (valueChange)="refreshLibraryView()">
<mat-option *ngFor="let sortOption of sortOptions" [value]="sortOption">
{{sortOption.title}}
</mat-option>
</mat-select>
</mat-card>
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title fxLayout="row" fxLayoutAlign="start start" fxLayoutGap="6px">
<h3 class="filter-category-title">Gamemodes</h3>
<mat-icon matTooltip="Filter may not work correctly, working on a fix" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<div fxLayout="column">
<mat-checkbox [(ngModel)]="offlineCoopFilterEnabled" (change)="refreshLibraryView()" color="primary">Offline
Co-op
</mat-checkbox>
<mat-checkbox [(ngModel)]="onlineCoopFilterEnabled" (change)="filterGames()" color="primary">Online Co-op
<mat-checkbox [(ngModel)]="onlineCoopFilterEnabled" (change)="refreshLibraryView()" color="primary">Online
Co-op
</mat-checkbox>
<mat-checkbox [(ngModel)]="lanSupportFilterEnabled" (change)="filterGames()" color="primary">LAN Support
<mat-checkbox [(ngModel)]="lanSupportFilterEnabled" (change)="refreshLibraryView()" color="primary">LAN
Support
</mat-checkbox>
</div>
</div>
</mat-expansion-panel>
<div *ngIf="availableGenres.length > 0">
<h3 class="filter-category-title">Genres</h3>
<div fxLayout="column" class="filter-category-content">
<mat-expansion-panel *ngIf="availableGenres.length > 0">
<mat-expansion-panel-header>
<h3 class="filter-category-title">Genres</h3>
</mat-expansion-panel-header>
<div fxLayout="column">
<mat-checkbox *ngFor="let genre of availableGenres" (change)="toggleGenreFilter(genre.slug)"
[checked]="activeGenreFilters.includes(genre.slug)"
color="primary">{{genre.name}}</mat-checkbox>
</div>
</div>
</mat-expansion-panel>
<div *ngIf="availableThemes.length > 0">
<h3 class="filter-category-title">Themes</h3>
<div fxLayout="column" class="filter-category-content">
<mat-expansion-panel *ngIf="availableThemes.length > 0">
<mat-expansion-panel-header>
<h3 class="filter-category-title">Themes</h3>
</mat-expansion-panel-header>
<div fxLayout="column">
<mat-checkbox *ngFor="let theme of availableThemes" (change)="toggleThemeFilter(theme.slug)"
[checked]="activeThemeFilters.includes(theme.slug)"
color="primary">{{theme.name}}</mat-checkbox>
</div>
</div>
</mat-expansion-panel>
<mat-expansion-panel *ngIf="availablePlayerPerspectives.length > 0">
<mat-expansion-panel-header>
<h3 class="filter-category-title">Player Perspectives</h3>
</mat-expansion-panel-header>
<div fxLayout="column">
<mat-checkbox *ngFor="let playerPerspective of availablePlayerPerspectives" (change)="togglePlayerPerspectiveFilter(playerPerspective.slug)"
[checked]="activePlayerPerspectiveFilters.includes(playerPerspective.slug)"
color="primary">{{playerPerspective.name}}</mat-checkbox>
</div>
</mat-expansion-panel>
</div>
<div fxFlex="0 1 1"><!--SPACER--></div>
<div fxFlex fxLayout="row wrap" fxLayoutGap="16px grid">
<div *ngFor="let game of games">
<game-cover [game]="game"></game-cover>
@@ -1,6 +1,6 @@
@use 'sass:map';
@use '@angular/material' as mat;
@import '../../theme/default-theme';
@import 'src/app/themes/dark-theme';
.fullscreen-overlay {
position: absolute;
@@ -19,21 +19,15 @@
@include mat.elevation(16);
$config: mat.get-color-config($custom-theme);
$background: map.get($config, background);
position: absolute;
right: 56px;
top: 72px;
width: 250px;
border-radius: 6px;
background: mat.get-color-from-palette($background, app-bar);
border-color: mat.get-color-from-palette($background, app-bar);
border-style: solid;
color: white;
p {
padding: 0 12px 12px 16px;
}
}
}
.content {
@@ -44,18 +38,18 @@
margin-bottom: 0;
}
.filter-category-content {
margin-left: 6px;
.mat-card-48 {
height: 48px;
}
::ng-deep .mat-checkbox-frame {
$config: mat.get-color-config($custom-theme);
$config: mat.get-color-config($dark-theme);
$primary-palette: map.get($config, 'primary');
border-color: mat.get-color-from-palette($primary-palette, 500);
border-color: mat.get-color-from-palette($primary-palette, 500) !important;
}
::ng-deep .mat-form-field-underline {
$config: mat.get-color-config($custom-theme);
$config: mat.get-color-config($dark-theme);
$primary-palette: map.get($config, 'primary');
background-color: mat.get-color-from-palette($primary-palette, 500) !important;
}
@@ -1,9 +1,26 @@
import {AfterContentInit, AfterViewInit, Component, Input} from '@angular/core';
import {AfterContentInit, Component} from '@angular/core';
import {GamesService} from "../../services/games.service";
import {DetectedGameDto} from "../../models/dtos/DetectedGameDto";
import {GenreDto} from "../../models/dtos/GenreDto";
import {ThemeDto} from "../../models/dtos/ThemeDto";
import {forkJoin, Observable} from "rxjs";
import {firstValueFrom, forkJoin, Observable} from "rxjs";
import {SortDirection} from "@angular/material/sort";
import {PlayerPerspectiveDto} from "../../models/dtos/PlayerPerspectiveDto";
import {ActivatedRoute, Params, Router} from "@angular/router";
import {Location} from "@angular/common";
import {HttpParams} from "@angular/common/http";
class SortOption {
title: string;
field: string;
direction: SortDirection;
constructor(title: string, field: string, direction: SortDirection) {
this.title = title;
this.field = field;
this.direction = direction;
}
}
@Component({
selector: 'app-gameserver-list',
@@ -12,27 +29,49 @@ import {forkJoin, Observable} from "rxjs";
})
export class LibraryOverviewComponent implements AfterContentInit {
defaultSortOption: SortOption = new SortOption("Title (A-Z)", "title", "asc");
sortOptions: SortOption[] = [
this.defaultSortOption,
new SortOption("Title (Z-A)", "title", "desc"),
new SortOption("Release (newest first)", "releaseDate", "desc"),
new SortOption("Release (oldest first)", "releaseDate", "asc"),
new SortOption("Added to library (newest first)", "addedToLibrary", "desc"),
new SortOption("Added to library (oldest first)", "addedToLibrary", "asc"),
new SortOption("Rating (highest first)", "totalRating", "desc"),
new SortOption("Rating (lowest first)", "totalRating", "asc")
];
searchTerm: string = "";
selectedSortOption: SortOption = this.defaultSortOption;
offlineCoopFilterEnabled: boolean = false;
onlineCoopFilterEnabled: boolean = false;
lanSupportFilterEnabled: boolean = false;
activeThemeFilters: string[] = [];
activeGenreFilters: string[] = [];
activePlayerPerspectiveFilters: string[] = [];
games: DetectedGameDto[] = [];
availableGenres: GenreDto[] = [];
availableThemes: ThemeDto[] = [];
availablePlayerPerspectives: PlayerPerspectiveDto[] = [];
loading: boolean = true;
gameLibraryIsEmpty: boolean = false;
constructor(private gameServerService: GamesService) {
constructor(private gameServerService: GamesService,
private route: ActivatedRoute,
private router: Router,
private location: Location) {
}
ngAfterContentInit(): void {
this.gameServerService.getAllGames().subscribe(
detectedGames => {
if(detectedGames.length === 0) {
if (detectedGames.length === 0) {
this.gameLibraryIsEmpty = true;
this.loading = false;
return;
@@ -42,43 +81,80 @@ export class LibraryOverviewComponent implements AfterContentInit {
let genreObservable: Observable<ThemeDto[]> = this.gameServerService.getAvailableGenres();
let themeObservable: Observable<GenreDto[]> = this.gameServerService.getAvailableThemes();
let playerPerspectiveObservable: Observable<PlayerPerspectiveDto[]> = this.gameServerService.getAvailablePlayerPerspectives();
forkJoin([themeObservable, genreObservable]).subscribe(result => {
this.availableThemes = result[0];
this.availableGenres = result[1];
this.filterGames();
this.loading = false;
forkJoin([genreObservable, themeObservable, playerPerspectiveObservable]).subscribe(result => {
this.availableGenres = result[0];
this.availableThemes = result[1];
this.availablePlayerPerspectives = result[2];
this.route.queryParams.subscribe(params => {
if (params['search'] !== undefined) this.searchTerm = params['search'];
if (params['sort'] !== undefined) this.selectedSortOption = this.matchSelectedSortOptionFromParam(params['sort']);
if (params['gamemodes'] !== undefined) this.setSelectedGamemodesFromParam(params['gamemodes']);
if (params['genres'] !== undefined) this.activeGenreFilters = this.matchSelectedFilters(this.availableGenres, params['genres']);
if (params['themes'] !== undefined) this.activeThemeFilters = this.matchSelectedFilters(this.availableThemes, params['themes']);
if (params['playerPerspectives'] !== undefined) this.activePlayerPerspectiveFilters = this.matchSelectedFilters(this.availablePlayerPerspectives, params['playerPerspectives']);
this.refreshLibraryView().then(() => this.loading = false);
});
});
}
);
}
filterGames(): void {
this.gameServerService.getAllGames().subscribe(games => {
let filteredGames: DetectedGameDto[] = games;
async refreshLibraryView(): Promise<void> {
let games: DetectedGameDto[] = await firstValueFrom(this.gameServerService.getAllGames());
this.games = this.sortGames(this.filterGames(games));
this.saveStateToRoute();
}
if(this.searchTerm.trim().toLowerCase().length > 0) {
filteredGames = filteredGames.filter(game => game.title.trim().toLowerCase().includes(this.searchTerm.trim().toLowerCase()));
}
clearSearchTerm(): void {
this.searchTerm = "";
this.refreshLibraryView();
}
if(this.offlineCoopFilterEnabled || this.onlineCoopFilterEnabled || this.lanSupportFilterEnabled) {
filteredGames = filteredGames.filter(game => (game.offlineCoop === this.offlineCoopFilterEnabled || game.onlineCoop === this.onlineCoopFilterEnabled || game.lanSupport === this.lanSupportFilterEnabled));
}
filterGames(games: DetectedGameDto[]): DetectedGameDto[] {
if (this.searchTerm.trim().toLowerCase().length > 0) {
games = games.filter(game => game.title.trim().toLowerCase().includes(this.searchTerm.trim().toLowerCase()));
}
if(this.activeGenreFilters.length > 0) {
filteredGames = filteredGames.filter(game => this.activeGenreFilters.every(activeGenreFilter => game.genres?.map(g => g.slug).includes(activeGenreFilter)));
}
if (this.offlineCoopFilterEnabled || this.onlineCoopFilterEnabled || this.lanSupportFilterEnabled) {
games = games.filter(game => (game.offlineCoop === this.offlineCoopFilterEnabled || game.onlineCoop === this.onlineCoopFilterEnabled || game.lanSupport === this.lanSupportFilterEnabled));
}
if(this.activeThemeFilters.length > 0) {
filteredGames = filteredGames.filter(game => this.activeThemeFilters.every(activeThemeFilter => game.themes?.map(g => g.slug).includes(activeThemeFilter)));
}
if (this.activeGenreFilters.length > 0) {
games = games.filter(game => this.activeGenreFilters.every(activeGenreFilter => game.genres?.map(g => g.slug).includes(activeGenreFilter)));
}
this.games = filteredGames;
})
if (this.activeThemeFilters.length > 0) {
games = games.filter(game => this.activeThemeFilters.every(activeThemeFilter => game.themes?.map(g => g.slug).includes(activeThemeFilter)));
}
if (this.activePlayerPerspectiveFilters.length > 0) {
games = games.filter(game => this.activePlayerPerspectiveFilters.every(activePlayerPerspectiveFilter => game.playerPerspectives?.map(g => g.slug).includes(activePlayerPerspectiveFilter)));
}
return games;
}
sortGames(games: DetectedGameDto[]): DetectedGameDto[] {
games = games.sort((g1, g2) => {
// @ts-ignore
let f1 = g1[this.selectedSortOption.field];
// @ts-ignore
let f2 = g2[this.selectedSortOption.field];
if (f1 > f2) return 1;
if (f1 < f2) return -1;
return 0;
});
if (this.selectedSortOption.direction === "desc") games = games.reverse();
return games;
}
toggleGenreFilter(slug: string): void {
if(this.activeGenreFilters.includes(slug)) {
if (this.activeGenreFilters.includes(slug)) {
const index = this.activeGenreFilters.indexOf(slug, 0);
if (index > -1) {
@@ -89,11 +165,11 @@ export class LibraryOverviewComponent implements AfterContentInit {
this.activeGenreFilters.push(slug);
}
this.filterGames();
this.refreshLibraryView();
}
toggleThemeFilter(slug: string) {
if(this.activeThemeFilters.includes(slug)) {
if (this.activeThemeFilters.includes(slug)) {
const index = this.activeThemeFilters.indexOf(slug, 0);
if (index > -1) {
@@ -104,7 +180,67 @@ export class LibraryOverviewComponent implements AfterContentInit {
this.activeThemeFilters.push(slug);
}
this.filterGames();
this.refreshLibraryView();
}
togglePlayerPerspectiveFilter(slug: string) {
if (this.activePlayerPerspectiveFilters.includes(slug)) {
const index = this.activePlayerPerspectiveFilters.indexOf(slug, 0);
if (index > -1) {
this.activePlayerPerspectiveFilters.splice(index, 1);
}
} else {
this.activePlayerPerspectiveFilters.push(slug);
}
this.refreshLibraryView();
}
private saveStateToRoute(): void {
let stateParams: Params = {};
if (this.searchTerm.trim().length > 0) stateParams['search'] = this.searchTerm;
if (this.selectedSortOption !== this.defaultSortOption) stateParams['sort'] = this.toParam(this.selectedSortOption);
if (this.getActiveGameModesFilters().length > 0) stateParams['gamemodes'] = this.getActiveGameModesFilters().join(',');
if (this.activeGenreFilters.length > 0) stateParams['genres'] = this.activeGenreFilters.join(',');
if (this.activeThemeFilters.length > 0) stateParams['themes'] = this.activeThemeFilters.join(',');
if (this.activePlayerPerspectiveFilters.length > 0) stateParams['playerPerspectives'] = this.activePlayerPerspectiveFilters.join(',');
const url = this.router.createUrlTree([], {relativeTo: this.route, queryParams: stateParams}).toString();
this.location.go(url);
}
private toParam(sortOption: SortOption): string {
return `${sortOption.field}_${sortOption.direction}`;
}
private matchSelectedSortOptionFromParam(sortParam: string): SortOption {
return this.sortOptions.find(s => sortParam === this.toParam(s)) ?? this.defaultSortOption;
}
private matchSelectedFilters(options: any[], paramString: string): string[] {
let params: string[] = paramString.split(",");
return options.filter(o => params.includes(o.slug)).map(o => o.slug);
}
private getActiveGameModesFilters(): string[] {
let activeFilters: string[] = [];
if (this.offlineCoopFilterEnabled) activeFilters.push('offlineCoop');
if (this.onlineCoopFilterEnabled) activeFilters.push('onlineCoop');
if (this.lanSupportFilterEnabled) activeFilters.push('lanSupport');
return activeFilters;
}
private setSelectedGamemodesFromParam(paramString: string): void {
let params: string[] = paramString.split(",");
if (params.includes('offlineCoop')) this.offlineCoopFilterEnabled = true;
if (params.includes('onlineCoop')) this.onlineCoopFilterEnabled = true;
if (params.includes('lanSupport')) this.lanSupportFilterEnabled = true;
}
}
@@ -4,7 +4,7 @@ import {Component, OnInit} from '@angular/core';
selector: 'app-navbar-layout',
template: `
<div class="main-container" fxLayout="column">
<div fxFlex="none" style="position: sticky; top: 0; z-index: 99999">
<div fxFlex="none" style="position: sticky; top: 0; z-index: 999">
<app-header></app-header>
</div>
<div fxFlex>
@@ -30,4 +30,5 @@ export class DetectedGameDto {
path!: string;
diskSize!: number;
confirmedMatch!: boolean | undefined;
addedToLibrary!: Date;
}
@@ -1,4 +1,4 @@
export class PlayerPerspectiveDto {
slug!: string;
name?: string;
name!: string;
}
@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { CookieService } from './cookie.service';
describe('CookieService', () => {
let service: CookieService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(CookieService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});
@@ -0,0 +1,34 @@
import {Injectable} from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class CookieService {
constructor() {
}
setCookie(name: string, value: any): void {
document.cookie = `${name}=${value.toString()};`;
}
getCookie(name: string): string | null {
let end;
const dc = document.cookie;
const prefix = name + "=";
let begin = dc.indexOf("; " + prefix);
if (begin == -1) {
begin = dc.indexOf(prefix);
if (begin != 0) return null;
} else {
begin += 2;
end = document.cookie.indexOf(";", begin);
if (end == -1) {
end = dc.length;
}
}
return decodeURI(dc.substring(begin + prefix.length, end));
}
}
+17 -1
View File
@@ -6,6 +6,8 @@ import {DetectedGameDto} from "../models/dtos/DetectedGameDto";
import {GameOverviewDto} from "../models/dtos/GameOverviewDto";
import {GenreDto} from "../models/dtos/GenreDto";
import {ThemeDto} from "../models/dtos/ThemeDto";
import {CompanyDto} from "../models/dtos/CompanyDto";
import {PlayerPerspectiveDto} from "../models/dtos/PlayerPerspectiveDto";
@Injectable({
providedIn: 'root'
@@ -64,7 +66,7 @@ export class GamesService implements GamesApi {
return this.getAllGames().pipe(
map<DetectedGameDto[], ThemeDto[]>(
games => {
let availableThemesMap: Map<string, ThemeDto> = new Map<string, GenreDto>;
let availableThemesMap: Map<string, ThemeDto> = new Map<string, ThemeDto>;
games.map(game => game.themes === undefined ? [] : game.themes).flat().forEach(theme => availableThemesMap.set(theme.slug, theme));
return Array.from(availableThemesMap.values()).sort((t1, t2) => t1.name.localeCompare(t2.name));
}
@@ -72,6 +74,20 @@ export class GamesService implements GamesApi {
);
}
// TODO: This method of removing duplicates is most certainly an anti-pattern in RxJS
// TODO: However, I did not get the 'distinct()' pipe to work properly, so I have to take another look in the future
getAvailablePlayerPerspectives(): Observable<PlayerPerspectiveDto[]> {
return this.getAllGames().pipe(
map<DetectedGameDto[], PlayerPerspectiveDto[]>(
games => {
let availablePlayerPerspectivesMap: Map<string, PlayerPerspectiveDto> = new Map<string, PlayerPerspectiveDto>;
games.map(game => game.playerPerspectives === undefined ? [] : game.playerPerspectives).flat().forEach(playerPerspective => availablePlayerPerspectivesMap.set(playerPerspective.slug, playerPerspective));
return Array.from(availablePlayerPerspectivesMap.values()).sort((t1, t2) => t1.name.localeCompare(t2.name));
}
)
);
}
downloadGame(slug: String): void {
window.open(`v1${this.apiPath}/game/${slug}/download`, '_top');
}
@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { ThemingService } from './theming.service';
describe('ThemingService', () => {
let service: ThemingService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ThemingService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});
@@ -0,0 +1,53 @@
import {Injectable} from '@angular/core';
import {OverlayContainer} from "@angular/cdk/overlay";
import {CookieService} from "./cookie.service";
@Injectable({
providedIn: 'root'
})
export class ThemingService {
private darkmodeEnabled!: boolean;
private darkmodeClassName: string = 'darkMode';
constructor(private cookieService: CookieService,
private overlay: OverlayContainer) {
if (this.cookieService.getCookie("darkmode") !== null) {
this.darkmodeEnabled = this.cookieService.getCookie("darkmode") === "true";
} else if (window.matchMedia) {
this.darkmodeEnabled = window.matchMedia('(prefers-color-scheme: dark)').matches;
} else {
this.darkmodeEnabled = false;
}
this.setTheme();
}
toggleTheme(): void {
this.darkmodeEnabled = !this.darkmodeEnabled;
this.setTheme();
}
private setTheme(): void {
this.darkmodeEnabled ? this.setDarkmode() : this.setLightmode();
this.cookieService.setCookie("darkmode", this.darkmodeEnabled);
}
private setDarkmode(): void {
document.body.classList.add(this.darkmodeClassName);
document.body.style.colorScheme = "dark";
document.body.style.background = "#303030";
document.body.style.color = "white";
this.overlay.getContainerElement().classList.add(this.darkmodeClassName);
}
private setLightmode(): void {
document.body.classList.remove(this.darkmodeClassName);
document.body.style.colorScheme = "light";
document.body.style.background = "white";
document.body.style.color = "black";
this.overlay.getContainerElement().classList.remove(this.darkmodeClassName);
}
}
@@ -5,7 +5,7 @@ $custom-theme-primary: mat.define-palette(mat.$green-palette);
$custom-theme-accent: mat.define-palette(mat.$grey-palette, A200, A100, A400);
$custom-theme-warn: mat.define-palette(mat.$red-palette);
$custom-theme: mat.define-dark-theme((
$dark-theme: mat.define-dark-theme((
color: (
primary: $custom-theme-primary,
accent: $custom-theme-accent,
+14
View File
@@ -0,0 +1,14 @@
@use '@angular/material' as mat;
@import "@angular/material/theming";
$custom-theme-primary: mat.define-palette(mat.$green-palette);
$custom-theme-accent: mat.define-palette(mat.$grey-palette, A200, A100, A400);
$custom-theme-warn: mat.define-palette(mat.$red-palette);
$light-theme: mat.define-light-theme((
color: (
primary: $custom-theme-primary,
accent: $custom-theme-accent,
warn: $custom-theme-warn
)
));
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

+8 -8
View File
@@ -4,7 +4,8 @@
@use '@angular/material' as mat;
// Plus imports for other components in your app.
@import "src/app/theme/default-theme";
@import "src/app/themes/light-theme";
@import "src/app/themes/dark-theme";
// Include the common styles for Angular Material. We include this here so that you only
// have to load a single css file for Angular Material in your app.
// Be sure that you only ever include this mixin once!
@@ -13,7 +14,11 @@
// Include theme styles for core and each component used in your app.
// Alternatively, you can import and @include the theme mixins for each component
// that you are using.
@include mat.all-component-themes($custom-theme);
@include mat.all-component-themes($light-theme);
.darkMode {
@include mat.all-component-colors($dark-theme);
}
/* You can add global styles to this file, and also import other style files */
html, body { height: 100%; }
@@ -23,12 +28,7 @@ html {
overflow-y: scroll;
}
.snackbar-dark {
color: white;
$config: mat.get-color-config($custom-theme);
$background: map.get($config, background);
background: mat.get-color-from-palette($background, app-bar);
.formatted-snackbar {
// add support for formatting (newlines)
white-space: pre-wrap;
}