mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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
@@ -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/
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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" }
|
||||
BIN
Binary file not shown.
@@ -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
@@ -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" "$@"
|
||||
Vendored
+92
@@ -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
|
||||
@@ -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)
|
||||
};
|
||||
Generated
+17764
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
Binary file not shown.
@@ -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>
|
||||
);
|
||||
}
|
||||
+76
@@ -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
|
||||
<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 /
|
||||
{scan.result?.removed} removed /
|
||||
{scan.result?.unmatched} unmatched
|
||||
(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=" "
|
||||
value={formik.values.internalPath}
|
||||
isDisabled
|
||||
required
|
||||
/>
|
||||
<ArrowRight className="mb-8"/>
|
||||
<Input
|
||||
name="externalPath"
|
||||
label="External path (optional)"
|
||||
placeholder=" "
|
||||
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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user