The moment has come to sundown development of Gameyfin v1

Gameyfin v2 will be the only version in active development from now on
This commit is contained in:
Simon
2025-06-15 17:53:45 +02:00
committed by GitHub
614 changed files with 40504 additions and 26936 deletions
-56
View File
@@ -1,56 +0,0 @@
name: Gameyfin CI Pipeline
on:
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
jobs:
build:
name: Build, Test & Scan
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, '[ci skip]')"
steps:
- name: Git checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Set up JDK
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Cache SonarCloud packages
uses: actions/cache@v4
with:
path: ~/.sonar/cache
key: ${{ runner.os }}-sonar
restore-keys: ${{ runner.os }}-sonar
- name: Cache Maven packages
uses: actions/cache@v4
with:
path: ~/.m2
key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
restore-keys: ${{ runner.os }}-m2
- name: Extract Maven project version
id: project
run: echo "GAMEYFIN_VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)" >> $GITHUB_OUTPUT
- name: Build and analyze
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: mvn -B verify org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Dsonar.projectKey=grimsi_gameyfin
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: gameyfin-${{ steps.project.outputs.GAMEYFIN_VERSION }}.jar
path: backend/target/gameyfin-*.jar
-92
View File
@@ -1,92 +0,0 @@
name: Gameyfin Docker Build & Push
on:
workflow_dispatch:
inputs:
branch:
description: "The branch to checkout when cutting the release."
required: true
default: "main"
tag:
description: "Docker image tag."
required: true
default: "X.Y.Z"
jobs:
release:
runs-on: ubuntu-latest
name: Release
steps:
- name: Git checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch }}
- name: Set up JDK
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'maven'
- name: Configure Git User
run: |
git config user.email "actions@github.com"
git config user.name "GitHub Actions"
- name: Maven Package
run: mvn package -B -s .maven_settings.xml -DreleaseVersion=${{ github.event.inputs.tag }} -Darguments="-Dmaven.deploy.skip=true -Dmaven.test.skip=true -Dmaven.javadoc.skip=true"
env:
GITHUB_ACTOR: ${{ github.actor }}
GITHUB_TOKEN: ${{ github.token }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
grimsi/gameyfin
tags: |
type=semver,pattern={{version}},value=${{ github.event.inputs.tag }}
type=semver,pattern={{major}}.{{minor}},value=${{ github.event.inputs.tag }}
type=semver,pattern={{major}},value=${{ github.event.inputs.tag }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Cache Docker layers
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: ./docker/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
- # Temp fix
# https://github.com/docker/build-push-action/issues/252
# https://github.com/moby/buildkit/issues/1896
name: Move Docker cache (temp fix)
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
+35
View File
@@ -0,0 +1,35 @@
name: Plugin-API Release
on:
push:
tags:
- '*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
cache: gradle
- name: Decrypt and import GPG key
run: |
echo "$GPG_PRIVATE_KEY" | gpg --batch --import
env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
- name: Build and deploy with JReleaser
run: ./gradlew jreleaserFullRelease
env:
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
MAVENCENTRAL_USERNAME: ${{ secrets.MAVENCENTRAL_USERNAME }}
MAVENCENTRAL_TOKEN: ${{ secrets.MAVENCENTRAL_TOKEN }}
JRELEASER_GITHUB_TOKEN: ${{ GITHUB_TOKEN }}
-114
View File
@@ -1,114 +0,0 @@
name: Gameyfin Release
on:
workflow_dispatch:
inputs:
branch:
description: "The branch to checkout when cutting the release."
required: true
default: "main"
releaseVersion:
description: "Default version to use when preparing a release."
required: true
default: "X.Y.Z"
developmentVersion:
description: "Default version to use for new local working copy."
required: true
default: "X.Y.Z-SNAPSHOT"
jobs:
release:
runs-on: ubuntu-latest
name: Release
steps:
- name: Git checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch }}
- name: Set up JDK
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'maven'
- name: Configure Git User
run: |
git config user.email "actions@github.com"
git config user.name "GitHub Actions"
- name: Maven Release
run: mvn release:prepare release:perform -B -s .maven_settings.xml -DreleaseVersion=${{ github.event.inputs.releaseVersion }} -DdevelopmentVersion=${{ github.event.inputs.developmentVersion }} -Darguments="-Dmaven.deploy.skip=true -Dmaven.test.skip=true -Dmaven.javadoc.skip=true"
env:
GITHUB_ACTOR: ${{ github.actor }}
GITHUB_TOKEN: ${{ github.token }}
- name: Git tag
uses: mathieudutour/github-tag-action@v6.2
with:
github_token: ${{ github.token }}
default_bump: false
custom_tag: ${{ github.event.inputs.releaseVersion }}
- name: Github Release
uses: "marvinpinto/action-automatic-releases@v1.2.1"
with:
repo_token: ${{ github.token }}
prerelease: false
automatic_release_tag: v${{ github.event.inputs.releaseVersion }}
files: |
LICENSE.md
backend/target/gameyfin-*.jar
config/gameyfin.properties
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
grimsi/gameyfin
tags: |
type=semver,pattern={{version}},value=${{ github.event.inputs.releaseVersion }}
type=semver,pattern={{major}}.{{minor}},value=${{ github.event.inputs.releaseVersion }}
type=semver,pattern={{major}},value=${{ github.event.inputs.releaseVersion }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Cache Docker layers
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: ./docker/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
- # Temp fix
# https://github.com/docker/build-push-action/issues/252
# https://github.com/moby/buildkit/issues/1896
name: Move Docker cache (temp fix)
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
+26 -12
View File
@@ -1,8 +1,10 @@
node_modules
HELP.md
target/
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
### STS ###
.apt_generated
@@ -12,12 +14,18 @@ target/
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### NetBeans ###
/nbproject/private/
@@ -25,16 +33,22 @@ target/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/
/.mvn/
### Kotlin ###
.kotlin
### Custom ###
/data/
/backend/src/main/resources/static/
/docker/docker-compose.yml
/.gameyfin/
/generated
/db
/data
/packaged_plugins
/logs
/templates
/app/src/main/bundles/
/app/src/main/frontend/**/*.js
/app/src/main/frontend/**/*.js.map
/app/src/main/frontend/generated/
/torrent_dotfiles/
-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<settings xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/settings-1.0.0.xsd">
<servers>
<server>
<id>github</id>
<username>${env.GITHUB_ACTOR}</username>
<password>${env.GITHUB_TOKEN}</password>
</server>
</servers>
</settings>
-5
View File
@@ -1,5 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Angular Application" type="JavascriptDebugType" uri="http://localhost:4200" useFirstLineBreakpoints="true">
<method v="2" />
</configuration>
</component>
-12
View File
@@ -1,12 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Angular CLI Server" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/frontend/package.json" />
<command value="run" />
<scripts>
<script value="start" />
</scripts>
<node-interpreter value="project" />
<envs />
<method v="2" />
</configuration>
</component>
+23
View File
@@ -0,0 +1,23 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="GameyfinApplication" type="SpringBootApplicationConfigurationType"
factoryName="Spring Boot" nameIsGenerated="true">
<option name="ACTIVE_PROFILES" value="dev"/>
<option name="ALTERNATIVE_JRE_PATH" value="BUNDLED"/>
<envs>
<env name="APP_KEY" value="8ODYedBBEA6qTd2Z/dZiWA=="/>
</envs>
<module name="Gameyfin.app.main"/>
<option name="SHORTEN_COMMAND_LINE" value="ARGS_FILE"/>
<option name="SPRING_BOOT_MAIN_CLASS" value="org.gameyfin.app.GameyfinApplication"/>
<option name="VM_PARAMETERS" value="-Dpf4j.mode=development"/>
<extension name="coverage">
<pattern>
<option name="PATTERN" value="org.gameyfin.app.*"/>
<option name="ENABLED" value="true"/>
</pattern>
</extension>
<method v="2">
<option name="Make" enabled="true"/>
</method>
</configuration>
</component>
+24
View File
@@ -0,0 +1,24 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Generate Hilla sources" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="hillaGenerate" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>
+25
View File
@@ -0,0 +1,25 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Production build" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="-Pvaadin.productionMode=true" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value=":gameyfin:clean" />
<option value=":gameyfin:build" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>
+25
View File
@@ -0,0 +1,25 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Publish Plugin API" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="--no-configuration-cache" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value=":plugin-api:clean" />
<option value=":plugin-api:publishAndReleaseToMavenCentral" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>
+25
View File
@@ -0,0 +1,25 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Rebuild all" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="clean" />
<option value="build" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>
+25
View File
@@ -0,0 +1,25 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Rebuild plugins" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$/plugins" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="clean" />
<option value="build" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>
+5
View File
@@ -0,0 +1,5 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="UI debug" type="JavascriptDebugType" engineId="37cae5b9-e8b2-4949-9172-aafa37fbc09c" uri="http://localhost:8080">
<method v="2" />
</configuration>
</component>
+27 -32
View File
@@ -1,46 +1,41 @@
<div align="center">
<img src="assets/Gameyfin_Logo_White_Border.svg" height="128px" width="auto" alt="Gameyfin Logo">
<h1>Gameyfin</h1>
<p align="center">A simple game library manager.</p>
<img src="assets/v2/Banner.svg" width="auto" alt="Gameyfin Logo">
<h2>Gameyfin</h2>
<p align="center">Manage your video games.</p>
</div>
# Overview
> [!IMPORTANT]
> Gameyfin v2 currently in beta stage.
> Expect bugs and breaking changes until the `2.0.0` release.
## Overview
Name and functionality inspired by [Jellyfin](https://jellyfin.org/).
## Video
### Features
Click [this link](https://youtu.be/BSaccEm0tpo) to watch how to install and set up Gameyfin on your machine.
✨ Automatically scans and indexes your game libraries
⬇️ Access your library via your web browser & download games directly from there
👥 Share your library with friends & family
⚛️ LAN-friendly (everything is cached locally)
🐋 Runs in a container or as single <binary file / JAR file> on bare metal
🌈 Themes (including light and dark mode)
🔌 Easily expandable with plugins
🔒 Integrates into your SSO solution via OAuth2 / OpenID Connect
## Features
### Documentation
* Automatically scans your game library folder and downloads additional metadata from IGDB
* Access your library via your Web-Browser
* Download games directly from your browser
* LAN-friendly (everything is cached locally)
* Native Docker support (alternatively it's only one .jar file to run on bare metal)
* Light and dark theme
The documentation is available at [gameyfin.org](https://gameyfin.org/).
## Preview
### Contribute to Gameyfin
https://user-images.githubusercontent.com/9295182/197277953-d69464a4-d280-407b-9274-ae62e6917981.mp4
Currently, no contribution guide is available. After the `2.0.0` release, contributions will be welcome.
## Installation
### Technical Details
### General
Gameyfin v2 is written in Kotlin and uses the following libraries/frameworks:
Since Gameyfin loads information from IGDB, you need to register yourself there. Follow [this guide](https://api-docs.igdb.com/#account-creation).
### Docker
1. Download the `docker-compose.example.yml` file from this repository and rename it to just `docker-compose.yml`
2. Edit the configuration values to your liking
3. Run `docker-compose up -d`
### Bare metal
1. Make sure you have a JRE or JDK with version 18 or greater installed
2. Download the latest `gameyfin.jar` and `gameyfin.properties` file from the releases page
3. Edit the config options in the `gameyfin.properties` file
4. Use the following command to start Gameyfin: `java -jar gameyfin.jar`
5. Open the address of your Gameyfin host in your browser, Gameyfin runs under port 8080 by default
* Spring Boot 3 for the backend
* Vaadin Hilla & React for the frontend
* PF4J for the plugin system
* H2 database for persistence
+93
View File
@@ -0,0 +1,93 @@
group = "de.grimsi"
val appMainClass = "org.gameyfin.GameyfinApplicationKt"
plugins {
id("org.springframework.boot")
id("io.spring.dependency-management")
id("com.vaadin")
kotlin("jvm")
kotlin("plugin.spring")
kotlin("plugin.jpa")
id("com.google.devtools.ksp")
application
}
application {
mainClass.set(appMainClass)
}
allOpen {
annotations("javax.persistence.Entity", "javax.persistence.MappedSuperclass", "javax.persistence.Embedabble")
}
repositories {
maven {
setUrl("https://maven.vaadin.com/vaadin-addons")
}
}
dependencies {
// Spring Boot
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.cloud:spring-cloud-starter")
implementation("jakarta.validation:jakarta.validation-api:3.1.0")
// Kotlin extensions
implementation(kotlin("reflect"))
// Reactive
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
// Vaadin Hilla
implementation("com.vaadin:vaadin-core") {
exclude("com.vaadin:flow-react")
}
implementation("com.vaadin:vaadin-spring-boot-starter")
// Logging
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
// Persistence & I/O
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("com.github.paulcwarren:spring-content-fs-boot-starter:3.0.17")
implementation("commons-io:commons-io:2.18.0")
// SSO
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
implementation("org.springframework.security:spring-security-oauth2-jose")
// Notifications
implementation("org.springframework.boot:spring-boot-starter-mail")
implementation("ch.digitalfondue.mjml4j:mjml4j:1.0.3")
// Plugins
implementation(project(":plugin-api"))
// Utils
implementation("org.apache.tika:tika-core:3.1.0")
implementation("me.xdrop:fuzzywuzzy:1.4.0")
// Development
developmentOnly("org.springframework.boot:spring-boot-devtools")
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
runtimeOnly("com.h2database:h2")
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
}
dependencyManagement {
imports {
mavenBom("com.vaadin:vaadin-bom:${rootProject.extra["vaadinVersion"]}")
mavenBom("org.springframework.cloud:spring-cloud-dependencies:${rootProject.extra["springCloudVersion"]}")
}
}
tasks.withType<Test> {
useJUnitPlatform()
}
+26
View File
@@ -0,0 +1,26 @@
# This file was generated by the Gradle 'init' task.
# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format
[versions]
com-h2database-h2 = "2.2.224"
dev-hilla-hilla-react = "2.5.6"
dev-hilla-hilla-spring-boot-starter = "2.5.6"
org-parttio-line-awesome = "2.0.0"
org-springframework-boot-spring-boot-devtools = "3.2.2"
org-springframework-boot-spring-boot-starter-data-jpa = "3.2.2"
org-springframework-boot-spring-boot-starter-oauth2-resource-server = "3.2.2"
org-springframework-boot-spring-boot-starter-security = "3.2.2"
org-springframework-boot-spring-boot-starter-test = "3.2.2"
org-springframework-boot-spring-boot-starter-validation = "3.2.2"
[libraries]
com-h2database-h2 = { module = "com.h2database:h2", version.ref = "com-h2database-h2" }
dev-hilla-hilla-react = { module = "dev.hilla:hilla-react", version.ref = "dev-hilla-hilla-react" }
dev-hilla-hilla-spring-boot-starter = { module = "dev.hilla:hilla-spring-boot-starter", version.ref = "dev-hilla-hilla-spring-boot-starter" }
org-parttio-line-awesome = { module = "org.parttio:line-awesome", version.ref = "org-parttio-line-awesome" }
org-springframework-boot-spring-boot-devtools = { module = "org.springframework.boot:spring-boot-devtools", version.ref = "org-springframework-boot-spring-boot-devtools" }
org-springframework-boot-spring-boot-starter-data-jpa = { module = "org.springframework.boot:spring-boot-starter-data-jpa", version.ref = "org-springframework-boot-spring-boot-starter-data-jpa" }
org-springframework-boot-spring-boot-starter-oauth2-resource-server = { module = "org.springframework.boot:spring-boot-starter-oauth2-resource-server", version.ref = "org-springframework-boot-spring-boot-starter-oauth2-resource-server" }
org-springframework-boot-spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security", version.ref = "org-springframework-boot-spring-boot-starter-security" }
org-springframework-boot-spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test", version.ref = "org-springframework-boot-spring-boot-starter-test" }
org-springframework-boot-spring-boot-starter-validation = { module = "org.springframework.boot:spring-boot-starter-validation", version.ref = "org-springframework-boot-spring-boot-starter-validation" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+249
View File
@@ -0,0 +1,249 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+92
View File
@@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+7
View File
@@ -0,0 +1,7 @@
import {HeroUIPluginConfig} from "@heroui/react";
import {compileThemes, themes} from "./src/main/frontend/theming/themes"
export const HeroUIConfig: HeroUIPluginConfig = {
prefix: "gf",
themes: compileThemes(themes)
};
+17764
View File
File diff suppressed because it is too large Load Diff
+211
View File
@@ -0,0 +1,211 @@
{
"name": "gameyfin",
"version": "2.0.0-ALPHA",
"type": "module",
"dependencies": {
"@heroui/react": "2.7.9",
"@material-tailwind/react": "^2.1.10",
"@phosphor-icons/react": "^2.1.7",
"@polymer/polymer": "3.5.2",
"@react-stately/data": "^3.12.2",
"@react-types/shared": "^3.28.0",
"@vaadin/bundles": "24.8.0-rc1",
"@vaadin/common-frontend": "0.0.19",
"@vaadin/hilla-file-router": "24.8.0-rc1",
"@vaadin/hilla-frontend": "24.8.0-rc1",
"@vaadin/hilla-lit-form": "24.8.0-rc1",
"@vaadin/hilla-react-auth": "24.8.0-rc1",
"@vaadin/hilla-react-crud": "24.8.0-rc1",
"@vaadin/hilla-react-form": "24.8.0-rc1",
"@vaadin/hilla-react-i18n": "24.8.0-rc1",
"@vaadin/hilla-react-signals": "24.8.0-rc1",
"@vaadin/polymer-legacy-adapter": "24.8.0-rc1",
"@vaadin/react-components": "24.8.0-rc1",
"@vaadin/vaadin-development-mode-detector": "2.0.7",
"@vaadin/vaadin-lumo-styles": "24.8.0-rc1",
"@vaadin/vaadin-material-styles": "24.8.0-rc1",
"@vaadin/vaadin-themable-mixin": "24.8.0-rc1",
"@vaadin/vaadin-usage-statistics": "2.1.3",
"classnames": "^2.5.1",
"construct-style-sheets-polyfill": "3.1.0",
"cron-validator": "^1.3.1",
"date-fns": "2.29.3",
"formik": "^2.4.6",
"framer-motion": "^12.5.0",
"fzf": "^0.5.2",
"http-status-codes": "^2.3.0",
"lit": "3.3.0",
"moment": "^2.30.1",
"moment-timezone": "^0.5.47",
"next-themes": "^0.4.6",
"rand-seed": "^2.1.7",
"react": "18.3.1",
"react-accessible-treeview": "^2.11.1",
"react-aria-components": "^1.7.1",
"react-confetti-boom": "^1.0.0",
"react-dom": "18.3.1",
"react-markdown": "^10.1.0",
"react-player": "^2.16.0",
"react-router": "7.6.1",
"remark-breaks": "^4.0.0",
"swiper": "^11.2.6",
"valtio": "^2.1.5",
"valtio-reactive": "^0.1.2",
"yup": "^1.6.1"
},
"devDependencies": {
"@babel/preset-react": "7.27.1",
"@lit-labs/react": "^2.1.3",
"@preact/signals-react-transform": "0.5.1",
"@rollup/plugin-replace": "6.0.2",
"@rollup/pluginutils": "5.1.4",
"@types/node": "^22.4.0",
"@types/react": "18.3.23",
"@types/react-dom": "18.3.7",
"@vaadin/hilla-generator-cli": "24.8.0-rc1",
"@vaadin/hilla-generator-core": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-backbone": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-barrel": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-client": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-model": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-push": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-signals": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-subtypes": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-transfertypes": "24.8.0-rc1",
"@vaadin/hilla-generator-utils": "24.8.0-rc1",
"@vitejs/plugin-react": "4.5.0",
"@vitejs/plugin-react-swc": "^3.7.0",
"async": "3.2.6",
"autoprefixer": "^10.4.20",
"glob": "11.0.2",
"magic-string": "0.30.17",
"postcss": "^8.4.41",
"postcss-import": "^16.1.0",
"rollup-plugin-brotli": "3.1.0",
"rollup-plugin-visualizer": "5.14.0",
"strip-css-comments": "5.0.0",
"tailwindcss": "^3.4.13",
"transform-ast": "2.4.4",
"typescript": "5.8.3",
"vite": "6.3.5",
"vite-plugin-checker": "0.9.3",
"workbox-build": "7.3.0",
"workbox-core": "7.3.0",
"workbox-precaching": "7.3.0"
},
"overrides": {
"@react-aria/utils": "^3.28.1",
"classnames": "$classnames",
"react": "$react",
"react-dom": "$react-dom",
"@vaadin/bundles": "$@vaadin/bundles",
"@vaadin/common-frontend": "$@vaadin/common-frontend",
"construct-style-sheets-polyfill": "$construct-style-sheets-polyfill",
"lit": "$lit",
"@polymer/polymer": "$@polymer/polymer",
"@phosphor-icons/react": "$@phosphor-icons/react",
"formik": "$formik",
"yup": "$yup",
"next-themes": "$next-themes",
"@heroui/react": "$@heroui/react",
"framer-motion": "$framer-motion",
"@material-tailwind/react": "$@material-tailwind/react",
"http-status-codes": "$http-status-codes",
"@vaadin/polymer-legacy-adapter": "$@vaadin/polymer-legacy-adapter",
"@vaadin/vaadin-development-mode-detector": "$@vaadin/vaadin-development-mode-detector",
"@vaadin/vaadin-usage-statistics": "$@vaadin/vaadin-usage-statistics",
"@vaadin/react-components": "$@vaadin/react-components",
"@vaadin/hilla-frontend": "$@vaadin/hilla-frontend",
"@vaadin/hilla-react-auth": "$@vaadin/hilla-react-auth",
"@vaadin/hilla-react-crud": "$@vaadin/hilla-react-crud",
"@vaadin/hilla-file-router": "$@vaadin/hilla-file-router",
"@vaadin/hilla-react-i18n": "$@vaadin/hilla-react-i18n",
"@vaadin/hilla-lit-form": "$@vaadin/hilla-lit-form",
"@vaadin/hilla-react-form": "$@vaadin/hilla-react-form",
"@vaadin/hilla-react-signals": "$@vaadin/hilla-react-signals",
"cron-validator": "$cron-validator",
"moment": "$moment",
"moment-timezone": "$moment-timezone",
"react-confetti-boom": "$react-confetti-boom",
"date-fns": "$date-fns",
"@vaadin/vaadin-themable-mixin": "$@vaadin/vaadin-themable-mixin",
"@vaadin/vaadin-lumo-styles": "$@vaadin/vaadin-lumo-styles",
"@vaadin/vaadin-material-styles": "$@vaadin/vaadin-material-styles",
"@react-types/shared": "$@react-types/shared",
"@react-stately/data": "$@react-stately/data",
"react-aria-components": "$react-aria-components",
"react-accessible-treeview": "$react-accessible-treeview",
"rand-seed": "$rand-seed",
"react-router": "$react-router",
"swiper": "$swiper",
"react-player": "$react-player",
"react-markdown": "$react-markdown",
"remark-breaks": "$remark-breaks",
"valtio": "$valtio",
"valtio-reactive": "$valtio-reactive",
"fzf": "$fzf"
},
"vaadin": {
"disableUsageStatistics": true,
"dependencies": {
"@polymer/polymer": "3.5.2",
"@vaadin/bundles": "24.8.0-rc1",
"@vaadin/common-frontend": "0.0.19",
"@vaadin/hilla-file-router": "24.8.0-rc1",
"@vaadin/hilla-frontend": "24.8.0-rc1",
"@vaadin/hilla-lit-form": "24.8.0-rc1",
"@vaadin/hilla-react-auth": "24.8.0-rc1",
"@vaadin/hilla-react-crud": "24.8.0-rc1",
"@vaadin/hilla-react-form": "24.8.0-rc1",
"@vaadin/hilla-react-i18n": "24.8.0-rc1",
"@vaadin/hilla-react-signals": "24.8.0-rc1",
"@vaadin/polymer-legacy-adapter": "24.8.0-rc1",
"@vaadin/react-components": "24.8.0-rc1",
"@vaadin/vaadin-development-mode-detector": "2.0.7",
"@vaadin/vaadin-lumo-styles": "24.8.0-rc1",
"@vaadin/vaadin-material-styles": "24.8.0-rc1",
"@vaadin/vaadin-themable-mixin": "24.8.0-rc1",
"@vaadin/vaadin-usage-statistics": "2.1.3",
"construct-style-sheets-polyfill": "3.1.0",
"date-fns": "2.29.3",
"lit": "3.3.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router": "7.6.1"
},
"devDependencies": {
"@babel/preset-react": "7.27.1",
"@preact/signals-react-transform": "0.5.1",
"@rollup/plugin-replace": "6.0.2",
"@rollup/pluginutils": "5.1.4",
"@types/react": "18.3.23",
"@types/react-dom": "18.3.7",
"@vaadin/hilla-generator-cli": "24.8.0-rc1",
"@vaadin/hilla-generator-core": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-backbone": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-barrel": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-client": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-model": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-push": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-signals": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-subtypes": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-transfertypes": "24.8.0-rc1",
"@vaadin/hilla-generator-utils": "24.8.0-rc1",
"@vitejs/plugin-react": "4.5.0",
"async": "3.2.6",
"glob": "11.0.2",
"magic-string": "0.30.17",
"rollup-plugin-brotli": "3.1.0",
"rollup-plugin-visualizer": "5.14.0",
"strip-css-comments": "5.0.0",
"transform-ast": "2.4.4",
"typescript": "5.8.3",
"vite": "6.3.5",
"vite-plugin-checker": "0.9.3",
"workbox-build": "7.3.0",
"workbox-core": "7.3.0",
"workbox-precaching": "7.3.0"
},
"hash": "b2ffd3ce9b28bc9b88c070ed3a53ff5a6db02bd4f3127932d7c0be123cf7be25"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.
+64
View File
@@ -0,0 +1,64 @@
import {Outlet, useHref, useNavigate} from 'react-router';
import "./main.css";
import "Frontend/util/custom-validators";
import {HeroUIProvider} from "@heroui/react";
import {ThemeProvider as NextThemesProvider} from "next-themes";
import {themeNames} from "Frontend/theming/themes";
import {AuthProvider, useAuth} from "Frontend/util/auth";
import {IconContext, X} from "@phosphor-icons/react";
import client from "Frontend/generated/connect-client.default";
import {ErrorHandlingMiddleware} from "Frontend/util/middleware";
import {initializeLibraryState} from "Frontend/state/LibraryState";
import {initializeGameState} from "Frontend/state/GameState";
import {initializeScanState} from "Frontend/state/ScanState";
import {ToastProvider} from "@heroui/toast";
import {initializePluginState} from "Frontend/state/PluginState";
import {isAdmin} from "Frontend/util/utils";
export default function App() {
client.middlewares = [ErrorHandlingMiddleware];
const navigate = useNavigate();
return (
<HeroUIProvider className="size-full" navigate={navigate} useHref={useHref}>
<NextThemesProvider attribute="class" themes={themeNames()} defaultTheme="gameyfin-violet-dark">
<AuthProvider>
<ViewWithAuth/>
</AuthProvider>
</NextThemesProvider>
</HeroUIProvider>
);
}
function ViewWithAuth() {
const auth = useAuth();
initializeLibraryState();
initializeGameState();
if (isAdmin(auth)) {
initializeScanState();
initializePluginState();
}
return <>
<IconContext.Provider value={{size: 20}}>
<Outlet/>
<ToastProvider
toastProps={{
shouldShowTimeoutProgress: true,
radius: "sm",
variant: "flat",
hideIcon: true,
closeIcon: <X/>,
classNames: {
closeButton: "opacity-100 absolute right-4 top-1/2 -translate-y-1/2",
progressTrack: "h-1",
}
}}
toastOffset={64}
/>
</IconContext.Provider>
</>;
}
@@ -0,0 +1,85 @@
import {useAuth} from "Frontend/util/auth";
import {GearFine, Question, SignOut, User} from "@phosphor-icons/react";
import {Dropdown, DropdownItem, DropdownMenu, DropdownTrigger} from "@heroui/react";
import {useNavigate} from "react-router";
import {ConfigEndpoint} from "Frontend/generated/endpoints";
import Avatar from "Frontend/components/general/Avatar";
import {CollectionElement} from "@react-types/shared";
import {isAdmin} from "Frontend/util/utils";
export default function ProfileMenu() {
const auth = useAuth();
const navigate = useNavigate();
async function logout() {
if (auth.state.user?.managedBySso) {
window.location.href = (await ConfigEndpoint.getLogoutUrl()) || "/";
} else {
await auth.logout();
}
}
const profileMenuItems = [
{
label: "My Profile",
icon: <User/>,
onClick: () => navigate("/settings/profile")
},
{
label: "Administration",
icon: <GearFine/>,
onClick: () => navigate("/administration/libraries"),
showIf: isAdmin(auth)
},
{
label: "Help",
icon: <Question/>,
onClick: () => window.open("https://gameyfin.org", "_blank")
},
{
label: "Sign Out",
icon: <SignOut/>,
onClick: logout,
color: "primary"
},
];
// @ts-ignore
return (
<Dropdown placement="bottom-end">
<DropdownTrigger>
{/* div is necessary so dropdown menu will appear in the correct place */}
<div>
<Avatar radius="full"
as="button"
className="transition-transform size-8"
classNames={{
base: "gradient-primary",
icon: "text-background/80"
}}
/>
</div>
</DropdownTrigger>
<DropdownMenu disabledKeys={["username"]}>
<DropdownItem key="username" textValue={auth.state.user?.username}>
<p className="font-bold">Signed in as {auth.state.user?.username}</p>
</DropdownItem>
{profileMenuItems.filter(item => item.showIf !== false).map(({label, icon, onClick, color}) => {
return (
<DropdownItem
key={label}
onPress={onClick}
startContent={<div color={color}>{icon}</div>}
/* @ts-ignore */
color={color ? color : ""}
className={`text-${color} hover:bg-primary/20`}
textValue={label}
>
{label}
</DropdownItem>
);
}) as unknown as CollectionElement<object>}
</DropdownMenu>
</Dropdown>
);
}
@@ -0,0 +1,48 @@
import ConfigEntryDto from "Frontend/generated/org/gameyfin/app/config/dto/ConfigEntryDto";
import React from "react";
import Input from "Frontend/components/general/input/Input";
import CheckboxInput from "Frontend/components/general/input/CheckboxInput";
import SelectInput from "Frontend/components/general/input/SelectInput";
import ArrayInput from "Frontend/components/general/input/ArrayInput";
export default function ConfigFormField({configElement, ...props}: any) {
function inputElement(configElement: ConfigEntryDto) {
if (configElement.allowedValues != null && configElement.allowedValues.length > 0) {
return (
<SelectInput label={configElement.description} name={configElement.key}
values={configElement.allowedValues} {...props}/>
);
}
switch (configElement.type.toLowerCase()) {
case "boolean":
return (
<CheckboxInput label={configElement.description} name={configElement.key} {...props}/>
);
case "string":
return (
<Input label={configElement.description} name={configElement.key}
type={props.type && "text"} {...props}/>
);
case "float":
return (
<Input label={configElement.description} name={configElement.key} type="number"
step="0.1" {...props}/>
);
case "int":
return (
<Input label={configElement.description} name={configElement.key} type="number"
step="1" {...props}/>
);
case "array":
return (
<ArrayInput label={configElement.description} name={configElement.key} type="text" {...props}/>
);
default:
return <pre>Unsupported type: {configElement.type} for key {configElement.key}</pre>;
}
}
return inputElement(configElement!);
}
@@ -0,0 +1,102 @@
import React from "react";
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import withConfigPage from "Frontend/components/administration/withConfigPage";
import Section from "Frontend/components/general/Section";
import * as Yup from 'yup';
import {addToast, Button, Divider, Tooltip, useDisclosure} from "@heroui/react";
import {Plus} from "@phosphor-icons/react";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import {LibraryOverviewCard} from "Frontend/components/general/cards/LibraryOverviewCard";
import LibraryCreationModal from "Frontend/components/general/modals/LibraryCreationModal";
import LibraryUpdateDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryUpdateDto";
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import {useSnapshot} from "valtio/react";
import {libraryState} from "Frontend/state/LibraryState";
function LibraryManagementLayout({getConfig, formik}: any) {
const libraryCreationModal = useDisclosure();
const state = useSnapshot(libraryState);
async function updateLibrary(library: LibraryUpdateDto) {
await LibraryEndpoint.updateLibrary(library);
addToast({
title: "Library updated",
description: `Library ${library.name} has been updated.`,
color: "success"
})
}
async function removeLibrary(library: LibraryDto) {
await LibraryEndpoint.deleteLibrary(library.id);
addToast({
title: "Library removed",
description: `Library ${library.name} has been removed.`,
color: "success"
})
}
return (
<div className="flex flex-col">
<Section title="Permissions"/>
<ConfigFormField configElement={getConfig("library.allow-public-access")} isDisabled/>
<Section title="Scanning"/>
<div className="flex flex-col gap-4">
<ConfigFormField configElement={getConfig("library.scan.enable-filesystem-watcher")} isDisabled/>
<ConfigFormField configElement={getConfig("library.scan.scan-empty-directories")}/>
<div className="flex flex-row gap-4 items-baseline">
<ConfigFormField configElement={getConfig("library.scan.title-match-min-ratio")}/>
</div>
<ConfigFormField configElement={getConfig("library.scan.game-file-extensions")}/>
</div>
<Section title="Metadata"/>
<div className="flex flex-row items-baseline">
<ConfigFormField configElement={getConfig("library.metadata.update.enabled")} isDisabled/>
<ConfigFormField configElement={getConfig("library.metadata.update.schedule")}
isDisabled={!formik.values.library.metadata.update.enabled}/>
</div>
<div className="flex flex-row items-baseline justify-between">
<h2 className="text-xl font-bold mt-8 mb-1">Libraries</h2>
<Tooltip content="Add new library">
<Button isIconOnly variant="flat" onPress={libraryCreationModal.onOpen}>
<Plus/>
</Button>
</Tooltip>
</div>
<Divider className="mb-4"/>
{state.sorted.length > 0 ?
// Aspect ratio of cover = 12/17 -> 5 covers = 60/17 -> 353px * 100px
<div id="library-cards" className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(353px,1fr))]">
{state.sorted.map((library) =>
// @ts-ignore
<LibraryOverviewCard library={library} updateLibrary={updateLibrary}
removeLibrary={removeLibrary} key={library.name}/>
)}
</div> :
<p className="mt-4 text-center text-default-500">No libraries found</p>
}
<LibraryCreationModal
// @ts-ignore
libraries={state.sorted}
isOpen={libraryCreationModal.isOpen}
onOpenChange={libraryCreationModal.onOpenChange}
/>
</div>
);
}
const validationSchema = Yup.object({
library: Yup.object({
metadata: Yup.object({
update: Yup.object({
// @ts-ignore
schedule: Yup.string().cron()
})
})
})
});
export const LibraryManagement = withConfigPage(LibraryManagementLayout, "Library Management", validationSchema);
@@ -0,0 +1,97 @@
import React, {useEffect, useRef, useState} from "react";
import {LogEndpoint} from "Frontend/generated/endpoints";
import withConfigPage from "Frontend/components/administration/withConfigPage";
import * as Yup from 'yup';
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import {addToast, Button, Code, Divider, Tooltip} from "@heroui/react";
import {ArrowUDownLeft, SortAscending} from "@phosphor-icons/react";
function LogManagementLayout({getConfig, formik}: any) {
const [logEntries, setLogEntries] = useState<string[]>([]);
const [autoScroll, setAutoScroll] = useState(true);
const [softWrap, setSoftWrap] = useState(false);
const logEndRef = useRef<null | HTMLDivElement>(null);
useEffect(() => {
const sub = LogEndpoint.getApplicationLogs().onNext((newEntry: string | undefined) =>
setLogEntries((currentEntries) => [...currentEntries, newEntry as string])
);
return () => sub.cancel();
}, []);
useEffect(() => {
if (formik.isSubmitting == false && formik.submitCount > 0) {
LogEndpoint.reloadLogConfig()
.catch(() => addToast({
title: "Error",
description: "Failed to apply log configuration",
color: "danger"
}));
}
}, [formik.isSubmitting]);
useEffect(() => {
if (autoScroll) {
scrollToBottom();
}
}, [logEntries, autoScroll, softWrap]);
function scrollToBottom() {
logEndRef.current?.scrollIntoView();
}
return (
<div className="flex flex-col mt-4">
<div className="flex flex-row gap-4">
<ConfigFormField configElement={getConfig("logs.folder")}/>
<ConfigFormField configElement={getConfig("logs.max-history-days")}/>
<ConfigFormField configElement={getConfig("logs.level.gameyfin")}/>
<ConfigFormField configElement={getConfig("logs.level.root")}/>
</div>
<div className="flex flex-col">
<div className="flex flex-row flex-grow justify-between items-baseline">
<h2 className={"text-xl font-bold mt-8 mb-1"}>Application logs</h2>
<div className="flex flex-row gap-1">
<Tooltip content="Soft-wrap" placement="bottom">
<Button isIconOnly
onPress={() => setSoftWrap(!softWrap)}
variant={softWrap ? "solid" : "ghost"}
>
<ArrowUDownLeft/>
</Button>
</Tooltip>
<Tooltip content="Auto-scroll" placement="bottom">
<Button isIconOnly
onPress={() => setAutoScroll(!autoScroll)}
variant={autoScroll ? "solid" : "ghost"}
>
<SortAscending/>
</Button>
</Tooltip>
</div>
</div>
<Divider className="mb-4"/>
</div>
<Code size="sm" radius="none"
className={`flex flex-col h-[50vh] max-h-[50vh] text-sm overflow-auto ${softWrap ? "whitespace-normal break-words" : "whitespace-nowrap"}`}>
{logEntries.map((entry, index) => <p key={index}>{entry}</p>)}
<div ref={logEndRef}/>
</Code>
</div>
);
}
const validationSchema = Yup.object({
logs: Yup.object({
folder: Yup.string().required("Required"),
"max-history-days": Yup.number().required("Required"),
level: Yup.object({
gameyfin: Yup.string().required("Required"),
root: Yup.string().required("Required")
})
})
});
export const LogManagement = withConfigPage(LogManagementLayout, "Logging", validationSchema);
@@ -0,0 +1,129 @@
import React, {useEffect, useState} from "react";
import withConfigPage from "Frontend/components/administration/withConfigPage";
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import Section from "Frontend/components/general/Section";
import {addToast, Button, Card, Tooltip, useDisclosure} from "@heroui/react";
import {MessageEndpoint, MessageTemplateEndpoint} from "Frontend/generated/endpoints";
import {PaperPlaneRight, Pencil} from "@phosphor-icons/react";
import MessageTemplateDto from "Frontend/generated/org/gameyfin/app/messages/templates/MessageTemplateDto";
import SendTestNotificationModal from "Frontend/components/administration/messages/SendTestNotificationModal";
import EditTemplateModal from "Frontend/components/administration/messages/EditTemplateModel";
function MessageManagementLayout({getConfig, formik}: any) {
const editorModal = useDisclosure();
const testNotificationModal = useDisclosure();
const [availableTemplates, setAvailableTemplates] = useState<MessageTemplateDto[]>([]);
const [selectedTemplate, setSelectedTemplate] = useState<MessageTemplateDto>();
useEffect(() => {
MessageTemplateEndpoint.getAll().then((response: any) => {
setAvailableTemplates(response as MessageTemplateDto[]);
});
}, []);
async function verifyCredentials(provider: string) {
const credentials: Record<string, any> = {
host: formik.values.messages.providers.email.host,
port: formik.values.messages.providers.email.port,
username: formik.values.messages.providers.email.username,
password: formik.values.messages.providers.email.password
}
const areCredentialsValid = await MessageEndpoint.verifyCredentials(provider, credentials);
if (areCredentialsValid) {
addToast({
title: "Credentials are valid",
color: "success"
});
} else {
addToast({
title: "Credentials are invalid",
color: "warning"
});
}
}
async function openEditor(template: MessageTemplateDto) {
setSelectedTemplate(template);
editorModal.onOpen();
}
function openTestNotification(template: MessageTemplateDto) {
setSelectedTemplate(template);
testNotificationModal.onOpen();
}
return (
<div className="flex flex-col">
<div className="flex flex-row">
<div className="flex flex-col flex-1">
<div className="flex flex-row gap-8">
<div className="flex flex-col flex-1 h-fit">
<Section title="E-Mail"/>
<ConfigFormField configElement={getConfig("messages.providers.email.enabled")}
className="mb-2"/>
<ConfigFormField configElement={getConfig("messages.providers.email.host")}
isDisabled={!formik.values.messages.providers.email.enabled}/>
<ConfigFormField configElement={getConfig("messages.providers.email.port")}
isDisabled={!formik.values.messages.providers.email.enabled}/>
<ConfigFormField configElement={getConfig("messages.providers.email.username")}
isDisabled={!formik.values.messages.providers.email.enabled}/>
<ConfigFormField configElement={getConfig("messages.providers.email.password")}
type="password"
isDisabled={!formik.values.messages.providers.email.enabled}/>
<Button onPress={() => verifyCredentials("email")}
isDisabled={!(
formik.values.messages.providers.email.enabled &&
formik.values.messages.providers.email.host &&
formik.values.messages.providers.email.port &&
formik.values.messages.providers.email.username)}>Test</Button>
</div>
<div className="flex flex-col flex-1 h-fit">
<Section title="Message Templates"/>
<div className="flex flex-col gap-4">
{availableTemplates.map((template: MessageTemplateDto) =>
<Card className="flex flex-row items-center gap-2 p-4" key={template.key}>
<Tooltip content="Edit template">
<Button isIconOnly
size="sm"
onPress={() => openEditor(template)}
>
<Pencil/>
</Button>
</Tooltip>
<Tooltip content="Send test notification">
<Button isIconOnly
size="sm"
onPress={() => openTestNotification(template)}
isDisabled={!formik.values.messages.providers.email.enabled}
>
<PaperPlaneRight/>
</Button>
</Tooltip>
<p className="text-lg">{template.description}</p>
</Card>
)}
</div>
</div>
</div>
</div>
</div>
<EditTemplateModal
isOpen={editorModal.isOpen}
onOpenChange={editorModal.onOpenChange}
selectedTemplate={selectedTemplate!!}
/>
<SendTestNotificationModal
isOpen={testNotificationModal.isOpen}
onOpenChange={testNotificationModal.onOpenChange}
selectedTemplate={selectedTemplate!!}
/>
</div>
);
}
export const MessageManagement = withConfigPage(MessageManagementLayout, "Messages", "messages");
@@ -0,0 +1,27 @@
import React from "react";
import {PluginManagementSection} from "Frontend/components/general/plugin/PluginManagementSection";
import {pluginState} from "Frontend/state/PluginState";
import {useSnapshot} from "valtio/react";
export default function PluginManagement() {
// Defined manually for now to control the layout (order of categories)
const pluginTypes = ["GameMetadataProvider", "DownloadProvider"];
const state = useSnapshot(pluginState);
return state.isLoaded && (
<div className="flex flex-col">
<div className="flex flex-row flex-grow justify-between mb-8">
<h2 className="text-2xl font-bold">Plugins</h2>
</div>
<div className="flex flex-col gap-8">
{pluginTypes.map(type =>
// @ts-ignore
<PluginManagementSection key={type} type={type} plugins={state.pluginsByType[type]}/>
)}
</div>
</div>
);
}
@@ -0,0 +1,173 @@
import Section from "Frontend/components/general/Section";
import Input from "Frontend/components/general/input/Input";
import {addToast, Button, Input as NextUiInput, Tooltip} from "@heroui/react";
import {Form, Formik} from "formik";
import {ArrowCounterClockwise, Check, Info, Trash} from "@phosphor-icons/react";
import React, {useEffect, useState} from "react";
import {useAuth} from "Frontend/util/auth";
import * as Yup from "yup";
import UserUpdateDto from "Frontend/generated/org/gameyfin/app/users/dto/UserUpdateDto";
import {EmailConfirmationEndpoint, MessageEndpoint, UserEndpoint} from "Frontend/generated/endpoints";
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
import {removeAvatar, uploadAvatar} from "Frontend/endpoints/AvatarEndpoint";
import Avatar from "Frontend/components/general/Avatar";
export default function ProfileManagement() {
const auth = useAuth();
const [avatar, setAvatar] = useState<any>();
const [configSaved, setConfigSaved] = useState(false);
const [messagesEnabled, setMessagesEnabled] = useState(false);
useEffect(() => {
MessageEndpoint.isEnabled().then(setMessagesEnabled);
}, []);
useEffect(() => {
if (configSaved) {
setTimeout(() => setConfigSaved(false), 2000);
}
}, [configSaved])
function onFileSelected(event: any) {
setAvatar(event.target.files[0]);
}
async function handleSubmit(values: any) {
const userUpdate: UserUpdateDto = {
username: values.username,
email: values.email
}
if (values.newPassword.length > 0) {
userUpdate.password = values.newPassword;
}
await UserEndpoint.updateUser(userUpdate);
setConfigSaved(true);
if (values.newPassword.length > 0) {
addToast({
title: "Password changed",
description: "Please log in again",
color: "success"
});
setTimeout(() => {
auth.logout();
}, 500);
}
}
return (
<>
<Formik
initialValues={{
username: auth.state.user?.username,
email: auth.state.user?.email,
newPassword: "",
passwordRepeat: ""
}}
onSubmit={handleSubmit}
validationSchema={Yup.object({
username: Yup.string()
.required('Required'),
newPassword: Yup.string()
.min(8, 'Password must be at least 8 characters long'),
email: Yup.string()
.email()
.required('Required'),
passwordRepeat: Yup.string()
.equals([Yup.ref('newPassword')], 'Passwords do not match')
})}
>
{(formik: { values: any; isSubmitting: any; dirty: boolean; }) => (
<Form>
<div className="flex flex-row flex-grow justify-between mb-8">
<h2 className="text-2xl font-bold">My Profile</h2>
{auth.state.user?.managedBySso &&
<p className="text-warning">Your account is managed externally.</p>}
<div className="flex flex-row items-center gap-4">
{formik.values.newPassword.length > 0 &&
<SmallInfoField icon={Info}
message="You will be logged out of all current sessions"
className="text-foreground/70"
/>
}
<Button
color="primary"
isLoading={formik.isSubmitting}
isDisabled={!formik.dirty || formik.isSubmitting || configSaved || auth.state.user?.managedBySso}
type="submit"
>
{formik.isSubmitting ? "" : configSaved ? <Check/> : "Save"}
</Button>
</div>
</div>
<div className="flex flex-row flex-1 justify-between gap-16">
<div className="flex flex-col basis-1/4 mt-8 gap-4">
<div className="flex flex-row justify-center">
<Avatar className="size-40 m-4 flex flex-row"/>
</div>
<div className="flex flex-row gap-2">
<NextUiInput type="file" accept="image/*" onChange={onFileSelected}
isDisabled={auth.state.user?.managedBySso}/>
<Button onPress={() => uploadAvatar(avatar)} isDisabled={avatar == null}
color="success">Upload</Button>
<Tooltip content="Remove your current avatar">
<Button onPress={removeAvatar} isIconOnly color="danger"
isDisabled={auth.state.user?.managedBySso}><Trash/></Button>
</Tooltip>
</div>
</div>
<div className="flex flex-col flex-grow">
<Section title="Personal information"/>
<Input name="username" label="Username" type="text" autocomplete="username"
isDisabled={auth.state.user?.managedBySso}/>
<div className="flex flex-row gap-4">
<Input name="email" label="Email" type="email" autocomplete="email"
isDisabled={auth.state.user?.managedBySso || !messagesEnabled}/>
{(auth.state.user?.emailConfirmed === false && !auth.state.user.managedBySso) &&
<Tooltip content="Resend email confirmation message">
<Button isIconOnly
onPress={() => {
EmailConfirmationEndpoint.resendEmailConfirmation().then(
() => addToast({
title: "Email confirmation message sent",
description: "Please check your inbox",
color: "success"
})
)
}}
isDisabled={!messagesEnabled}
variant="ghost"
className="size-14"
>
<ArrowCounterClockwise size={26}/>
</Button>
</Tooltip>
}
</div>
{!messagesEnabled &&
<div className="flex flex-row gap-2 text-warning -mt-5">
<Info/>
<small>
Email services are disabled. Please contact your administrator.
</small>
</div>
}
<Section title="Security"/>
<Input name="newPassword" label="New Password" type="password"
autocomplete="new-password" isDisabled={auth.state.user?.managedBySso}/>
<Input name="passwordRepeat" label="Repeat password" type="password"
autocomplete="new-password" isDisabled={auth.state.user?.managedBySso}/>
</div>
</div>
</Form>
)}
</Formik>
</>
);
}
@@ -0,0 +1,124 @@
import React, {useEffect} from "react";
import withConfigPage from "Frontend/components/administration/withConfigPage";
import * as Yup from 'yup';
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import Section from "Frontend/components/general/Section";
import {addToast, Button} from "@heroui/react";
import {MagicWand} from "@phosphor-icons/react";
function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) {
useEffect(() => {
if (formik.dirty) {
setSaveMessage("Gameyfin must be restarted for the changes to take effect");
} else {
setSaveMessage(null);
}
}, [formik.dirty]);
function isAutoPopulateDisabled() {
return !formik.values.sso.oidc.enabled || !formik.values.sso.oidc["issuer-url"];
}
async function autoPopulate() {
let issuerUrl: string = formik.values.sso.oidc["issuer-url"];
if (issuerUrl.endsWith("/")) issuerUrl = issuerUrl.slice(0, -1);
try {
const response = await fetch(issuerUrl + "/.well-known/openid-configuration");
const data = await response.json();
formik.setFieldValue("sso.oidc.authorize-url", data.authorization_endpoint);
formik.setFieldValue("sso.oidc.token-url", data.token_endpoint);
formik.setFieldValue("sso.oidc.userinfo-url", data.userinfo_endpoint);
formik.setFieldValue("sso.oidc.logout-url", data.end_session_endpoint);
formik.setFieldValue("sso.oidc.jwks-url", data.jwks_uri);
} catch (e) {
addToast({
title: "Failed to auto-populate SSO configuration",
color: "warning"
});
}
}
return (
<div className="flex flex-col">
<div className="flex flex-row">
<div className="flex flex-col flex-1">
<Section title="SSO configuration"/>
<ConfigFormField configElement={getConfig("sso.oidc.enabled")}/>
<Section title="SSO user handling"/>
<div className="flex flex-row items-baseline">
<ConfigFormField configElement={getConfig("sso.oidc.auto-register-new-users")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.match-existing-users-by")}
isDisabled={!formik.values.sso.oidc.enabled ||
!formik.values.sso.oidc["auto-register-new-users"]}/>
</div>
<Section title="SSO provider configuration"/>
<ConfigFormField configElement={getConfig("sso.oidc.client-id")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.client-secret")}
type="password"
isDisabled={!formik.values.sso.oidc.enabled}/>
<div className="flex flex-row gap-2">
<ConfigFormField configElement={getConfig("sso.oidc.issuer-url")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<Button
isDisabled={isAutoPopulateDisabled()}
onPress={autoPopulate}
className="h-14"><MagicWand className="min-w-5"/>Auto-populate</Button>
</div>
<ConfigFormField configElement={getConfig("sso.oidc.authorize-url")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.token-url")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.userinfo-url")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.logout-url")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.jwks-url")}
isDisabled={!formik.values.sso.oidc.enabled}/>
</div>
</div>
</div>
);
}
const validationSchema = Yup.object({
sso: Yup.object({
oidc: Yup.object({
enabled: Yup.boolean(),
"auto-register-new-users": Yup.boolean().required(),
"match-existing-users-by": Yup.string().required(),
"client-id": Yup.string().when("enabled", ([enabled], schema) =>
enabled ? schema.required("Client ID is required") : schema
),
"client-secret": Yup.string().when("enabled", ([enabled], schema) =>
enabled ? schema.required("Client Secret is required") : schema
),
"issuer-url": Yup.string().when("enabled", ([enabled], schema) =>
enabled ? schema.required("Issuer URL is required") : schema
),
"authorize-url": Yup.string().when("enabled", ([enabled], schema) =>
enabled ? schema.required("Authorize URL is required") : schema
),
"token-url": Yup.string().when("enabled", ([enabled], schema) =>
enabled ? schema.required("Token URL is required") : schema
),
"userinfo-url": Yup.string().when("enabled", ([enabled], schema) =>
enabled ? schema.required("Userinfo URL is required") : schema
),
"logout-url": Yup.string().when("enabled", ([enabled], schema) =>
enabled ? schema.required("Logout URL is required") : schema
),
"jwks-url": Yup.string().when("enabled", ([enabled], schema) =>
enabled ? schema.required("JWKS URL is required") : schema
)
})
})
});
export const SsoManagement = withConfigPage(SsoManagementLayout, "Single Sign-On", validationSchema);
@@ -0,0 +1,29 @@
import React, {useEffect} from "react";
import {SystemEndpoint} from "Frontend/generated/endpoints";
import withConfigPage from "Frontend/components/administration/withConfigPage";
import {Button} from "@heroui/react";
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import Section from "Frontend/components/general/Section";
function SystemManagementLayout({getConfig, formik, setSaveMessage}: any) {
useEffect(() => {
if (formik.dirty && (formik.initialValues.system.cors["allowed-origins"] !== formik.values.system.cors["allowed-origins"])) {
setSaveMessage("Gameyfin must be restarted for the changes to take effect");
} else {
setSaveMessage(null);
}
}, [formik.dirty]);
return (
<div className="flex flex-col mt-4">
<Section title="Security configuration"/>
<ConfigFormField configElement={getConfig("system.cors.allowed-origins")}/>
<Section title="Restart Gameyfin"/>
<Button onPress={() => SystemEndpoint.restart()}>Restart</Button>
</div>
);
}
export const SystemManagement = withConfigPage(SystemManagementLayout, "System");
@@ -0,0 +1,54 @@
import React, {useEffect, useState} from "react";
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import withConfigPage from "Frontend/components/administration/withConfigPage";
import Section from "Frontend/components/general/Section";
import {UserEndpoint} from "Frontend/generated/endpoints";
import UserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/UserInfoDto";
import {UserManagementCard} from "Frontend/components/general/cards/UserManagementCard";
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
import {Info, UserPlus} from "@phosphor-icons/react";
import {Button, Divider, Tooltip, useDisclosure} from "@heroui/react";
import InviteUserModal from "Frontend/components/general/modals/InviteUserModal";
function UserManagementLayout({getConfig, formik}: any) {
const inviteUserModal = useDisclosure();
const [users, setUsers] = useState<UserInfoDto[]>([]);
useEffect(() => {
UserEndpoint.getAllUsers().then(
(response) => setUsers(response)
);
}, []);
return (
<div className="flex flex-col flex-grow">
<Section title="Sign-Ups"/>
<div className="flex flex-row">
<ConfigFormField configElement={getConfig("users.sign-ups.allow")}/>
<ConfigFormField configElement={getConfig("users.sign-ups.confirmation-required")}
isDisabled={!formik.values.users["sign-ups"].allow}/>
</div>
<div className="flex flex-row items-baseline justify-between">
<h2 className="text-xl font-bold mt-8 mb-1">Users</h2>
{!getConfig("sso.oidc.auto-register-new-users").value &&
<SmallInfoField className="mb-4 text-warning" icon={Info}
message="Automatic user registration for SSO users is disabled"/>
}
<Tooltip content="Invite new user">
<Button isIconOnly variant="flat" onPress={inviteUserModal.onOpen}>
<UserPlus/>
</Button>
</Tooltip>
</div>
<Divider className="mb-4"/>
<div className="grid grid-cols-300px gap-4">
{users.map((user) => <UserManagementCard user={user} key={user.username}/>)}
</div>
<InviteUserModal isOpen={inviteUserModal.isOpen} onOpenChange={inviteUserModal.onOpenChange}/>
</div>
);
}
export const UserManagement = withConfigPage(UserManagementLayout, "User Management");
@@ -0,0 +1,130 @@
import React, {useEffect, useState} from "react";
import {
addToast,
Button,
Chip,
Link,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Textarea
} from "@heroui/react";
import {MessageTemplateEndpoint} from "Frontend/generated/endpoints";
import MessageTemplateDto from "Frontend/generated/org/gameyfin/app/messages/templates/MessageTemplateDto";
import TemplateType from "Frontend/generated/org/gameyfin/app/messages/templates/TemplateType";
interface EditTemplateModalProps {
isOpen: boolean;
onOpenChange: () => void;
selectedTemplate: MessageTemplateDto | null;
}
export default function EditTemplateModal({isOpen, onOpenChange, selectedTemplate}: EditTemplateModalProps) {
const [templateContent, setTemplateContent] = useState<string>("");
const [defaultPlaceholders, setDefaultPlaceholders] = useState<string[]>([]);
useEffect(() => {
if (!isOpen) return;
MessageTemplateEndpoint.read(selectedTemplate?.key as string, TemplateType.MJML).then((response: any) => {
setTemplateContent(response as string);
});
MessageTemplateEndpoint.getDefaultPlaceholders(TemplateType.MJML).then((response: any) => {
setDefaultPlaceholders(response as string[]);
});
}, [isOpen]);
async function saveTemplate(template: MessageTemplateDto) {
await MessageTemplateEndpoint.save(template.key, TemplateType.MJML, templateContent);
}
function templateContainsAllRequiredPlaceholders(): boolean {
if (!selectedTemplate || !selectedTemplate.availablePlaceholders) return false;
return selectedTemplate.availablePlaceholders
.every((p) => templateContent.includes(`{${p}}`))
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="5xl">
<ModalContent>
{(onClose) => (
<>
<ModalHeader
className="flex flex-col gap-1">Edit {selectedTemplate?.name} Template</ModalHeader>
<ModalBody>
<div className="flex flex-row justify-between items-end">
<table cellPadding="4rem">
<tbody>
<tr>
<td>Required placeholders:</td>
<td>
<div className="flex flex-row gap-2">
{selectedTemplate?.availablePlaceholders?.map((placeholder) =>
<Chip radius="sm"
key={placeholder}
color={templateContent.includes(`{${placeholder as string}}`) ? "success" : "danger"}
>{placeholder}</Chip>
)}
</div>
</td>
</tr>
<tr>
<td>Optional placeholders:</td>
<td>
<div className="flex flex-row gap-2">
{defaultPlaceholders.map((placeholder) =>
<Chip radius="sm"
key={placeholder}
color={templateContent.includes(`{${placeholder as string}}`) ? "success" : "default"}
>{placeholder}</Chip>
)}
</div>
</td>
</tr>
</tbody>
</table>
<small className="text-right">Powered by <Link href="https://documentation.mjml.io/"
target="_blank">mjml.io</Link></small>
</div>
<Textarea
size="lg"
autoFocus
disableAutosize
value={templateContent}
onChange={(e) => {
setTemplateContent(e.target.value)
}}
classNames={{
input: "resize-y min-h-[500px]"
}}
/>
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary"
isDisabled={!templateContainsAllRequiredPlaceholders()}
onPress={async () => {
if (selectedTemplate) {
await saveTemplate(selectedTemplate);
addToast({
title: "Template saved",
description: "Template has been saved",
color: "success"
});
onClose();
}
}}>
Save
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,76 @@
import React from "react";
import {Form, Formik} from "formik";
import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import Input from "Frontend/components/general/input/Input";
import {MessageEndpoint} from "Frontend/generated/endpoints";
import * as Yup from "yup";
import MessageTemplateDto from "Frontend/generated/org/gameyfin/app/messages/templates/MessageTemplateDto";
interface SendTestNotificationModalProps {
isOpen: boolean;
onOpenChange: () => void;
selectedTemplate: MessageTemplateDto;
}
export default function SendTestNotificationModal({
isOpen,
onOpenChange,
selectedTemplate
}: SendTestNotificationModalProps) {
function generateValidationSchema(placeholders: string[]) {
const shape: { [key: string]: Yup.StringSchema } = {};
placeholders.forEach(placeholder => {
shape[placeholder] = Yup.string().required(`Placeholder ${placeholder} is required`);
});
return Yup.object().shape(shape);
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="3xl">
<ModalContent>
{(onClose) => (
<>
<Formik
initialValues={{}}
isInitialValid={false}
onSubmit={async (values) => {
await MessageEndpoint.sendTestNotification(selectedTemplate.key, values);
addToast({
title: "Notification sent",
description: "Test notification to you has been sent",
color: "success"
});
onClose();
}}
validationSchema={generateValidationSchema(selectedTemplate.availablePlaceholders)}
>
{(formik) => (
<Form>
<ModalHeader className="flex flex-col gap-1">
Send {selectedTemplate?.name} Test Message
</ModalHeader>
<ModalBody>
<p className="text-ls font-semibold mb-4">Fill the placeholders of the
template</p>
{selectedTemplate.availablePlaceholders.map((placeholder) =>
<Input key={placeholder} label={placeholder} name={placeholder}/>
)}
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
Close
</Button>
<Button color="primary" type="submit" isDisabled={!formik.isValid}>
Send
</Button>
</ModalFooter>
</Form>
)}
</Formik>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,136 @@
import React, {useEffect, useState} from "react";
import {ConfigEndpoint} from "Frontend/generated/endpoints";
import ConfigEntryDto from "Frontend/generated/org/gameyfin/app/config/dto/ConfigEntryDto";
import {Form, Formik} from "formik";
import {Button, Skeleton} from "@heroui/react";
import {Check, Info} from "@phosphor-icons/react";
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
import {configState, initializeConfigState, NestedConfig} from "Frontend/state/ConfigState";
import {useSnapshot} from "valtio/react";
export default function withConfigPage(WrappedComponent: React.ComponentType<any>, title: String, validationSchema?: any) {
return function ConfigPage(props: any) {
const [configSaved, setConfigSaved] = useState(false);
const [saveMessage, setSaveMessage] = useState<string>();
const state = useSnapshot(configState);
useEffect(() => {
initializeConfigState();
}, []);
useEffect(() => {
if (configSaved) {
setTimeout(() => setConfigSaved(false), 2000);
}
}, [configSaved])
async function handleSubmit(values: NestedConfig): Promise<void> {
const changed = getChangedValues(state.config, values);
await ConfigEndpoint.update({updates: changed});
setConfigSaved(true);
}
function getConfig(key: string): ConfigEntryDto | undefined {
// @ts-ignore
return state.state[key];
}
function getChangedValues(initial: NestedConfig, current: NestedConfig): Record<string, any> {
const flatten = (obj: NestedConfig, parentKey = ''): Record<string, any> => {
let result: Record<string, any> = {};
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
const newKey = parentKey ? `${parentKey}.${key}` : key;
if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) {
Object.assign(result, flatten(obj[key], newKey));
} else {
result[newKey] = obj[key];
}
}
}
return result;
};
const arraysEqual = (a: any[], b: any[]): boolean => {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (Array.isArray(a[i]) && Array.isArray(b[i])) {
if (!arraysEqual(a[i], b[i])) return false;
} else if (a[i] !== b[i]) {
return false;
}
}
return true;
};
const flatInitial = flatten(initial);
const flatCurrent = flatten(current);
const changed: Record<string, any> = {};
for (const key in flatCurrent) {
const valA = flatCurrent[key];
const valB = flatInitial[key];
if (Array.isArray(valA) && Array.isArray(valB)) {
if (!arraysEqual(valA, valB)) {
changed[key] = valA;
}
} else if (valA !== valB) {
changed[key] = valA;
}
}
return changed;
}
return (
<>
{state.isLoaded ?
<Formik
initialValues={state.config}
onSubmit={handleSubmit}
validationSchema={validationSchema}
enableReinitialize={true}
>
{(formik) => (
<Form>
<div className="flex flex-row flex-grow justify-between">
<h1 className="text-2xl font-bold">{title}</h1>
<div className="flex flex-row items-center gap-4">
{saveMessage && <SmallInfoField icon={Info}
message={saveMessage}
className="text-warning"/>}
<Button
color="primary"
isLoading={formik.isSubmitting}
isDisabled={formik.isSubmitting || configSaved || !formik.dirty}
type="submit"
>
{formik.isSubmitting ? "" : configSaved ? <Check/> : "Save"}
</Button>
</div>
</div>
<WrappedComponent {...props}
getConfig={getConfig}
formik={formik}
setSaveMessage={setSaveMessage}/>
</Form>
)}
</Formik> :
[...Array(4)].map((_e, i) =>
<div className="flex flex-col flex-grow gap-8 mb-12" key={i}>
<Skeleton className="h-10 w-full rounded-md"/>
<Skeleton className="h-12 flex w-1/3 rounded-md"/>
<div className="flex flex-row gap-8">
<Skeleton className="h-12 flex w-1/3 rounded-md"/>
<Skeleton className="h-12 flex w-1/3 rounded-md"/>
</div>
</div>
)
}
</>
);
}
}
@@ -0,0 +1,27 @@
import {useAuth} from "Frontend/util/auth";
import {Avatar as NextUiAvatar} from "@heroui/react";
// @ts-ignore
const Avatar = ({...props}) => {
const auth = useAuth();
const username = getUsername();
function getUsername() {
if (props.username === undefined || props.username === null || props.username == "") {
return auth.state.user?.username;
}
return props.username;
}
// TODO: Check if avatar can be loaded from SSO
return (
<NextUiAvatar
showFallback
src={`/images/avatar?username=${username}`}
{...props}
/>
);
}
export default Avatar;
@@ -0,0 +1,32 @@
import {
Alien,
CastleTurret,
GameController,
Ghost,
Joystick,
Lego,
Skull,
SoccerBall,
Strategy,
Sword,
TreasureChest,
Trophy
} from "@phosphor-icons/react";
import React from "react";
export default function IconBackgroundPattern() {
return <div className="absolute w-full h-full opacity-50">
<GameController size={32} className="absolute fill-primary top-[10%] left-[10%] rotate-[350deg]"/>
<SoccerBall size={34} className="absolute fill-primary top-[50%] left-[35%] rotate-[60deg]"/>
<Joystick size={40} className="absolute top-[30%] left-[50%] rotate-[90deg]"/>
<Strategy size={36} className="absolute fill-primary top-[50%] left-[70%] rotate-[30deg]"/>
<Sword size={28} className="absolute top-[70%] left-[10%] rotate-[60deg]"/>
<Alien size={34} className="absolute fill-primary top-[10%] left-[85%] rotate-[15deg]"/>
<CastleTurret size={30} className="absolute top-[5%] left-[40%] rotate-[320deg]"/>
<Ghost size={38} className="absolute fill-primary top-[40%] left-[5%] rotate-[300deg]"/>
<Skull size={32} className="absolute top-[80%] left-[30%] rotate-[90deg]"/>
<Trophy size={36} className="absolute fill-primary top-[10%] left-[60%] rotate-[45deg]"/>
<Lego size={28} className="absolute top-[30%] left-[20%] rotate-[30deg]"/>
<TreasureChest size={40} className="absolute top-[70%] left-[50%] rotate-[75deg]"/>
</div>
}
@@ -0,0 +1,10 @@
import {Chip} from "@heroui/react";
import {roleToColor, roleToRoleName} from "Frontend/util/utils";
export default function RoleChip({role}: { role: string }) {
return (
<Chip key={role} size="sm" radius="sm" className={`text-xs bg-${roleToColor(role)}-500`}>
{roleToRoleName(role)}
</Chip>
);
}
@@ -0,0 +1,111 @@
import {
Button,
Divider,
Link,
Popover,
PopoverContent,
PopoverTrigger,
Progress,
ScrollShadow,
Spinner
} from "@heroui/react";
import {useSnapshot} from "valtio/react";
import {scanState} from "Frontend/state/ScanState";
import LibraryScanProgress from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryScanProgress";
import {libraryState} from "Frontend/state/LibraryState";
import {Target} from "@phosphor-icons/react";
import {timeBetween, timeUntil} from "Frontend/util/utils";
import LibraryScanStatus from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryScanStatus";
import {useEffect, useState} from "react";
export default function ScanProgressPopover() {
const libraries = useSnapshot(libraryState).state;
const scans = useSnapshot(scanState).sortedByStartTime as LibraryScanProgress[];
const scanInProgress = useSnapshot(scanState).isScanning;
// Add state to track current time and force re-renders
const [currentTime, setCurrentTime] = useState(Date.now());
// Set up an interval to update the time every second
useEffect(() => {
const intervalId = setInterval(() => {
setCurrentTime(Date.now());
}, 1000);
// Clean up the interval when component unmounts
return () => clearInterval(intervalId);
}, []);
return (
<Popover placement="bottom-end" showArrow={true}>
<PopoverTrigger>
<Button isIconOnly variant="light">
{scanInProgress ?
<Spinner size="sm" color="default" variant="spinner"
classNames={{
spinnerBars: "bg-foreground-500",
}}/> :
<Target className="fill-foreground-500"/>
}
</Button>
</PopoverTrigger>
<PopoverContent>
<div className="flex flex-col gap-2 m-2 w-96">
{scans.length === 0 ?
<p className="flex h-12 items-center justify-center text-sm text-default-500">
No scans in progress or in history.
</p> :
<ScrollShadow hideScrollBar className="max-h-96">
{scans.map((scan, index) =>
<div className="flex flex-col">
<div
className="flex flex-row justify-between items-center text-default-500 mb-1">
<p>Scan for library&nbsp;
<Link underline="always"
color="foreground"
size="sm"
href={`/administration/libraries/library/${scan.libraryId}`}>
{libraries[scan.libraryId].name}
</Link>
</p>
{scan.finishedAt ?
<p className="text-default-500">
Finished {timeUntil(scan.finishedAt)}
</p> :
<p className="text-default-500">
Started {timeUntil(scan.startedAt)}
</p>
}
</div>
{scan.status === LibraryScanStatus.IN_PROGRESS ?
scan.currentStep.current && scan.currentStep.total ?
<div className="flex flex-col gap-1">
<p className="text-default-500">
{`${scan.currentStep.description} (${scan.currentStep.current}/${scan.currentStep.total})`}
</p>
<Progress
value={scan.currentStep.current / scan.currentStep.total * 100}
size="sm"/>
</div> :
<div className="flex flex-col gap-1">
<p className="text-default-500">{scan.currentStep.description}</p>
<Progress isIndeterminate size="sm"/>
</div>
:
<p>
{scan.result?.new} new /&nbsp;
{scan.result?.removed} removed /&nbsp;
{scan.result?.unmatched} unmatched&nbsp;
(in {timeBetween(scan.startedAt, scan.finishedAt!)})
</p>
}
{scans.length > 1 && index < (scans.length - 1) && <Divider className="my-2"/>}
</div>
)}
</ScrollShadow>
}
</div>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,62 @@
import {Autocomplete, AutocompleteItem} from "@heroui/react";
import {CaretRight, MagnifyingGlass} from "@phosphor-icons/react";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {useNavigate} from "react-router";
import {GameCover} from "Frontend/components/general/covers/GameCover";
export default function SearchBar() {
const navigate = useNavigate();
const state = useSnapshot(gameState);
const games = state.recentlyUpdated as GameDto[];
return <Autocomplete
aria-label="Search for games"
classNames={{
selectorButton: "text-default-500",
endContentWrapper: "display-none"
}}
defaultItems={games}
inputProps={{
classNames: {
input: "text-small w-96",
inputWrapper: "h-full font-normal text-default-500 bg-default-400/20 dark:bg-default-500/20"
},
}}
listboxProps={{
hideSelectedIcon: true,
itemClasses: {
base: [
"text-default-500",
"transition-opacity",
"data-[hover=true]:text-foreground",
"dark:data-[hover=true]:bg-default-50",
"data-[pressed=true]:opacity-70",
"data-[hover=true]:bg-default-200",
"data-[selectable=true]:focus:bg-default-100",
"data-[focus-visible=true]:ring-default-500",
],
},
}}
placeholder="Type to search..."
startContent={<MagnifyingGlass/>}
isVirtualized={true}
maxListboxHeight={300}
itemHeight={91} // 75px (cover) + 16px (margin top/bottom) = 91px
>
{(item) => (
<AutocompleteItem key={item.id} textValue={item.title} onPress={() => navigate("/game/" + item.id)}>
<div className="flex flex-row gap-4 items-center">
<GameCover game={item} size={75}/>
<div className="flex flex-col flex-1 gap-2">
<p><b>{item.title}</b> ({item.release && new Date(item.release).getFullYear()})</p>
<p className="text-default-500">{item.developers && [...item.developers].sort().join(" / ")}</p>
</div>
<CaretRight/>
</div>
</AutocompleteItem>
)}
</Autocomplete>
}
@@ -0,0 +1,10 @@
import {Divider} from "@heroui/react";
export default function Section({title}: { title: string }) {
return (
<>
<h2 className="text-xl font-bold mt-8 mb-1">{title}</h2>
<Divider className="mb-4"/>
</>
);
}
@@ -0,0 +1,12 @@
import React from 'react';
// @ts-ignore
export function SmallInfoField({icon: IconComponent, message, ...props}) {
return (
<div {...props}>
<small className="flex flex-row items-center gap-1">
<IconComponent weight="fill" size={14}/> {message}
</small>
</div>
);
}
@@ -0,0 +1,76 @@
import {Button, Card, Chip, Tooltip} from "@heroui/react";
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import React from "react";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import {GameCover} from "Frontend/components/general/covers/GameCover";
import {MagnifyingGlass, SlidersHorizontal} from "@phosphor-icons/react";
import ScanType from "Frontend/generated/org/gameyfin/app/libraries/enums/ScanType";
import {useNavigate} from "react-router";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
interface LibraryOverviewCardProps {
library: LibraryDto;
}
export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
const MAX_COVER_COUNT = 5;
const navigate = useNavigate();
const state = useSnapshot(gameState);
const randomGames = getRandomGames();
function getRandomGames() {
const games = state.randomlyOrderedGamesByLibraryId[library.id] as GameDto[];
if (!games) return [];
return games.slice(0, MAX_COVER_COUNT);
}
async function triggerScan() {
await LibraryEndpoint.triggerScan(ScanType.QUICK, [library]);
}
return (
<Card className="flex flex-col justify-between w-[353px]">
<div className="flex flex-1 justify-center items-center">
<div className="flex flex-1 opacity-10 min-h-[100px]">
<IconBackgroundPattern/>
{randomGames.length > 0 &&
<div className="absolute flex flex-row">
{randomGames.map((game) => (
<GameCover game={game} size={100} radius="none" key={game.coverId}/>
))}
</div>
}
</div>
<p className="absolute text-2xl font-bold">{library.name}</p>
<div className="absolute right-0 top-0 flex flex-row">
<Tooltip content="Scan library" placement="bottom" color="foreground">
<Button isIconOnly variant="light" onPress={triggerScan}>
<MagnifyingGlass/>
</Button>
</Tooltip>
<Tooltip content="Configuration" placement="bottom" color="foreground">
<Button isIconOnly variant="light" onPress={() => navigate('library/' + library.id)}>
<SlidersHorizontal/>
</Button>
</Tooltip>
</div>
</div>
{library.stats &&
<div className="grid grid-rows-2 grid-cols-3 justify-items-center items-center p-2 pt-4">
<p>Games</p>
<p>Downloads</p>
<p>Platforms</p>
<p className="font-bold">{library.stats.gamesCount}</p>
<p className="font-bold">{library.stats.downloadedGamesCount}</p>
<Chip size="sm">PC</Chip>
</div>
}
</Card>
);
}
@@ -0,0 +1,180 @@
import {Button, Card, Chip, Tooltip, useDisclosure} from "@heroui/react";
import {
CheckCircle,
IconContext,
PauseCircle,
PlayCircle,
Power,
Question,
QuestionMark,
SealCheck,
SealQuestion,
SealWarning,
SlidersHorizontal,
StopCircle,
WarningCircle,
XCircle
} from "@phosphor-icons/react";
import PluginState from "Frontend/generated/org/pf4j/PluginState";
import React, {ReactNode} from "react";
import PluginDetailsModal from "Frontend/components/general/modals/PluginDetailsModal";
import PluginLogo from "Frontend/components/general/plugin/PluginLogo";
import PluginTrustLevel from "Frontend/generated/org/gameyfin/app/core/plugins/management/PluginTrustLevel";
import {PluginEndpoint} from "Frontend/generated/endpoints";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
import PluginConfigValidationResult
from "Frontend/generated/org/gameyfin/pluginapi/core/config/PluginConfigValidationResult";
import PluginConfigValidationResultType
from "Frontend/generated/org/gameyfin/pluginapi/core/config/PluginConfigValidationResultType";
export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
const pluginDetailsModal = useDisclosure();
function borderColor(state: PluginState | undefined, trustLevel: PluginTrustLevel | undefined): "success" | "warning" | "danger" | "default" {
if (trustLevel === PluginTrustLevel.UNTRUSTED) return "danger";
if (isDisabled(state)) return "warning";
return stateToColor(state);
}
function stateToColor(state: PluginState | undefined): "success" | "warning" | "danger" | "default" {
switch (state) {
case PluginState.STARTED:
return "success";
case PluginState.DISABLED:
return "warning";
case PluginState.FAILED:
case PluginState.STOPPED:
return "danger";
default:
return "default";
}
}
function stateToIcon(state: PluginState | undefined): ReactNode {
switch (state) {
case PluginState.STARTED:
return <PlayCircle/>;
case PluginState.DISABLED:
return <PauseCircle/>;
case PluginState.STOPPED:
case PluginState.FAILED:
return <StopCircle/>;
case PluginState.UNLOADED:
case PluginState.RESOLVED:
return <XCircle/>;
default:
return <QuestionMark/>;
}
}
function configValidationResultToChip(validationResult: PluginConfigValidationResult | undefined): ReactNode {
switch (validationResult?.result) {
case PluginConfigValidationResultType.VALID:
return <Tooltip content="Config valid" placement="bottom" color="foreground">
<Chip size="sm" radius="sm" className="text-xs" color="success">
<CheckCircle/>
</Chip>
</Tooltip>
case PluginConfigValidationResultType.INVALID:
return <Tooltip content="Config invalid" placement="bottom" color="foreground">
<Chip size="sm" radius="sm" className="text-xs" color="danger">
<WarningCircle/>
</Chip>
</Tooltip>;
default:
return <Tooltip content="Config could not be validated" placement="bottom" color="foreground">
<Chip size="sm" radius="sm" className="text-xs">
<Question/>
</Chip>
</Tooltip>
}
}
function trustLevelToBadge(trustLevel: PluginTrustLevel | undefined): React.ReactNode {
switch (trustLevel) {
case PluginTrustLevel.OFFICIAL:
return <Tooltip color="foreground" placement="bottom" content="Official plugin">
<SealCheck className="fill-success"/>
</Tooltip>;
case PluginTrustLevel.BUNDLED:
return <Tooltip color="foreground" placement="bottom" content="Bundled plugin">
<SealCheck/>
</Tooltip>;
case PluginTrustLevel.THIRD_PARTY:
return <Tooltip color="foreground" placement="bottom" content="3rd party plugin">
<SealWarning/>
</Tooltip>;
case PluginTrustLevel.UNTRUSTED:
return <Tooltip color="foreground" placement="bottom" content="Invalid plugin signature">
<SealWarning className="fill-danger"/>
</Tooltip>;
default:
return <Tooltip color="foreground" placement="bottom" content="Unkown verification status">
<SealQuestion/>
</Tooltip>;
}
}
function isDisabled(state: PluginState | undefined): boolean {
return state === PluginState.DISABLED;
}
function togglePluginEnabled() {
if (isDisabled(plugin.state)) {
PluginEndpoint.enablePlugin(plugin.id);
} else {
PluginEndpoint.disablePlugin(plugin.id);
}
}
// @ts-ignore
return (
<>
<Card
className={`flex flex-row justify-between p-2 border-2 border-${borderColor(plugin.state, plugin.trustLevel)}`}>
<div className="absolute right-0 top-0 flex flex-row">
<Tooltip content={`${isDisabled(plugin.state) ? "Enable" : "Disable"} plugin`} placement="bottom"
color="foreground">
<Button isIconOnly
variant="light"
onPress={() => togglePluginEnabled()}
isDisabled={plugin.state == PluginState.UNLOADED || plugin.state == PluginState.RESOLVED}
>
<Power/>
</Button>
</Tooltip>
<Tooltip content="Configuration" placement="bottom" color="foreground">
<Button isIconOnly variant="light" onPress={pluginDetailsModal.onOpen}>
<SlidersHorizontal/>
</Button>
</Tooltip>
</div>
<div className="flex flex-1 flex-col items-center gap-2">
<PluginLogo plugin={plugin}/>
<p className="flex flex-row items-center gap-1 font-semibold">
{plugin.name}
<IconContext.Provider value={{size: 18, weight: "fill"}}>
{trustLevelToBadge(plugin.trustLevel)}
</IconContext.Provider>
</p>
<div className="flex flex-row gap-2">
<Chip size="sm" radius="sm" className="text-xs">{plugin.version}</Chip>
<Chip size="sm" radius="sm" className="text-xs" color={stateToColor(plugin.state)}>
<Tooltip content={`Plugin ${plugin.state?.toLowerCase()}`} placement="bottom"
color="foreground">
{stateToIcon(plugin.state)}
</Tooltip>
</Chip>
{configValidationResultToChip(plugin.configValidation)}
</div>
</div>
</Card>
<PluginDetailsModal plugin={plugin}
isOpen={pluginDetailsModal.isOpen}
onOpenChange={pluginDetailsModal.onOpenChange}
/>
</>
)
}
@@ -0,0 +1,158 @@
import {Card, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, useDisclosure} from "@heroui/react";
import {DotsThreeVertical} from "@phosphor-icons/react";
import {useAuth} from "Frontend/util/auth";
import {useEffect, useState} from "react";
import {MessageEndpoint, PasswordResetEndpoint, UserEndpoint} from "Frontend/generated/endpoints";
import {AvatarEndpoint} from "Frontend/endpoints/endpoints";
import Avatar from "Frontend/components/general/Avatar";
import ConfirmUserDeletionModal from "Frontend/components/general/modals/ConfirmUserDeletionModal";
import PasswordResetTokenModal from "Frontend/components/general/modals/PasswortResetTokenModal";
import TokenDto from "Frontend/generated/org/gameyfin/app/shared/token/TokenDto";
import UserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/UserInfoDto";
import RoleChip from "Frontend/components/general/RoleChip";
import AssignRolesModal from "Frontend/components/general/modals/AssignRolesModal";
export function UserManagementCard({user}: { user: UserInfoDto }) {
const userDeletionConfirmationModal = useDisclosure();
const passwordResetTokenModal = useDisclosure();
const roleAssignmentModal = useDisclosure();
const [userEnabled, setUserEnabled] = useState(true);
const [disabledKeys, setDisabledKeys] = useState<string[]>([]);
const [dropdownItems, setDropdownItems] = useState<any[]>([]);
const [passwordResetToken, setPasswordResetToken] = useState<TokenDto>();
const auth = useAuth();
useEffect(() => {
setUserEnabled(user.enabled);
let keysToBeDisabled: string[] = [];
MessageEndpoint.isEnabled().then((isEnabled) => {
if (isEnabled) keysToBeDisabled.push("resetPassword");
if (!user.hasAvatar) keysToBeDisabled.push("removeAvatar");
setDisabledKeys(keysToBeDisabled);
});
UserEndpoint.canCurrentUserManage(user.username).then((canManage) => {
if (!canManage) keysToBeDisabled.push("assignRole", "disableUser", "delete");
setDisabledKeys(keysToBeDisabled);
});
}, []);
useEffect(() => {
setDropdownItems(getDropdownItems());
}, [userEnabled]);
async function resetPassword() {
let token = await PasswordResetEndpoint.createPasswordResetTokenForUser(user.username);
if (token === undefined) return;
setPasswordResetToken(token);
passwordResetTokenModal.onOpen();
}
function getDropdownItems() {
let items = [];
if (!user.managedBySso) {
if (!userEnabled) {
items.push(
{
key: "enableUser",
onPress: () => {
UserEndpoint.setUserEnabled(user.username, true).then(() => {
setUserEnabled(true);
})
},
label: "Enable user"
}
);
} else {
items.push(
{
key: "disableUser",
onPress: () => {
UserEndpoint.setUserEnabled(user.username, false).then(() => {
setUserEnabled(false);
})
},
label: "Disable user"
}
);
}
items.push(
{
key: "removeAvatar",
onPress: () => AvatarEndpoint.removeAvatarByName(user.username!),
label: "Remove avatar"
},
{
key: "assignRole",
onPress: roleAssignmentModal.onOpen,
label: "Assign role"
},
{
key: "resetPassword",
onPress: resetPassword,
label: "Reset password"
}
);
}
items.push({
key: "delete",
onPress: userDeletionConfirmationModal.onOpen,
label: "Delete user"
}
);
return items;
}
return (
<>
<Card
className={`flex flex-row justify-between p-2 ${userEnabled ? "" : "bg-warning/25"} ${user.managedBySso ? "text-foreground/50" : ""}`}>
<div className="flex flex-row items-center gap-4">
<Avatar username={user.username}
name={user.username?.charAt(0)}
classNames={{
base: "gradient-primary size-20",
icon: "text-background/80",
name: "text-background/80 text-5xl",
}}/>
<div className="flex flex-col gap-1">
<p className="font-semibold">{user.username}</p>
<p className="text-sm">{user.email}</p>
{user.roles?.map((role) => (
<RoleChip role={role as string}/>
))}
</div>
</div>
<Dropdown placement="bottom-end" size="sm" backdrop="opaque">
<DropdownTrigger>
<DotsThreeVertical cursor="pointer"/>
</DropdownTrigger>
<DropdownMenu aria-label="Static Actions" items={dropdownItems} disabledKeys={disabledKeys}>
{(item) => (
<DropdownItem
key={item.key}
onPress={item.onPress}
color={item.key === "delete" ? "danger" : "default"}
className={item.key === "delete" ? "text-danger" : ""}
>
{item.label}
</DropdownItem>
)}
</DropdownMenu>
</Dropdown>
</Card>
<ConfirmUserDeletionModal isOpen={userDeletionConfirmationModal.isOpen}
onOpenChange={userDeletionConfirmationModal.onOpenChange}
user={user}/>
<PasswordResetTokenModal isOpen={passwordResetTokenModal.isOpen}
onOpenChange={passwordResetTokenModal.onOpenChange}
token={passwordResetToken as TokenDto}/>
<AssignRolesModal isOpen={roleAssignmentModal.isOpen} onOpenChange={roleAssignmentModal.onOpenChange}
user={user}/>
</>
)
}
@@ -0,0 +1,16 @@
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {GameCover} from "Frontend/components/general/covers/GameCover";
interface CoverGridProps {
games: GameDto[];
}
export default function CoverGrid({games}: CoverGridProps) {
return (
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,212px))] gap-4 justify-center">
{games.map((game) => (
<GameCover key={game.id} game={game} interactive={true}/>
))}
</div>
);
}
@@ -0,0 +1,70 @@
import React, {useEffect, useRef, useState} from "react";
import {GameCover} from "Frontend/components/general/covers/GameCover";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {ArrowRight} from "@phosphor-icons/react";
import {useNavigate} from "react-router";
interface CoverRowProps {
games: GameDto[];
title: string;
onPressShowMore: () => void;
}
const aspectRatio = 12 / 17; // aspect ratio of the game cover
const defaultImageHeight = 300; // default height for the image
const defaultImageWidth = aspectRatio * defaultImageHeight; // default width for the image
export function CoverRow({games, title, onPressShowMore}: CoverRowProps) {
const navigate = useNavigate();
const containerRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(games.length);
useEffect(() => {
const calculateVisible = () => {
if (containerRef.current) {
const containerWidth = containerRef.current.offsetWidth;
const maxFit = Math.floor((containerWidth - defaultImageWidth) / defaultImageWidth) + 1;
setVisibleCount(maxFit < games.length ? maxFit : games.length);
}
};
const resizeObserver = new ResizeObserver(calculateVisible);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
calculateVisible(); // initial calculation
return () => resizeObserver.disconnect();
}, [games.length]);
const showMore = visibleCount < games.length;
return (
<div className="flex flex-col mb-4">
<p className="text-2xl font-bold mb-4">{title}</p>
<div className="w-full relative">
<div ref={containerRef} className="flex flex-row gap-2 rounded-md bg-transparent">
{games.slice(0, visibleCount).map((game, index) => (
<GameCover key={index} game={game} radius="sm" interactive={true}/>
))}
</div>
{showMore && (
<div className="flex flex-row items-center justify-end cursor-pointer"
onClick={onPressShowMore}>
<div className="absolute h-full w-1/4 right-0 bottom-0
bg-gradient-to-r from-transparent to-background
transition-all duration-300 ease-in-out hover:opacity-80"/>
<div
className="absolute h-full right-0 bottom-0 flex flex-row items-center gap-2 pointer-events-none">
<p className="text-xl font-semibold">Show more</p>
<ArrowRight weight="bold"/>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,33 @@
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {Image} from "@heroui/react";
import {GameCoverFallback} from "Frontend/components/general/covers/GameCoverFallback";
interface GameCoverProps {
game: GameDto;
size?: number;
radius?: "none" | "sm" | "md" | "lg";
interactive?: boolean;
}
export function GameCover({game, size = 300, radius = "sm", interactive = false}: GameCoverProps) {
const coverContent = Number.isInteger(game.coverId) ? (
<div className={`${interactive ? "rounded-md scale-95 hover:scale-100 shine transition-all" : ""}`}>
<Image
alt={game.title}
className="z-0 object-cover aspect-[12/17]"
src={`images/cover/${game.coverId}`}
radius={radius}
height={size}
fallbackSrc={<GameCoverFallback title={game.title} size={size} radius={radius}/>}
/>
</div>
) : (
<GameCoverFallback title={game.title} size={size} radius={radius} hover={interactive}/>
);
return interactive ? (
<a href={`/game/${game.id}`}>
{coverContent}
</a>
) : coverContent;
}
@@ -0,0 +1,20 @@
import {Card} from "@heroui/react";
interface GameCoverFallbackProps {
title: string;
size?: number;
radius?: "none" | "sm" | "md" | "lg";
hover?: boolean;
}
export function GameCoverFallback({title, size = 300, radius = "sm", hover = false}: GameCoverFallbackProps) {
return (
<Card style={{aspectRatio: "12 /17", height: size, borderRadius: radius}}
radius={radius}
className={hover ? "scale-95 hover:scale-100" : ""}>
<div className="flex flex-col items-center justify-center h-full">
{title}
</div>
</Card>
);
}
@@ -0,0 +1,152 @@
import {Autoplay, Navigation, Pagination} from 'swiper/modules';
import {Swiper, SwiperSlide} from "swiper/react";
import {Card, Image, Modal, ModalContent, useDisclosure} from "@heroui/react";
import ReactPlayer from 'react-player';
import "swiper/css";
import "swiper/css/navigation";
import "swiper/css/pagination";
import "swiper/css/autoplay";
import {useEffect, useState} from "react";
import {CaretLeft, CaretRight, IconContext, Play} from "@phosphor-icons/react";
interface ImageCarouselProps {
imageUrls?: string[];
videosUrls?: string[];
className?: string;
}
interface SlideData {
isActive: boolean;
isVisible: boolean;
isPrev: boolean;
isNext: boolean;
}
export default function ImageCarousel({imageUrls, videosUrls, className}: ImageCarouselProps) {
interface CarouselElement {
type: "image" | "video";
url: string;
}
const DEFAULT_SLIDES_PER_VIEW = 3;
const [elements, setElements] = useState<CarouselElement[]>();
const [selectedImageUrl, setSelectedImageUrl] = useState<string>();
const imagePopup = useDisclosure();
useEffect(() => {
const images = imageUrls?.map((imageUrl) => ({
type: "image" as const,
url: imageUrl
})) || [];
const videos = videosUrls?.map((videoUrl) => ({
type: "video" as const,
url: videoUrl
})) || [];
setElements([...images, ...videos]);
}, [imageUrls, videosUrls])
function showImagePopup(imageUrl: string) {
setSelectedImageUrl(imageUrl);
imagePopup.onOpen();
}
return (
<div className={className}>
{elements && elements.length > 0 &&
<div className="w-full flex flex-col gap-2 items-center">
<div className="w-full flex flex-row items-center">
<IconContext.Provider value={{size: 50}}>
<CaretLeft className="swiper-custom-button-prev cursor-pointer fill-primary"/>
<Swiper
modules={[Pagination, Navigation, Autoplay]}
slidesPerView={DEFAULT_SLIDES_PER_VIEW > elements.length ? elements.length : DEFAULT_SLIDES_PER_VIEW}
pagination={{
clickable: true,
el: ".swiper-custom-pagination"
}}
navigation={{
prevEl: ".swiper-custom-button-prev",
nextEl: ".swiper-custom-button-next"
}}
centeredSlides={true}
loop={true}
spaceBetween={0}
autoplay={{
delay: 10000,
disableOnInteraction: true
}}
className="w-full"
>
{elements && elements.map((e, index) => (
<SwiperSlide key={index} virtualIndex={index}>
{({isActive}: SlideData) => {
if (e.type === "image") {
return (
<Image
src={e.url}
alt={`Game screenshot slide ${index}`}
className={`w-full h-full object-cover aspect-[16/9] cursor-zoom-in ${!isActive ? "scale-90" : ""}`}
onClick={() => showImagePopup(e.url)}
/>
)
}
return (
<Card
className={`w-full h-full aspect-[16/9] ${!isActive ? "scale-90" : ""}`}>
<ReactPlayer
url={e.url}
width="100%"
height="100%"
light={true}
controls={true}
playing={isActive}
playIcon={<Play weight="fill"/>}
/>
</Card>
)
}}
</SwiperSlide>
))}
<ImagePopup imageUrl={selectedImageUrl} isOpen={imagePopup.isOpen}
onOpenChange={imagePopup.onOpenChange}/>
</Swiper>
<CaretRight className="swiper-custom-button-next cursor-pointer fill-primary"/>
</IconContext.Provider>
</div>
<div>
{/* Wrap the pagination in a div because it gets replaced at runtime be SwiperJS and loses all styling */}
<div className="swiper-custom-pagination"/>
</div>
</div>
}
</div>
);
}
function ImagePopup({imageUrl, isOpen, onOpenChange}: {
imageUrl?: string,
isOpen: boolean,
onOpenChange: (isOpen: boolean) => void
}) {
return (imageUrl &&
<Modal isOpen={isOpen} onOpenChange={onOpenChange} hideCloseButton size="full" backdrop="blur">
<ModalContent className="bg-transparent">
{(onClose) => (
<div className="flex flex-grow items-center justify-center cursor-zoom-out"
onClick={onClose}>
<Image
src={imageUrl}
alt="Game screenshot"
className="max-w-[80vw] max-h-[80vh] object-contain"
/>
</div>
)}
</ModalContent>
</Modal>
)
}
@@ -0,0 +1,50 @@
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import React from "react";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
import {Card} from "@heroui/react";
interface LibraryHeaderProps {
library: LibraryDto;
className?: string;
}
export default function LibraryHeader({library, className}: LibraryHeaderProps) {
const MAX_COVER_COUNT = 5;
const state = useSnapshot(gameState);
const randomGames = getRandomGames();
function getRandomGames() {
const games = state.randomlyOrderedGamesByLibraryId[library.id] as GameDto[];
if (!games) return [];
return games.slice(0, MAX_COVER_COUNT);
}
return (
<Card className={`overflow-hidden rounded-lg relative pointer-events-none select-none ${className}`}>
<IconBackgroundPattern/>
<div className="flex flex-row items-center w-full h-full brightness-50">
{randomGames.map((game, idx) => (
<div
key={idx}
className="flex-none overflow-hidden -ml-[10%]"
style={{
width: `calc(100% / ${MAX_COVER_COUNT - 2})`,
clipPath: 'polygon(15% 0, 100% 0, 85% 100%, 0% 100%)',
}}
>
<img
src={`/images/screenshot/${game.imageIds![0]}`}
alt={`Image ${idx}`}
/>
</div>
))}
</div>
<div className="absolute inset-0 flex items-center justify-center">
<h2 className="text-white text-3xl font-bold">{library.name}</h2>
</div>
</Card>
);
}
@@ -0,0 +1,70 @@
import {FieldArray, useField} from "formik";
import {Button, Chip, Input, Popover, PopoverContent, PopoverTrigger} from "@heroui/react";
import {KeyboardEvent, useState} from "react";
import {Plus} from "@phosphor-icons/react";
// @ts-ignore
const ArrayInput = ({label, ...props}) => {
// @ts-ignore
const [field, meta] = useField(props);
const [newElementValue, setNewElementValue] = useState<string>("");
return (
<FieldArray name={field.name}
render={arrayHelpers => {
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === "Enter" || event.key == "Tab" || event.key === ",") {
event.preventDefault();
newElementValue
.split(",")
.map((value) => value.trim())
.filter((value) => value !== "")
.forEach((value) => arrayHelpers.push(value));
setNewElementValue("");
}
}
return (
<div className="flex flex-col flex-1 gap-2">
<div className="flex flex-row justify-between">
<p>{label}</p>
<small>{field.value.length} {field.value.length == 1 ? "element" : "elements"}</small>
</div>
<div className="flex flex-row flex-wrap gap-2 items-center">
{field.value.map((element: any, index: number) => (
<Chip key={index} onClose={() => arrayHelpers.remove(index)}>
{element}
</Chip>
))}
<Popover placement="bottom" showArrow={true}>
<PopoverTrigger>
<Button isIconOnly size="sm" variant="light" radius="full"><Plus/></Button>
</PopoverTrigger>
<PopoverContent>
<Input
value={newElementValue}
onChange={(e) => setNewElementValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="New element..."
variant="bordered"
/>
</PopoverContent>
</Popover>
</div>
<div className="min-h-6 text-danger">
{meta.touched && meta.error && meta.error.trim().length > 0 && (
meta.error
)}
</div>
</div>
);
}}
/>
);
}
export default ArrayInput;
@@ -0,0 +1,29 @@
import {useField} from "formik";
import {Checkbox, CheckboxGroup} from "@heroui/react";
// @ts-ignore
const CheckboxInput = ({label, ...props}) => {
// @ts-ignore
const [field, meta] = useField(props);
return (
<CheckboxGroup
className="flex flex-row flex-1 items-baseline gap-2"
isInvalid={!!meta.error}
errorMessage={meta.initialError || meta.error}
value={field.value ? [field.name] : []}
>
<Checkbox
className="items-baseline"
{...field}
{...props}
// @ts-ignore
value={field.name}
>
{label}
</Checkbox>
</CheckboxGroup>
);
}
export default CheckboxInput;
@@ -0,0 +1,85 @@
import {useEffect, useState} from "react";
import {
Button,
ButtonGroup,
Dropdown,
DropdownItem,
DropdownMenu,
DropdownTrigger,
SharedSelection
} from "@heroui/react";
import {CaretDown} from "@phosphor-icons/react";
import {UserPreferenceService} from "Frontend/util/user-preference-service";
export interface ComboButtonOption {
label: string;
description: string;
action: () => void;
isDisabled?: boolean;
}
export interface ComboButtonProps {
description?: string;
options: Record<string, ComboButtonOption>;
preferredOptionKey?: string;
}
export default function ComboButton({options, preferredOptionKey, description}: ComboButtonProps) {
const [selectedOption, setSelectedOption] = useState(new Set([Object.keys(options)[0]]));
const selectedOptionValue = Array.from(selectedOption)[0];
useEffect(() => {
if (!preferredOptionKey) return;
UserPreferenceService.get(preferredOptionKey).then((key) => {
if (key && options[key]) {
setSelectedOption(new Set([key]));
} else {
setSelectedOption(new Set([Object.keys(options)[0]]));
}
})
}, []);
async function onSelectionChange(keys: SharedSelection) {
if (!keys.currentKey) return;
if (preferredOptionKey) {
await UserPreferenceService.set(preferredOptionKey, keys.currentKey);
}
setSelectedOption(new Set([keys.currentKey]));
}
return options[selectedOptionValue] && (
<ButtonGroup className="gap-[1px]">
<Button color="primary" className="w-52"
onPress={options[selectedOptionValue].action}>
<div className="flex flex-col items-center">
<p className="font-semibold">{options[selectedOptionValue].label}</p>
<p className="text-xs font-normal opacity-70 ">{description}</p>
</div>
</Button>
<Dropdown placement="bottom-end">
<DropdownTrigger>
<Button isIconOnly color="primary">
<CaretDown/>
</Button>
</DropdownTrigger>
<DropdownMenu
disallowEmptySelection
aria-label="Merge options"
selectedKeys={selectedOption}
selectionMode="single"
onSelectionChange={onSelectionChange}
className="w-60"
>
{Object.entries(options).map(([key, option]) => (
<DropdownItem key={key} description={option.description}>
{option.label}
</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
</ButtonGroup>
);
}
@@ -0,0 +1,35 @@
import {useField} from "formik";
import {DatePicker, DateValue} from "@heroui/react";
import {parseDate} from "@internationalized/date";
import {useState} from "react";
// @ts-ignore
export default function DatePickerInput({label, showErrorUntouched = false, ...props}) {
// @ts-ignore
const [field, meta] = useField(props);
const [value, setValue] = useState<DateValue | null>(field.value ? parseDate(field.value) : null);
return (
<DatePicker
className="min-h-20 flex-grow"
showMonthAndYearPickers
fullWidth={false}
{...props}
{...field}
value={value}
onChange={(date) => {
setValue(date);
field.onChange({
target: {
name: field.name,
value: date ? date.toString() : ''
}
});
}}
id={label}
label={label}
isInvalid={(meta.touched || showErrorUntouched) && !!meta.error}
errorMessage={meta.initialError || meta.error}
/>
);
}
@@ -0,0 +1,79 @@
import React from "react";
import {Button, Code, useDisclosure} from "@heroui/react";
import {ArrowRight, Minus, Plus, XCircle} from "@phosphor-icons/react";
import PathPickerModal from "Frontend/components/general/modals/PathPickerModal";
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
import DirectoryMappingDto from "Frontend/generated/org/gameyfin/app/libraries/dto/DirectoryMappingDto";
import {useField} from "formik";
interface DirectoryMappingInputProps {
name: string;
}
export default function DirectoryMappingInput({name}: DirectoryMappingInputProps) {
const pathPickerModal = useDisclosure();
const [field, meta, helpers] = useField<DirectoryMappingDto[]>({name});
function addDirectoryMapping(directory: DirectoryMappingDto) {
helpers.setValue([...(field.value || []), directory]);
}
function removeDirectoryMapping(directory: DirectoryMappingDto) {
helpers.setValue((field.value || []).filter((d) => d !== directory));
}
return (
<div className="flex flex-col gap-2">
<div className="flex flex-row justify-between items-center">
<p className="font-bold">Directories</p>
<Button isIconOnly variant="light" size="sm" color="default"
onPress={pathPickerModal.onOpen}>
<Plus/>
</Button>
</div>
{(field.value || []).map((directory) => (
<Code
className="w-full flex items-center gap-2 overflow-hidden px-2 py-1"
key={directory.internalPath}>
<input
type="text"
value={directory.internalPath}
readOnly
className="flex-1 bg-transparent border-none outline-none overflow-x-auto whitespace-nowrap"
/>
{directory.externalPath && (
<>
<div className="flex-shrink-0 flex items-center justify-center mx-2">
<ArrowRight size={20}/>
</div>
<input
type="text"
value={directory.externalPath}
readOnly
className="flex-1 bg-transparent border-none outline-none overflow-x-auto whitespace-nowrap"
/>
</>
)}
<Button
isIconOnly
variant="light"
size="sm"
color="default"
onPress={() => removeDirectoryMapping(directory)}
className="ml-2"
>
<Minus/>
</Button>
</Code>
))}
<div className="min-h-6 text-danger">
{meta.touched && meta.error && (
<SmallInfoField icon={XCircle} message={meta.error}/>
)}
</div>
<PathPickerModal returnSelectedPath={addDirectoryMapping}
isOpen={pathPickerModal.isOpen}
onOpenChange={pathPickerModal.onOpenChange}/>
</div>
);
}
@@ -0,0 +1,154 @@
import TreeView, {flattenTree, INode, NodeId} from "react-accessible-treeview";
import {File, Folder, FolderOpen, IconContext} from "@phosphor-icons/react";
import {useEffect, useState} from "react";
import {FilesystemEndpoint} from "Frontend/generated/endpoints";
import FileDto from "Frontend/generated/org/gameyfin/app/core/filesystem/FileDto";
import FileType from "Frontend/generated/org/gameyfin/app/core/filesystem/FileType";
import {IFlatMetadata} from "react-accessible-treeview/dist/TreeView/utils";
import OperatingSystemType from "Frontend/generated/org/gameyfin/app/core/filesystem/OperatingSystemType";
interface ITreeNode<M extends IFlatMetadata = IFlatMetadata> {
id?: NodeId;
name: string;
isBranch?: boolean;
children?: ITreeNode<M>[];
metadata?: M;
}
export default function FileTreeView({onPathChange}: { onPathChange: (file: string) => void }) {
const rootNode: INode = {
id: "root",
name: "",
children: [],
parent: null
}
const [hostOSType, setHostOSType] = useState<OperatingSystemType>();
const [fileTree, setFileTree] = useState<ITreeNode>();
const [flattenedFileTree, setFlattenedFileTree] = useState<INode[]>([rootNode]);
useEffect(() => {
FilesystemEndpoint.getHostOperatingSystem().then((response) => {
setHostOSType(response);
})
FilesystemEndpoint.listSubDirectories("").then(
result => {
if (result === undefined) return;
const nodes = fileDtosToTree(result as FileDto[]);
const tree = flattenTree(nodes);
setFileTree(nodes);
setFlattenedFileTree(tree);
}
)
}, []);
function getAbsolutePath(node: INode, path: string = ""): string {
let pathSeparator = "/";
if (hostOSType === OperatingSystemType.WINDOWS) {
pathSeparator = "\\";
if (path.startsWith(pathSeparator)) path = path.substring(1);
}
path = path.replace(`${pathSeparator}${pathSeparator}`, pathSeparator);
if (node.parent === null) {
if (hostOSType === OperatingSystemType.WINDOWS) return path;
return `${pathSeparator}${path}`;
}
const parentNode = flattenedFileTree.find(n => n.id === node.parent);
if (!parentNode) {
throw new Error(`Parent node with id ${node.parent} not found`);
}
return getAbsolutePath(parentNode, `${node.name}${pathSeparator}${path}`);
}
async function onLoadData({element}: { element: INode }) {
const absolutePath = getAbsolutePath(element);
let subDirectories = await FilesystemEndpoint.listSubDirectories(absolutePath);
if (subDirectories === undefined) return;
const newNodes = fileDtosToNodes(subDirectories as FileDto[]);
const updatedTree = updateTreeWithNewNodes(fileTree!!, element.id, newNodes);
setFileTree(updatedTree);
setFlattenedFileTree(flattenTree(updatedTree));
onPathChange(absolutePath);
}
function updateTreeWithNewNodes(tree: ITreeNode, nodeId: NodeId, newNodes: ITreeNode[]): ITreeNode {
if (tree.id === nodeId) {
return {...tree, children: newNodes};
}
if (tree.children) {
return {
...tree,
children: tree.children.map(child => updateTreeWithNewNodes(child, nodeId, newNodes))
};
}
return tree;
}
function fileDtosToTree(fileDtos: FileDto[], parent: (INode | null) = null): ITreeNode {
const nodes = fileDtosToNodes(fileDtos);
if (parent === null) {
return {...rootNode, children: nodes};
}
return {...parent, children: nodes};
}
function fileDtosToNodes(fileDtos: FileDto[]): ITreeNode[] {
return fileDtos.map(fileDto => ({
id: fileDto.hash,
name: fileDto.name || "",
isBranch: fileDto.type === FileType.DIRECTORY,
children: []
}));
}
return (
<div className="flex flex-col flex-1 w-full gap-4 overflow-hidden">
<TreeView
data={flattenedFileTree}
aria-label="directory tree"
onLoadData={onLoadData}
nodeRenderer={({
element,
isBranch,
isExpanded,
isSelected,
getNodeProps,
level,
}) => (
<IconContext.Provider value={{size: 32, weight: "regular"}}>
<div {...getNodeProps()}
className={`
flex flex-row items-center gap-2 w-full
rounded-md cursor-pointer
${isSelected ? 'bg-primary' : 'hover:bg-primary/20'}`
}
style={{paddingLeft: 10 * (level - 1)}}>
{isBranch ? <FolderIcon isOpen={isExpanded}/> : <FileIcon fileName={element.name}/>}
{element.name}
</div>
</IconContext.Provider>
)}
/>
</div>
);
}
function FolderIcon({isOpen}: { isOpen: boolean }) {
return isOpen ? <FolderOpen/> : <Folder/>;
}
function FileIcon({fileName}: { fileName: string }) {
return <File/>;
}
@@ -0,0 +1,45 @@
import {Image, useDisclosure} from "@heroui/react";
import {GameCoverFallback} from "Frontend/components/general/covers/GameCoverFallback";
import React from "react";
import {useField} from "formik";
import {GameCoverPickerModal} from "Frontend/components/general/modals/GameCoverPickerModal";
import {Pencil} from "@phosphor-icons/react";
// @ts-ignore
export default function GameCoverPicker({game, label, showErrorUntouched = false, ...props}) {
// @ts-ignore
const [field] = useField(props);
const gameCoverPickerModal = useDisclosure();
return (<>
<div className="relative group w-fit h-fit cursor-pointer"
onClick={gameCoverPickerModal.onOpenChange}>
<Image
alt={game.title}
className="z-0 object-cover aspect-[12/17] group-hover:brightness-50"
src={field.value ? field.value : `images/cover/${game.coverId}`}
{...props}
{...field}
radius="none"
height={216}
fallbackSrc={<GameCoverFallback title={game.title}
size={216}
radius="none"/>}
/>
<div
className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
>
<Pencil size={46}/>
</div>
</div>
<GameCoverPickerModal
game={game}
isOpen={gameCoverPickerModal.isOpen}
onOpenChange={gameCoverPickerModal.onOpenChange}
setCoverUrl={(coverUrl) => field.onChange({target: {name: field.name, value: coverUrl}})}
/>
</>);
}
@@ -0,0 +1,23 @@
import {useField} from "formik";
import {Input as NextUiInput} from "@heroui/react";
// @ts-ignore
const Input = ({label, showErrorUntouched = false, ...props}) => {
// @ts-ignore
const [field, meta] = useField(props);
return (
<NextUiInput
className="min-h-20 flex-grow"
fullWidth={false}
{...props}
{...field}
id={label}
label={label}
isInvalid={(meta.touched || showErrorUntouched) && !!meta.error}
errorMessage={meta.initialError || meta.error}
/>
);
}
export default Input;
@@ -0,0 +1,30 @@
import {useField} from "formik";
import {Select, SelectItem} from "@heroui/react";
// @ts-ignore
const SelectInput = ({label, values, ...props}) => {
// @ts-ignore
const [field, meta] = useField(props);
const items = values.map((v: string) => ({key: v, label: v}));
return (
<div className="min-h-20 flex-grow">
<Select
fullWidth={true}
{...field}
{...props}
label={label}
items={items}
selectedKeys={[field.value]}
isInvalid={!!meta.error}
errorMessage={meta.initialError || meta.error}
disallowEmptySelection
>
{(item: { key: string, label: string }) => <SelectItem>{item.label}</SelectItem>}
</Select>
</div>
);
}
export default SelectInput;
@@ -0,0 +1,21 @@
import {useField} from "formik";
import {Textarea} from "@heroui/react";
// @ts-ignore
export default function TextAreaInput({label, showErrorUntouched = false, ...props}) {
// @ts-ignore
const [field, meta] = useField(props);
return (
<Textarea
className={`flex-grow ${meta.initialError || meta.error ? "" : "mb-6"}`}
fullWidth={false}
{...props}
{...field}
id={label}
label={label}
isInvalid={(meta.touched || showErrorUntouched) && !!meta.error}
errorMessage={meta.initialError || meta.error}
/>
);
}
@@ -0,0 +1,92 @@
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import {Check} from "@phosphor-icons/react";
import {addToast, Button} from "@heroui/react";
import React from "react";
import {Form, Formik} from "formik";
import {deepDiff} from "Frontend/util/utils";
import LibraryUpdateDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryUpdateDto";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import Input from "Frontend/components/general/input/Input";
import DirectoryMappingInput from "Frontend/components/general/input/DirectoryMappingInput";
import Section from "Frontend/components/general/Section";
import {useNavigate} from "react-router";
import * as Yup from "yup";
interface LibraryManagementDetailsProps {
library: LibraryDto;
}
export default function LibraryManagementDetails({library}: LibraryManagementDetailsProps) {
const navigate = useNavigate();
const [librarySaved, setLibrarySaved] = React.useState(false);
async function handleSubmit(values: LibraryDto): Promise<void> {
const changed = deepDiff(library, values) as LibraryUpdateDto;
if (Object.keys(changed).length === 0) return;
changed.id = library.id;
await LibraryEndpoint.updateLibrary(changed);
setLibrarySaved(true);
setTimeout(() => setLibrarySaved(false), 2000);
}
async function handleDelete(): Promise<void> {
try {
await LibraryEndpoint.deleteLibrary(library.id);
addToast({
title: "Library deleted",
description: `Library ${library.name} deleted!`,
color: "success"
});
navigate("/administration/libraries");
} catch (e) {
addToast({
title: "Error deleting library",
description: `Library ${library.name} could not be deleted!`,
color: "warning"
});
}
}
return <Formik
initialValues={library}
onSubmit={handleSubmit}
enableReinitialize={true}
validationSchema={Yup.object({
name: Yup.string()
.required("Library name is required")
.max(255, "Library name must be 255 characters or less"),
directories: Yup.array()
.of(Yup.object())
.min(1, "At least one directory is required")
})}
>
{(formik) => (
<Form>
<div className="flex flex-row flex-grow justify-between mb-4">
<h1 className="text-2xl font-bold">Edit library details</h1>
<Button
color="primary"
isLoading={formik.isSubmitting}
isDisabled={formik.isSubmitting || librarySaved || !formik.dirty}
type="submit"
>
{formik.isSubmitting ? "" : librarySaved ? <Check/> : "Save"}
</Button>
</div>
<Input label="Library name" name="name"/>
<DirectoryMappingInput name="directories"/>
<Section title="Danger zone"/>
<Button color="danger" onPress={handleDelete}>
Delete library
</Button>
</Form>
)}
</Formik>;
}
@@ -0,0 +1,233 @@
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {
Button,
Input,
Link,
Pagination,
Select,
SelectItem,
SortDescriptor,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
Tooltip,
useDisclosure
} from "@heroui/react";
import {CheckCircle, MagnifyingGlass, Pencil, Trash} from "@phosphor-icons/react";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import {GameEndpoint} from "Frontend/generated/endpoints";
import GameUpdateDto from "Frontend/generated/org/gameyfin/app/games/dto/GameUpdateDto";
import {useMemo, useState} from "react";
import EditGameMetadataModal from "Frontend/components/general/modals/EditGameMetadataModal";
import MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
interface LibraryManagementGamesProps {
library: LibraryDto;
}
export default function LibraryManagementGames({library}: LibraryManagementGamesProps) {
const rowsPerPage = 25;
const state = useSnapshot(gameState);
const games = state.gamesByLibraryId[library.id] ? state.gamesByLibraryId[library.id] as GameDto[] : [];
const [searchTerm, setSearchTerm] = useState("");
const [filter, setFilter] = useState<"all" | "confirmed" | "nonConfirmed">("all");
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({column: "title", direction: "ascending"});
const [selectedGame, setSelectedGame] = useState<GameDto>(games[0]);
const editGameModal = useDisclosure();
const matchGameModal = useDisclosure();
const [page, setPage] = useState(1);
const pages = useMemo(() => {
return Math.ceil(getFilteredGames().length / rowsPerPage);
}, [games, filter]);
const filteredItems = useMemo(() => {
return getFilteredGames();
}, [games, filter, searchTerm]);
const sortedItems = useMemo(() => {
return filteredItems.slice().sort((a, b) => {
let cmp: number;
switch (sortDescriptor.column) {
case "title":
cmp = a.title.localeCompare(b.title);
break;
case "addedToLibrary":
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
break;
case "downloadCount":
cmp = a.metadata.downloadCount - b.metadata.downloadCount;
break;
default:
return 0; // No sorting if the column is not recognized
}
if (sortDescriptor.direction === "descending") {
cmp *= -1; // Reverse the comparison if sorting in descending order
}
return cmp;
});
}, [filteredItems, sortDescriptor]);
const pagedItems = useMemo(() => {
const start = (page - 1) * rowsPerPage;
const end = start + rowsPerPage;
return sortedItems.slice(start, end);
}, [page, sortedItems]);
function getFilteredGames() {
let filteredGames = games.filter((game) =>
game.metadata.path!!.toLowerCase().includes(searchTerm.toLowerCase()) ||
game.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
game.publishers?.some(publisher => publisher.toLowerCase().includes(searchTerm.toLowerCase())) ||
game.developers?.some(developer => developer.toLowerCase().includes(searchTerm.toLowerCase()))
)
if (filter === "confirmed") {
return filteredGames.filter(g => g.metadata.matchConfirmed);
}
if (filter === "nonConfirmed") {
return filteredGames.filter(g => !g.metadata.matchConfirmed);
}
return filteredGames;
}
async function toggleMatchConfirmed(game: GameDto) {
await GameEndpoint.updateGame(
{
id: game.id,
metadata: {matchConfirmed: !game.metadata.matchConfirmed}
} as GameUpdateDto
)
}
async function deleteGame(game: GameDto) {
await GameEndpoint.deleteGame(game.id);
}
return selectedGame && <div className="flex flex-col gap-4">
<h1 className="text-2xl font-bold">Manage games in library</h1>
<div className="flex flex-row gap-2 justify-between">
<Input
className="w-96"
isClearable
placeholder="Search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onClear={() => setSearchTerm("")}
/>
<Select
selectedKeys={[filter]}
disallowEmptySelection
onSelectionChange={keys => setFilter(Array.from(keys)[0] as any)}
className="w-64"
>
<SelectItem key="all">Show all</SelectItem>
<SelectItem key="confirmed">Show only confirmed</SelectItem>
<SelectItem key="nonConfirmed">Show only non confirmed</SelectItem>
</Select>
</div>
<Table removeWrapper isStriped
sortDescriptor={sortDescriptor}
onSortChange={setSortDescriptor}
bottomContent={
<div className="flex w-full justify-center sticky">
{pagedItems.length > 0 &&
<Pagination
isCompact
showControls
showShadow
color="primary"
page={page}
total={pages}
onChange={(page) => setPage(page)}
/>}
</div>
}>
<TableHeader>
<TableColumn key="title" allowsSorting>Game</TableColumn>
<TableColumn key="addedToLibrary" allowsSorting>Added to library</TableColumn>
<TableColumn key="downloadCount" allowsSorting>Download count</TableColumn>
<TableColumn>Path</TableColumn>
{/* width={1} keeps the column as far to the right as possible*/}
<TableColumn width={1}>Actions</TableColumn>
</TableHeader>
<TableBody emptyContent="Your filter did not match any games." items={pagedItems}>
{(item) => (
<TableRow key={item.id}>
<TableCell>
<Link href={`/game/${item.id}`}
color="foreground"
className="text-sm"
underline="hover">{item.title} ({item.release !== undefined ? new Date(item.release).getFullYear() : "unknown"})
</Link>
</TableCell>
<TableCell>
{new Date(item.createdAt).toLocaleString()}
</TableCell>
<TableCell>
{item.metadata.downloadCount}
</TableCell>
<TableCell>
{item.metadata.path}
</TableCell>
<TableCell>
<div className="flex flex-row gap-2">
<Button isIconOnly size="sm" onPress={() => toggleMatchConfirmed(item)}>
{item.metadata.matchConfirmed ?
<Tooltip content="Unconfirm match">
<CheckCircle weight="fill" className="fill-success"/>
</Tooltip> :
<Tooltip content="Confirm match">
<CheckCircle/>
</Tooltip>}
</Button>
<Button isIconOnly size="sm" onPress={() => {
setSelectedGame(item);
editGameModal.onOpenChange();
}}>
<Tooltip content="Edit metadata">
<Pencil/>
</Tooltip>
</Button>
<Button isIconOnly size="sm" onPress={() => {
setSelectedGame(item);
matchGameModal.onOpenChange();
}}>
<Tooltip content="Match game">
<MagnifyingGlass/>
</Tooltip>
</Button>
<Button isIconOnly size="sm" color="danger"
onPress={() => deleteGame(item)}>
<Tooltip content="Remove from library">
<Trash/>
</Tooltip>
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<EditGameMetadataModal game={selectedGame}
isOpen={editGameModal.isOpen}
onOpenChange={editGameModal.onOpenChange}/>
<MatchGameModal path={selectedGame.metadata.path!!}
libraryId={library.id}
replaceGameId={selectedGame.id}
initialSearchTerm={selectedGame.title}
isOpen={matchGameModal.isOpen}
onOpenChange={matchGameModal.onOpenChange}/>
</div>;
}
@@ -0,0 +1,148 @@
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import {
Button,
Input,
Pagination,
SortDescriptor,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
Tooltip,
useDisclosure
} from "@heroui/react";
import {MagnifyingGlass, Trash} from "@phosphor-icons/react";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import {useMemo, useState} from "react";
import LibraryUpdateDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryUpdateDto";
import {fileNameFromPath, hashCode} from "Frontend/util/utils";
import MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
interface LibraryManagementUnmatchedPathsProps {
library: LibraryDto;
}
export default function LibraryManagementUnmatchedPaths({library}: LibraryManagementUnmatchedPathsProps) {
const matchGameModal = useDisclosure();
const [page, setPage] = useState(1);
const rowsPerPage = 25;
const [searchTerm, setSearchTerm] = useState("");
const [selectedPath, setSelectedPath] = useState(library.unmatchedPaths ? library.unmatchedPaths[0] : null);
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({column: "path", direction: "ascending"});
const pages = useMemo(() => {
return Math.ceil(getFilteredPaths().length / rowsPerPage);
}, [library.unmatchedPaths, searchTerm]);
const filteredPaths = useMemo(() => {
return library.unmatchedPaths!
.filter((path) => path.toLowerCase().includes(searchTerm.toLowerCase()))
.map((path) => ({key: hashCode(path), path}));
}, [library, searchTerm]);
const sortedPaths = useMemo(() => {
return filteredPaths.slice().sort((a, b) => {
let cmp: number;
switch (sortDescriptor.column) {
case "path":
cmp = a.path.localeCompare(b.path);
break;
default:
cmp = 0;
}
if (sortDescriptor.direction === "descending") {
cmp *= -1;
}
return cmp;
});
}, [filteredPaths, sortDescriptor]);
const pagedPaths = useMemo(() => {
const start = (page - 1) * rowsPerPage;
const end = start + rowsPerPage;
return sortedPaths.slice(start, end);
}, [page, sortedPaths]);
async function deleteUnmatchedPath(unmatchedPath: string) {
const libraryUpdateDto: LibraryUpdateDto = {
id: library.id,
unmatchedPaths: library.unmatchedPaths!.filter((path) => path !== unmatchedPath)
}
await LibraryEndpoint.updateLibrary(libraryUpdateDto);
}
function getFilteredPaths() {
return library.unmatchedPaths!!.filter((path) =>
path.toLowerCase().includes(searchTerm.toLowerCase())
)
}
return <div className="flex flex-col gap-4">
<h1 className="text-2xl font-bold">Manage unmatched paths</h1>
<Input
className="w-96"
isClearable
placeholder="Search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onClear={() => setSearchTerm("")}
/>
<Table removeWrapper isStriped isHeaderSticky
sortDescriptor={sortDescriptor}
onSortChange={setSortDescriptor}
bottomContent={
<div className="flex w-full justify-center">
{pagedPaths.length > 0 &&
<Pagination
isCompact
showControls
showShadow
color="primary"
page={page}
total={pages}
onChange={(page) => setPage(page)}
/>}
</div>
}>
<TableHeader>
<TableColumn key="path" allowsSorting>Path</TableColumn>
<TableColumn width={1}>Actions</TableColumn>
</TableHeader>
<TableBody emptyContent="This library has no unmatched paths." items={pagedPaths}>
{(item) => (
<TableRow key={item.key}>
<TableCell>
{item.path}
</TableCell>
<TableCell>
<div className="flex flex-row gap-2">
<Tooltip content="Match game">
<Button isIconOnly size="sm" onPress={() => {
setSelectedPath(item.path);
matchGameModal.onOpenChange();
}}>
<MagnifyingGlass/>
</Button>
</Tooltip>
<Tooltip content="Remove entry from list">
<Button isIconOnly size="sm" color="danger"
onPress={() => deleteUnmatchedPath(item.path)}><Trash/>
</Button>
</Tooltip>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{selectedPath && <MatchGameModal path={selectedPath}
libraryId={library.id}
initialSearchTerm={fileNameFromPath(selectedPath, false)}
isOpen={matchGameModal.isOpen}
onOpenChange={matchGameModal.onOpenChange}/>
}
</div>;
}
@@ -0,0 +1,117 @@
import React, {useEffect, useState} from "react";
import {
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Select,
SelectedItems,
Selection,
SelectItem
} from "@heroui/react";
import {UserEndpoint} from "Frontend/generated/endpoints";
import UserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/UserInfoDto";
import RoleChip from "Frontend/components/general/RoleChip";
import RoleAssignmentResult from "Frontend/generated/org/gameyfin/app/users/enums/RoleAssignmentResult";
interface AssignRolesModalProps {
isOpen: boolean;
onOpenChange: () => void;
user: UserInfoDto;
}
interface Role {
id: string;
}
export default function AssignRolesModal({isOpen, onOpenChange, user}: AssignRolesModalProps) {
const [availableRoles, setAvailableRoles] = useState<Role[]>([]);
const [selectedRole, setSelectedRole] = useState<Selection>();
const [error, setError] = useState<string>();
useEffect(() => {
setSelectedRole(rolesToSelection(user.roles));
UserEndpoint.getRolesBelow().then((availableRoles) => {
setAvailableRoles(availableRoles.map((role) => ({id: role.toString()})));
});
}, []);
function rolesToSelection(roles: Array<string>): Selection {
return new Set(roles.map((role) => role.toString()));
}
async function assignRoles() {
if (!selectedRole) return;
let selectedRolesArray = Array.from(selectedRole).map((role) => role.toString());
let result = await UserEndpoint.assignRoles(user.username, selectedRolesArray);
switch (result) {
case RoleAssignmentResult.SUCCESS:
window.location.reload();
break;
case RoleAssignmentResult.NO_ROLES_PROVIDED:
setError("Select at least one role");
break;
case RoleAssignmentResult.TARGET_POWER_LEVEL_TOO_HIGH:
setError("Power level of user too high");
break;
case RoleAssignmentResult.ASSIGNED_ROLE_POWER_LEVEL_TOO_HIGH:
setError("Power level of assigned role too high");
break;
default:
setError("An error occurred");
break;
}
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" isDismissable={false}
hideCloseButton={true} size="lg">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">Assign roles to {user.username}</ModalHeader>
<ModalBody className="flex flex-col gap-2">
<Select
items={availableRoles}
selectionMode="single"
disallowEmptySelection={true}
selectedKeys={selectedRole}
onSelectionChange={setSelectedRole}
placeholder="Select roles"
renderValue={(items: SelectedItems<Role>) => {
return (
<div className="flex flex-grow flex-wrap gap-2">
{items.map((item) => (
<RoleChip key={item.key} role={item.textValue as string}/>
))}
</div>
);
}}
>
{(role) => (
<SelectItem key={role.id} textValue={role.id}>
<RoleChip key={role.id} role={role.id}/>
</SelectItem>
)}
</Select>
{error &&
<small className="text-danger">{error}</small>
}
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary" onPress={assignRoles} isDisabled={!selectedRole}>
Assign roles
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,52 @@
import React, {useEffect, useState} from "react";
import {Button, Code, Input, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {UserEndpoint} from "Frontend/generated/endpoints";
import UserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/UserInfoDto";
interface ConfirmUserDeletionModalProps {
isOpen: boolean;
onOpenChange: () => void;
user: UserInfoDto;
}
export default function ConfirmUserDeletionModal({isOpen, onOpenChange, user}: ConfirmUserDeletionModalProps) {
const [confirmUsername, setConfirmUsername] = useState<string>("");
useEffect(() => {
setConfirmUsername("");
}, []);
async function deleteUser() {
await UserEndpoint.deleteUserByName(user.username);
window.location.reload();
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" isDismissable={false}
hideCloseButton={true} size="lg">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">Confirm user deletion</ModalHeader>
<ModalBody>
<p>
Confirm deletion of user <Code>{user.username}</Code> by entering the username
below
</p>
<Input onChange={(e) => setConfirmUsername(e.target.value)}/>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="danger" onPress={deleteUser}
isDisabled={confirmUsername != user.username}>
Confirm deletion
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,112 @@
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {
Accordion,
AccordionItem,
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader
} from "@heroui/react";
import {Form, Formik} from "formik";
import Input from "Frontend/components/general/input/Input";
import React from "react";
import GameUpdateDto from "Frontend/generated/org/gameyfin/app/games/dto/GameUpdateDto";
import {deepDiff} from "Frontend/util/utils";
import {GameEndpoint} from "Frontend/generated/endpoints";
import TextAreaInput from "Frontend/components/general/input/TextAreaInput";
import * as Yup from "yup";
import GameCoverPicker from "Frontend/components/general/input/GameCoverPicker";
import DatePickerInput from "Frontend/components/general/input/DatePickerInput";
import ArrayInput from "Frontend/components/general/input/ArrayInput";
interface EditGameMetadataModalProps {
game: GameDto;
isOpen: boolean;
onOpenChange: () => void;
}
export default function EditGameMetadataModal({game, isOpen, onOpenChange}: EditGameMetadataModalProps) {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="3xl">
<ModalContent>
{(onClose) => {
async function updateGame(values: GameUpdateDto) {
//@ts-ignore
const changed = deepDiff(game, values) as GameUpdateDto;
if (Object.keys(changed).length === 0) return;
changed.id = game.id;
await GameEndpoint.updateGame(changed);
onClose();
}
return (
<Formik initialValues={game}
enableReinitialize={true}
onSubmit={updateGame}
validationSchema={Yup.object({
title: Yup.string().required("Title is required")
})}
>
{(formik: any) => (
<Form>
<ModalHeader className="flex flex-col gap-1">
Update game metadata
</ModalHeader>
<ModalBody>
<div className="flex flex-row gap-8">
{/*@ts-ignore*/}
<GameCoverPicker key="coverUrl" name="coverUrl" game={game}/>
<div className="flex flex-col flex-1">
<Input key="metadata.path" name="metadata.path" label="Path"
isDisabled/>
<Input key="title" name="title" label="Title" isRequired/>
<DatePickerInput key="release" name="release" label="Release"/>
</div>
</div>
<TextAreaInput key="summary" name="summary" label="Summary (HTML)"/>
<TextAreaInput key="comment" name="comment" label="Comment (Markdown)"/>
<Accordion variant="splitted"
itemClasses={{
base: "-mx-2",
content: "max-h-80 overflow-y-auto",
}}>
<AccordionItem key="additional-metadata"
aria-label="Additional Metadata"
title="Additional Metadata">
<ArrayInput key="developers" name="developers" label="Developers"/>
<ArrayInput key="publishers" name="publishers" label="Publishers"/>
<ArrayInput key="genres" name="genres" label="Genres"/>
<ArrayInput key="themes" name="themes" label="Themes"/>
<ArrayInput key="keywords" name="keywords" label="Keywords"/>
<ArrayInput key="features" name="features" label="Features"/>
<ArrayInput key="perspectives" name="perspectives"
label="Perspectives"/>
</AccordionItem>
</Accordion>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button
color="primary"
isLoading={formik.isSubmitting}
isDisabled={formik.isSubmitting || !formik.dirty}
type="submit"
>
{formik.isSubmitting ? "" : "Save"}
</Button>
</ModalFooter>
</Form>
)}
</Formik>
)
}}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,110 @@
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {Button, Image, Input, Modal, ModalBody, ModalContent, ModalHeader, ScrollShadow} from "@heroui/react";
import React, {useEffect, useState} from "react";
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
import {GameEndpoint} from "Frontend/generated/endpoints";
import {ArrowRight, MagnifyingGlass} from "@phosphor-icons/react";
interface GameCoverPickerModalProps {
game: GameDto;
isOpen: boolean;
onOpenChange: () => void;
setCoverUrl: (url: string) => void;
}
export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}: GameCoverPickerModalProps) {
const [coverUrl, setCoverUrlState] = useState("");
const [searchTerm, setSearchTerm] = useState(game.title);
const [searchResults, setSearchResults] = useState<GameSearchResultDto[]>([]);
const [isSearching, setIsSearching] = useState(false)
useEffect(() => {
if (isOpen && searchTerm.length > 0 && searchResults.length === 0) {
search();
}
}, [isOpen]);
async function search() {
setIsSearching(true);
const results = await GameEndpoint.getPotentialMatches(searchTerm, false);
let validResults = results.filter(result => result.coverUrl && result.coverUrl.length > 0 && result.coverUrl !== "null");
setSearchResults(validResults);
setIsSearching(false);
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="2xl">
<ModalContent>
{(onClose) => {
return (<>
<ModalHeader>
Enter a URL or search for a cover
</ModalHeader>
<ModalBody className="flex flex-col gap-4">
<div className="flex flex-row gap-2 mb-4">
<Input isClearable
placeholder="Enter a URL"
value={coverUrl}
onValueChange={setCoverUrlState}
onClear={() => setCoverUrlState("")}
/>
<Button isIconOnly onPress={() => {
setCoverUrl(coverUrl);
onClose();
}}>
<ArrowRight/>
</Button>
</div>
<div className="flex flex-row gap-2 mb-4">
<Input placeholder="Search"
value={searchTerm}
onValueChange={setSearchTerm}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await search();
}
}}
/>
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}>
<MagnifyingGlass/>
</Button>
</div>
{searchResults.length === 0 && !isSearching &&
<p className="text-center">No results found.</p>
}
{searchResults.length === 0 && isSearching &&
<p className="text-center text-foreground/70">Searching...</p>
}
<ScrollShadow
className="grid grid-cols-auto-fill gap-4 h-96 overflow-scroll justify-evenly">
{searchResults.map((result) => (
<div className="relative group w-fit h-fit cursor-pointer"
onClick={() => {
setCoverUrl(result.coverUrl!);
onClose();
}}>
<Image
key={result.id}
alt={result.title}
className="z-0 object-cover aspect-[12/17] group-hover:brightness-50"
src={result.coverUrl!}
radius="none"
height={216}
/>
<div
className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
>
<ArrowRight size={46}/>
</div>
</div>
))}
</ScrollShadow>
</ModalBody>
</>)
}}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,65 @@
import React, {useEffect, useState} from "react";
import {addToast, Button, Input, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {RegistrationEndpoint, UserEndpoint} from "Frontend/generated/endpoints";
interface InviteUserModalProps {
isOpen: boolean;
onOpenChange: () => void;
}
export default function InviteUserModal({isOpen, onOpenChange}: InviteUserModalProps) {
const [email, setEmail] = useState<string | null>();
const [error, setError] = useState<string | null>();
useEffect(() => {
setEmail(null);
setError(null);
}, []);
async function inviteUser(onClose: () => void) {
if (!email) return;
if (await UserEndpoint.existsByMail(email)) {
setError("User with this email already exists");
return;
}
try {
await RegistrationEndpoint.createInvitation(email);
addToast({
title: "Invitation sent",
description: "The user will receive an email with further instructions shortly.",
color: "success"
});
onClose();
} catch (e) {
setError("Failed to create invitation");
}
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">Invite a new user</ModalHeader>
<ModalBody>
<p>Enter the email address of the user you want to invite:</p>
<Input errorMessage={error} onChange={(e) => setEmail(e.target.value)} type="email"/>
{error && <small className="text-danger">{error}</small>}
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="success" onPress={() => inviteUser(onClose)}
isDisabled={email === null || email === undefined || email.length < 1}>
Send invitation
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,103 @@
import React, {useState} from "react";
import {addToast, Button, Checkbox, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {Form, Formik} from "formik";
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import Input from "Frontend/components/general/input/Input";
import * as Yup from "yup";
import DirectoryMappingInput from "Frontend/components/general/input/DirectoryMappingInput";
interface LibraryCreationModalProps {
libraries: LibraryDto[];
setLibraries: (libraries: LibraryDto[]) => void;
isOpen: boolean;
onOpenChange: () => void;
}
export default function LibraryCreationModal({
libraries,
isOpen,
onOpenChange
}: LibraryCreationModalProps) {
const [scanAfterCreation, setScanAfterCreation] = useState<boolean>(true);
async function createLibrary(library: LibraryDto) {
try {
await LibraryEndpoint.createLibrary(library as LibraryDto, scanAfterCreation);
addToast({
title: "New library created",
description: `Library ${library.name} created!`,
color: "success"
});
} catch (e) {
addToast({
title: "Error creating library",
description: `Library ${library.name} could not be created!`,
color: "warning"
});
throw "Error creating library: " + e;
}
}
return (
<>
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="xl">
<ModalContent>
{(onClose) => (
<Formik initialValues={{name: "", directories: []}}
validationSchema={Yup.object({
name: Yup.string()
.required("Library name is required")
.max(255, "Library name must be 255 characters or less"),
directories: Yup.array()
.of(Yup.object())
.min(1, "At least one directory is required")
})}
isInitialValid={false}
onSubmit={async (values: any) => {
await createLibrary(values);
onClose();
}}
>
{(formik) =>
<Form>
<ModalHeader className="flex flex-col gap-1">Add a new library</ModalHeader>
<ModalBody>
<div className="flex flex-col gap-2">
<Input
name="name"
label="Library Name"
placeholder="Enter library name"
value={formik.values.name}
required
/>
<DirectoryMappingInput name="directories"/>
</div>
</ModalBody>
<ModalFooter className="flex flex-row justify-between">
<Checkbox isSelected={scanAfterCreation} onValueChange={setScanAfterCreation}>Scan
after creation?</Checkbox>
<div className="flex flex-row">
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary"
isLoading={formik.isSubmitting}
isDisabled={formik.isSubmitting}
type="submit"
>
{formik.isSubmitting ? "" : "Add"}
</Button>
</div>
</ModalFooter>
</Form>
}
</Formik>
)}
</ModalContent>
</Modal>
</>
);
}
@@ -0,0 +1,152 @@
import {
Button,
Input,
Modal,
ModalBody,
ModalContent,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
Tooltip
} from "@heroui/react";
import React, {useEffect, useState} from "react";
import {ArrowRight, MagnifyingGlass} from "@phosphor-icons/react";
import {GameEndpoint} from "Frontend/generated/endpoints";
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
import PluginIcon from "../plugin/PluginIcon";
interface EditGameMetadataModalProps {
path: string;
libraryId: number;
replaceGameId?: number;
initialSearchTerm: string;
isOpen: boolean;
onOpenChange: () => void;
}
export default function MatchGameModal({
path,
libraryId,
replaceGameId,
initialSearchTerm,
isOpen,
onOpenChange
}: EditGameMetadataModalProps) {
const [searchTerm, setSearchTerm] = useState("");
const [searchResults, setSearchResults] = useState<GameSearchResultDto[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [isMatching, setIsMatching] = useState<string | null>(null);
useEffect(() => {
setSearchTerm(initialSearchTerm);
setSearchResults([]);
}, [isOpen]);
async function matchGame(result: GameSearchResultDto) {
await GameEndpoint.matchManually(result.originalIds, path, libraryId, replaceGameId);
}
async function search() {
setIsSearching(true);
const results = await GameEndpoint.getPotentialMatches(searchTerm, true);
setSearchResults(results);
setIsSearching(false);
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}
hideCloseButton
isDismissable={!isSearching && !isMatching}
isKeyboardDismissDisabled={!isSearching && !isMatching}
backdrop="opaque" size="5xl">
<ModalContent>
{(onClose) => (
<ModalBody className="my-4">
<div className="flex flex-col items-center">
<pre>{path}</pre>
</div>
<div className="flex flex-row gap-2 mb-4">
<Input value={searchTerm}
onValueChange={setSearchTerm}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await search();
}
}}
/>
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}>
<MagnifyingGlass/>
</Button>
</div>
<div>
<Table removeWrapper isStriped isHeaderSticky
classNames={{
base: "h-80 overflow-scroll",
}}
>
<TableHeader>
<TableColumn>Title & Release</TableColumn>
<TableColumn>Developer(s)</TableColumn>
<TableColumn>Publisher(s)</TableColumn>
{/* width={1} keeps the column as far to the right as possible*/}
<TableColumn>Sources</TableColumn>
<TableColumn width={1}> </TableColumn>
</TableHeader>
<TableBody emptyContent="Your filter did not match any games." items={searchResults}>
{(item) => (
<TableRow key={item.id}>
<TableCell>
{item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"})
</TableCell>
<TableCell>
<div className="flex flex-col">
{item.developers ? item.developers.map(
developer => <p>{developer}</p>
) : "unknown"}
</div>
</TableCell>
<TableCell>
<div className="flex flex-col">
{item.publishers ? item.publishers.map(
publisher => <p>{publisher}</p>
) : "unknown"}
</div>
</TableCell>
<TableCell>
<div className="flex flex-row gap-2">
{Object.values(item.originalIds).map(
originalId => <PluginIcon pluginId={originalId.pluginId}/>
)}
</div>
</TableCell>
<TableCell>
<Tooltip content="Pick this result">
<Button isIconOnly size="sm"
isDisabled={isMatching !== null}
isLoading={isMatching === item.id}
onPress={async () => {
setIsMatching(item.id);
await matchGame(item);
setIsMatching(null);
onClose();
}}>
<ArrowRight/>
</Button>
</Tooltip>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</ModalBody>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,76 @@
import React, {useEffect, useState} from "react";
import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {Input as NextInput} from "@heroui/input";
import {WarningCircle} from "@phosphor-icons/react";
import {MessageEndpoint, PasswordResetEndpoint} from "Frontend/generated/endpoints";
interface PasswordResetModalProps {
isOpen: boolean;
onOpenChange: () => void;
}
export default function PasswordResetModal({
isOpen,
onOpenChange
}: PasswordResetModalProps) {
const [canResetPassword, setCanResetPassword] = useState(false);
const [resetEmail, setResetEmail] = useState<string>();
useEffect(() => {
MessageEndpoint.isEnabled().then(setCanResetPassword);
}, []);
async function resetPassword() {
if (!resetEmail) return;
await PasswordResetEndpoint.requestPasswordReset(resetEmail);
addToast({
title: "Password reset requested",
description: "If the email address is registered, you will receive a message with further instructions.",
color: "success"
});
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="xl">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">Request a password reset</ModalHeader>
<ModalBody>
{canResetPassword ?
<NextInput
onChange={(event: any) => {
setResetEmail(event.target.value);
}}
type="email"
placeholder="Email"
/> :
<div className="flex flex-row items-center gap-4 text-warning">
<WarningCircle size={40}/>
<p>
Password self-service is disabled.<br/>
To reset your password please contact your administrator.
</p>
</div>
}
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary"
isDisabled={!canResetPassword}
onPress={async () => {
await resetPassword();
onClose();
}}>
Send request
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,65 @@
import React, {useEffect, useState} from "react";
import {Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, Snippet} from "@heroui/react";
import TokenDto from "Frontend/generated/org/gameyfin/app/shared/token/TokenDto";
import {timeUntil} from "Frontend/util/utils";
interface PasswordResetTokenModalProps {
isOpen: boolean;
onOpenChange: () => void;
token: TokenDto;
}
export default function PasswordResetTokenModal({isOpen, onOpenChange, token}: PasswordResetTokenModalProps) {
const [timeUntilExpiry, setTimeUntilExpiry] = useState<string>("");
const timeoutRefresh = setInterval(updateTimeUntilExpiry, 1000);
useEffect(updateTimeUntilExpiry, [token]);
useEffect(() => {
return () => {
clearInterval(timeoutRefresh);
};
}, []);
function passwordResetLink() {
return `${document.baseURI}reset-password?token=${token.secret}`;
}
function updateTimeUntilExpiry() {
if (!token) return;
setTimeUntilExpiry(timeUntil(token.expiresAt as string));
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} isDismissable={false}
backdrop="opaque" size="4xl">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
The user can reset their password using the following link
</ModalHeader>
<ModalBody>
<Snippet symbol="">{passwordResetLink()}</Snippet>
{
!timeUntilExpiry.endsWith("ago")
? <small className="text-warning">
This link will expire {timeUntilExpiry}
</small>
: <small className="text-danger">
This link has expired {timeUntilExpiry}
</small>
}
</ModalBody>
<ModalFooter>
<Button color="primary" onPress={onClose}>
OK
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,80 @@
import {Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {Form, Formik} from "formik";
import React, {useEffect, useState} from "react";
import Input from "Frontend/components/general/input/Input";
import FileTreeView from "Frontend/components/general/input/FileTreeView";
import DirectoryMappingDto from "Frontend/generated/org/gameyfin/app/libraries/dto/DirectoryMappingDto";
import {ArrowRight} from "@phosphor-icons/react";
interface PathPickerModalProps {
returnSelectedPath: (path: DirectoryMappingDto) => void;
isOpen: boolean;
onOpenChange: () => void;
}
export default function PathPickerModal({returnSelectedPath, isOpen, onOpenChange}: PathPickerModalProps) {
const [internalPath, setInternalPath] = useState("");
const [externalPath, setExternalPath] = useState("");
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="3xl">
<ModalContent>
{(onClose) => (
<Formik initialValues={{internalPath: internalPath, externalPath: externalPath}}
onSubmit={(values: DirectoryMappingDto) => {
returnSelectedPath(values);
setInternalPath("");
setExternalPath("");
onClose();
}}>
{(formik) => {
useEffect(() => {
formik.setFieldValue("internalPath", internalPath);
}, [internalPath]);
return (
<Form>
<ModalHeader className="flex flex-col gap-1">Select a folder</ModalHeader>
<ModalBody>
<div className="flex flex-row gap-2 items-center">
<Input
name="internalPath"
label="Selected directory"
placeholder="&nbsp;"
value={formik.values.internalPath}
isDisabled
required
/>
<ArrowRight className="mb-8"/>
<Input
name="externalPath"
label="External path (optional)"
placeholder="&nbsp;"
value={formik.values.externalPath}
/>
</div>
<div className="h-64 overflow-auto">
<FileTreeView onPathChange={setInternalPath}/>
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary"
isLoading={formik.isSubmitting}
isDisabled={formik.isSubmitting}
type="submit"
>
{formik.isSubmitting ? "" : "Select"}
</Button>
</ModalFooter>
</Form>
);
}}
</Formik>
)}
</ModalContent>
</Modal>
)
}
@@ -0,0 +1,201 @@
import React, {useState} from "react";
import {addToast, Button, Link, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, Tooltip} from "@heroui/react";
import {Form, Formik} from "formik";
import PluginLogo from "Frontend/components/general/plugin/PluginLogo";
import Markdown from "react-markdown";
import remarkBreaks from "remark-breaks";
import {PluginEndpoint} from "Frontend/generated/endpoints";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
import {ArrowClockwise} from "@phosphor-icons/react";
import PluginConfigMetadataDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginConfigMetadataDto";
import PluginConfigFormField from "Frontend/components/general/plugin/PluginConfigFormField";
interface PluginDetailsModalProps {
plugin: PluginDto;
isOpen: boolean;
onOpenChange: () => void;
}
enum ValidationState {
UNCHECKED,
VALID,
INVALID,
IN_PROGRESS
}
export default function PluginDetailsModal({plugin, isOpen, onOpenChange}: PluginDetailsModalProps) {
const [configValidated, setConfigValidated] = useState<ValidationState>(ValidationState.UNCHECKED);
async function saveConfig(values: Record<string, string>) {
await PluginEndpoint.updateConfig(plugin.id, values);
addToast({
title: "Configuration saved",
description: `Configuration for plugin ${plugin.name} saved!`,
color: "success"
});
}
function getEffectiveConfig(): Record<string, any> {
const effectiveConfig: Record<string, any> = {};
if (!plugin.configMetadata) return effectiveConfig;
for (const meta of plugin.configMetadata) {
const key = meta.key;
let value = plugin.config?.[key] ?? meta.default;
if (value != null) {
switch (meta.type.toLowerCase()) {
case "float":
case "int":
effectiveConfig[key] = Number(value);
break;
case "boolean":
effectiveConfig[key] = value === true || value === "true";
break;
default:
effectiveConfig[key] = value.toString();
}
}
}
return effectiveConfig;
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
<ModalContent>
{(onClose) => {
async function handleSubmit(values: Record<string, string>): Promise<void> {
await saveConfig(values);
onClose();
}
return (
<Formik initialValues={getEffectiveConfig()}
initialErrors={plugin.configValidation?.errors}
enableReinitialize={true}
onSubmit={handleSubmit}
>
{(formik: any) => (
<Form>
<ModalHeader className="flex flex-col gap-1">
Plugin configuration for {plugin.name}
</ModalHeader>
<ModalBody>
<div className="flex flex-col text-sm">
<div className="flex flex-row items-center gap-8 mb-4">
<PluginLogo plugin={plugin}/>
<table className="text-left table-auto">
<tbody>
{Object.entries({
"Author(s)": plugin.author,
"Version": plugin.version,
"License": plugin.license,
"URL": <Link isExternal
showAnchorIcon
color="foreground"
size="sm"
href={plugin.url}>
{plugin.url}
</Link>,
}).map(([key, value]) => {
if (!value) return;
return (
<tr key={key}>
<td className="text-default-500 w-0 min-w-20">{key}</td>
<td className="flex flex-row gap-1">{value}</td>
</tr>
)
})}
</tbody>
</table>
</div>
<p className="text-default-500">Description</p>
<Markdown
remarkPlugins={[remarkBreaks]}
components={{
a(props) {
return <Link isExternal
showAnchorIcon
color="foreground"
underline="always"
href={props.href}
size="sm">
{props.children}
</Link>
}
}}
>{plugin.description}</Markdown>
</div>
<div className="flex flex-row items-center mt-4 gap-2">
<h4 className="text-l font-bold">Configuration</h4>
{(plugin.configMetadata && plugin.configMetadata.length > 0) && <>
<div className="flex-1"/>
{(() => {
switch (configValidated) {
case ValidationState.VALID:
return <p className="text-small text-success">
Configuration valid
</p>;
case ValidationState.INVALID:
return <p className="text-small text-danger">
Configuration invalid
</p>;
default:
return null;
}
})()}
<Tooltip content="Re-validate configuration" placement="bottom"
color="foreground">
<Button isIconOnly variant="light" size="sm"
isLoading={configValidated === ValidationState.IN_PROGRESS}
onPress={async () => {
setConfigValidated(ValidationState.IN_PROGRESS);
let result = await PluginEndpoint.validateNewConfig(plugin.id, formik.values)
if (result.errors) {
formik.setErrors(result.errors);
setConfigValidated(ValidationState.INVALID);
} else {
setConfigValidated(ValidationState.VALID);
}
setTimeout(() => setConfigValidated(ValidationState.UNCHECKED), 5000);
}}>
<ArrowClockwise/>
</Button>
</Tooltip>
</>}
</div>
{(plugin.configMetadata && plugin.configMetadata.length > 0) ?
plugin.configMetadata.map((entry: PluginConfigMetadataDto) => (
<PluginConfigFormField
key={entry.key}
pluginConfigMetadata={entry}
showErrorUntouched={true}/>
)) : "This plugin has no configuration options."
}
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
{(plugin.configMetadata && plugin.configMetadata?.length > 0) ?
<Button
color="primary"
isLoading={formik.isSubmitting}
isDisabled={formik.isSubmitting || !formik.dirty}
type="submit"
>
{formik.isSubmitting ? "" : "Save"}
</Button> : ""}
</ModalFooter>
</Form>
)
}
</Formik>
)
}}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,113 @@
import React from "react";
import {addToast, Button, Chip, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {ListBox, ListBoxItem, useDragAndDrop} from "react-aria-components";
import {CaretUpDown} from "@phosphor-icons/react";
import {useListData} from "@react-stately/data";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
import {PluginEndpoint} from "Frontend/generated/endpoints";
interface PluginPrioritiesModalProps {
plugins: PluginDto[];
isOpen: boolean;
onOpenChange: () => void;
}
export default function PluginPrioritiesModal({plugins, isOpen, onOpenChange}: PluginPrioritiesModalProps) {
const sortedPlugins = useListData({
initialItems: plugins, // Already sorted in parent
getKey: (plugin) => plugin.id
});
let {dragAndDropHooks} = useDragAndDrop({
getItems: (keys) =>
[...keys].map((key) => ({'text/plain': sortedPlugins.getItem(key)!.name})),
onReorder(e) {
if (e.keys.has(e.target.key)) return;
if (e.target.dropPosition === 'before' || e.target.dropPosition === 'on') {
sortedPlugins.moveBefore(e.target.key, e.keys);
} else if (e.target.dropPosition === 'after') {
sortedPlugins.moveAfter(e.target.key, e.keys);
}
// Recalculate priority based on new position (reversed)
sortedPlugins.items.forEach((plugin, index) => {
const reversedPriority = sortedPlugins.items.length - index;
sortedPlugins.update(plugin.id, {...plugin, priority: reversedPriority});
});
}
});
function generatePrioritiesMap(): Record<string, number> {
let map: Record<string, number> = {};
const totalPlugins = sortedPlugins.items.length;
sortedPlugins.items.forEach((plugin, index) => {
map[plugin.id] = totalPlugins - index; // Reverse order
});
return map;
}
async function setPluginPriorities(onClose: () => void) {
try {
const prioritiesMap = generatePrioritiesMap();
await PluginEndpoint.setPluginPriorities(prioritiesMap);
addToast({
title: "Plugin order updated",
description: "Plugin order has been updated successfully.",
color: "success"
});
onClose();
} catch (e) {
addToast({
title: "Error",
description: "An error occurred while updating plugin order.",
color: "warning"
});
}
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<p>Edit plugin order</p>
<p className="text-small font-normal">Plugins higher on the list are preferred</p>
</ModalHeader>
<ModalBody>
<ListBox items={sortedPlugins.items}
dragAndDropHooks={dragAndDropHooks}
className="flex flex-col gap-2">
{(plugin: PluginDto) => (
<ListBoxItem
key={plugin.id}
className="flex flex-row p-2 rounded-lg justify-between items-center bg-foreground/5">
<div className="flex flex-row gap-2 items-center">
<Chip size="sm" color="primary">
{sortedPlugins.items.findIndex(p => p.id === plugin.id) + 1}
</Chip>
<p className="font-normal text-small">{plugin.name}</p>
</div>
<CaretUpDown/>
</ListBoxItem>
)}
</ListBox>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="success" onPress={() => setPluginPriorities(onClose)}>
Save
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,111 @@
import React from "react";
import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {RegistrationEndpoint} from "Frontend/generated/endpoints";
import UserRegistrationDto from "Frontend/generated/org/gameyfin/app/users/dto/UserRegistrationDto";
import {Form, Formik} from "formik";
import * as Yup from "yup";
import Input from "Frontend/components/general/input/Input";
interface SignUpModalProps {
isOpen: boolean;
onOpenChange: () => void;
}
export default function SignUpModal({
isOpen,
onOpenChange
}: SignUpModalProps) {
async function signUp(registration: UserRegistrationDto, onClose: () => void) {
try {
await RegistrationEndpoint.registerUser({
username: registration.username,
password: registration.password,
email: registration.email
});
onClose();
addToast({
title: "Account created",
description: "You will receive an email with further instructions shortly.",
color: "success"
});
} catch (_) {
addToast({
title: "Registration failed",
description: "An error occurred while registering your account.",
color: "danger"
});
return;
}
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="xl">
<ModalContent>
{(onClose) => (
<Formik initialValues={{}}
onSubmit={async (values: any, {setFieldError}) => {
let usernameAvailable = await RegistrationEndpoint.isUsernameAvailable(values.username);
if (!usernameAvailable) {
setFieldError('username', 'Username already taken');
return;
} else {
await signUp(values, onClose);
}
}}
validationSchema={Yup.object({
username: Yup.string()
.required('Required'),
password: Yup.string()
.min(8, 'Password must be at least 8 characters long')
.required('Required'),
email: Yup.string()
.email()
.required('Required'),
passwordRepeat: Yup.string()
.equals([Yup.ref('password')], 'Passwords do not match')
.required('Required')
})}>
<Form>
<ModalHeader className="flex flex-col gap-1">Register a new account</ModalHeader>
<ModalBody>
<div className="flex flex-col">
<Input
label="Username"
name="username"
type="text"
/>
<Input
label="E-Mail"
name="email"
type="email"
/>
<Input
label="Password"
name="password"
type="password"
/>
<Input
label="Password (repeat)"
name="passwordRepeat"
type="password"
/>
</div>
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary" type="submit">
Create account
</Button>
</ModalFooter>
</Form>
</Formik>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,59 @@
import SelectInput from "Frontend/components/general/input/SelectInput";
import CheckboxInput from "Frontend/components/general/input/CheckboxInput";
import Input from "Frontend/components/general/input/Input";
import React from "react";
import PluginConfigMetadataDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginConfigMetadataDto";
export default function PluginConfigFormField({pluginConfigMetadata, ...props}: any) {
function inputElement(metadata: PluginConfigMetadataDto) {
if (metadata.allowedValues != null && metadata.allowedValues.length > 0) {
return (
<SelectInput label={metadata.label}
name={metadata.key}
values={metadata.allowedValues}
isRequired={metadata.required}
{...props}/>
);
}
switch (metadata.type.toLowerCase()) {
case "boolean":
return (
<CheckboxInput label={metadata.label}
name={metadata.key}
{...props}/>
);
case "string":
return (
<Input label={metadata.label}
name={metadata.key}
type={metadata.secret ? "password" : "text"}
isRequired={metadata.required}
{...props}/>
);
case "float":
return (
<Input label={metadata.label}
name={metadata.key}
type="number"
isRequired={metadata.required}
step="0.1"
{...props}/>
);
case "int":
return (
<Input label={metadata.label}
name={metadata.key}
type="number"
isRequired={metadata.required}
step="1"
{...props}/>
);
default:
return <pre>Unsupported type: {metadata.type} for key {metadata.key}</pre>;
}
}
return inputElement(pluginConfigMetadata!);
}
@@ -0,0 +1,21 @@
import {Image, Tooltip} from "@heroui/react";
import {Plug} from "@phosphor-icons/react";
import {pluginState} from "Frontend/state/PluginState";
import {useSnapshot} from "valtio/react";
interface PluginLogoProps {
pluginId: string;
}
export default function PluginIcon({pluginId}: PluginLogoProps) {
const state = useSnapshot(pluginState);
return state.isLoaded && (
<Tooltip content={state.state[pluginId].name}>
{state.state[pluginId].hasLogo ?
<Image src={`/images/plugins/${state.state[pluginId].id}/logo`} width={16} height={16} radius="none"/> :
<Plug size={16} weight="fill"/>
}
</Tooltip>
)
}
@@ -0,0 +1,19 @@
import {Plug} from "@phosphor-icons/react";
import React from "react";
import {Image} from "@heroui/react";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
interface PluginLogoProps {
plugin: PluginDto;
}
export default function PluginLogo({plugin}: PluginLogoProps) {
return (
<>
{plugin.hasLogo ?
<Image isBlurred src={`/images/plugins/${plugin.id}/logo`} width={64} height={64} radius="none"/> :
<Plug size={64} weight="fill"/>
}
</>
);
}
@@ -0,0 +1,42 @@
import {Button, Tooltip, useDisclosure} from "@heroui/react";
import {ListNumbers} from "@phosphor-icons/react";
import {PluginManagementCard} from "Frontend/components/general/cards/PluginManagementCard";
import React from "react";
import PluginPrioritiesModal from "Frontend/components/general/modals/PluginPrioritiesModal";
import {camelCaseToTitle} from "Frontend/util/utils";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
interface PluginManagementSectionProps {
type: string;
plugins: PluginDto[];
}
export function PluginManagementSection({type, plugins}: PluginManagementSectionProps) {
const pluginPrioritiesModal = useDisclosure();
return (
<div className="flex flex-col gap-2">
<div className="flex flex-row flex-grow justify-between">
<h2 className="text-xl font-bold">{camelCaseToTitle(type)}</h2>
<Tooltip color="foreground" placement="left" content="Change plugin order">
<Button isIconOnly variant="flat" onPress={pluginPrioritiesModal.onOpen}>
<ListNumbers/>
</Button>
</Tooltip>
</div>
<div className="grid grid-cols-300px gap-4">
{plugins.map((plugin) =>
<PluginManagementCard plugin={plugin} key={plugin.id}/>
)}
</div>
<PluginPrioritiesModal
key={plugins.map(p => p.id + p.priority).join(',')} // force re-mount if plugin order changes
plugins={[...plugins].sort((a, b) => b.priority - a.priority)}
isOpen={pluginPrioritiesModal.isOpen}
onOpenChange={pluginPrioritiesModal.onOpenChange}
/>
</div>);
}
@@ -0,0 +1,62 @@
import {Outlet} from "react-router";
import {Icon} from "@phosphor-icons/react";
import {Listbox, ListboxItem} from "@heroui/react";
import {ReactElement, useState} from "react";
export type MenuItem = {
title: string,
url: string,
icon: ReactElement<Icon>
}
export default function withSideMenu(baseUrl: string, menuItems: MenuItem[]) {
return function PageWithSideMenu() {
const [selectedItem, setSelectedItem] = useState<string>(initialSelected)
/**
* Remove a "/" at the start if it exists
*/
function key(k: string): string {
return k.replace(/^(\/)/, "");
}
/**
* If the key starts with "/" assume it's an absolute link, else assume it's relative
*/
function link(l: string): string {
if (l.startsWith("/")) return baseUrl + l;
return baseUrl + "/" + l;
}
/**
* Match the initially selected item by current URL path
*/
function initialSelected(): string {
const p = window.location.pathname;
const idx = p.indexOf(baseUrl);
if (idx === -1) return "";
const afterBase = p.substring(idx + baseUrl.length);
// Remove leading slash, then split and take the first segment
return afterBase.replace(/^\/+/, "").split("/")[0] || "";
}
return (
<div className="flex flex-row">
<div className="flex flex-col pr-8">
<Listbox className="w-60 fixed" color="primary">
{menuItems.map((i) => (
<ListboxItem key={key(i.url)} startContent={i.icon} href={link(i.url)}
onPress={() => setSelectedItem(i.url)}
className={`h-12 ${key(i.url) === selectedItem ? "bg-primary" : ""}`}>
<p>{i.title}</p>
</ListboxItem>
))}
</Listbox>
</div>
<div className="ml-60 flex-1 overflow-auto">
<Outlet/>
</div>
</div>
);
};
}
@@ -0,0 +1,16 @@
export default function GameyfinLogo({className}: {
className?: string
}) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 365.58 336.34" className={className}>
<polygon points="190.1 49.13 190.1 69.24 207.98 44.13 190.1 49.13"/>
<polygon points="365.58 0 263.22 28.66 205.64 95.97 365.58 51.18 365.58 0"/>
<polygon
points="190.1 283.11 248.6 266.73 248.6 149.74 365.58 116.99 365.58 73.12 190.1 122.25 190.1 283.11"/>
<polygon
points="58.49 144.48 155.98 117.18 175.48 89.79 175.48 53.23 0 102.36 0 336.34 58.49 254.15 58.49 144.48"/>
<polygon
points="116.99 199.59 116.99 245.09 65.81 259.42 0 336.34 175.48 287.2 175.48 170.22 131.61 182.5 116.99 199.59"/>
</svg>
);
}
@@ -0,0 +1,23 @@
import {Theme} from "Frontend/theming/theme";
import {Tooltip} from "@heroui/react";
export default function ThemePreview({theme, isSelected}: {
theme: Theme,
isSelected?: boolean
}) {
return (
<Tooltip content={<p className="capitalize">{theme.name?.replace("-", " ")}</p>} placement="bottom">
<div className={`flex flex-col flex-grow aspect-square border-2 rounded-large overflow-hidden
${theme.name}-dark
${isSelected ? "border-foreground" : "border-foreground-200 hover:border-focus"}`}>
<div className="flex-1 bg-primary"/>
<div className="basis-1/4 flex flex-row">
<div className="flex-1 bg-secondary"/>
<div className="flex-1 bg-success"/>
<div className="flex-1 bg-warning"/>
<div className="flex-1 bg-danger"/>
</div>
</div>
</Tooltip>
);
}
@@ -0,0 +1,82 @@
import {useTheme} from "next-themes";
import React, {useEffect, useState} from "react";
import {Button, Card, Divider, Select, Selection, SelectItem} from "@heroui/react";
import {themes} from "Frontend/theming/themes";
import {Theme} from "Frontend/theming/theme";
import ThemePreview from "Frontend/components/theming/ThemePreview";
import {toTitleCase} from "Frontend/util/utils";
import {UserPreferenceService} from "Frontend/util/user-preference-service";
export function ThemeSelector() {
const {theme, setTheme} = useTheme();
const [selectedTheme, setSelectedTheme] = useState(theme?.substring(0, theme?.lastIndexOf("-")));
const [selectedMode, setSelectedMode] = useState<Selection>();
useEffect(() => {
if (!selectedMode)
setSelectedMode(new Set([theme?.split('-').pop() ?? "dark"]));
}, [theme]);
useEffect(updateTheme, [selectedTheme, selectedMode]);
function updateTheme() {
if (selectedMode instanceof Set) {
let theme = `${selectedTheme}-${selectedMode.values().next().value}`;
setTheme(theme);
UserPreferenceService.set("preferred-theme", theme).catch(console.error);
}
}
return (
<div className="flex flex-col items-center gap-8">
<Select label="Theme mode" className="max-w-xs"
disallowEmptySelection
selectionMode={"single"}
defaultSelectedKeys={selectedMode}
onSelectionChange={setSelectedMode}
selectedKeys={selectedMode}>
<SelectItem key="light">
Light
</SelectItem>
<SelectItem key="dark">
Dark
</SelectItem>
</Select>
<div className="grid grid-flow-row grid-cols-8 gap-8">
{
//min-w-[468px]
themes.map(((t: Theme) => (
<div className="size-[10vh] min-h-[50px] min-w-[50px]"
key={t.name}
onClick={() => setSelectedTheme(t.name)}>
<ThemePreview
theme={t}
isSelected={selectedTheme === t.name}/>
</div>
)))
}
</div>
<p className="text-2xl font-semibold mt-8">Preview for theme
"{toTitleCase(theme!.replaceAll("-", " "))}"
</p>
<Divider/>
<div className="flex flex-row gap-8 items-baseline">
<div className="flex flex-row gap-4">
<Button color="primary">Primary</Button>
<Button color="secondary">Secondary</Button>
<Button color="success">Success</Button>
<Button color="warning">Warning</Button>
<Button color="danger">Danger</Button>
</div>
<Card className="flex flex-row gap-4 p-4">
<Button color="primary">Primary</Button>
<Button color="secondary">Secondary</Button>
<Button color="success">Success</Button>
<Button color="warning">Warning</Button>
<Button color="danger">Danger</Button>
</Card>
</div>
</div>
)
}
@@ -0,0 +1,101 @@
import React, {ReactNode, useState} from "react";
import {Form, Formik, FormikBag, FormikHelpers} from "formik";
import {ArrowLeft, ArrowRight, Check} from "@phosphor-icons/react";
import {Button} from "@heroui/react";
import {Step, Stepper} from "@material-tailwind/react";
const Wizard = ({children, initialValues, onSubmit}: {
children: ReactNode,
initialValues: any,
onSubmit: (values: any, bag: FormikHelpers<any> | FormikBag<any, any>) => Promise<any>
}) => {
const [stepNumber, setStepNumber] = useState(0);
const steps = React.Children.toArray(children);
const [snapshot, setSnapshot] = useState(initialValues);
const step = steps[stepNumber];
const totalSteps = steps.length;
const isFirstStep = stepNumber === 0;
const isLastStep = stepNumber === totalSteps - 1;
const next = (values: any) => {
setSnapshot(values);
setStepNumber(Math.min(stepNumber + 1, totalSteps - 1));
};
const previous = (values: any) => {
setSnapshot(values);
setStepNumber(Math.max(stepNumber - 1, 0));
};
const handleSubmit = async (values: any, bag: FormikBag<any, any> | FormikHelpers<any>) => {
/*// @ts-ignore*/
if (step.props.onSubmit) {
/*// @ts-ignore*/
await step.props.onSubmit(values, bag);
}
if (isLastStep) {
return onSubmit(values, bag);
} else {
await bag.setTouched({});
next(values);
}
};
return (
<Formik
initialValues={snapshot}
onSubmit={handleSubmit}
/*// @ts-ignore*/
validationSchema={step.props.validationSchema}
>
{(formik) => (
<Form className="flex flex-col h-full">
<div className="w-full mb-8">
<Stepper activeStep={stepNumber} activeLineClassName="bg-primary"
lineClassName="bg-foreground"
placeholder={undefined}
onPointerEnterCapture={undefined}
onPointerLeaveCapture={undefined}
onResize={undefined}
onResizeCapture={undefined}>
{steps.map((child, index) => (
<Step key={index}
className="bg-foreground text-background"
activeClassName="bg-primary"
completedClassName="bg-primary"
placeholder={undefined}
onPointerEnterCapture={undefined}
onPointerLeaveCapture={undefined}
onResize={undefined}
onResizeCapture={undefined}>
{/*@ts-ignore*/}
{child.props.icon}
</Step>
))}
</Stepper>
</div>
<div className="flex grow">
{step}
</div>
<div className="left-8 right-8 absolute bottom-8 -z-1">
<div className="flex justify-between">
<Button color="primary" onClick={() => previous(formik.values)} isDisabled={isFirstStep}>
<ArrowLeft/>
</Button>
<Button
color="primary"
isLoading={formik.isSubmitting}
type="submit"
>
{formik.isSubmitting ? "" : isLastStep ? <Check/> : <ArrowRight/>}
</Button>
</div>
</div>
</Form>
)}
</Formik>
);
};
export default Wizard;
@@ -0,0 +1,11 @@
import {JSX, ReactNode} from "react";
import * as Yup from 'yup';
export default function WizardStep({children, icon, validationSchema}: {
children: ReactNode,
icon: JSX.Element,
validationSchema?: Yup.Schema,
onSubmit?: (...values: any) => Promise<void>
}) {
return children;
}
@@ -0,0 +1,53 @@
import {fetchWithAuth} from "Frontend/util/utils";
import {addToast} from "@heroui/react";
export async function uploadAvatar(avatar: any) {
const formData = new FormData();
formData.append("file", avatar);
const response = await fetchWithAuth("images/avatar/upload", formData);
const result = await response.text();
if (response.ok) {
window.location.reload();
} else {
addToast({
title: "Error uploading avatar",
description: result,
color: "danger"
});
}
}
export async function removeAvatar() {
const response = await fetchWithAuth("images/avatar/delete")
const result = await response.text();
if (response.ok) {
window.location.reload();
} else {
addToast({
title: "Error removing avatar",
description: result,
color: "danger"
});
}
}
export async function removeAvatarByName(name: string) {
const response = await fetchWithAuth("images/avatar/deleteByName?" + new URLSearchParams({name: name}))
const result = await response.text();
if (response.ok) {
window.location.reload();
} else {
addToast({
title: "Error removing avatar",
description: result,
color: "danger"
});
}
}
@@ -0,0 +1,3 @@
export function downloadGame(gameId: number, provider: string) {
window.open(`/download/${gameId}?provider=${provider}`, '_top');
}
@@ -0,0 +1,4 @@
import * as AvatarEndpoint from './AvatarEndpoint'
import * as DownloadEndpoint from './DownloadEndpoint'
export {AvatarEndpoint, DownloadEndpoint}
+25
View File
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Gameyfin</title>
<style>
body {
margin: 0;
width: 100vw;
height: 100vh;
}
#outlet {
height: 100%;
width: 100%;
}
</style>
<!-- index.ts is included here automatically (either by the dev server or during the build) -->
</head>
<body>
<div id="outlet"></div>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
import {createRoot} from 'react-dom/client';
import {StrictMode} from "react";
import {RouterProvider} from "react-router";
import {router} from './routes';
const container = document.getElementById('outlet')!;
const root = createRoot(container);
root.render(
<StrictMode>
<RouterProvider router={router}/>
</StrictMode>
);
+74
View File
@@ -0,0 +1,74 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
.gradient-primary {
@apply bg-gradient-to-br from-primary-400 to-primary-700;
}
.button-secondary {
@apply bg-primary-300 text-background/80;
}
}
/* Custom CSS */
:root {
/* Overwrite default Hilla styles (e.g. loading indicator) */
--lumo-primary-color: theme(colors.primary);
/* Overwrite SwiperJS styles */
--swiper-navigation-color: theme(colors.primary);
--swiper-pagination-color: theme(colors.primary);
.swiper-pagination-bullet {
background-color: theme(colors.primary);
}
}
/* List box drag & drop */
.react-aria-ListBoxItem {
&[data-dragging] {
opacity: 0.6;
}
}
.react-aria-DropIndicator[data-drop-target] {
outline: 1px solid theme(colors.primary);
}
.shine {
position: relative;
overflow: hidden;
}
.shine::before {
background: linear-gradient(
to right,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.7) 100%
);
content: "";
display: block;
height: 100%;
left: -100%;
position: absolute;
top: 0;
transform: skewX(-25deg);
width: 50%;
z-index: 2;
pointer-events: none;
}
.shine:hover::before,
.shine:focus::before {
animation: shine 0.85s;
}
@keyframes shine {
100% {
left: 125%;
}
}
+105
View File
@@ -0,0 +1,105 @@
import LoginView from "Frontend/views/LoginView";
import MainLayout from "Frontend/views/MainLayout";
import HomeView from "Frontend/views/HomeView";
import SetupView from "Frontend/views/SetupView";
import {ThemeSelector} from "Frontend/components/theming/ThemeSelector";
import App from "Frontend/App";
import {LibraryManagement} from "Frontend/components/administration/LibraryManagement";
import {UserManagement} from "Frontend/components/administration/UserManagement";
import ProfileManagement from "Frontend/components/administration/ProfileManagement";
import {SsoManagement} from "Frontend/components/administration/SsoManagement";
import {AdministrationView} from "Frontend/views/AdministrationView";
import {ProfileView} from "Frontend/views/ProfileView";
import {MessageManagement} from "Frontend/components/administration/MessageManagement";
import {LogManagement} from "Frontend/components/administration/LogManagement";
import PasswordResetView from "Frontend/views/PasswordResetView";
import EmailConfirmationView from "Frontend/views/EmailConfirmationView";
import InvitationRegistrationView from "Frontend/views/InvitationRegistrationView";
import PluginManagement from "Frontend/components/administration/PluginManagement";
import {SystemManagement} from "Frontend/components/administration/SystemManagement";
import GameView from "Frontend/views/GameView";
import LibraryManagementView from "Frontend/views/LibraryManagementView";
import SearchView from "Frontend/views/SearchView";
import RecentlyAddedView from "Frontend/views/RecentlyAddedView";
import LibraryView from "Frontend/views/LibraryView";
import {RouterConfigurationBuilder} from "@vaadin/hilla-file-router/runtime.js";
export const {router, routes} = new RouterConfigurationBuilder()
.withReactRoutes([
{
element: <App/>,
handle: {requiresLogin: false},
children: [
{
element: <MainLayout/>,
handle: {requiresLogin: true},
children: [
{
index: true, element: <HomeView/>
},
{
path: 'search',
element: <SearchView/>
},
{
path: 'recently-added',
element: <RecentlyAddedView/>
},
{
path: 'library/:libraryId',
element: <LibraryView/>
},
{
path: 'game/:gameId',
element: <GameView/>
},
{
path: 'settings',
element: <ProfileView/>,
children: [
{path: 'profile', element: <ProfileManagement/>},
{path: 'appearance', element: <ThemeSelector/>}
]
},
{
path: 'administration',
element: <AdministrationView/>,
children: [
{
path: 'libraries',
element: <LibraryManagement/>
},
{
path: 'libraries/library/:libraryId',
element: <LibraryManagementView/>
},
{path: 'users', element: <UserManagement/>},
{path: 'sso', element: <SsoManagement/>},
{path: 'messages', element: <MessageManagement/>},
{path: 'plugins', element: <PluginManagement/>},
{path: 'logs', element: <LogManagement/>},
{path: 'system', element: <SystemManagement/>}
]
}
]
},
{
path: 'login', element: <LoginView/>, handle: {requiresLogin: false}
},
{
path: 'setup', element: <SetupView/>, handle: {requiresLogin: false}
},
{
path: 'accept-invitation', element: <InvitationRegistrationView/>, handle: {requiresLogin: false}
},
{
path: 'reset-password', element: <PasswordResetView/>, handle: {requiresLogin: false}
},
{
path: 'confirm-email', element: <EmailConfirmationView/>, handle: {requiresLogin: true}
},
]
}
])
.protect("/login")
.build();

Some files were not shown because too many files have changed in this diff Show More