Merge pull request #14 from grimsi/release-1.1.0

Release 1.1.0
This commit is contained in:
Simon
2022-08-15 17:47:41 +02:00
committed by GitHub
44 changed files with 757 additions and 276 deletions
+2 -1
View File
@@ -36,4 +36,5 @@ build/
### Custom ###
/data/
/backend/src/main/resources/static/
/docker/docker-compose.yml
/docker/docker-compose.yml
/.gameyfin/
+14 -2
View File
@@ -7,7 +7,7 @@
<parent>
<artifactId>gameyfin</artifactId>
<groupId>de.grimsi</groupId>
<version>1.0.1</version>
<version>1.1.0</version>
</parent>
<artifactId>gameyfin-backend</artifactId>
@@ -16,12 +16,16 @@
<properties>
<java.version>18</java.version>
<springdoc-openapi-ui.version>1.6.9</springdoc-openapi-ui.version>
<resilience4j.version>1.7.1</resilience4j.version>
<commons-io.version>2.11.0</commons-io.version>
<commons-compress.version>1.21</commons-compress.version>
<protoc.plugin.version>3.11.4</protoc.plugin.version>
<protobuf-java.version>3.21.3</protobuf-java.version>
<!-- Use older version because the newer versions have problems with non-ASCII characters in properties files (umlauts etc.) -->
<maven-resources-plugin.version>3.1.0</maven-resources-plugin.version>
</properties>
<dependencies>
@@ -83,6 +87,11 @@
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<!-- File handling -->
<dependency>
@@ -140,7 +149,9 @@
<include>**/*.properties</include>
<include>**/*.yml</include>
<include>**/*.yaml</include>
<include>**/*.sql</include>
<include>**/*.txt</include>
<include>**/*.json</include>
<include>**/*.js</include>
<include>**/*.css</include>
<include>**/*.html</include>
@@ -166,6 +177,7 @@
<!-- Import the compiled frontend -->
<plugin>
<artifactId>maven-resources-plugin</artifactId>
<version>${maven-resources-plugin.version}</version>
<executions>
<execution>
<id>copy-resources</id>
@@ -215,4 +227,4 @@
</plugins>
</build>
</project>
</project>
@@ -8,7 +8,7 @@ public class GameyfinApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(GameyfinApplication.class)
.properties("spring.config.name=application,gameyfin,database,secure")
.properties( "file.encoding=UTF-8", "spring.config.name=application,gameyfin,database,secure")
.build()
.run(args);
}
@@ -0,0 +1,66 @@
package de.grimsi.gameyfin.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.core.env.*;
import org.springframework.util.PropertyPlaceholderHelper;
import javax.sql.DataSource;
import java.util.Arrays;
import java.util.Properties;
import java.util.stream.StreamSupport;
@Configuration
public class FilesystemConfig {
@Value("#{'${gameyfin.sources}'.split(',')[0]}")
private String firstLibraryPath;
@Autowired
Environment env;
@Autowired
public void setConfigurableEnvironment(ConfigurableEnvironment env) {
Properties props = new Properties();
props.setProperty("gameyfin.db", "%s/.gameyfin/db".formatted(firstLibraryPath));
props.setProperty("gameyfin.cache", "%s/.gameyfin/cache".formatted(firstLibraryPath));
env.getPropertySources().addFirst(new PropertiesPropertySource("gameyfinFilesystemProperties", props));
}
/**
* This bean is needed so Spring initializes the data source after we are done messing with the configuration environment
* @return DataSource
*/
@ConfigurationProperties(prefix = "spring.datasource")
@Bean
@Primary
public DataSource getDataSource() {
Properties properties = loadAllProperties();
return DataSourceBuilder
.create()
.url(properties.getProperty("spring.datasource.url"))
.build();
}
private Properties loadAllProperties() {
Properties props = new Properties();
MutablePropertySources propSrcs = ((AbstractEnvironment) env).getPropertySources();
StreamSupport.stream(propSrcs.spliterator(), false)
.filter(ps -> ps instanceof EnumerablePropertySource)
.map(ps -> ((EnumerablePropertySource<?>) ps).getPropertyNames())
.flatMap(Arrays::stream)
.forEach(propName -> props.setProperty(propName, env.getProperty(propName)));
return props;
}
}
@@ -16,7 +16,7 @@ public class SecureProperties {
@Autowired
public void setConfigurableEnvironment(ConfigurableEnvironment env) {
try {
Resource resource = new ClassPathResource("/config/secure.properties");
Resource resource = new ClassPathResource("/config/secure.yml");
env.getPropertySources().addFirst(new PropertiesPropertySource(Objects.requireNonNull(resource.getFilename()), PropertiesLoaderUtils.loadProperties(resource)));
} catch (Exception ex) {
throw new RuntimeException(ex.getMessage(), ex);
@@ -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;
@@ -10,13 +10,13 @@ import de.grimsi.gameyfin.repositories.DetectedGameRepository;
import de.grimsi.gameyfin.repositories.UnmappableFileRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StopWatch;
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Stream;
@@ -29,25 +29,29 @@ import static de.grimsi.gameyfin.util.FilenameUtil.hasGameArchiveExtension;
@RequiredArgsConstructor
public class LibraryService {
@Value("${gameyfin.root}")
private String rootFolderPath;
@Value("${gameyfin.sources}")
private List<String> libraryFolders;
private final IgdbWrapper igdbWrapper;
private final DetectedGameRepository detectedGameRepository;
private final UnmappableFileRepository unmappableFileRepository;
public List<Path> getGameFiles() {
List<Path> gamefiles = new ArrayList<>();
Path rootFolder = Path.of(rootFolderPath);
libraryFolders.stream().map(Path::of).forEach(
folder -> {
try (Stream<Path> stream = Files.list(folder)) {
// return all sub-folders (non-recursive) and files that have an extension that indicates that they are a downloadable file
List<Path> gameFilesFromThisFolder = stream.filter(p -> Files.isDirectory(p) || hasGameArchiveExtension(p)).toList();
gamefiles.addAll(gameFilesFromThisFolder);
try (Stream<Path> stream = Files.list(rootFolder)) {
// return all sub-folders (non-recursive) and files that have an extension that indicates that they are a downloadable file
return stream
.filter(p -> Files.isDirectory(p) || hasGameArchiveExtension(p))
.toList();
} catch (IOException e) {
throw new RuntimeException("Error while opening root folder", e);
}
} catch (IOException e) {
throw new RuntimeException("Error while opening library folder '%s'".formatted(folder), e);
}
}
);
return gamefiles;
}
public void scanGameLibrary() {
@@ -0,0 +1,9 @@
{
"properties": [
{
"name": "gameyfin.sources",
"type": "java.lang.String[]",
"description": "List of directories Gameyfin should scan for games."
}
]
}
@@ -1,8 +1,6 @@
gameyfin:
user: admin
password: password
cache: ${gameyfin.root}\.gameyfin\cache
db: ${gameyfin.root}\.gameyfin\db
logging:
level:
@@ -1,13 +0,0 @@
# This file contains properties related to the database configuration
#
#
spring.jpa.properties.hibernate.enable_lazy_load_no_trans=true
spring.datasource.db-name=gameyfin_db
spring.datasource.url=jdbc:h2:file:${gameyfin.db}/${spring.datasource.db-name}
spring.datasource.username=gfadmin
spring.datasource.password=gameyfin
spring.datasource.driverClassName=org.h2.Driver
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=update
spring.jpa.open-in-view=true
spring.jpa.properties.hibernate.event.merge.entity_copy_observer=allow
@@ -0,0 +1,20 @@
spring:
jpa:
open-in-view: true
properties:
hibernate:
enable_lazy_load_no_trans: true
event:
merge:
entity_copy_observer: allow
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: none
flyway:
baseline-on-migrate: true
datasource:
username: gfadmin
password: gameyfin
db-name: gameyfin_db
url: jdbc:h2:file:${gameyfin.db}/${spring.datasource.db-name}
driverClassName: org.h2.Driver
@@ -1,28 +0,0 @@
# This file contains properties related to the configuration of Gameyfin
#
#
# Username and password for the web interface
gameyfin.user=
gameyfin.password=
# Root folder of your game library
gameyfin.root=
# Folders where gameyfin will store cached images and the database
gameyfin.cache=${gameyfin.root}/.gameyfin/cache
gameyfin.db=${gameyfin.root}/.gameyfin/db
# File extensions which gameyfin will recognize as game files
gameyfin.file-extensions=iso, zip, rar, 7z, exe
# List of IGDB platform enums to limit search results. For possible values see: https://api-docs.igdb.com/#platform
gameyfin.igdb.config.preferred-platforms=6
# Twitch Client ID and Client Secret
gameyfin.igdb.api.client-id=
gameyfin.igdb.api.client-secret=
# The IGDB API has a rate limit of 4 req/s
gameyfin.igdb.api.max-requests-per-second=4
# According to the docs, there is a maximum of 8 concurrent requests, but in my tests the actual limit was 4 and even then it sometimes failed, so I set it to 2 to be sure
gameyfin.igdb.api.max-concurrent-requests=2
@@ -0,0 +1,10 @@
gameyfin:
file-extensions: iso, zip, rar, 7z, exe
igdb:
api:
client-id:
client-secret:
max-concurrent-requests: 2
max-requests-per-second: 4
config:
preferred-platforms: 6
@@ -1,19 +0,0 @@
# This file contains properties that are *NOT* safe to override by the user
# In theory a user should not be able to override them since they will be loaded from the classpath at launch, overriding existing user properties with the same key
#
#
# System Info
application.name=Gameyfin
application.version=@project.version@
# API
server.servlet.context-path=/
# Spring Actuator
management.endpoints.enabled-by-default=false
management.endpoint.health.enabled=true
# Server
server.error.include-stacktrace=never
spring.mvc.async.request-timeout=-1
# Jackson JSON Mapping
spring.jackson.default-property-inclusion=non_null
spring.jackson.mapper.accept-case-insensitive-enums=true
spring.jackson.deserialization.fail-on-unknown-properties=false
@@ -0,0 +1,27 @@
application:
version: '@project.version@'
name: Gameyfin
server:
servlet:
context-path: /
error:
include-stacktrace: never
spring:
jackson:
deserialization:
fail-on-unknown-properties: false
default-property-inclusion: non_null
mapper:
accept-case-insensitive-enums: true
mvc:
async:
request-timeout: -1
management:
endpoint:
health:
enabled: true
endpoints:
enabled-by-default: false
@@ -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()
+3 -3
View File
@@ -1,10 +1,10 @@
FROM openjdk:18
ENV GAMEYFIN_ROOT=/opt/gameyfin-library
ENV GAMEYFIN_SOURCES=/opt/gameyfin-library
RUN groupadd gameyfin && useradd gameyfin -g gameyfin && \
mkdir -p /opt/gameyfin ${GAMEYFIN_ROOT} && \
chown -R gameyfin:gameyfin /opt/gameyfin ${GAMEYFIN_ROOT}
mkdir -p /opt/gameyfin ${GAMEYFIN_SOURCES} && \
chown -R gameyfin:gameyfin /opt/gameyfin ${GAMEYFIN_SOURCES}
USER gameyfin:gameyfin
+1 -1
View File
@@ -5,7 +5,7 @@
<parent>
<artifactId>gameyfin</artifactId>
<groupId>de.grimsi</groupId>
<version>1.0.1</version>
<version>1.1.0</version>
</parent>
<modelVersion>4.0.0</modelVersion>
+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>
@@ -4,6 +4,8 @@ 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 {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 {
@@ -77,38 +51,14 @@ export class HeaderComponent {
}
onLibraryScreen(): boolean {
return this.router.url === "/library";
return this.router.url.startsWith("/library?") || this.router.url === "/library";
}
onLibraryManagementScreen(): boolean {
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;
}
+1 -1
View File
@@ -5,7 +5,7 @@
<groupId>de.grimsi</groupId>
<artifactId>gameyfin</artifactId>
<version>1.0.1</version>
<version>1.1.0</version>
<name>gameyfin</name>
<description>gameyfin</description>