diff --git a/backend/pom.xml b/backend/pom.xml index 6906b6d..c795310 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -87,6 +87,11 @@ h2 runtime + + org.flywaydb + flyway-core + + @@ -144,8 +149,9 @@ **/*.properties **/*.yml **/*.yaml - **/*.json + **/*.sql **/*.txt + **/*.json **/*.js **/*.css **/*.html diff --git a/backend/src/main/java/de/grimsi/gameyfin/config/SecurityConfiguration.java b/backend/src/main/java/de/grimsi/gameyfin/config/SecurityConfiguration.java index 22d2812..889989b 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/config/SecurityConfiguration.java +++ b/backend/src/main/java/de/grimsi/gameyfin/config/SecurityConfiguration.java @@ -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(); } diff --git a/backend/src/main/java/de/grimsi/gameyfin/dto/GameDto.java b/backend/src/main/java/de/grimsi/gameyfin/dto/GameDto.java deleted file mode 100644 index 8f72c3a..0000000 --- a/backend/src/main/java/de/grimsi/gameyfin/dto/GameDto.java +++ /dev/null @@ -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 files; - private Long fileSize; -} diff --git a/backend/src/main/java/de/grimsi/gameyfin/dto/UsernamePasswordDto.java b/backend/src/main/java/de/grimsi/gameyfin/dto/UsernamePasswordDto.java deleted file mode 100644 index 2f16123..0000000 --- a/backend/src/main/java/de/grimsi/gameyfin/dto/UsernamePasswordDto.java +++ /dev/null @@ -1,9 +0,0 @@ -package de.grimsi.gameyfin.dto; - -import lombok.Data; - -@Data -public class UsernamePasswordDto { - private String username; - private String password; -} diff --git a/backend/src/main/java/de/grimsi/gameyfin/entities/DetectedGame.java b/backend/src/main/java/de/grimsi/gameyfin/entities/DetectedGame.java index 5d27d91..7435cba 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/entities/DetectedGame.java +++ b/backend/src/main/java/de/grimsi/gameyfin/entities/DetectedGame.java @@ -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; diff --git a/backend/src/main/resources/config/database.yml b/backend/src/main/resources/config/database.yml index 0e02ae7..7551f4d 100644 --- a/backend/src/main/resources/config/database.yml +++ b/backend/src/main/resources/config/database.yml @@ -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 diff --git a/backend/src/main/resources/db/migration/V1_0_0__Initial_Database_Setup.sql b/backend/src/main/resources/db/migration/V1_0_0__Initial_Database_Setup.sql new file mode 100644 index 0000000..1c3687f --- /dev/null +++ b/backend/src/main/resources/db/migration/V1_0_0__Initial_Database_Setup.sql @@ -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; diff --git a/backend/src/main/resources/db/migration/V1_1_0__Add_Field_addedToLibrary_to_DetectedGame.sql b/backend/src/main/resources/db/migration/V1_1_0__Add_Field_addedToLibrary_to_DetectedGame.sql new file mode 100644 index 0000000..e75fe44 --- /dev/null +++ b/backend/src/main/resources/db/migration/V1_1_0__Add_Field_addedToLibrary_to_DetectedGame.sql @@ -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() \ No newline at end of file diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index ac2dae5..bc2d9d4 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -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] diff --git a/frontend/src/app/components/footer/footer.component.scss b/frontend/src/app/components/footer/footer.component.scss index b7d02fb..47698af 100644 --- a/frontend/src/app/components/footer/footer.component.scss +++ b/frontend/src/app/components/footer/footer.component.scss @@ -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); } diff --git a/frontend/src/app/components/header/header.component.html b/frontend/src/app/components/header/header.component.html index dd877db..ab0068b 100644 --- a/frontend/src/app/components/header/header.component.html +++ b/frontend/src/app/components/header/header.component.html @@ -5,7 +5,8 @@ - + + + + + + + {{game.title}} - + -
-
-

Gamemodes

- error -
-
- Offline Co-op + +

Sort by:

+ + + {{sortOption.title}} + + +
+ + + + +

Gamemodes

+ error +
+
+ +
+ Offline + Co-op - Online Co-op + Online + Co-op - LAN Support + LAN + Support
-
+ -
-

Genres

-
+ + +

Genres

+
+ +
{{genre.name}}
-
+ -
-

Themes

-
+ + +

Themes

+
+ +
{{theme.name}}
-
+ + + +

Player Perspectives

+
+ +
+ {{playerPerspective.name}} +
+
+
+
diff --git a/frontend/src/app/components/library-overview/library-overview.component.scss b/frontend/src/app/components/library-overview/library-overview.component.scss index 92d78a1..c3d9f7e 100644 --- a/frontend/src/app/components/library-overview/library-overview.component.scss +++ b/frontend/src/app/components/library-overview/library-overview.component.scss @@ -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; } diff --git a/frontend/src/app/components/library-overview/library-overview.component.ts b/frontend/src/app/components/library-overview/library-overview.component.ts index 774ad40..c40b4f7 100644 --- a/frontend/src/app/components/library-overview/library-overview.component.ts +++ b/frontend/src/app/components/library-overview/library-overview.component.ts @@ -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 = this.gameServerService.getAvailableGenres(); let themeObservable: Observable = this.gameServerService.getAvailableThemes(); + let playerPerspectiveObservable: Observable = 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 { + 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; } } diff --git a/frontend/src/app/layouts/navbar-layout/navbar-layout.component.ts b/frontend/src/app/layouts/navbar-layout/navbar-layout.component.ts index 9ce31f1..397b20a 100644 --- a/frontend/src/app/layouts/navbar-layout/navbar-layout.component.ts +++ b/frontend/src/app/layouts/navbar-layout/navbar-layout.component.ts @@ -4,7 +4,7 @@ import {Component, OnInit} from '@angular/core'; selector: 'app-navbar-layout', template: `
-
+
diff --git a/frontend/src/app/models/dtos/DetectedGameDto.ts b/frontend/src/app/models/dtos/DetectedGameDto.ts index 543c091..ccb7bff 100644 --- a/frontend/src/app/models/dtos/DetectedGameDto.ts +++ b/frontend/src/app/models/dtos/DetectedGameDto.ts @@ -30,4 +30,5 @@ export class DetectedGameDto { path!: string; diskSize!: number; confirmedMatch!: boolean | undefined; + addedToLibrary!: Date; } diff --git a/frontend/src/app/models/dtos/PlayerPerspectiveDto.ts b/frontend/src/app/models/dtos/PlayerPerspectiveDto.ts index ff0ee82..e4f7e0d 100644 --- a/frontend/src/app/models/dtos/PlayerPerspectiveDto.ts +++ b/frontend/src/app/models/dtos/PlayerPerspectiveDto.ts @@ -1,4 +1,4 @@ export class PlayerPerspectiveDto { slug!: string; - name?: string; + name!: string; } diff --git a/frontend/src/app/services/cookie.service.spec.ts b/frontend/src/app/services/cookie.service.spec.ts new file mode 100644 index 0000000..43ea274 --- /dev/null +++ b/frontend/src/app/services/cookie.service.spec.ts @@ -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(); + }); +}); diff --git a/frontend/src/app/services/cookie.service.ts b/frontend/src/app/services/cookie.service.ts new file mode 100644 index 0000000..00af0e3 --- /dev/null +++ b/frontend/src/app/services/cookie.service.ts @@ -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)); + } +} diff --git a/frontend/src/app/services/games.service.ts b/frontend/src/app/services/games.service.ts index c91f940..87bd6f4 100644 --- a/frontend/src/app/services/games.service.ts +++ b/frontend/src/app/services/games.service.ts @@ -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( games => { - let availableThemesMap: Map = new Map; + let availableThemesMap: Map = new Map; 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 { + return this.getAllGames().pipe( + map( + games => { + let availablePlayerPerspectivesMap: Map = new Map; + 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'); } diff --git a/frontend/src/app/services/theming.service.spec.ts b/frontend/src/app/services/theming.service.spec.ts new file mode 100644 index 0000000..f932a5e --- /dev/null +++ b/frontend/src/app/services/theming.service.spec.ts @@ -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(); + }); +}); diff --git a/frontend/src/app/services/theming.service.ts b/frontend/src/app/services/theming.service.ts new file mode 100644 index 0000000..787718b --- /dev/null +++ b/frontend/src/app/services/theming.service.ts @@ -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); + } +} diff --git a/frontend/src/app/theme/default-theme.scss b/frontend/src/app/themes/dark-theme.scss similarity index 90% rename from frontend/src/app/theme/default-theme.scss rename to frontend/src/app/themes/dark-theme.scss index 93993e4..e0a9323 100644 --- a/frontend/src/app/theme/default-theme.scss +++ b/frontend/src/app/themes/dark-theme.scss @@ -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, diff --git a/frontend/src/app/themes/light-theme.scss b/frontend/src/app/themes/light-theme.scss new file mode 100644 index 0000000..83897e1 --- /dev/null +++ b/frontend/src/app/themes/light-theme.scss @@ -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 + ) +)); diff --git a/frontend/src/assets/Gameyfin_Logo_256px_dark.png b/frontend/src/assets/Gameyfin_Logo_256px_dark.png new file mode 100644 index 0000000..b6be344 Binary files /dev/null and b/frontend/src/assets/Gameyfin_Logo_256px_dark.png differ diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 1b418e3..5ea48e4 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -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; }