diff --git a/.gitignore b/.gitignore index 5d839f3..bbaa093 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,5 @@ build/ ### Custom ### /data/ /backend/src/main/resources/static/ -/docker/docker-compose.yml \ No newline at end of file +/docker/docker-compose.yml +/.gameyfin/ diff --git a/backend/pom.xml b/backend/pom.xml index 9734656..a629033 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -7,7 +7,7 @@ gameyfin de.grimsi - 1.0.1 + 1.1.0 gameyfin-backend @@ -16,12 +16,16 @@ 18 + 1.6.9 1.7.1 2.11.0 1.21 3.11.4 3.21.3 + + + 3.1.0 @@ -83,6 +87,11 @@ h2 runtime + + org.flywaydb + flyway-core + + @@ -140,7 +149,9 @@ **/*.properties **/*.yml **/*.yaml + **/*.sql **/*.txt + **/*.json **/*.js **/*.css **/*.html @@ -166,6 +177,7 @@ maven-resources-plugin + ${maven-resources-plugin.version} copy-resources @@ -215,4 +227,4 @@ - \ No newline at end of file + diff --git a/backend/src/main/java/de/grimsi/gameyfin/GameyfinApplication.java b/backend/src/main/java/de/grimsi/gameyfin/GameyfinApplication.java index 4c79e1b..f17d349 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/GameyfinApplication.java +++ b/backend/src/main/java/de/grimsi/gameyfin/GameyfinApplication.java @@ -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); } diff --git a/backend/src/main/java/de/grimsi/gameyfin/config/FilesystemConfig.java b/backend/src/main/java/de/grimsi/gameyfin/config/FilesystemConfig.java new file mode 100644 index 0000000..5b9cf98 --- /dev/null +++ b/backend/src/main/java/de/grimsi/gameyfin/config/FilesystemConfig.java @@ -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; + } + +} diff --git a/backend/src/main/java/de/grimsi/gameyfin/config/SecureProperties.java b/backend/src/main/java/de/grimsi/gameyfin/config/SecureProperties.java index 6d66527..3140b37 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/config/SecureProperties.java +++ b/backend/src/main/java/de/grimsi/gameyfin/config/SecureProperties.java @@ -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); 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/java/de/grimsi/gameyfin/service/LibraryService.java b/backend/src/main/java/de/grimsi/gameyfin/service/LibraryService.java index 3531176..f5c7308 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/service/LibraryService.java +++ b/backend/src/main/java/de/grimsi/gameyfin/service/LibraryService.java @@ -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 libraryFolders; private final IgdbWrapper igdbWrapper; private final DetectedGameRepository detectedGameRepository; private final UnmappableFileRepository unmappableFileRepository; public List getGameFiles() { + List gamefiles = new ArrayList<>(); - Path rootFolder = Path.of(rootFolderPath); + libraryFolders.stream().map(Path::of).forEach( + folder -> { + try (Stream 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 gameFilesFromThisFolder = stream.filter(p -> Files.isDirectory(p) || hasGameArchiveExtension(p)).toList(); + gamefiles.addAll(gameFilesFromThisFolder); - try (Stream 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() { diff --git a/backend/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/backend/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 0000000..e346e41 --- /dev/null +++ b/backend/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,9 @@ +{ + "properties": [ + { + "name": "gameyfin.sources", + "type": "java.lang.String[]", + "description": "List of directories Gameyfin should scan for games." + } + ] +} \ No newline at end of file diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml index 30efaba..37e7822 100644 --- a/backend/src/main/resources/application-dev.yml +++ b/backend/src/main/resources/application-dev.yml @@ -1,8 +1,6 @@ gameyfin: user: admin password: password - cache: ${gameyfin.root}\.gameyfin\cache - db: ${gameyfin.root}\.gameyfin\db logging: level: diff --git a/backend/src/main/resources/config/database.properties b/backend/src/main/resources/config/database.properties deleted file mode 100644 index 6123b15..0000000 --- a/backend/src/main/resources/config/database.properties +++ /dev/null @@ -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 \ No newline at end of file diff --git a/backend/src/main/resources/config/database.yml b/backend/src/main/resources/config/database.yml new file mode 100644 index 0000000..7551f4d --- /dev/null +++ b/backend/src/main/resources/config/database.yml @@ -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 \ No newline at end of file diff --git a/backend/src/main/resources/config/gameyfin.properties b/backend/src/main/resources/config/gameyfin.properties deleted file mode 100644 index 776836d..0000000 --- a/backend/src/main/resources/config/gameyfin.properties +++ /dev/null @@ -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 \ No newline at end of file diff --git a/backend/src/main/resources/config/gameyfin.yml b/backend/src/main/resources/config/gameyfin.yml new file mode 100644 index 0000000..f70079e --- /dev/null +++ b/backend/src/main/resources/config/gameyfin.yml @@ -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 \ No newline at end of file diff --git a/backend/src/main/resources/config/secure.properties b/backend/src/main/resources/config/secure.properties deleted file mode 100644 index cda7c4e..0000000 --- a/backend/src/main/resources/config/secure.properties +++ /dev/null @@ -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 diff --git a/backend/src/main/resources/config/secure.yml b/backend/src/main/resources/config/secure.yml new file mode 100644 index 0000000..cf80710 --- /dev/null +++ b/backend/src/main/resources/config/secure.yml @@ -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 \ No newline at end of file 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/docker/Dockerfile b/docker/Dockerfile index c6077b7..743516b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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 diff --git a/frontend/pom.xml b/frontend/pom.xml index db25e89..f780850 100644 --- a/frontend/pom.xml +++ b/frontend/pom.xml @@ -5,7 +5,7 @@ gameyfin de.grimsi - 1.0.1 + 1.1.0 4.0.0 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; } diff --git a/pom.xml b/pom.xml index dab9593..fd45415 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ de.grimsi gameyfin - 1.0.1 + 1.1.0 gameyfin gameyfin