mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
+2
-1
@@ -36,4 +36,5 @@ build/
|
||||
### Custom ###
|
||||
/data/
|
||||
/backend/src/main/resources/static/
|
||||
/docker/docker-compose.yml
|
||||
/docker/docker-compose.yml
|
||||
/.gameyfin/
|
||||
|
||||
+14
-2
@@ -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;
|
||||
+4
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user