Release v2.2.0 (#741)

* Migrate to TailwindCSS v4 (#740)

* Remove "material-tailwind" dependencies due to incompatibility of Stepper component with Tailwind v4

* Clean up Tailwind configs before upgrade

* Run HeroUI upgrade

* Run TailwindCSS upgrade

* Replace PostCSS with Vite

* Migrate custom styles to v4

* Remove tailwind.config.ts

* Add heroui.ts
Add tailwind vite plugin

* Fix small UI color inconsistency

* Fix theming system
Rename purple theme to pink

* Re-implement stepper in HeroUI

* Fix RoleChip colors

* Migrate icon names (#743)

* Add migration script for phosphor-icons

* Migrate icon usages

* Update version to 2.2.0-preview

* Revert accidental rename of menu title

* Bump stefanzweifel/git-auto-commit-action from 6 to 7 (#750)

Bumps [stefanzweifel/git-auto-commit-action](https://github.com/stefanzweifel/git-auto-commit-action) from 6 to 7.
- [Release notes](https://github.com/stefanzweifel/git-auto-commit-action/releases)
- [Changelog](https://github.com/stefanzweifel/git-auto-commit-action/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stefanzweifel/git-auto-commit-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: stefanzweifel/git-auto-commit-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Improve library scanning (#749)

* Update script to generate example libraries using SteamSpy API

* Refactor library scanning process

* Display Flyway startup log by default

* Fix race condition in CompanyService

* Fix race condition in ImageService
Remove obsolete table

* Fix SMTP config requiring an email as username (#755)

* Disable length limit for config values (#757)

* Deprecate DockerHub image (#759)

* Remove deprecation warning from web UI

* Reworked the CICD pipelines

* Optimize container image (#761)

* Fix Gradle warning

* Rework Docker image to improve layer caching

* Bump stefanzweifel/git-auto-commit-action from 6 to 7 (#765)

Bumps [stefanzweifel/git-auto-commit-action](https://github.com/stefanzweifel/git-auto-commit-action) from 6 to 7.
- [Release notes](https://github.com/stefanzweifel/git-auto-commit-action/releases)
- [Changelog](https://github.com/stefanzweifel/git-auto-commit-action/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stefanzweifel/git-auto-commit-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: stefanzweifel/git-auto-commit-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Multi platform support (#764)

* Remove migrate-phosphor-icons.js since migration has been successful
* Refactor GameMetadata into separate files
* Add Platform enum
* Implement platform support in Plugin API
* Implement platform support in Steam Plugin
* Implement platform support in IGDB Plugin
* Add database migration for platform support
* Implement platform support in GameService
* Implement platform support on most endpoints and features, some are still missing
Implemented platform support in all bundled plugins (although not finished polishing yet)
* Implement platforms in UI
* Make GameRequest platform aware
* Return headerImages from IGDB
* Implement proper PlatformMapper for IGDB plugin
* Fix various smaller issues and inconsistencies

* Replace placeholder in LibraryOverviewCard (#767)

* Bump actions/download-artifact from 5 to 6 (#769)

* Bump actions/upload-artifact from 4 to 5 (#770)

* Multi platform support (#773)

* Fix bug in Plugin API related to state loading/saving

* Hide Flyway query logs by default

* Extend migration script for multi platform tables

* Plugins now store their data and state in ./plugindata

* Add "plugindata" directory to entrypoint scripts

* Improve download handling (#756)

* Process download in background thread to avoid session timeout affecting it

* Increase default session timeout to 24h

* Use virtual thread pool for download task in background

* Make KSP extensions.idx generation more robust

* Implement download bandwidth limiter
Implement SliderInput
Refactor NumberInput

* Implement download bandwidth throttling
Implement real-time download monitoring

* Improve UI for DownloadManagement
Track more stats in SessionStats

* Update Hilla
Use React 19

* Implement real-time graph to track bandwidth usage
Implement downloaded data sum over last day
Small bug fixes
Small refactorings

* Update docker-compose.example.yml

* Improve DownloadSessionCard (#784)

* Fix unit on y-axis of download graph

* Show game size and library in tooltip
Make game chips interactive in DownloadSessionCard (leads to game page when clicked)
Optimize graph settings

* Migrate torrent plugin to libtorrent (#775)

* Disable TorrentDownloadPlugin in Alpine based Docker image

* Improve test coverage (#785)

* Fix potential divide by zero bug

* Add mockk dependency

* Add tests for org.gameyfin.app.core.download

* Add tests for Filesytem package
Fix DownloadServiceTest

* Fix FilesystemServiceTest

* Add tests for "job" package

* Upgrade Gradle wrapper
Enable Gradle config cache

* Added more tests

* Added tests for the "security" package

* Add tests for "game" package

* Fix AsyncFileTailer not shutting down properly on Windows

* Fix GameServiceTest

* Added tests for "libraries" package

* Added tests for "media" package

* Fix warning in ImageService

* Add tests fpr "messages" package
Make sure transport is closed even in case an exception is thrown

* Add tests for "platforms" package

* Add tests for "requests" package

* Moved "token" package to "core" package (from "shared")

* Add tests for "token" package

* Fix issue in RoleEnum.safeValueOf() throwing Exception

* Fix potential issue in UserEndpoint.getUserInfo() when auth is null

* Added tests for "user" package

* Migrate package for "token" in FE

* Publish test report in CI

* Fix workflow permissions

* Remove test because of timing issue in CI

* Replaced "unmatched paths" with "ignored paths" (#791)

* Use new "AutoComplete" component (#793)

* Use ArrayInputAutocomplete in EditGameMetadataModal

* Add test for getEnumPropertyValues

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
Simon
2025-11-17 08:45:39 +01:00
committed by GitHub
parent dd3b18e5e3
commit 717a423449
357 changed files with 39213 additions and 7918 deletions
+39
View File
@@ -0,0 +1,39 @@
# Exclude VCS and IDE files
.git
.gitignore
.idea/
*.iml
# Gradle caches
.gradle/
**/.gradle/
# Node modules and app build cache
app/node_modules/
app/.pnpm-store/
app/.npm/
app/.yarn/
app/.vite/
app/dist/
# General build outputs (keep only the jars we actually need)
**/build/
!app/build/
!app/build/libs/
!app/build/libs/app.jar
# Only keep plugin jars in build/libs
plugins/**
!plugins/*/build/
!plugins/*/build/libs/
!plugins/*/build/libs/*.jar
# Large local/runtime data not needed in image context
data/
db/
logs/
plugindata/
# Docker intermediate artifacts
**/.DS_Store
+9 -14
View File
@@ -1,18 +1,12 @@
name: 'Docker Build and Push' name: 'Docker Build and Push'
description: 'Builds and pushes Docker images to Docker Hub and GHCR with flexible tagging.' description: 'Builds and pushes Docker images to GHCR with flexible tagging.'
runs: runs:
using: 'composite' using: 'composite'
steps: steps:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub - name: Log in to GHCR
uses: docker/login-action@v3
with:
username: ${{ inputs.dockerhub_username }}
password: ${{ inputs.dockerhub_token }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
@@ -21,6 +15,7 @@ runs:
- name: Prepare Ubuntu tags - name: Prepare Ubuntu tags
id: ubuntu_tags id: ubuntu_tags
if: ${{ inputs.variant != 'alpine' }}
shell: bash shell: bash
run: | run: |
TAGS="${{ inputs.tags }}" TAGS="${{ inputs.tags }}"
@@ -28,6 +23,7 @@ runs:
echo "ubuntu_tags=$UBUNTU_TAGS" >> $GITHUB_OUTPUT echo "ubuntu_tags=$UBUNTU_TAGS" >> $GITHUB_OUTPUT
- name: Build and push Docker image (Alpine) - name: Build and push Docker image (Alpine)
if: ${{ inputs.variant != 'ubuntu' }}
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: ${{ inputs.context }} context: ${{ inputs.context }}
@@ -39,6 +35,7 @@ runs:
cache-to: type=gha cache-to: type=gha
- name: Build and push Docker image (Ubuntu) - name: Build and push Docker image (Ubuntu)
if: ${{ inputs.variant != 'alpine' }}
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: ${{ inputs.context }} context: ${{ inputs.context }}
@@ -50,12 +47,6 @@ runs:
cache-to: type=gha cache-to: type=gha
inputs: inputs:
dockerhub_username:
required: true
description: 'Docker Hub username'
dockerhub_token:
required: true
description: 'Docker Hub token'
ghcr_username: ghcr_username:
required: true required: true
description: 'GHCR username' description: 'GHCR username'
@@ -74,3 +65,7 @@ inputs:
tags: tags:
required: true required: true
description: 'Comma-separated list of image tags' description: 'Comma-separated list of image tags'
variant:
required: true
default: 'both'
description: 'Image variant to build: alpine, ubuntu, or both'
@@ -1,4 +1,4 @@
name: Delete Docker Tag on Merge name: Delete Image Tags on Merge
on: on:
pull_request: pull_request:
@@ -9,10 +9,11 @@ jobs:
delete-docker-tag: delete-docker-tag:
if: startsWith(github.event.pull_request.head.ref, 'fix/') || startsWith(github.event.pull_request.head.ref, 'release/') if: startsWith(github.event.pull_request.head.ref, 'fix/') || startsWith(github.event.pull_request.head.ref, 'release/')
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Cleanup Image Tags from GHCR
permissions: permissions:
packages: write packages: write
steps: steps:
- name: Extract merged branch name and tag - name: Extract tag from branch name
id: extract_branch id: extract_branch
run: | run: |
BRANCH="${{ github.event.pull_request.head.ref }}" BRANCH="${{ github.event.pull_request.head.ref }}"
@@ -26,50 +27,8 @@ jobs:
echo "tag=$TAG" >> $GITHUB_OUTPUT echo "tag=$TAG" >> $GITHUB_OUTPUT
shell: bash shell: bash
- name: Delete image tag from Docker Hub - name: Delete tags
if: steps.extract_branch.outputs.tag != '' if: steps.extract_branch.outputs.tag != ''
env: uses: dataaxiom/ghcr-cleanup-action@v1
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} with:
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} tags: ${{ steps.extract_branch.outputs.tag }},${{ steps.extract_branch.outputs.tag }}-ubuntu
TAG: ${{ steps.extract_branch.outputs.tag }}
run: |
echo "Deleting Docker tag from Docker Hub: $TAG"
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE -u "$DOCKERHUB_USERNAME:$DOCKERHUB_TOKEN" \
"https://hub.docker.com/v2/repositories/grimsi/gameyfin/tags/$TAG/")
if [ "$RESPONSE" != "204" ]; then
echo "Failed to delete Docker Hub tag: $TAG (HTTP $RESPONSE)" >&2
exit 1
fi
shell: bash
- name: Delete image tag from GHCR
if: steps.extract_branch.outputs.tag != ''
env:
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.extract_branch.outputs.tag }}
REPO: gameyfin/gameyfin
OWNER: ${{ github.repository_owner }}
run: |
echo "Deleting Docker tag from GHCR: $TAG"
# Get the package ID
PACKAGE_ID=$(curl -s -H "Authorization: Bearer $GHCR_TOKEN" \
"https://api.github.com/users/$OWNER/packages/container/$REPO" | jq -r '.id')
if [ "$PACKAGE_ID" = "null" ] || [ -z "$PACKAGE_ID" ]; then
echo "Failed to get GHCR package ID for $REPO" >&2
exit 1
fi
# Get the version ID for the tag
VERSION_ID=$(curl -s -H "Authorization: Bearer $GHCR_TOKEN" \
"https://api.github.com/users/$OWNER/packages/container/$REPO/versions" | jq -r ".[] | select(.metadata.container.tags[]? == \"$TAG\") | .id")
if [ -z "$VERSION_ID" ]; then
echo "Failed to find GHCR version for tag: $TAG" >&2
exit 1
fi
# Delete the version
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE -H "Authorization: Bearer $GHCR_TOKEN" \
"https://api.github.com/users/$OWNER/packages/container/$REPO/versions/$VERSION_ID")
if [ "$RESPONSE" != "204" ]; then
echo "Failed to delete GHCR tag: $TAG (HTTP $RESPONSE)" >&2
exit 1
fi
shell: bash
-44
View File
@@ -1,44 +0,0 @@
name: Build and Push Docker Image
on:
push:
branches:
- main
workflow_dispatch:
inputs:
image_tag:
description: 'Docker image tag'
required: false
default: 'develop'
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '21'
- name: Run production build
env:
GAMEYFIN_KEYSTORE_PASSWORD: ${{ secrets.GAMEYFIN_KEYSTORE_PASSWORD }}
run: ./gradlew clean build -Pvaadin.productionMode=true
- name: Build and push Docker image
uses: ./.github/actions/docker-build-push
with:
dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }}
ghcr_username: ${{ github.actor }}
ghcr_token: ${{ secrets.GITHUB_TOKEN }}
context: .
dockerfile: docker/Dockerfile
platforms: linux/arm64/v8,linux/amd64
tags: grimsi/gameyfin:${{ inputs.image_tag || 'develop' }},ghcr.io/gameyfin/gameyfin:${{ inputs.image_tag || 'develop' }}
+39 -5
View File
@@ -6,10 +6,12 @@ on:
- 'fix/*' - 'fix/*'
jobs: jobs:
build-and-push: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write
packages: write packages: write
checks: write
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v5
@@ -25,6 +27,39 @@ jobs:
GAMEYFIN_KEYSTORE_PASSWORD: ${{ secrets.GAMEYFIN_KEYSTORE_PASSWORD }} GAMEYFIN_KEYSTORE_PASSWORD: ${{ secrets.GAMEYFIN_KEYSTORE_PASSWORD }}
run: ./gradlew clean build -Pvaadin.productionMode=true run: ./gradlew clean build -Pvaadin.productionMode=true
- name: Publish Test Report
uses: mikepenz/action-junit-report@v6
if: success() || failure() # always run even if the previous step fails
with:
report_paths: '**/build/test-results/test/TEST-*.xml'
- name: Upload build outputs
uses: actions/upload-artifact@v4
with:
name: build-outputs
path: |
app/build/libs/**
plugins/**/build/libs/**/*.jar
docker:
needs: build
runs-on: ubuntu-latest
permissions:
packages: write
strategy:
fail-fast: false
matrix:
variant: [ alpine, ubuntu ]
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Download build outputs
uses: actions/download-artifact@v5
with:
name: build-outputs
path: .
- name: Extract tag from branch name - name: Extract tag from branch name
id: extract_tag id: extract_tag
run: | run: |
@@ -32,14 +67,13 @@ jobs:
TAG="${BRANCH_NAME#fix/}" TAG="${BRANCH_NAME#fix/}"
echo "tag=$TAG" >> $GITHUB_OUTPUT echo "tag=$TAG" >> $GITHUB_OUTPUT
- name: Build and push Docker image - name: Build and push Docker image (${{ matrix.variant }})
uses: ./.github/actions/docker-build-push uses: ./.github/actions/docker-build-push
with: with:
dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }}
ghcr_username: ${{ github.actor }} ghcr_username: ${{ github.actor }}
ghcr_token: ${{ secrets.GITHUB_TOKEN }} ghcr_token: ${{ secrets.GITHUB_TOKEN }}
context: . context: .
dockerfile: docker/Dockerfile dockerfile: docker/Dockerfile
platforms: linux/arm64/v8,linux/amd64 platforms: linux/arm64/v8,linux/amd64
tags: grimsi/gameyfin:${{ steps.extract_tag.outputs.tag }},ghcr.io/gameyfin/gameyfin:${{ steps.extract_tag.outputs.tag }} tags: ghcr.io/gameyfin/gameyfin:${{ steps.extract_tag.outputs.tag }}
variant: ${{ matrix.variant }}
+70 -11
View File
@@ -6,13 +6,19 @@ on:
- 'release/*' - 'release/*'
jobs: jobs:
build-and-push: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write
packages: write packages: write
checks: write
outputs:
version: ${{ steps.extract_version.outputs.version }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v5 uses: actions/setup-java@v5
@@ -20,26 +26,79 @@ jobs:
distribution: 'temurin' distribution: 'temurin'
java-version: '21' java-version: '21'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
- name: Extract version from branch name
id: extract_version
run: |
BRANCH_NAME="${GITHUB_REF#refs/heads/}"
VERSION="${BRANCH_NAME#release/}-preview"
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Update version in build.gradle.kts
run: |
sed -i "s/^version = .*/version = \"${{ steps.extract_version.outputs.version }}\"/" build.gradle.kts
- name: Update version in app/package.json
run: |
jq ".version = \"${{ steps.extract_version.outputs.version }}\"" app/package.json > app/package.json.tmp && mv app/package.json.tmp app/package.json
- name: Commit version bump (only if changes)
uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: 'chore: bump version to v${{ steps.extract_version.outputs.version }}'
file_pattern: |
build.gradle.kts
app/package.json
- name: Run production build - name: Run production build
env: env:
GAMEYFIN_KEYSTORE_PASSWORD: ${{ secrets.GAMEYFIN_KEYSTORE_PASSWORD }} GAMEYFIN_KEYSTORE_PASSWORD: ${{ secrets.GAMEYFIN_KEYSTORE_PASSWORD }}
run: ./gradlew clean build -Pvaadin.productionMode=true run: ./gradlew clean build -Pvaadin.productionMode=true
- name: Extract tag from branch name - name: Publish Test Report
id: extract_tag uses: mikepenz/action-junit-report@v6
run: | if: success() || failure() # always run even if the previous step fails
BRANCH_NAME="${GITHUB_REF#refs/heads/}" with:
TAG="${BRANCH_NAME#release/}-preview" report_paths: '**/build/test-results/test/TEST-*.xml'
echo "tag=$TAG" >> $GITHUB_OUTPUT
- name: Build and push Docker image - name: Upload build outputs
uses: actions/upload-artifact@v4
with:
name: build-outputs
path: |
app/build/libs/**
plugins/**/build/libs/**/*.jar
docker:
needs: build
runs-on: ubuntu-latest
permissions:
packages: write
strategy:
fail-fast: false
matrix:
variant: [ alpine, ubuntu ]
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Download build outputs
uses: actions/download-artifact@v5
with:
name: build-outputs
path: .
- name: Build and push Docker image (${{ matrix.variant }})
uses: ./.github/actions/docker-build-push uses: ./.github/actions/docker-build-push
with: with:
dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }}
ghcr_username: ${{ github.actor }} ghcr_username: ${{ github.actor }}
ghcr_token: ${{ secrets.GITHUB_TOKEN }} ghcr_token: ${{ secrets.GITHUB_TOKEN }}
context: . context: .
dockerfile: docker/Dockerfile dockerfile: docker/Dockerfile
platforms: linux/arm64/v8,linux/amd64 platforms: linux/arm64/v8,linux/amd64
tags: grimsi/gameyfin:${{ steps.extract_tag.outputs.tag }},ghcr.io/gameyfin/gameyfin:${{ steps.extract_tag.outputs.tag }} tags: ghcr.io/gameyfin/gameyfin:${{ needs.build.outputs.version }}
variant: ${{ matrix.variant }}
@@ -0,0 +1,35 @@
name: GHCR Image Registry Maintenance
on:
workflow_dispatch:
inputs:
older_than:
description: 'Only remove images older than (e.g. "1 year", leave empty to remove all untagged images)'
required: false
dry_run:
description: 'Dry run?'
required: true
default: true
type: boolean
validate:
description: 'Validate all multi-architecture images in the registry after cleanup?'
required: true
default: false
type: boolean
jobs:
delete-untagged-images:
runs-on: ubuntu-latest
name: Delete Untagged Images from GHCR
permissions:
packages: write
steps:
- name: Delete untagged, ghost, and orphaned images
uses: dataaxiom/ghcr-cleanup-action@v1
with:
older-than: ${{ github.event.inputs.older_than }}
dry-run: ${{ github.event.inputs.dry_run }}
validate: ${{ github.event.inputs.validate }}
delete-untagged: true
delete-ghost-images: true
delete-orphaned-images: true
+52 -13
View File
@@ -50,18 +50,20 @@ jobs:
jq ".version = \"$RELEASE_VERSION\"" app/package.json > app/package.json.tmp && mv app/package.json.tmp app/package.json jq ".version = \"$RELEASE_VERSION\"" app/package.json > app/package.json.tmp && mv app/package.json.tmp app/package.json
- name: Upload modified files - name: Upload modified files
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
with: with:
name: modified-files name: modified-files
path: | path: |
build.gradle.kts build.gradle.kts
app/package.json app/package.json
docker: build:
needs: setup needs: setup
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write
packages: write packages: write
checks: write
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v5
@@ -69,7 +71,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Download modified files - name: Download modified files
uses: actions/download-artifact@v5 uses: actions/download-artifact@v6
with: with:
name: modified-files name: modified-files
@@ -86,33 +88,70 @@ jobs:
GAMEYFIN_KEYSTORE_PASSWORD: ${{ secrets.GAMEYFIN_KEYSTORE_PASSWORD }} GAMEYFIN_KEYSTORE_PASSWORD: ${{ secrets.GAMEYFIN_KEYSTORE_PASSWORD }}
run: ./gradlew clean build -Pvaadin.productionMode=true run: ./gradlew clean build -Pvaadin.productionMode=true
- name: Publish Test Report
uses: mikepenz/action-junit-report@v6
if: success() || failure() # always run even if the previous step fails
with:
report_paths: '**/build/test-results/test/TEST-*.xml'
- name: Upload build outputs
uses: actions/upload-artifact@v4
with:
name: build-outputs
path: |
app/build/libs/**
plugins/**/build/libs/**/*.jar
docker:
needs: [ setup, build ]
runs-on: ubuntu-latest
permissions:
packages: write
strategy:
fail-fast: false
matrix:
variant: [ alpine, ubuntu ]
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Download modified files
uses: actions/download-artifact@v5
with:
name: modified-files
- name: Download build outputs
uses: actions/download-artifact@v5
with:
name: build-outputs
path: .
- name: Generate container image tags - name: Generate container image tags
id: docker_tags id: docker_tags
run: | run: |
VERSION="${{ needs.setup.outputs.release_version }}" VERSION='${{ needs.setup.outputs.release_version }}'
DOCKERHUB_TAGS="grimsi/gameyfin:$VERSION"
GHCR_TAGS="ghcr.io/gameyfin/gameyfin:$VERSION" GHCR_TAGS="ghcr.io/gameyfin/gameyfin:$VERSION"
if [[ "$VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then if [[ "$VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
MAJOR=${BASH_REMATCH[1]} MAJOR=${BASH_REMATCH[1]}
MINOR=${BASH_REMATCH[2]} MINOR=${BASH_REMATCH[2]}
PATCH=${BASH_REMATCH[3]} PATCH=${BASH_REMATCH[3]}
DOCKERHUB_TAGS="grimsi/gameyfin:latest,grimsi/gameyfin:develop,grimsi/gameyfin:$VERSION,grimsi/gameyfin:$MAJOR.$MINOR,grimsi/gameyfin:$MAJOR"
GHCR_TAGS="ghcr.io/gameyfin/gameyfin:latest,ghcr.io/gameyfin/gameyfin:develop,ghcr.io/gameyfin/gameyfin:$VERSION,ghcr.io/gameyfin/gameyfin:$MAJOR.$MINOR,ghcr.io/gameyfin/gameyfin:$MAJOR" GHCR_TAGS="ghcr.io/gameyfin/gameyfin:latest,ghcr.io/gameyfin/gameyfin:develop,ghcr.io/gameyfin/gameyfin:$VERSION,ghcr.io/gameyfin/gameyfin:$MAJOR.$MINOR,ghcr.io/gameyfin/gameyfin:$MAJOR"
fi fi
TAGS="$DOCKERHUB_TAGS,$GHCR_TAGS" TAGS="$GHCR_TAGS"
echo "tags=$TAGS" >> $GITHUB_OUTPUT echo "tags=$TAGS" >> $GITHUB_OUTPUT
- name: Build and push Docker image - name: Build and push Docker image (${{ matrix.variant }})
uses: ./.github/actions/docker-build-push uses: ./.github/actions/docker-build-push
with: with:
dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }}
ghcr_username: ${{ github.actor }} ghcr_username: ${{ github.actor }}
ghcr_token: ${{ secrets.GITHUB_TOKEN }} ghcr_token: ${{ secrets.GITHUB_TOKEN }}
context: . context: .
dockerfile: docker/Dockerfile dockerfile: docker/Dockerfile
platforms: linux/arm64/v8,linux/amd64 platforms: linux/arm64/v8,linux/amd64
tags: ${{ steps.docker_tags.outputs.tags }} tags: ${{ steps.docker_tags.outputs.tags }}
variant: ${{ matrix.variant }}
plugin_api: plugin_api:
needs: setup needs: setup
@@ -124,7 +163,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Download modified files - name: Download modified files
uses: actions/download-artifact@v5 uses: actions/download-artifact@v6
with: with:
name: modified-files name: modified-files
@@ -155,13 +194,13 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Download modified files - name: Download modified files
uses: actions/download-artifact@v5 uses: actions/download-artifact@v6
with: with:
name: modified-files name: modified-files
- name: Commit version bump - name: Commit version bump
if: ${{ github.event.inputs.update_version }} if: ${{ github.event.inputs.update_version }}
uses: stefanzweifel/git-auto-commit-action@v6 uses: stefanzweifel/git-auto-commit-action@v7
with: with:
commit_message: 'chore: release v${{ github.event.inputs.version }}' commit_message: 'chore: release v${{ github.event.inputs.version }}'
tagging_message: v${{ github.event.inputs.version }} tagging_message: v${{ github.event.inputs.version }}
+6 -1
View File
@@ -48,9 +48,14 @@ out/
/packaged_plugins /packaged_plugins
/logs /logs
/templates /templates
/docker/docker-compose.yml
/app/src/main/bundles/ /app/src/main/bundles/
/app/src/main/frontend/**/*.js /app/src/main/frontend/**/*.js
/app/src/main/frontend/**/*.js.map /app/src/main/frontend/**/*.js.map
/app/src/main/frontend/generated/ /app/src/main/frontend/generated/
/torrent_dotfiles/ **/torrent_dotfiles/
*.state.json *.state.json
/plugins/data/
/plugins/state/
/plugindata/
/docker-debug/
+1 -1
View File
@@ -9,7 +9,7 @@
<module name="Gameyfin.app.main" /> <module name="Gameyfin.app.main" />
<option name="SHORTEN_COMMAND_LINE" value="ARGS_FILE" /> <option name="SHORTEN_COMMAND_LINE" value="ARGS_FILE" />
<option name="SPRING_BOOT_MAIN_CLASS" value="org.gameyfin.app.GameyfinApplication" /> <option name="SPRING_BOOT_MAIN_CLASS" value="org.gameyfin.app.GameyfinApplication" />
<option name="VM_PARAMETERS" value="-Dpf4j.mode=development" /> <option name="VM_PARAMETERS" value="-Dpf4j.mode=development -Djava.net.preferIPv4Stack=true" />
<extension name="coverage"> <extension name="coverage">
<pattern> <pattern>
<option name="PATTERN" value="org.gameyfin.app.*" /> <option name="PATTERN" value="org.gameyfin.app.*" />
+1 -1
View File
@@ -4,7 +4,7 @@
<option name="executionName" /> <option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" /> <option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" /> <option name="scriptParameters" value="-x test" />
<option name="taskDescriptions"> <option name="taskDescriptions">
<list /> <list />
</option> </option>
+1 -1
View File
@@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="UI debug" type="JavascriptDebugType" uri="http://localhost:8080"> <configuration default="false" name="UI debug" type="JavascriptDebugType" engineId="37cae5b9-e8b2-4949-9172-aafa37fbc09c" uri="http://localhost:8080">
<method v="2" /> <method v="2" />
</configuration> </configuration>
</component> </component>
+9 -2
View File
@@ -1,4 +1,4 @@
group = "de.grimsi" group = "org.gameyfin"
val appMainClass = "org.gameyfin.app.GameyfinApplicationKt" val appMainClass = "org.gameyfin.app.GameyfinApplicationKt"
plugins { plugins {
@@ -78,8 +78,15 @@ dependencies {
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
runtimeOnly("com.h2database:h2") runtimeOnly("com.h2database:h2")
runtimeOnly("io.micrometer:micrometer-registry-prometheus") runtimeOnly("io.micrometer:micrometer-registry-prometheus")
testImplementation("org.springframework.boot:spring-boot-starter-test")
// Testing
testImplementation(kotlin("test"))
testImplementation("org.springframework.boot:spring-boot-starter-test") {
exclude(group = "org.mockito", module = "mockito-core")
}
testImplementation("io.mockk:mockk:${rootProject.extra["mockkVersion"]}")
testImplementation("org.springframework.security:spring-security-test") testImplementation("org.springframework.security:spring-security-test")
testImplementation("io.projectreactor:reactor-test")
} }
dependencyManagement { dependencyManagement {
-7
View File
@@ -1,7 +0,0 @@
import {HeroUIPluginConfig} from "@heroui/react";
import {compileThemes, themes} from "./src/main/frontend/theming/themes"
export const HeroUIConfig: HeroUIPluginConfig = {
prefix: "gf",
themes: compileThemes(themes)
};
+5457 -5979
View File
File diff suppressed because it is too large Load Diff
+149 -148
View File
@@ -1,51 +1,54 @@
{ {
"name": "gameyfin", "name": "gameyfin",
"version": "2.1.2", "version": "2.2.0-preview",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@heroui/react": "2.7.9", "@heroui/react": "^2.8.5",
"@material-tailwind/react": "^2.1.10",
"@phosphor-icons/react": "^2.1.7", "@phosphor-icons/react": "^2.1.7",
"@polymer/polymer": "3.5.2", "@polymer/polymer": "3.5.2",
"@react-stately/data": "^3.12.2", "@react-stately/data": "^3.12.2",
"@react-types/shared": "^3.28.0", "@react-types/shared": "^3.28.0",
"@vaadin/bundles": "24.9.0", "@tailwindcss/vite": "4.1.13",
"@vaadin/bundles": "24.9.4",
"@vaadin/common-frontend": "0.0.19", "@vaadin/common-frontend": "0.0.19",
"@vaadin/hilla-file-router": "24.9.0", "@vaadin/hilla-file-router": "24.9.4",
"@vaadin/hilla-frontend": "24.9.0", "@vaadin/hilla-frontend": "24.9.4",
"@vaadin/hilla-lit-form": "24.9.0", "@vaadin/hilla-lit-form": "24.9.4",
"@vaadin/hilla-react-auth": "24.9.0", "@vaadin/hilla-react-auth": "24.9.4",
"@vaadin/hilla-react-crud": "24.9.0", "@vaadin/hilla-react-crud": "24.9.4",
"@vaadin/hilla-react-form": "24.9.0", "@vaadin/hilla-react-form": "24.9.4",
"@vaadin/hilla-react-i18n": "24.9.0", "@vaadin/hilla-react-i18n": "24.9.4",
"@vaadin/hilla-react-signals": "24.9.0", "@vaadin/hilla-react-signals": "24.9.4",
"@vaadin/polymer-legacy-adapter": "24.9.0", "@vaadin/polymer-legacy-adapter": "24.9.4",
"@vaadin/react-components": "24.9.0", "@vaadin/react-components": "24.9.4",
"@vaadin/vaadin-development-mode-detector": "2.0.7", "@vaadin/vaadin-development-mode-detector": "2.0.7",
"@vaadin/vaadin-lumo-styles": "24.9.0", "@vaadin/vaadin-lumo-styles": "24.9.4",
"@vaadin/vaadin-material-styles": "24.9.0", "@vaadin/vaadin-material-styles": "24.9.4",
"@vaadin/vaadin-themable-mixin": "24.9.0", "@vaadin/vaadin-themable-mixin": "24.9.4",
"@vaadin/vaadin-usage-statistics": "2.1.3", "@vaadin/vaadin-usage-statistics": "2.1.3",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"construct-style-sheets-polyfill": "3.1.0", "construct-style-sheets-polyfill": "3.1.0",
"date-fns": "2.29.3", "date-fns": "2.29.3",
"formik": "^2.4.6", "formik": "^2.4.6",
"framer-motion": "^12.5.0", "framer-motion": "^12.23.22",
"fzf": "^0.5.2", "fzf": "^0.5.2",
"http-status-codes": "^2.3.0", "http-status-codes": "^2.3.0",
"lit": "3.3.0", "lit": "3.3.1",
"moment": "^2.30.1", "moment": "^2.30.1",
"moment-timezone": "^0.5.47", "moment-timezone": "^0.5.47",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"postcss": "^8.5.6",
"postcss-import": "^16.1.1",
"rand-seed": "^2.1.7", "rand-seed": "^2.1.7",
"react": "18.3.1", "react": "19.1.1",
"react-accessible-treeview": "^2.11.1", "react-accessible-treeview": "^2.11.1",
"react-aria-components": "^1.7.1", "react-aria-components": "^1.7.1",
"react-confetti-boom": "^1.0.0", "react-confetti-boom": "^1.0.0",
"react-dom": "18.3.1", "react-dom": "19.1.1",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-player": "^2.16.0", "react-player": "^2.16.0",
"react-router": "7.6.1", "react-realtime-chart": "^0.8.1",
"react-router": "7.6.3",
"remark-breaks": "^4.0.0", "remark-breaks": "^4.0.0",
"swiper": "^11.2.6", "swiper": "^11.2.6",
"valtio": "^2.1.5", "valtio": "^2.1.5",
@@ -55,39 +58,35 @@
"devDependencies": { "devDependencies": {
"@babel/preset-react": "7.27.1", "@babel/preset-react": "7.27.1",
"@lit-labs/react": "^2.1.3", "@lit-labs/react": "^2.1.3",
"@preact/signals-react-transform": "0.5.1", "@preact/signals-react-transform": "0.6.0",
"@rollup/plugin-replace": "6.0.2", "@rollup/plugin-replace": "6.0.2",
"@rollup/pluginutils": "5.1.4", "@rollup/pluginutils": "5.3.0",
"@types/node": "^22.4.0", "@types/node": "^22.4.0",
"@types/react": "18.3.23", "@types/react": "19.1.17",
"@types/react-dom": "18.3.7", "@types/react-dom": "19.1.11",
"@vaadin/hilla-generator-cli": "24.9.0", "@vaadin/hilla-generator-cli": "24.9.4",
"@vaadin/hilla-generator-core": "24.9.0", "@vaadin/hilla-generator-core": "24.9.4",
"@vaadin/hilla-generator-plugin-backbone": "24.9.0", "@vaadin/hilla-generator-plugin-backbone": "24.9.4",
"@vaadin/hilla-generator-plugin-barrel": "24.9.0", "@vaadin/hilla-generator-plugin-barrel": "24.9.4",
"@vaadin/hilla-generator-plugin-client": "24.9.0", "@vaadin/hilla-generator-plugin-client": "24.9.4",
"@vaadin/hilla-generator-plugin-model": "24.9.0", "@vaadin/hilla-generator-plugin-model": "24.9.4",
"@vaadin/hilla-generator-plugin-push": "24.9.0", "@vaadin/hilla-generator-plugin-push": "24.9.4",
"@vaadin/hilla-generator-plugin-signals": "24.9.0", "@vaadin/hilla-generator-plugin-signals": "24.9.4",
"@vaadin/hilla-generator-plugin-subtypes": "24.9.0", "@vaadin/hilla-generator-plugin-subtypes": "24.9.4",
"@vaadin/hilla-generator-plugin-transfertypes": "24.9.0", "@vaadin/hilla-generator-plugin-transfertypes": "24.9.4",
"@vaadin/hilla-generator-utils": "24.9.0", "@vaadin/hilla-generator-utils": "24.9.4",
"@vitejs/plugin-react": "4.5.0", "@vitejs/plugin-react": "4.7.0",
"@vitejs/plugin-react-swc": "^3.7.0", "@vitejs/plugin-react-swc": "^3.7.0",
"async": "3.2.6", "glob": "11.0.3",
"autoprefixer": "^10.4.20", "magic-string": "0.30.19",
"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-brotli": "3.1.0",
"rollup-plugin-visualizer": "5.14.0", "rollup-plugin-visualizer": "5.14.0",
"strip-css-comments": "5.0.0", "strip-css-comments": "5.0.0",
"tailwindcss": "^3.4.13", "tailwindcss": "4.1.13",
"transform-ast": "2.4.4", "transform-ast": "2.4.4",
"typescript": "5.8.3", "typescript": "5.8.3",
"vite": "6.3.6", "vite": "6.4.1",
"vite-plugin-checker": "0.9.3", "vite-plugin-checker": "0.10.3",
"workbox-build": "7.3.0", "workbox-build": "7.3.0",
"workbox-core": "7.3.0", "workbox-core": "7.3.0",
"workbox-precaching": "7.3.0" "workbox-precaching": "7.3.0"
@@ -105,10 +104,8 @@
"@phosphor-icons/react": "$@phosphor-icons/react", "@phosphor-icons/react": "$@phosphor-icons/react",
"formik": "$formik", "formik": "$formik",
"yup": "$yup", "yup": "$yup",
"next-themes": "$next-themes",
"@heroui/react": "$@heroui/react", "@heroui/react": "$@heroui/react",
"framer-motion": "$framer-motion", "framer-motion": "$framer-motion",
"@material-tailwind/react": "$@material-tailwind/react",
"http-status-codes": "$http-status-codes", "http-status-codes": "$http-status-codes",
"@vaadin/polymer-legacy-adapter": "$@vaadin/polymer-legacy-adapter", "@vaadin/polymer-legacy-adapter": "$@vaadin/polymer-legacy-adapter",
"@vaadin/vaadin-development-mode-detector": "$@vaadin/vaadin-development-mode-detector", "@vaadin/vaadin-development-mode-detector": "$@vaadin/vaadin-development-mode-detector",
@@ -142,127 +139,131 @@
"valtio": "$valtio", "valtio": "$valtio",
"valtio-reactive": "$valtio-reactive", "valtio-reactive": "$valtio-reactive",
"fzf": "$fzf", "fzf": "$fzf",
"@vaadin/a11y-base": "24.9.0",
"@vaadin/accordion": "24.9.0",
"@vaadin/app-layout": "24.9.0",
"@vaadin/avatar": "24.9.0",
"@vaadin/avatar-group": "24.9.0",
"@vaadin/button": "24.9.0",
"@vaadin/card": "24.9.0",
"@vaadin/checkbox": "24.9.0",
"@vaadin/checkbox-group": "24.9.0",
"@vaadin/combo-box": "24.9.0",
"@vaadin/component-base": "24.9.0",
"@vaadin/confirm-dialog": "24.9.0",
"@vaadin/context-menu": "24.9.0",
"@vaadin/custom-field": "24.9.0",
"@vaadin/date-picker": "24.9.0",
"@vaadin/date-time-picker": "24.9.0",
"@vaadin/details": "24.9.0",
"@vaadin/dialog": "24.9.0",
"@vaadin/email-field": "24.9.0",
"@vaadin/field-base": "24.9.0",
"@vaadin/field-highlighter": "24.9.0",
"@vaadin/form-layout": "24.9.0",
"@vaadin/grid": "24.9.0",
"@vaadin/horizontal-layout": "24.9.0",
"@vaadin/icon": "24.9.0",
"@vaadin/icons": "24.9.0",
"@vaadin/input-container": "24.9.0",
"@vaadin/integer-field": "24.9.0",
"@vaadin/item": "24.9.0",
"@vaadin/list-box": "24.9.0",
"@vaadin/lit-renderer": "24.9.0",
"@vaadin/login": "24.9.0",
"@vaadin/markdown": "24.9.0",
"@vaadin/master-detail-layout": "24.9.0",
"@vaadin/menu-bar": "24.9.0",
"@vaadin/message-input": "24.9.0",
"@vaadin/message-list": "24.9.0",
"@vaadin/multi-select-combo-box": "24.9.0",
"@vaadin/notification": "24.9.0",
"@vaadin/number-field": "24.9.0",
"@vaadin/overlay": "24.9.0",
"@vaadin/password-field": "24.9.0",
"@vaadin/popover": "24.9.0",
"@vaadin/progress-bar": "24.9.0",
"@vaadin/radio-group": "24.9.0",
"@vaadin/scroller": "24.9.0",
"@vaadin/select": "24.9.0",
"@vaadin/side-nav": "24.9.0",
"@vaadin/split-layout": "24.9.0",
"@vaadin/tabs": "24.9.0",
"@vaadin/tabsheet": "24.9.0",
"@vaadin/text-area": "24.9.0",
"@vaadin/text-field": "24.9.0",
"@vaadin/time-picker": "24.9.0",
"@vaadin/tooltip": "24.9.0",
"@vaadin/upload": "24.9.0",
"@vaadin/router": "2.0.0", "@vaadin/router": "2.0.0",
"@vaadin/vertical-layout": "24.9.0", "@tailwindcss/vite": "$@tailwindcss/vite",
"@vaadin/virtual-list": "24.9.0" "postcss": "$postcss",
"postcss-import": "$postcss-import",
"next-themes": "$next-themes",
"@vaadin/a11y-base": "24.9.4",
"@vaadin/accordion": "24.9.4",
"@vaadin/app-layout": "24.9.4",
"@vaadin/avatar": "24.9.4",
"@vaadin/avatar-group": "24.9.4",
"@vaadin/button": "24.9.4",
"@vaadin/card": "24.9.4",
"@vaadin/checkbox": "24.9.4",
"@vaadin/checkbox-group": "24.9.4",
"@vaadin/combo-box": "24.9.4",
"@vaadin/component-base": "24.9.4",
"@vaadin/confirm-dialog": "24.9.4",
"@vaadin/context-menu": "24.9.4",
"@vaadin/custom-field": "24.9.4",
"@vaadin/date-picker": "24.9.4",
"@vaadin/date-time-picker": "24.9.4",
"@vaadin/details": "24.9.4",
"@vaadin/dialog": "24.9.4",
"@vaadin/email-field": "24.9.4",
"@vaadin/field-base": "24.9.4",
"@vaadin/field-highlighter": "24.9.4",
"@vaadin/form-layout": "24.9.4",
"@vaadin/grid": "24.9.4",
"@vaadin/horizontal-layout": "24.9.4",
"@vaadin/icon": "24.9.4",
"@vaadin/icons": "24.9.4",
"@vaadin/input-container": "24.9.4",
"@vaadin/integer-field": "24.9.4",
"@vaadin/item": "24.9.4",
"@vaadin/list-box": "24.9.4",
"@vaadin/lit-renderer": "24.9.4",
"@vaadin/login": "24.9.4",
"@vaadin/markdown": "24.9.4",
"@vaadin/master-detail-layout": "24.9.4",
"@vaadin/menu-bar": "24.9.4",
"@vaadin/message-input": "24.9.4",
"@vaadin/message-list": "24.9.4",
"@vaadin/multi-select-combo-box": "24.9.4",
"@vaadin/notification": "24.9.4",
"@vaadin/number-field": "24.9.4",
"@vaadin/overlay": "24.9.4",
"@vaadin/password-field": "24.9.4",
"@vaadin/popover": "24.9.4",
"@vaadin/progress-bar": "24.9.4",
"@vaadin/radio-group": "24.9.4",
"@vaadin/scroller": "24.9.4",
"@vaadin/select": "24.9.4",
"@vaadin/side-nav": "24.9.4",
"@vaadin/split-layout": "24.9.4",
"@vaadin/tabs": "24.9.4",
"@vaadin/tabsheet": "24.9.4",
"@vaadin/text-area": "24.9.4",
"@vaadin/text-field": "24.9.4",
"@vaadin/time-picker": "24.9.4",
"@vaadin/tooltip": "24.9.4",
"@vaadin/upload": "24.9.4",
"@vaadin/vertical-layout": "24.9.4",
"@vaadin/virtual-list": "24.9.4",
"react-realtime-chart": "$react-realtime-chart"
}, },
"vaadin": { "vaadin": {
"dependencies": { "dependencies": {
"@polymer/polymer": "3.5.2", "@polymer/polymer": "3.5.2",
"@vaadin/bundles": "24.9.0", "@vaadin/bundles": "24.9.4",
"@vaadin/common-frontend": "0.0.19", "@vaadin/common-frontend": "0.0.19",
"@vaadin/hilla-file-router": "24.9.0", "@vaadin/hilla-file-router": "24.9.4",
"@vaadin/hilla-frontend": "24.9.0", "@vaadin/hilla-frontend": "24.9.4",
"@vaadin/hilla-lit-form": "24.9.0", "@vaadin/hilla-lit-form": "24.9.4",
"@vaadin/hilla-react-auth": "24.9.0", "@vaadin/hilla-react-auth": "24.9.4",
"@vaadin/hilla-react-crud": "24.9.0", "@vaadin/hilla-react-crud": "24.9.4",
"@vaadin/hilla-react-form": "24.9.0", "@vaadin/hilla-react-form": "24.9.4",
"@vaadin/hilla-react-i18n": "24.9.0", "@vaadin/hilla-react-i18n": "24.9.4",
"@vaadin/hilla-react-signals": "24.9.0", "@vaadin/hilla-react-signals": "24.9.4",
"@vaadin/polymer-legacy-adapter": "24.9.0", "@vaadin/polymer-legacy-adapter": "24.9.4",
"@vaadin/react-components": "24.9.0", "@vaadin/react-components": "24.9.4",
"@vaadin/vaadin-development-mode-detector": "2.0.7", "@vaadin/vaadin-development-mode-detector": "2.0.7",
"@vaadin/vaadin-lumo-styles": "24.9.0", "@vaadin/vaadin-lumo-styles": "24.9.4",
"@vaadin/vaadin-material-styles": "24.9.0", "@vaadin/vaadin-material-styles": "24.9.4",
"@vaadin/vaadin-themable-mixin": "24.9.0", "@vaadin/vaadin-themable-mixin": "24.9.4",
"@vaadin/vaadin-usage-statistics": "2.1.3", "@vaadin/vaadin-usage-statistics": "2.1.3",
"construct-style-sheets-polyfill": "3.1.0", "construct-style-sheets-polyfill": "3.1.0",
"date-fns": "2.29.3", "date-fns": "2.29.3",
"lit": "3.3.0", "lit": "3.3.1",
"react": "18.3.1", "react": "19.1.1",
"react-dom": "18.3.1", "react-dom": "19.1.1",
"react-router": "7.6.1" "react-router": "7.6.3"
}, },
"devDependencies": { "devDependencies": {
"@babel/preset-react": "7.27.1", "@babel/preset-react": "7.27.1",
"@preact/signals-react-transform": "0.5.1", "@preact/signals-react-transform": "0.6.0",
"@rollup/plugin-replace": "6.0.2", "@rollup/plugin-replace": "6.0.2",
"@rollup/pluginutils": "5.1.4", "@rollup/pluginutils": "5.3.0",
"@types/react": "18.3.23", "@types/react": "19.1.17",
"@types/react-dom": "18.3.7", "@types/react-dom": "19.1.11",
"@vaadin/hilla-generator-cli": "24.9.0", "@vaadin/hilla-generator-cli": "24.9.4",
"@vaadin/hilla-generator-core": "24.9.0", "@vaadin/hilla-generator-core": "24.9.4",
"@vaadin/hilla-generator-plugin-backbone": "24.9.0", "@vaadin/hilla-generator-plugin-backbone": "24.9.4",
"@vaadin/hilla-generator-plugin-barrel": "24.9.0", "@vaadin/hilla-generator-plugin-barrel": "24.9.4",
"@vaadin/hilla-generator-plugin-client": "24.9.0", "@vaadin/hilla-generator-plugin-client": "24.9.4",
"@vaadin/hilla-generator-plugin-model": "24.9.0", "@vaadin/hilla-generator-plugin-model": "24.9.4",
"@vaadin/hilla-generator-plugin-push": "24.9.0", "@vaadin/hilla-generator-plugin-push": "24.9.4",
"@vaadin/hilla-generator-plugin-signals": "24.9.0", "@vaadin/hilla-generator-plugin-signals": "24.9.4",
"@vaadin/hilla-generator-plugin-subtypes": "24.9.0", "@vaadin/hilla-generator-plugin-subtypes": "24.9.4",
"@vaadin/hilla-generator-plugin-transfertypes": "24.9.0", "@vaadin/hilla-generator-plugin-transfertypes": "24.9.4",
"@vaadin/hilla-generator-utils": "24.9.0", "@vaadin/hilla-generator-utils": "24.9.4",
"@vitejs/plugin-react": "4.5.0", "@vitejs/plugin-react": "4.7.0",
"async": "3.2.6", "glob": "11.0.3",
"glob": "11.0.2", "magic-string": "0.30.19",
"magic-string": "0.30.17",
"rollup-plugin-brotli": "3.1.0", "rollup-plugin-brotli": "3.1.0",
"rollup-plugin-visualizer": "5.14.0", "rollup-plugin-visualizer": "5.14.0",
"strip-css-comments": "5.0.0", "strip-css-comments": "5.0.0",
"transform-ast": "2.4.4", "transform-ast": "2.4.4",
"typescript": "5.8.3", "typescript": "5.8.3",
"vite": "6.3.6", "vite": "6.4.1",
"vite-plugin-checker": "0.9.3", "vite-plugin-checker": "0.10.3",
"workbox-build": "7.3.0", "workbox-build": "7.3.0",
"workbox-core": "7.3.0", "workbox-core": "7.3.0",
"workbox-precaching": "7.3.0" "workbox-precaching": "7.3.0"
}, },
"disableUsageStatistics": true, "disableUsageStatistics": true,
"hash": "dba97848bdace60924f9cee496353baae70cfa4fccc7bacaf827807c51908866" "hash": "45fe1cd9320d2da603b811b433279d79b37370c9732e877490fc304807ef6163"
} }
} }
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.
+8 -2
View File
@@ -4,7 +4,7 @@ import {HeroUIProvider} from "@heroui/react";
import {ThemeProvider as NextThemesProvider} from "next-themes"; import {ThemeProvider as NextThemesProvider} from "next-themes";
import {themeNames} from "Frontend/theming/themes"; import {themeNames} from "Frontend/theming/themes";
import {AuthProvider, useAuth} from "Frontend/util/auth"; import {AuthProvider, useAuth} from "Frontend/util/auth";
import {IconContext, X} from "@phosphor-icons/react"; import {IconContext, XIcon} from "@phosphor-icons/react";
import client from "Frontend/generated/connect-client.default"; import client from "Frontend/generated/connect-client.default";
import {ErrorHandlingMiddleware} from "Frontend/util/middleware"; import {ErrorHandlingMiddleware} from "Frontend/util/middleware";
import {initializeLibraryState} from "Frontend/state/LibraryState"; import {initializeLibraryState} from "Frontend/state/LibraryState";
@@ -16,6 +16,9 @@ import {isAdmin} from "Frontend/util/utils";
import {useRouteMetadata} from "Frontend/util/routing"; import {useRouteMetadata} from "Frontend/util/routing";
import {useEffect} from "react"; import {useEffect} from "react";
import {initializeGameRequestState} from "Frontend/state/GameRequestState"; import {initializeGameRequestState} from "Frontend/state/GameRequestState";
import {initializePlatformState} from "Frontend/state/PlatformState";
import {initializeDownloadSessionState} from "Frontend/state/DownloadSessionState";
import {initializeUserState} from "Frontend/state/UserState";
export default function App() { export default function App() {
client.middlewares = [ErrorHandlingMiddleware]; client.middlewares = [ErrorHandlingMiddleware];
@@ -46,11 +49,14 @@ function ViewWithAuth() {
initializeLibraryState(); initializeLibraryState();
initializeGameState(); initializeGameState();
initializePlatformState();
initializeGameRequestState(); initializeGameRequestState();
initializePluginState(); initializePluginState();
if (isAdmin(auth)) { if (isAdmin(auth)) {
initializeScanState(); initializeScanState();
initializeDownloadSessionState();
initializeUserState();
} }
}, [auth]); }, [auth]);
@@ -63,7 +69,7 @@ function ViewWithAuth() {
radius: "sm", radius: "sm",
variant: "flat", variant: "flat",
hideIcon: true, hideIcon: true,
closeIcon: <X/>, closeIcon: <XIcon/>,
classNames: { classNames: {
closeButton: "opacity-100 absolute right-4 top-1/2 -translate-y-1/2", closeButton: "opacity-100 absolute right-4 top-1/2 -translate-y-1/2",
progressTrack: "h-1", progressTrack: "h-1",
@@ -1,5 +1,5 @@
import {useAuth} from "Frontend/util/auth"; import {useAuth} from "Frontend/util/auth";
import {GearFine, Question, SignOut, User} from "@phosphor-icons/react"; import { GearFineIcon, QuestionIcon, SignOutIcon, UserIcon } from "@phosphor-icons/react";
import {Dropdown, DropdownItem, DropdownMenu, DropdownTrigger} from "@heroui/react"; import {Dropdown, DropdownItem, DropdownMenu, DropdownTrigger} from "@heroui/react";
import {useNavigate} from "react-router"; import {useNavigate} from "react-router";
import Avatar from "Frontend/components/general/Avatar"; import Avatar from "Frontend/components/general/Avatar";
@@ -13,23 +13,23 @@ export default function ProfileMenu() {
const profileMenuItems = [ const profileMenuItems = [
{ {
label: "My Profile", label: "My Profile",
icon: <User/>, icon: <UserIcon/>,
onClick: () => navigate("/settings/profile") onClick: () => navigate("/settings/profile")
}, },
{ {
label: "Administration", label: "Administration",
icon: <GearFine/>, icon: <GearFineIcon/>,
onClick: () => navigate("/administration/libraries"), onClick: () => navigate("/administration/libraries"),
showIf: isAdmin(auth) showIf: isAdmin(auth)
}, },
{ {
label: "Help", label: "Help",
icon: <Question/>, icon: <QuestionIcon/>,
onClick: () => window.open("https://gameyfin.org", "_blank") onClick: () => window.open("https://gameyfin.org", "_blank")
}, },
{ {
label: "Sign Out", label: "Sign Out",
icon: <SignOut/>, icon: <SignOutIcon/>,
onClick: auth.logout, onClick: auth.logout,
color: "primary" color: "primary"
}, },
@@ -4,6 +4,8 @@ import Input from "Frontend/components/general/input/Input";
import CheckboxInput from "Frontend/components/general/input/CheckboxInput"; import CheckboxInput from "Frontend/components/general/input/CheckboxInput";
import SelectInput from "Frontend/components/general/input/SelectInput"; import SelectInput from "Frontend/components/general/input/SelectInput";
import ArrayInput from "Frontend/components/general/input/ArrayInput"; import ArrayInput from "Frontend/components/general/input/ArrayInput";
import NumberInput from "Frontend/components/general/input/NumberInput";
import SliderInput from "Frontend/components/general/input/SliderInput";
export default function ConfigFormField({configElement, ...props}: any) { export default function ConfigFormField({configElement, ...props}: any) {
function inputElement(configElement: ConfigEntryDto) { function inputElement(configElement: ConfigEntryDto) {
@@ -27,13 +29,22 @@ export default function ConfigFormField({configElement, ...props}: any) {
); );
case "float": case "float":
return ( return (
<Input label={configElement.description} name={configElement.key} type="number" <NumberInput label={configElement.description} name={configElement.key}
step="0.1" {...props}/> step={0.1} {...props}/>
); );
case "int": case "int":
if (configElement.min != null && configElement.max != null) {
return (
<SliderInput label={configElement.description} name={configElement.key}
min={configElement.min}
max={configElement.max}
step={configElement.step ?? 1}
{...props}/>
);
}
return ( return (
<Input label={configElement.description} name={configElement.key} type="number" <NumberInput label={configElement.description} name={configElement.key}
step="1" {...props}/> step={1} {...props}/>
); );
case "array": case "array":
return ( return (
@@ -0,0 +1,84 @@
import React from "react";
import withConfigPage from "Frontend/components/administration/withConfigPage";
import Section from "Frontend/components/general/Section";
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import * as Yup from "yup";
import {Alert, Button, Divider, Tooltip} from "@heroui/react";
import {FlaskIcon, SigmaIcon} from "@phosphor-icons/react";
import {useSnapshot} from "valtio/react";
import {downloadSessionState} from "Frontend/state/DownloadSessionState";
import SessionStatsDto from "Frontend/generated/org/gameyfin/app/core/download/bandwidth/SessionStatsDto";
import {DownloadSessionCard} from "Frontend/components/general/cards/DownloadSessionCard";
import {humanFileSize} from "Frontend/util/utils";
function DownloadManagementLayout({getConfig, formik}: any) {
const sessions = useSnapshot(downloadSessionState).all as SessionStatsDto[];
const [lastDaySum, setLastDaySum] = React.useState<number>(0);
React.useEffect(() => {
const sum = sessions.reduce((total: number, session: SessionStatsDto) => total + session.totalBytesTransferred, 0);
setLastDaySum(sum);
}, [sessions]);
return (
<div className="flex flex-col">
<Alert
title="Experimental Feature"
description="Bandwidth limiting is an experimental feature and may not work as expected. Please report any issues you encounter."
variant="solid"
hideIconWrapper={true}
icon={<FlaskIcon size={24}/>}
endContent={
<Button variant="flat"
className="bg-default-400"
onPress={() => window.open("https://github.com/gameyfin/gameyfin/issues", "_blank")}>
Open Issues
</Button>
}
classNames={{
title: "font-bold",
base: "mt-6"
}}
/>
<Section title="Bandwidth limiting"/>
<div className="flex flex-col gap-4">
<div className="flex flex-row items-baseline gap-4">
<ConfigFormField configElement={getConfig("downloads.bandwidth-limit.enabled")}/>
<ConfigFormField configElement={getConfig("downloads.bandwidth-limit.mbps")}
isDisabled={!formik.values.downloads["bandwidth-limit"].enabled}/>
</div>
</div>
<div className="flex flex-row justify-between items-end">
<h2 className="text-xl font-bold mt-8 mb-1">Live View</h2>
<Tooltip content="Sum over the last 24 hours" placement="left">
<div className="flex flex-row gap-1">
<SigmaIcon size={26} weight="bold"/>
<p className="font-semibold">{humanFileSize(lastDaySum)}</p>
</div>
</Tooltip>
</div>
<Divider className="mb-4"/>
{sessions.length === 0 &&
<p className="text-center text-default-500">No active download sessions.</p>
}
<div className="flex flex-col gap-2">
{sessions.map((session: SessionStatsDto) =>
<DownloadSessionCard key={session.sessionId} sessionId={session.sessionId}/>
)}
</div>
</div>
);
}
const validationSchema = Yup.object({
downloads: Yup.object({
"bandwidth-limit": Yup.object({
enabled: Yup.boolean().required("Required"),
mbps: Yup.number()
.min(1, "Must be at least 1 Mbps")
.required("Required"),
}).required("Required")
})
});
export const DownloadManagement = withConfigPage(DownloadManagementLayout, "Downloads", validationSchema);
@@ -5,7 +5,7 @@ import Section from "Frontend/components/general/Section";
import * as Yup from 'yup'; import * as Yup from 'yup';
import "Frontend/util/yup-extensions"; import "Frontend/util/yup-extensions";
import {addToast, Button, Divider, Tooltip, useDisclosure} from "@heroui/react"; import {addToast, Button, Divider, Tooltip, useDisclosure} from "@heroui/react";
import {Plus} from "@phosphor-icons/react"; import {PlusIcon} from "@phosphor-icons/react";
import {LibraryEndpoint} from "Frontend/generated/endpoints"; import {LibraryEndpoint} from "Frontend/generated/endpoints";
import {LibraryOverviewCard} from "Frontend/components/general/cards/LibraryOverviewCard"; import {LibraryOverviewCard} from "Frontend/components/general/cards/LibraryOverviewCard";
import LibraryCreationModal from "Frontend/components/general/modals/LibraryCreationModal"; import LibraryCreationModal from "Frontend/components/general/modals/LibraryCreationModal";
@@ -65,7 +65,7 @@ function LibraryManagementLayout({getConfig, formik}: any) {
<h2 className="text-xl font-bold mt-8 mb-1">Libraries</h2> <h2 className="text-xl font-bold mt-8 mb-1">Libraries</h2>
<Tooltip content="Add new library"> <Tooltip content="Add new library">
<Button isIconOnly variant="flat" onPress={libraryCreationModal.onOpen}> <Button isIconOnly variant="flat" onPress={libraryCreationModal.onOpen}>
<Plus/> <PlusIcon/>
</Button> </Button>
</Tooltip> </Tooltip>
</div> </div>
@@ -83,8 +83,6 @@ function LibraryManagementLayout({getConfig, formik}: any) {
} }
<LibraryCreationModal <LibraryCreationModal
// @ts-ignore
libraries={state.sorted}
isOpen={libraryCreationModal.isOpen} isOpen={libraryCreationModal.isOpen}
onOpenChange={libraryCreationModal.onOpenChange} onOpenChange={libraryCreationModal.onOpenChange}
/> />
@@ -4,7 +4,7 @@ import withConfigPage from "Frontend/components/administration/withConfigPage";
import * as Yup from 'yup'; import * as Yup from 'yup';
import ConfigFormField from "Frontend/components/administration/ConfigFormField"; import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import {addToast, Button, Code, Divider, Tooltip} from "@heroui/react"; import {addToast, Button, Code, Divider, Tooltip} from "@heroui/react";
import {ArrowUDownLeft, SortAscending} from "@phosphor-icons/react"; import { ArrowUDownLeftIcon, SortAscendingIcon } from "@phosphor-icons/react";
function LogManagementLayout({getConfig, formik}: any) { function LogManagementLayout({getConfig, formik}: any) {
const [logEntries, setLogEntries] = useState<string[]>([]); const [logEntries, setLogEntries] = useState<string[]>([]);
@@ -51,7 +51,7 @@ function LogManagementLayout({getConfig, formik}: any) {
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex flex-row flex-grow justify-between items-baseline"> <div className="flex flex-row grow justify-between items-baseline">
<h2 className={"text-xl font-bold mt-8 mb-1"}>Application logs</h2> <h2 className={"text-xl font-bold mt-8 mb-1"}>Application logs</h2>
<div className="flex flex-row gap-1"> <div className="flex flex-row gap-1">
<Tooltip content="Soft-wrap" placement="bottom"> <Tooltip content="Soft-wrap" placement="bottom">
@@ -59,7 +59,7 @@ function LogManagementLayout({getConfig, formik}: any) {
onPress={() => setSoftWrap(!softWrap)} onPress={() => setSoftWrap(!softWrap)}
variant={softWrap ? "solid" : "ghost"} variant={softWrap ? "solid" : "ghost"}
> >
<ArrowUDownLeft/> <ArrowUDownLeftIcon/>
</Button> </Button>
</Tooltip> </Tooltip>
<Tooltip content="Auto-scroll" placement="bottom"> <Tooltip content="Auto-scroll" placement="bottom">
@@ -67,7 +67,7 @@ function LogManagementLayout({getConfig, formik}: any) {
onPress={() => setAutoScroll(!autoScroll)} onPress={() => setAutoScroll(!autoScroll)}
variant={autoScroll ? "solid" : "ghost"} variant={autoScroll ? "solid" : "ghost"}
> >
<SortAscending/> <SortAscendingIcon/>
</Button> </Button>
</Tooltip> </Tooltip>
</div> </div>
@@ -4,7 +4,7 @@ import ConfigFormField from "Frontend/components/administration/ConfigFormField"
import Section from "Frontend/components/general/Section"; import Section from "Frontend/components/general/Section";
import {addToast, Button, Card, Tooltip, useDisclosure} from "@heroui/react"; import {addToast, Button, Card, Tooltip, useDisclosure} from "@heroui/react";
import {MessageEndpoint, MessageTemplateEndpoint} from "Frontend/generated/endpoints"; import {MessageEndpoint, MessageTemplateEndpoint} from "Frontend/generated/endpoints";
import {PaperPlaneRight, Pencil} from "@phosphor-icons/react"; import {PaperPlaneRightIcon, PencilIcon} from "@phosphor-icons/react";
import MessageTemplateDto from "Frontend/generated/org/gameyfin/app/messages/templates/MessageTemplateDto"; import MessageTemplateDto from "Frontend/generated/org/gameyfin/app/messages/templates/MessageTemplateDto";
import SendTestNotificationModal from "Frontend/components/administration/messages/SendTestNotificationModal"; import SendTestNotificationModal from "Frontend/components/administration/messages/SendTestNotificationModal";
import EditTemplateModal from "Frontend/components/administration/messages/EditTemplateModel"; import EditTemplateModal from "Frontend/components/administration/messages/EditTemplateModel";
@@ -31,7 +31,13 @@ function MessageManagementLayout({getConfig, formik}: any) {
password: formik.values.messages.providers.email.password password: formik.values.messages.providers.email.password
} }
const areCredentialsValid = await MessageEndpoint.verifyCredentials(provider, credentials); let areCredentialsValid: boolean;
try {
areCredentialsValid = await MessageEndpoint.verifyCredentials(provider, credentials);
} catch (error) {
areCredentialsValid = false;
}
if (areCredentialsValid) { if (areCredentialsValid) {
addToast({ addToast({
@@ -91,7 +97,7 @@ function MessageManagementLayout({getConfig, formik}: any) {
size="sm" size="sm"
onPress={() => openEditor(template)} onPress={() => openEditor(template)}
> >
<Pencil/> <PencilIcon/>
</Button> </Button>
</Tooltip> </Tooltip>
<Tooltip content="Send test notification"> <Tooltip content="Send test notification">
@@ -100,7 +106,7 @@ function MessageManagementLayout({getConfig, formik}: any) {
onPress={() => openTestNotification(template)} onPress={() => openTestNotification(template)}
isDisabled={!formik.values.messages.providers.email.enabled} isDisabled={!formik.values.messages.providers.email.enabled}
> >
<PaperPlaneRight/> <PaperPlaneRightIcon/>
</Button> </Button>
</Tooltip> </Tooltip>
<p className="text-lg">{template.description}</p> <p className="text-lg">{template.description}</p>
@@ -137,7 +143,6 @@ const validationSchema = Yup.object({
.min(0, "Port must be between 0 and 65535") .min(0, "Port must be between 0 and 65535")
.max(65535, "Port must be between 0 and 65535"), .max(65535, "Port must be between 0 and 65535"),
username: Yup.string() username: Yup.string()
.email("Invalid email address")
.required("Username is required"), .required("Username is required"),
}) })
}) })
@@ -12,7 +12,7 @@ export default function PluginManagement() {
return state.isLoaded && ( return state.isLoaded && (
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex flex-row flex-grow justify-between mb-8"> <div className="flex flex-row grow justify-between mb-8">
<h2 className="text-2xl font-bold">Plugins</h2> <h2 className="text-2xl font-bold">Plugins</h2>
</div> </div>
@@ -2,7 +2,7 @@ import Section from "Frontend/components/general/Section";
import Input from "Frontend/components/general/input/Input"; import Input from "Frontend/components/general/input/Input";
import {addToast, Button, Input as NextUiInput, Tooltip} from "@heroui/react"; import {addToast, Button, Input as NextUiInput, Tooltip} from "@heroui/react";
import {Form, Formik} from "formik"; import {Form, Formik} from "formik";
import {ArrowCounterClockwise, Check, Info, Trash} from "@phosphor-icons/react"; import { ArrowCounterClockwiseIcon, CheckIcon, InfoIcon, TrashIcon } from "@phosphor-icons/react";
import React, {useEffect, useState} from "react"; import React, {useEffect, useState} from "react";
import {useAuth} from "Frontend/util/auth"; import {useAuth} from "Frontend/util/auth";
import * as Yup from "yup"; import * as Yup from "yup";
@@ -82,14 +82,14 @@ export default function ProfileManagement() {
> >
{(formik: { values: any; isSubmitting: any; dirty: boolean; }) => ( {(formik: { values: any; isSubmitting: any; dirty: boolean; }) => (
<Form> <Form>
<div className="flex flex-row flex-grow justify-between mb-8"> <div className="flex flex-row grow justify-between mb-8">
<h2 className="text-2xl font-bold">My Profile</h2> <h2 className="text-2xl font-bold">My Profile</h2>
{auth.state.user?.managedBySso && {auth.state.user?.managedBySso &&
<p className="text-warning">Your account is managed externally.</p>} <p className="text-warning">Your account is managed externally.</p>}
<div className="flex flex-row items-center gap-4"> <div className="flex flex-row items-center gap-4">
{formik.values.newPassword.length > 0 && {formik.values.newPassword.length > 0 &&
<SmallInfoField icon={Info} <SmallInfoField icon={InfoIcon}
message="You will be logged out of all current sessions" message="You will be logged out of all current sessions"
className="text-default-500" className="text-default-500"
/> />
@@ -100,7 +100,7 @@ export default function ProfileManagement() {
isDisabled={!formik.dirty || formik.isSubmitting || configSaved || auth.state.user?.managedBySso} isDisabled={!formik.dirty || formik.isSubmitting || configSaved || auth.state.user?.managedBySso}
type="submit" type="submit"
> >
{formik.isSubmitting ? "" : configSaved ? <Check/> : "Save"} {formik.isSubmitting ? "" : configSaved ? <CheckIcon/> : "Save"}
</Button> </Button>
</div> </div>
</div> </div>
@@ -117,12 +117,12 @@ export default function ProfileManagement() {
color="success">Upload</Button> color="success">Upload</Button>
<Tooltip content="Remove your current avatar"> <Tooltip content="Remove your current avatar">
<Button onPress={removeAvatar} isIconOnly color="danger" <Button onPress={removeAvatar} isIconOnly color="danger"
isDisabled={auth.state.user?.managedBySso}><Trash/></Button> isDisabled={auth.state.user?.managedBySso}><TrashIcon/></Button>
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
<div className="flex flex-col flex-grow"> <div className="flex flex-col grow">
<Section title="Personal information"/> <Section title="Personal information"/>
<Input name="username" label="Username" type="text" autocomplete="username" <Input name="username" label="Username" type="text" autocomplete="username"
isDisabled={auth.state.user?.managedBySso}/> isDisabled={auth.state.user?.managedBySso}/>
@@ -145,14 +145,14 @@ export default function ProfileManagement() {
variant="ghost" variant="ghost"
className="size-14" className="size-14"
> >
<ArrowCounterClockwise size={26}/> <ArrowCounterClockwiseIcon size={26}/>
</Button> </Button>
</Tooltip> </Tooltip>
} }
</div> </div>
{!messagesEnabled && {!messagesEnabled &&
<div className="flex flex-row gap-2 text-warning -mt-5"> <div className="flex flex-row gap-2 text-warning -mt-5">
<Info/> <InfoIcon/>
<small> <small>
Email services are disabled. Please contact your administrator. Email services are disabled. Please contact your administrator.
</small> </small>
@@ -4,7 +4,7 @@ import * as Yup from 'yup';
import ConfigFormField from "Frontend/components/administration/ConfigFormField"; import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import Section from "Frontend/components/general/Section"; import Section from "Frontend/components/general/Section";
import {addToast, Button, Checkbox, CheckboxGroup, Tooltip} from "@heroui/react"; import {addToast, Button, Checkbox, CheckboxGroup, Tooltip} from "@heroui/react";
import {MagicWand, Warning} from "@phosphor-icons/react"; import { MagicWandIcon, WarningIcon } from "@phosphor-icons/react";
function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) { function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) {
@@ -57,7 +57,7 @@ function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) {
Automatically create new users after registration Automatically create new users after registration
</Checkbox> </Checkbox>
<Tooltip content={"Currently not configurable (always enabled)"} placement="right"> <Tooltip content={"Currently not configurable (always enabled)"} placement="right">
<Warning weight="fill"/> <WarningIcon weight="fill"/>
</Tooltip> </Tooltip>
</div> </div>
</CheckboxGroup> </CheckboxGroup>
@@ -89,7 +89,7 @@ function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) {
<Button <Button
isDisabled={isAutoPopulateDisabled()} isDisabled={isAutoPopulateDisabled()}
onPress={autoPopulate} onPress={autoPopulate}
className="h-14"><MagicWand className="min-w-5"/>Auto-populate</Button> className="h-14"><MagicWandIcon className="min-w-5"/>Auto-populate</Button>
</div> </div>
<ConfigFormField configElement={getConfig("sso.oidc.authorize-url")} <ConfigFormField configElement={getConfig("sso.oidc.authorize-url")}
isDisabled={!formik.values.sso.oidc.enabled}/> isDisabled={!formik.values.sso.oidc.enabled}/>
@@ -5,7 +5,7 @@ import Section from "Frontend/components/general/Section";
import {UserEndpoint} from "Frontend/generated/endpoints"; import {UserEndpoint} from "Frontend/generated/endpoints";
import {UserManagementCard} from "Frontend/components/general/cards/UserManagementCard"; import {UserManagementCard} from "Frontend/components/general/cards/UserManagementCard";
import {SmallInfoField} from "Frontend/components/general/SmallInfoField"; import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
import {Info, UserPlus} from "@phosphor-icons/react"; import { InfoIcon, UserPlusIcon } from "@phosphor-icons/react";
import {Button, Divider, Tooltip, useDisclosure} from "@heroui/react"; import {Button, Divider, Tooltip, useDisclosure} from "@heroui/react";
import InviteUserModal from "Frontend/components/general/modals/InviteUserModal"; import InviteUserModal from "Frontend/components/general/modals/InviteUserModal";
import ExtendedUserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/ExtendedUserInfoDto"; import ExtendedUserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/ExtendedUserInfoDto";
@@ -21,7 +21,7 @@ function UserManagementLayout({getConfig, formik}: any) {
}, []); }, []);
return ( return (
<div className="flex flex-col flex-grow"> <div className="flex flex-col grow">
<Section title="Sign-Ups"/> <Section title="Sign-Ups"/>
<div className="flex flex-row"> <div className="flex flex-row">
@@ -33,12 +33,12 @@ function UserManagementLayout({getConfig, formik}: any) {
<div className="flex flex-row items-baseline justify-between"> <div className="flex flex-row items-baseline justify-between">
<h2 className="text-xl font-bold mt-8 mb-1">Users</h2> <h2 className="text-xl font-bold mt-8 mb-1">Users</h2>
{!getConfig("sso.oidc.auto-register-new-users").value && {!getConfig("sso.oidc.auto-register-new-users").value &&
<SmallInfoField className="mb-4 text-warning" icon={Info} <SmallInfoField className="mb-4 text-warning" icon={InfoIcon}
message="Automatic user registration for SSO users is disabled"/> message="Automatic user registration for SSO users is disabled"/>
} }
<Tooltip content="Invite new user"> <Tooltip content="Invite new user">
<Button isIconOnly variant="flat" onPress={inviteUserModal.onOpen}> <Button isIconOnly variant="flat" onPress={inviteUserModal.onOpen}>
<UserPlus/> <UserPlusIcon/>
</Button> </Button>
</Tooltip> </Tooltip>
</div> </div>
@@ -3,7 +3,7 @@ import {ConfigEndpoint} from "Frontend/generated/endpoints";
import ConfigEntryDto from "Frontend/generated/org/gameyfin/app/config/dto/ConfigEntryDto"; import ConfigEntryDto from "Frontend/generated/org/gameyfin/app/config/dto/ConfigEntryDto";
import {Form, Formik} from "formik"; import {Form, Formik} from "formik";
import {Button, Skeleton} from "@heroui/react"; import {Button, Skeleton} from "@heroui/react";
import {Check, Info} from "@phosphor-icons/react"; import { CheckIcon, InfoIcon } from "@phosphor-icons/react";
import {SmallInfoField} from "Frontend/components/general/SmallInfoField"; import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
import {configState, initializeConfigState, NestedConfig} from "Frontend/state/ConfigState"; import {configState, initializeConfigState, NestedConfig} from "Frontend/state/ConfigState";
import {useSnapshot} from "valtio/react"; import {useSnapshot} from "valtio/react";
@@ -92,11 +92,11 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
> >
{(formik) => ( {(formik) => (
<Form> <Form>
<div className="flex flex-row flex-grow justify-between"> <div className="flex flex-row grow justify-between">
<h1 className="text-2xl font-bold">{title}</h1> <h1 className="text-2xl font-bold">{title}</h1>
<div className="flex flex-row items-center gap-4"> <div className="flex flex-row items-center gap-4">
{saveMessage && <SmallInfoField icon={Info} {saveMessage && <SmallInfoField icon={InfoIcon}
message={saveMessage} message={saveMessage}
className="text-warning"/>} className="text-warning"/>}
@@ -106,7 +106,7 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
isDisabled={formik.isSubmitting || configSaved || !formik.dirty} isDisabled={formik.isSubmitting || configSaved || !formik.dirty}
type="submit" type="submit"
> >
{formik.isSubmitting ? "" : configSaved ? <Check/> : "Save"} {formik.isSubmitting ? "" : configSaved ? <CheckIcon/> : "Save"}
</Button> </Button>
</div> </div>
</div> </div>
@@ -119,7 +119,7 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
)} )}
</Formik> : </Formik> :
[...Array(4)].map((_e, i) => [...Array(4)].map((_e, i) =>
<div className="flex flex-col flex-grow gap-8 mb-12" key={i}> <div className="flex flex-col grow gap-8 mb-12" key={i}>
<Skeleton className="h-10 w-full rounded-md"/> <Skeleton className="h-10 w-full rounded-md"/>
<Skeleton className="h-12 flex w-1/3 rounded-md"/> <Skeleton className="h-12 flex w-1/3 rounded-md"/>
<div className="flex flex-row gap-8"> <div className="flex flex-row gap-8">
@@ -0,0 +1,46 @@
import React from "react";
import {Chip, Tooltip} from "@heroui/react";
interface ChipListProps {
items: string[];
maxVisible?: number;
size?: "sm" | "md" | "lg";
radius?: "none" | "sm" | "md" | "lg" | "full";
defaultContent?: string;
}
export default function ChipList({items, maxVisible = 1, size = "sm", radius = "sm", defaultContent}: ChipListProps) {
if (items.length === 0) {
return defaultContent ? <Chip radius={radius} size={size}>{defaultContent}</Chip> : null;
}
const visibleItems = items.slice(0, maxVisible);
const remainingItems = items.slice(maxVisible);
const hasMore = remainingItems.length > 0;
return (
<div className="flex flex-row gap-1">
{visibleItems.map(item => (
<Chip key={item} radius={radius} size={size}>
{item}
</Chip>
))}
{hasMore && (
<Tooltip
content={
<div className="flex flex-col gap-1">
{remainingItems.map(item => (
<div key={item}>{item}</div>
))}
</div>
}
placement="right">
<Chip radius={radius} size={size}>
{maxVisible > 0 && "+"}{remainingItems.length}
</Chip>
</Tooltip>
)}
</div>
);
}
@@ -1,29 +1,4 @@
import { import { AlienIcon, BaseballIcon, BasketballIcon, CastleTurretIcon, DiceFiveIcon, GameControllerIcon, GhostIcon, IconContext, JoystickIcon, LegoIcon, MedalIcon, PuzzlePieceIcon, RocketIcon, SkullIcon, SoccerBallIcon, StarIcon, StrategyIcon, SwordIcon, TargetIcon, ThumbsUpIcon, TreasureChestIcon, TrophyIcon, UserIcon, VolleyballIcon } from "@phosphor-icons/react";
Alien,
Baseball,
Basketball,
CastleTurret,
DiceFive,
GameController,
Ghost,
IconContext,
Joystick,
Lego,
Medal,
PuzzlePiece,
Rocket,
Skull,
SoccerBall,
Star,
Strategy,
Sword,
Target,
ThumbsUp,
TreasureChest,
Trophy,
User,
Volleyball
} from "@phosphor-icons/react";
import React, {useEffect} from "react"; import React, {useEffect} from "react";
export default function IconBackgroundPattern() { export default function IconBackgroundPattern() {
@@ -54,29 +29,29 @@ export default function IconBackgroundPattern() {
return <div ref={containerRef} className="absolute w-full h-full opacity-50"> return <div ref={containerRef} className="absolute w-full h-full opacity-50">
<IconContext.Provider value={{size: iconSize}}> <IconContext.Provider value={{size: iconSize}}>
<GameController className="absolute fill-primary top-[8%] left-[8%] rotate-[350deg]"/> <GameControllerIcon className="absolute fill-primary top-[8%] left-[8%] rotate-350"/>
<SoccerBall className="absolute fill-primary top-[48%] left-[96%] rotate-[60deg]"/> <SoccerBallIcon className="absolute fill-primary top-[48%] left-[96%] rotate-60"/>
<Joystick className="absolute top-[28%] left-[52%] rotate-[90deg]"/> <JoystickIcon className="absolute top-[28%] left-[52%] rotate-90"/>
<Strategy className="absolute fill-primary top-[52%] left-[68%] rotate-[30deg]"/> <StrategyIcon className="absolute fill-primary top-[52%] left-[68%] rotate-30"/>
<Sword className="absolute top-[72%] left-[12%] rotate-[60deg]"/> <SwordIcon className="absolute top-[72%] left-[12%] rotate-60"/>
<Alien className="absolute fill-primary top-[12%] left-[88%] rotate-[15deg]"/> <AlienIcon className="absolute fill-primary top-[12%] left-[88%] rotate-15"/>
<CastleTurret className="absolute top-[6%] left-[38%] rotate-[320deg]"/> <CastleTurretIcon className="absolute top-[6%] left-[38%] rotate-320"/>
<Ghost className="absolute fill-primary top-[38%] left-[6%] rotate-[300deg]"/> <GhostIcon className="absolute fill-primary top-[38%] left-[6%] rotate-300"/>
<Skull className="absolute top-[82%] left-[28%] rotate-[90deg]"/> <SkullIcon className="absolute top-[82%] left-[28%] rotate-90"/>
<Trophy className="absolute fill-primary top-[12%] left-[62%] rotate-[45deg]"/> <TrophyIcon className="absolute fill-primary top-[12%] left-[62%] rotate-45"/>
<Lego className="absolute top-[32%] left-[18%] rotate-[30deg]"/> <LegoIcon className="absolute top-[32%] left-[18%] rotate-30"/>
<TreasureChest className="absolute top-[68%] left-[48%] rotate-[75deg]"/> <TreasureChestIcon className="absolute top-[68%] left-[48%] rotate-75"/>
<Basketball className="absolute fill-primary top-[22%] left-[37%] rotate-[10deg]"/> <BasketballIcon className="absolute fill-primary top-[22%] left-[37%] rotate-10"/>
<Baseball className="absolute top-[92%] left-[82%] rotate-[340deg]"/> <BaseballIcon className="absolute top-[92%] left-[82%] rotate-340"/>
<DiceFive className="absolute top-[62%] left-[22%] rotate-[120deg]"/> <DiceFiveIcon className="absolute top-[62%] left-[22%] rotate-120"/>
<Medal className="absolute fill-primary top-[18%] left-[28%] rotate-[300deg]"/> <MedalIcon className="absolute fill-primary top-[18%] left-[28%] rotate-300"/>
<PuzzlePiece className="absolute top-[42%] left-[78%] rotate-[45deg]"/> <PuzzlePieceIcon className="absolute top-[42%] left-[78%] rotate-45"/>
<Rocket className="absolute fill-primary top-[88%] left-[52%] rotate-[15deg]"/> <RocketIcon className="absolute fill-primary top-[88%] left-[52%] rotate-15"/>
<Star className="absolute top-[28%] left-[72%] rotate-[60deg]"/> <StarIcon className="absolute top-[28%] left-[72%] rotate-60"/>
<Target className="absolute fill-primary top-[68%] left-[62%] rotate-[330deg]"/> <TargetIcon className="absolute fill-primary top-[68%] left-[62%] rotate-330"/>
<ThumbsUp className="absolute top-[82%] left-[12%] rotate-[80deg]"/> <ThumbsUpIcon className="absolute top-[82%] left-[12%] rotate-80"/>
<User className="absolute fill-primary top-[38%] left-[62%] rotate-[20deg]"/> <UserIcon className="absolute fill-primary top-[38%] left-[62%] rotate-20"/>
<Volleyball className="absolute top-[78%] left-[92%] rotate-[100deg]"/> <VolleyballIcon className="absolute top-[78%] left-[92%] rotate-100"/>
</IconContext.Provider> </IconContext.Provider>
</div> </div>
} }
@@ -3,7 +3,7 @@ import {roleToColor, roleToRoleName} from "Frontend/util/utils";
export default function RoleChip({role}: { role: string }) { export default function RoleChip({role}: { role: string }) {
return ( return (
<Chip key={role} size="sm" radius="sm" className={`text-xs bg-${roleToColor(role)}-500`}> <Chip key={role} size="sm" radius="sm" className={`text-xs ${roleToColor(role)}`}>
{roleToRoleName(role)} {roleToRoleName(role)}
</Chip> </Chip>
); );
@@ -13,7 +13,7 @@ import {useSnapshot} from "valtio/react";
import {scanState} from "Frontend/state/ScanState"; import {scanState} from "Frontend/state/ScanState";
import LibraryScanProgress from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryScanProgress"; import LibraryScanProgress from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryScanProgress";
import {libraryState} from "Frontend/state/LibraryState"; import {libraryState} from "Frontend/state/LibraryState";
import {Target, Warning} from "@phosphor-icons/react"; import { TargetIcon, WarningIcon } from "@phosphor-icons/react";
import {timeBetween, timeUntil, toTitleCase} from "Frontend/util/utils"; import {timeBetween, timeUntil, toTitleCase} from "Frontend/util/utils";
import LibraryScanStatus from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryScanStatus"; import LibraryScanStatus from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryScanStatus";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
@@ -45,7 +45,7 @@ export default function ScanProgressPopover() {
classNames={{ classNames={{
spinnerBars: "bg-foreground-500", spinnerBars: "bg-foreground-500",
}}/> : }}/> :
<Target className="fill-foreground-500"/> <TargetIcon className="fill-foreground-500"/>
} }
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
@@ -103,7 +103,7 @@ export default function ScanProgressPopover() {
</p> </p>
} }
{scan.status === LibraryScanStatus.FAILED && {scan.status === LibraryScanStatus.FAILED &&
<p className="text-danger flex flex-row gap-1"><Warning weight="fill"/> <p className="text-danger flex flex-row gap-1"><WarningIcon weight="fill"/>
Scan failed (check logs for details) Scan failed (check logs for details)
</p> </p>
} }
@@ -1,5 +1,5 @@
import {Autocomplete, AutocompleteItem} from "@heroui/react"; import {Autocomplete, AutocompleteItem} from "@heroui/react";
import {CaretRight, MagnifyingGlass} from "@phosphor-icons/react"; import { CaretRightIcon, MagnifyingGlassIcon } from "@phosphor-icons/react";
import {useSnapshot} from "valtio/react"; import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState"; import {gameState} from "Frontend/state/GameState";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto"; import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
@@ -41,7 +41,7 @@ export default function SearchBar() {
}, },
}} }}
placeholder="Type to search..." placeholder="Type to search..."
startContent={<MagnifyingGlass/>} startContent={<MagnifyingGlassIcon/>}
isVirtualized={true} isVirtualized={true}
maxListboxHeight={300} maxListboxHeight={300}
itemHeight={91} // 75px (cover) + 16px (margin top/bottom) = 91px itemHeight={91} // 75px (cover) + 16px (margin top/bottom) = 91px
@@ -54,7 +54,7 @@ export default function SearchBar() {
<p><b>{item.title}</b> ({item.release && new Date(item.release).getFullYear()})</p> <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> <p className="text-default-500">{item.developers && [...item.developers].sort().join(" / ")}</p>
</div> </div>
<CaretRight/> <CaretRightIcon/>
</div> </div>
</AutocompleteItem> </AutocompleteItem>
)} )}
@@ -0,0 +1,136 @@
import {useSnapshot} from "valtio/react";
import {downloadSessionState} from "Frontend/state/DownloadSessionState";
import {Card, Chip, Tooltip} from "@heroui/react";
import {InfoIcon} from "@phosphor-icons/react";
import {convertBpsToMbps, hslToHex, humanFileSize, timeUntil} from "Frontend/util/utils";
import {gameState} from "Frontend/state/GameState";
import RealtimeChart, {RealtimeChartData, RealtimeChartOptions} from "react-realtime-chart";
import {useEffect, useState} from "react";
import {useNavigate} from "react-router";
import {libraryState} from "Frontend/state/LibraryState";
export function DownloadSessionCard({sessionId}: { sessionId: string }) {
const navigate = useNavigate();
const session = useSnapshot(downloadSessionState).byId[sessionId];
const games = useSnapshot(gameState).state;
const libraries = useSnapshot(libraryState).state;
const [currentTime, setCurrentTime] = useState<Date>(new Date());
const [chartData, setChartData] = useState<RealtimeChartData[][]>([]);
const [foregroundColor, setForegroundColor] = useState<string>("#00F");
// Get theme colors from CSS variables
useEffect(() => {
const chartColor = window.getComputedStyle(document.body).getPropertyValue('--heroui-foreground');
if (chartColor) {
setForegroundColor(hslToHex(chartColor.trim()));
}
}, []);
useEffect(() => {
const interval = setInterval(() => {
setCurrentTime(new Date());
}, 1000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (session) {
const dataPoints: RealtimeChartData[] = session.bandwidthHistory.map((bps, idx) => {
let date = new Date();
date.setSeconds(currentTime.getSeconds() - session.bandwidthHistory.length + idx + 1);
return {
date: date,
value: convertBpsToMbps(bps)
};
});
setChartData([dataPoints]);
}
}, [currentTime]);
const chartOptions: RealtimeChartOptions = {
fps: 60,
timeSlots: 30,
colors: [foregroundColor],
margin: {left: 60},
lines: [
{
area: true,
areaColor: foregroundColor,
areaOpacity: 0.03,
lineWidth: 2,
curve: "basis",
},
],
yGrid: {
min: 0,
color: foregroundColor,
opacity: 0.25,
size: 1,
tickNumber: 7,
tickFormat: (v) => `${v}Mb/s`
},
xGrid: {
color: foregroundColor,
opacity: 0.25,
size: 1,
tickNumber: 5
},
};
return (session &&
<Card
className={`flex flex-col gap-2 m-0.5 p-4 border-2
${(session.currentBytesPerSecond > 0) ? "border-primary bg-primary/10" : "border-default"}`}>
<div className="flex flex-row items-center">
<p className="flex flex-row items-center flex-1">
<b>User:</b>&nbsp;
{session.username ?? "Anonymous User"}&nbsp;
<Tooltip
content={<pre>Session ID: {session.sessionId}</pre>}
placement="right"
>
<InfoIcon size={18}/>
</Tooltip>
</p>
<div className="flex-1 flex justify-center">Remote IP:&nbsp;
{<Chip size="sm" radius="sm">
<pre>{session.remoteIp}</pre>
</Chip>}
</div>
<div
className="flex-1 flex justify-end">{session.activeGameIds.length > 0 ? "Session active since" : "Session inactive since"}&nbsp;
{<Chip size="sm" radius="sm">
{timeUntil(session.startTime, undefined, true)}
</Chip>}
</div>
</div>
{/* Only render chart when downloads are active or have been active within the last minute */}
{(session.activeGameIds.length > 0 || (currentTime.getTime() - new Date(session.startTime).getTime() < 60000)) &&
<div className="flex flex-col items-center">
<div className="flex flex-row gap-2">
Active downloads:
{session.activeGameIds.length === 0 && <p>No active downloads</p>}
{session.activeGameIds.map(gameId =>
games[gameId] &&
<Tooltip key={gameId}
size="sm"
content={`Size: ${humanFileSize(games[gameId].metadata.fileSize)} / Library: ${libraries[games[gameId].libraryId]?.name || "Unknown"}`}
placement="bottom">
<Chip size="sm" radius="sm"
onClick={() => navigate(`/game/${gameId}`)}
className="cursor-pointer"
>{games[gameId].title}
</Chip>
</Tooltip>
)}
</div>
<div className="w-full h-48">
<RealtimeChart options={chartOptions} data={chartData}/>
</div>
</div>
}
</Card>
)
}
@@ -1,15 +1,16 @@
import {Button, Card, Chip, Tooltip} from "@heroui/react"; import {Button, Card, Tooltip} from "@heroui/react";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto"; import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import React from "react"; import React from "react";
import {LibraryEndpoint} from "Frontend/generated/endpoints"; import {LibraryEndpoint} from "Frontend/generated/endpoints";
import {GameCover} from "Frontend/components/general/covers/GameCover"; import {GameCover} from "Frontend/components/general/covers/GameCover";
import {MagnifyingGlass, MagnifyingGlassPlus, SlidersHorizontal} from "@phosphor-icons/react"; import {MagnifyingGlassIcon, MagnifyingGlassPlusIcon, SlidersHorizontalIcon} from "@phosphor-icons/react";
import ScanType from "Frontend/generated/org/gameyfin/app/libraries/enums/ScanType"; import ScanType from "Frontend/generated/org/gameyfin/app/libraries/enums/ScanType";
import {useNavigate} from "react-router"; import {useNavigate} from "react-router";
import {useSnapshot} from "valtio/react"; import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState"; import {gameState} from "Frontend/state/GameState";
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern"; import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto"; import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
import ChipList from "Frontend/components/general/ChipList";
interface LibraryOverviewCardProps { interface LibraryOverviewCardProps {
library: LibraryAdminDto; library: LibraryAdminDto;
@@ -50,17 +51,17 @@ export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
<div className="absolute right-0 top-0 flex flex-row"> <div className="absolute right-0 top-0 flex flex-row">
<Tooltip content="Scan library (quick)" placement="bottom" color="foreground"> <Tooltip content="Scan library (quick)" placement="bottom" color="foreground">
<Button isIconOnly variant="light" onPress={() => triggerScan(ScanType.QUICK)}> <Button isIconOnly variant="light" onPress={() => triggerScan(ScanType.QUICK)}>
<MagnifyingGlass/> <MagnifyingGlassIcon/>
</Button> </Button>
</Tooltip> </Tooltip>
<Tooltip content="Scan library (full)" placement="bottom" color="foreground"> <Tooltip content="Scan library (full)" placement="bottom" color="foreground">
<Button isIconOnly variant="light" onPress={() => triggerScan(ScanType.FULL)}> <Button isIconOnly variant="light" onPress={() => triggerScan(ScanType.FULL)}>
<MagnifyingGlassPlus/> <MagnifyingGlassPlusIcon/>
</Button> </Button>
</Tooltip> </Tooltip>
<Tooltip content="Configuration" placement="bottom" color="foreground"> <Tooltip content="Configuration" placement="bottom" color="foreground">
<Button isIconOnly variant="light" onPress={() => navigate('library/' + library.id)}> <Button isIconOnly variant="light" onPress={() => navigate('library/' + library.id)}>
<SlidersHorizontal/> <SlidersHorizontalIcon/>
</Button> </Button>
</Tooltip> </Tooltip>
</div> </div>
@@ -73,7 +74,7 @@ export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
<p>Platforms</p> <p>Platforms</p>
<p className="font-bold">{library.stats.gamesCount}</p> <p className="font-bold">{library.stats.gamesCount}</p>
<p className="font-bold">{library.stats.downloadedGamesCount}</p> <p className="font-bold">{library.stats.downloadedGamesCount}</p>
<Chip size="sm">PC</Chip> <ChipList items={library.platforms} maxVisible={0} defaultContent="All"/>
</div> </div>
} }
</Card> </Card>
@@ -1,20 +1,5 @@
import {Button, Card, Chip, Tooltip, useDisclosure} from "@heroui/react"; import {Button, Card, Chip, Tooltip, useDisclosure} from "@heroui/react";
import { import { CheckCircleIcon, IconContext, PauseCircleIcon, PlayCircleIcon, PowerIcon, QuestionIcon, QuestionMarkIcon, SealCheckIcon, SealQuestionIcon, SealWarningIcon, SlidersHorizontalIcon, StopCircleIcon, WarningCircleIcon, XCircleIcon } from "@phosphor-icons/react";
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 PluginState from "Frontend/generated/org/pf4j/PluginState";
import React, {ReactNode} from "react"; import React, {ReactNode} from "react";
import PluginDetailsModal from "Frontend/components/general/modals/PluginDetailsModal"; import PluginDetailsModal from "Frontend/components/general/modals/PluginDetailsModal";
@@ -54,17 +39,17 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
function stateToIcon(state: PluginState | undefined): ReactNode { function stateToIcon(state: PluginState | undefined): ReactNode {
switch (state) { switch (state) {
case PluginState.STARTED: case PluginState.STARTED:
return <PlayCircle/>; return <PlayCircleIcon/>;
case PluginState.DISABLED: case PluginState.DISABLED:
return <PauseCircle/>; return <PauseCircleIcon/>;
case PluginState.STOPPED: case PluginState.STOPPED:
case PluginState.FAILED: case PluginState.FAILED:
return <StopCircle/>; return <StopCircleIcon/>;
case PluginState.UNLOADED: case PluginState.UNLOADED:
case PluginState.RESOLVED: case PluginState.RESOLVED:
return <XCircle/>; return <XCircleIcon/>;
default: default:
return <QuestionMark/>; return <QuestionMarkIcon/>;
} }
} }
@@ -73,19 +58,19 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
case PluginConfigValidationResultType.VALID: case PluginConfigValidationResultType.VALID:
return <Tooltip content="Config valid" placement="bottom" color="foreground"> return <Tooltip content="Config valid" placement="bottom" color="foreground">
<Chip size="sm" radius="sm" className="text-xs" color="success"> <Chip size="sm" radius="sm" className="text-xs" color="success">
<CheckCircle/> <CheckCircleIcon/>
</Chip> </Chip>
</Tooltip> </Tooltip>
case PluginConfigValidationResultType.INVALID: case PluginConfigValidationResultType.INVALID:
return <Tooltip content="Config invalid" placement="bottom" color="foreground"> return <Tooltip content="Config invalid" placement="bottom" color="foreground">
<Chip size="sm" radius="sm" className="text-xs" color="danger"> <Chip size="sm" radius="sm" className="text-xs" color="danger">
<WarningCircle/> <WarningCircleIcon/>
</Chip> </Chip>
</Tooltip>; </Tooltip>;
default: default:
return <Tooltip content="Config could not be validated" placement="bottom" color="foreground"> return <Tooltip content="Config could not be validated" placement="bottom" color="foreground">
<Chip size="sm" radius="sm" className="text-xs"> <Chip size="sm" radius="sm" className="text-xs">
<Question/> <QuestionIcon/>
</Chip> </Chip>
</Tooltip> </Tooltip>
} }
@@ -95,23 +80,23 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
switch (trustLevel) { switch (trustLevel) {
case PluginTrustLevel.OFFICIAL: case PluginTrustLevel.OFFICIAL:
return <Tooltip color="foreground" placement="bottom" content="Official plugin"> return <Tooltip color="foreground" placement="bottom" content="Official plugin">
<SealCheck className="fill-success"/> <SealCheckIcon className="fill-success"/>
</Tooltip>; </Tooltip>;
case PluginTrustLevel.BUNDLED: case PluginTrustLevel.BUNDLED:
return <Tooltip color="foreground" placement="bottom" content="Bundled plugin"> return <Tooltip color="foreground" placement="bottom" content="Bundled plugin">
<SealCheck/> <SealCheckIcon/>
</Tooltip>; </Tooltip>;
case PluginTrustLevel.THIRD_PARTY: case PluginTrustLevel.THIRD_PARTY:
return <Tooltip color="foreground" placement="bottom" content="3rd party plugin"> return <Tooltip color="foreground" placement="bottom" content="3rd party plugin">
<SealWarning/> <SealWarningIcon/>
</Tooltip>; </Tooltip>;
case PluginTrustLevel.UNTRUSTED: case PluginTrustLevel.UNTRUSTED:
return <Tooltip color="foreground" placement="bottom" content="Invalid plugin signature"> return <Tooltip color="foreground" placement="bottom" content="Invalid plugin signature">
<SealWarning className="fill-danger"/> <SealWarningIcon className="fill-danger"/>
</Tooltip>; </Tooltip>;
default: default:
return <Tooltip color="foreground" placement="bottom" content="Unkown verification status"> return <Tooltip color="foreground" placement="bottom" content="Unkown verification status">
<SealQuestion/> <SealQuestionIcon/>
</Tooltip>; </Tooltip>;
} }
} }
@@ -141,12 +126,12 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
onPress={() => togglePluginEnabled()} onPress={() => togglePluginEnabled()}
isDisabled={plugin.state == PluginState.UNLOADED || plugin.state == PluginState.RESOLVED} isDisabled={plugin.state == PluginState.UNLOADED || plugin.state == PluginState.RESOLVED}
> >
<Power/> <PowerIcon/>
</Button> </Button>
</Tooltip> </Tooltip>
<Tooltip content="Configuration" placement="bottom" color="foreground"> <Tooltip content="Configuration" placement="bottom" color="foreground">
<Button isIconOnly variant="light" onPress={pluginDetailsModal.onOpen}> <Button isIconOnly variant="light" onPress={pluginDetailsModal.onOpen}>
<SlidersHorizontal/> <SlidersHorizontalIcon/>
</Button> </Button>
</Tooltip> </Tooltip>
</div> </div>
@@ -1,12 +1,12 @@
import {Button, Card, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, useDisclosure} from "@heroui/react"; import {Button, Card, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, useDisclosure} from "@heroui/react";
import {DotsThreeVertical} from "@phosphor-icons/react"; import {DotsThreeVerticalIcon} from "@phosphor-icons/react";
import React, {useEffect, useState} from "react"; import React, {useEffect, useState} from "react";
import {MessageEndpoint, PasswordResetEndpoint, UserEndpoint} from "Frontend/generated/endpoints"; import {MessageEndpoint, PasswordResetEndpoint, UserEndpoint} from "Frontend/generated/endpoints";
import {AvatarEndpoint} from "Frontend/endpoints/endpoints"; import {AvatarEndpoint} from "Frontend/endpoints/endpoints";
import Avatar from "Frontend/components/general/Avatar"; import Avatar from "Frontend/components/general/Avatar";
import ConfirmUserDeletionModal from "Frontend/components/general/modals/ConfirmUserDeletionModal"; import ConfirmUserDeletionModal from "Frontend/components/general/modals/ConfirmUserDeletionModal";
import PasswordResetTokenModal from "Frontend/components/general/modals/PasswortResetTokenModal"; import PasswordResetTokenModal from "Frontend/components/general/modals/PasswortResetTokenModal";
import TokenDto from "Frontend/generated/org/gameyfin/app/shared/token/TokenDto"; import TokenDto from "Frontend/generated/org/gameyfin/app/core/token/TokenDto";
import RoleChip from "Frontend/components/general/RoleChip"; import RoleChip from "Frontend/components/general/RoleChip";
import AssignRolesModal from "Frontend/components/general/modals/AssignRolesModal"; import AssignRolesModal from "Frontend/components/general/modals/AssignRolesModal";
import ExtendedUserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/ExtendedUserInfoDto"; import ExtendedUserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/ExtendedUserInfoDto";
@@ -112,7 +112,7 @@ export function UserManagementCard({user}: { user: ExtendedUserInfoDto }) {
<Dropdown placement="bottom-end" size="sm" backdrop="opaque"> <Dropdown placement="bottom-end" size="sm" backdrop="opaque">
<DropdownTrigger> <DropdownTrigger>
<Button isIconOnly variant="light"> <Button isIconOnly variant="light">
<DotsThreeVertical/> <DotsThreeVerticalIcon/>
</Button> </Button>
</DropdownTrigger> </DropdownTrigger>
<DropdownMenu aria-label="Static Actions" items={dropdownItems} disabledKeys={disabledKeys}> <DropdownMenu aria-label="Static Actions" items={dropdownItems} disabledKeys={disabledKeys}>
@@ -1,8 +1,7 @@
import React, {useEffect, useRef, useState} from "react"; import React, {useEffect, useRef, useState} from "react";
import {GameCover} from "Frontend/components/general/covers/GameCover"; import {GameCover} from "Frontend/components/general/covers/GameCover";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto"; import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {ArrowRight} from "@phosphor-icons/react"; import {ArrowRightIcon} from "@phosphor-icons/react";
import {useNavigate} from "react-router";
interface CoverRowProps { interface CoverRowProps {
games: GameDto[]; games: GameDto[];
@@ -16,7 +15,6 @@ const defaultImageWidth = aspectRatio * defaultImageHeight; // default width for
export function CoverRow({games, title, onPressShowMore}: CoverRowProps) { export function CoverRow({games, title, onPressShowMore}: CoverRowProps) {
const navigate = useNavigate();
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(games.length); const [visibleCount, setVisibleCount] = useState(games.length);
@@ -55,12 +53,12 @@ export function CoverRow({games, title, onPressShowMore}: CoverRowProps) {
<div className="flex flex-row items-center justify-end cursor-pointer" <div className="flex flex-row items-center justify-end cursor-pointer"
onClick={onPressShowMore}> onClick={onPressShowMore}>
<div className="absolute h-full w-1/4 right-0 bottom-0 <div className="absolute h-full w-1/4 right-0 bottom-0
bg-gradient-to-r from-transparent to-background bg-linear-to-r from-transparent to-background
transition-all duration-300 ease-in-out hover:opacity-80"/> transition-all duration-300 ease-in-out hover:opacity-80"/>
<div <div
className="absolute h-full right-0 bottom-0 flex flex-row items-center gap-2 pointer-events-none"> 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> <p className="text-xl font-semibold">Show more</p>
<ArrowRight weight="bold"/> <ArrowRightIcon weight="bold"/>
</div> </div>
</div> </div>
)} )}
@@ -14,7 +14,7 @@ export function GameCover({game, size = 300, radius = "sm", interactive = false}
<div className={`${interactive ? "rounded-md scale-95 hover:scale-100 shine transition-all" : ""}`}> <div className={`${interactive ? "rounded-md scale-95 hover:scale-100 shine transition-all" : ""}`}>
<Image <Image
alt={game.title} alt={game.title}
className="z-0 object-cover aspect-[12/17]" className="z-0 object-cover aspect-12/17"
src={`images/cover/${game.coverId}`} src={`images/cover/${game.coverId}`}
radius={radius} radius={radius}
height={size} height={size}
@@ -8,7 +8,7 @@ import "swiper/css/navigation";
import "swiper/css/pagination"; import "swiper/css/pagination";
import "swiper/css/autoplay"; import "swiper/css/autoplay";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import {CaretLeft, CaretRight, IconContext, Play} from "@phosphor-icons/react"; import { CaretLeftIcon, CaretRightIcon, IconContext, PlayIcon } from "@phosphor-icons/react";
interface ImageCarouselProps { interface ImageCarouselProps {
@@ -61,7 +61,7 @@ export default function ImageCarousel({imageUrls, videosUrls, className}: ImageC
<div className="w-full flex flex-col gap-2 items-center"> <div className="w-full flex flex-col gap-2 items-center">
<div className="w-full flex flex-row items-center"> <div className="w-full flex flex-row items-center">
<IconContext.Provider value={{size: 50}}> <IconContext.Provider value={{size: 50}}>
<CaretLeft className="swiper-custom-button-prev cursor-pointer fill-primary"/> <CaretLeftIcon className="swiper-custom-button-prev cursor-pointer fill-primary"/>
<Swiper <Swiper
modules={[Pagination, Navigation, Autoplay]} modules={[Pagination, Navigation, Autoplay]}
slidesPerView={DEFAULT_SLIDES_PER_VIEW > elements.length ? elements.length : DEFAULT_SLIDES_PER_VIEW} slidesPerView={DEFAULT_SLIDES_PER_VIEW > elements.length ? elements.length : DEFAULT_SLIDES_PER_VIEW}
@@ -90,14 +90,14 @@ export default function ImageCarousel({imageUrls, videosUrls, className}: ImageC
<Image <Image
src={e.url} src={e.url}
alt={`Game screenshot slide ${index}`} alt={`Game screenshot slide ${index}`}
className={`w-full h-full object-cover aspect-[16/9] cursor-zoom-in ${!isActive ? "scale-90" : ""}`} className={`w-full h-full object-cover aspect-video cursor-zoom-in ${!isActive ? "scale-90" : ""}`}
onClick={() => showImagePopup(e.url)} onClick={() => showImagePopup(e.url)}
/> />
) )
} }
return ( return (
<Card <Card
className={`w-full h-full aspect-[16/9] ${!isActive ? "scale-90" : ""}`}> className={`w-full h-full aspect-video ${!isActive ? "scale-90" : ""}`}>
<ReactPlayer <ReactPlayer
url={e.url} url={e.url}
width="100%" width="100%"
@@ -105,7 +105,7 @@ export default function ImageCarousel({imageUrls, videosUrls, className}: ImageC
light={true} light={true}
controls={true} controls={true}
playing={isActive} playing={isActive}
playIcon={<Play weight="fill"/>} playIcon={<PlayIcon weight="fill"/>}
/> />
</Card> </Card>
) )
@@ -115,7 +115,7 @@ export default function ImageCarousel({imageUrls, videosUrls, className}: ImageC
<ImagePopup imageUrl={selectedImageUrl} isOpen={imagePopup.isOpen} <ImagePopup imageUrl={selectedImageUrl} isOpen={imagePopup.isOpen}
onOpenChange={imagePopup.onOpenChange}/> onOpenChange={imagePopup.onOpenChange}/>
</Swiper> </Swiper>
<CaretRight className="swiper-custom-button-next cursor-pointer fill-primary"/> <CaretRightIcon className="swiper-custom-button-next cursor-pointer fill-primary"/>
</IconContext.Provider> </IconContext.Provider>
</div> </div>
<div> <div>
@@ -137,7 +137,7 @@ function ImagePopup({imageUrl, isOpen, onOpenChange}: {
<Modal isOpen={isOpen} onOpenChange={onOpenChange} hideCloseButton size="full" backdrop="blur"> <Modal isOpen={isOpen} onOpenChange={onOpenChange} hideCloseButton size="full" backdrop="blur">
<ModalContent className="bg-transparent"> <ModalContent className="bg-transparent">
{(onClose) => ( {(onClose) => (
<div className="flex flex-grow items-center justify-center cursor-zoom-out" <div className="flex grow items-center justify-center cursor-zoom-out"
onClick={onClose}> onClick={onClose}>
<Image <Image
src={imageUrl} src={imageUrl}
@@ -1,7 +1,7 @@
import {FieldArray, useField} from "formik"; import {FieldArray, useField} from "formik";
import {Button, Chip, Input, Popover, PopoverContent, PopoverTrigger} from "@heroui/react"; import {Button, Chip, Input, Popover, PopoverContent, PopoverTrigger} from "@heroui/react";
import {KeyboardEvent, useState} from "react"; import {KeyboardEvent, useState} from "react";
import {Plus} from "@phosphor-icons/react"; import { PlusIcon } from "@phosphor-icons/react";
// @ts-ignore // @ts-ignore
const ArrayInput = ({label, ...props}) => { const ArrayInput = ({label, ...props}) => {
@@ -41,7 +41,7 @@ const ArrayInput = ({label, ...props}) => {
))} ))}
<Popover placement="bottom" showArrow={true}> <Popover placement="bottom" showArrow={true}>
<PopoverTrigger> <PopoverTrigger>
<Button isIconOnly size="sm" variant="light" radius="full"><Plus/></Button> <Button isIconOnly size="sm" variant="light" radius="full"><PlusIcon/></Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent> <PopoverContent>
<Input <Input
@@ -0,0 +1,100 @@
import React, {Key, useEffect, useState} from "react";
import {Autocomplete, AutocompleteItem, Chip} from "@heroui/react";
import {FieldArray, useField} from "formik";
type ArrayInputAutocompleteProps = {
label?: string;
placeholder?: string;
options: string[];
name: string;
defaultSelected?: string[];
};
export default function ArrayInputAutocomplete({
options,
label,
placeholder = "Search...",
defaultSelected = [],
...props
}: ArrayInputAutocompleteProps) {
const [field, meta, helpers] = useField(props);
const [search, setSearch] = useState("");
// Initialize field value if undefined or empty
useEffect(() => {
if (!field.value) {
helpers.setValue(defaultSelected.length > 0 ? defaultSelected : []);
} else if (field.value.length === 0 && defaultSelected.length > 0) {
helpers.setValue(defaultSelected);
}
}, [defaultSelected, field.value, helpers]);
return (
<FieldArray name={field.name}
render={arrayHelpers => {
const selectedValues = field.value || [];
const filteredOptions = options.filter(
(option) =>
option.toLowerCase().includes(search.toLowerCase()) &&
!selectedValues.find((selected: string) => selected === option),
);
const handleSelect = (item: string) => {
if (!selectedValues.find((selected: string) => selected === item)) {
arrayHelpers.push(item);
}
};
const handleRemove = (index: number) => {
arrayHelpers.remove(index);
};
return (
<div className="flex flex-col flex-1 gap-2">
{label && (
<div className="flex flex-row justify-between">
<p>{label}</p>
<small>{selectedValues.length} {selectedValues.length === 1 ? "element" : "elements"} selected</small>
</div>
)}
<Autocomplete
{...props}
aria-labelledby="search"
shouldCloseOnBlur={false}
placeholder={placeholder}
inputValue={search}
onInputChange={(value) => setSearch(value)}
onSelectionChange={(value: Key | null) => {
const item = options.find((option) => option === value);
if (item) handleSelect(item);
setSearch("");
}}
>
{filteredOptions.map((option) => (
<AutocompleteItem key={option} data-selected="true">
{option}
</AutocompleteItem>
))}
</Autocomplete>
<div className="flex flex-wrap gap-2">
{selectedValues.map((item: string, index: number) => (
<Chip key={index} variant="flat"
onClose={() => handleRemove(index)}>
{item}
</Chip>
))}
</div>
<div className="min-h-6 text-danger">
{meta.touched && meta.error && meta.error.trim().length > 0 && (
meta.error
)}
</div>
</div>
);
}}
/>
);
}
@@ -8,7 +8,7 @@ import {
DropdownTrigger, DropdownTrigger,
SharedSelection SharedSelection
} from "@heroui/react"; } from "@heroui/react";
import {CaretDown} from "@phosphor-icons/react"; import { CaretDownIcon } from "@phosphor-icons/react";
import {useUserPreferenceService} from "Frontend/util/user-preference-service"; import {useUserPreferenceService} from "Frontend/util/user-preference-service";
export interface ComboButtonOption { export interface ComboButtonOption {
@@ -52,7 +52,7 @@ export default function ComboButton({options, preferredOptionKey, description}:
} }
return options[selectedOptionValue] && ( return options[selectedOptionValue] && (
<ButtonGroup className="gap-[1px]"> <ButtonGroup className="gap-px">
<Button color="primary" className="w-52" <Button color="primary" className="w-52"
onPress={options[selectedOptionValue].action}> onPress={options[selectedOptionValue].action}>
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
@@ -63,7 +63,7 @@ export default function ComboButton({options, preferredOptionKey, description}:
<Dropdown placement="bottom-end"> <Dropdown placement="bottom-end">
<DropdownTrigger> <DropdownTrigger>
<Button isIconOnly color="primary"> <Button isIconOnly color="primary">
<CaretDown/> <CaretDownIcon/>
</Button> </Button>
</DropdownTrigger> </DropdownTrigger>
<DropdownMenu <DropdownMenu
@@ -11,7 +11,7 @@ export default function DatePickerInput({label, showErrorUntouched = false, ...p
return ( return (
<DatePicker <DatePicker
className="min-h-20 flex-grow" className="min-h-20 grow"
showMonthAndYearPickers showMonthAndYearPickers
fullWidth={false} fullWidth={false}
{...props} {...props}
@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import {Button, Code, useDisclosure} from "@heroui/react"; import {Button, Code, useDisclosure} from "@heroui/react";
import {ArrowRight, Minus, Plus, XCircle} from "@phosphor-icons/react"; import { ArrowRightIcon, MinusIcon, PlusIcon, XCircleIcon } from "@phosphor-icons/react";
import PathPickerModal from "Frontend/components/general/modals/PathPickerModal"; import PathPickerModal from "Frontend/components/general/modals/PathPickerModal";
import {SmallInfoField} from "Frontend/components/general/SmallInfoField"; import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
import DirectoryMappingDto from "Frontend/generated/org/gameyfin/app/libraries/dto/DirectoryMappingDto"; import DirectoryMappingDto from "Frontend/generated/org/gameyfin/app/libraries/dto/DirectoryMappingDto";
@@ -28,7 +28,7 @@ export default function DirectoryMappingInput({name}: DirectoryMappingInputProps
<p className="font-bold">Directories</p> <p className="font-bold">Directories</p>
<Button isIconOnly variant="light" size="sm" color="default" <Button isIconOnly variant="light" size="sm" color="default"
onPress={pathPickerModal.onOpen}> onPress={pathPickerModal.onOpen}>
<Plus/> <PlusIcon/>
</Button> </Button>
</div> </div>
{(field.value || []).map((directory) => ( {(field.value || []).map((directory) => (
@@ -43,8 +43,8 @@ export default function DirectoryMappingInput({name}: DirectoryMappingInputProps
/> />
{directory.externalPath && ( {directory.externalPath && (
<> <>
<div className="flex-shrink-0 flex items-center justify-center mx-2"> <div className="shrink-0 flex items-center justify-center mx-2">
<ArrowRight size={20}/> <ArrowRightIcon size={20}/>
</div> </div>
<input <input
type="text" type="text"
@@ -62,13 +62,13 @@ export default function DirectoryMappingInput({name}: DirectoryMappingInputProps
onPress={() => removeDirectoryMapping(directory)} onPress={() => removeDirectoryMapping(directory)}
className="ml-2" className="ml-2"
> >
<Minus/> <MinusIcon/>
</Button> </Button>
</Code> </Code>
))} ))}
<div className="min-h-6 text-danger"> <div className="min-h-6 text-danger">
{meta.touched && meta.error && ( {meta.touched && meta.error && (
<SmallInfoField icon={XCircle} message={meta.error}/> <SmallInfoField icon={XCircleIcon} message={meta.error}/>
)} )}
</div> </div>
<PathPickerModal returnSelectedPath={addDirectoryMapping} <PathPickerModal returnSelectedPath={addDirectoryMapping}
@@ -1,5 +1,10 @@
import TreeView, {flattenTree, INode, NodeId} from "react-accessible-treeview"; import TreeView, {flattenTree, INode, NodeId} from "react-accessible-treeview";
import {File, Folder, FolderOpen, IconContext} from "@phosphor-icons/react"; import {
FileIcon as PhFileIcon,
FolderIcon as PhFolderIcon,
FolderOpenIcon as PhFolderOpenIcon,
IconContext
} from "@phosphor-icons/react";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import {FilesystemEndpoint} from "Frontend/generated/endpoints"; import {FilesystemEndpoint} from "Frontend/generated/endpoints";
import FileDto from "Frontend/generated/org/gameyfin/app/core/filesystem/FileDto"; import FileDto from "Frontend/generated/org/gameyfin/app/core/filesystem/FileDto";
@@ -146,9 +151,9 @@ export default function FileTreeView({onPathChange}: { onPathChange: (file: stri
} }
function FolderIcon({isOpen}: { isOpen: boolean }) { function FolderIcon({isOpen}: { isOpen: boolean }) {
return isOpen ? <FolderOpen/> : <Folder/>; return isOpen ? <PhFolderOpenIcon/> : <PhFolderIcon/>;
} }
function FileIcon({fileName}: { fileName: string }) { function FileIcon({fileName}: { fileName: string }) {
return <File/>; return <PhFileIcon/>;
} }
@@ -2,7 +2,7 @@ import {Image, useDisclosure} from "@heroui/react";
import React from "react"; import React from "react";
import {useField} from "formik"; import {useField} from "formik";
import {GameCoverPickerModal} from "Frontend/components/general/modals/GameCoverPickerModal"; import {GameCoverPickerModal} from "Frontend/components/general/modals/GameCoverPickerModal";
import {ImageBroken, Pencil} from "@phosphor-icons/react"; import { ImageBrokenIcon, PencilIcon } from "@phosphor-icons/react";
// @ts-ignore // @ts-ignore
@@ -14,13 +14,13 @@ export default function GameCoverPicker({game, showErrorUntouched = false, ...pr
const gameCoverPickerModal = useDisclosure(); const gameCoverPickerModal = useDisclosure();
return (<> return (<>
<div className="relative group aspect-[12/17] cursor-pointer bg-background/50" <div className="relative group aspect-12/17 cursor-pointer bg-background/50"
onClick={gameCoverPickerModal.onOpenChange}> onClick={gameCoverPickerModal.onOpenChange}>
{field.value || game.coverId ? {field.value || game.coverId ?
<div className="size-full overflow-hidden"> <div className="size-full overflow-hidden">
<Image <Image
alt={game.title} alt={game.title}
className="z-0 object-cover group-hover:brightness-[25%]" className="z-0 object-cover group-hover:brightness-25"
src={field.value ? field.value : `images/cover/${game.coverId}`} src={field.value ? field.value : `images/cover/${game.coverId}`}
{...props} {...props}
{...field} {...field}
@@ -30,13 +30,13 @@ export default function GameCoverPicker({game, showErrorUntouched = false, ...pr
<div <div
className="absolute inset-0 flex flex-col text-center items-center justify-center group-hover:opacity-0" className="absolute inset-0 flex flex-col text-center items-center justify-center group-hover:opacity-0"
> >
<ImageBroken size={46}/> <ImageBrokenIcon size={46}/>
<p>No cover image available</p> <p>No cover image available</p>
</div>} </div>}
<div <div
className="absolute inset-0 flex flex-col gap-2 text-center items-center justify-center opacity-0 group-hover:opacity-100" className="absolute inset-0 flex flex-col gap-2 text-center items-center justify-center opacity-0 group-hover:opacity-100"
> >
<Pencil size={46}/> <PencilIcon size={46}/>
<p>Edit cover</p> <p>Edit cover</p>
</div> </div>
</div> </div>
@@ -1,7 +1,7 @@
import {Image, useDisclosure} from "@heroui/react"; import {Image, useDisclosure} from "@heroui/react";
import React from "react"; import React from "react";
import {useField} from "formik"; import {useField} from "formik";
import {ImageBroken, Pencil} from "@phosphor-icons/react"; import {ImageBrokenIcon, PencilIcon} from "@phosphor-icons/react";
import {GameHeaderPickerModal} from "Frontend/components/general/modals/GameHeaderPickerModal"; import {GameHeaderPickerModal} from "Frontend/components/general/modals/GameHeaderPickerModal";
@@ -20,7 +20,7 @@ export default function GameHeaderPicker({game, showErrorUntouched = false, ...p
<div className="size-full overflow-hidden"> <div className="size-full overflow-hidden">
<Image <Image
alt={game.title} alt={game.title}
className="z-0 object-cover group-hover:brightness-[25%]" className="z-0 object-cover group-hover:brightness-25"
src={field.value ? field.value : `images/cover/${game.headerId}`} src={field.value ? field.value : `images/cover/${game.headerId}`}
{...props} {...props}
{...field} {...field}
@@ -30,13 +30,13 @@ export default function GameHeaderPicker({game, showErrorUntouched = false, ...p
<div <div
className="absolute inset-0 flex flex-col text-center items-center justify-center group-hover:opacity-0" className="absolute inset-0 flex flex-col text-center items-center justify-center group-hover:opacity-0"
> >
<ImageBroken size={46}/> <ImageBrokenIcon size={46}/>
<p>No header image available</p> <p>No header image available</p>
</div>} </div>}
<div <div
className="absolute inset-0 flex flex-col gap-2 text-center items-center justify-center opacity-0 group-hover:opacity-100" className="absolute inset-0 flex flex-col gap-2 text-center items-center justify-center opacity-0 group-hover:opacity-100"
> >
<Pencil size={46}/> <PencilIcon size={46}/>
<p>Edit header image</p> <p>Edit header image</p>
</div> </div>
</div> </div>
@@ -1,5 +1,5 @@
import {useField} from "formik"; import {useField} from "formik";
import {Input as NextUiInput} from "@heroui/react"; import {Input as HeroUiInput} from "@heroui/react";
// @ts-ignore // @ts-ignore
const Input = ({label, showErrorUntouched = false, ...props}) => { const Input = ({label, showErrorUntouched = false, ...props}) => {
@@ -7,8 +7,8 @@ const Input = ({label, showErrorUntouched = false, ...props}) => {
const [field, meta] = useField(props); const [field, meta] = useField(props);
return ( return (
<NextUiInput <HeroUiInput
className="min-h-20 flex-grow" className="min-h-20 grow"
fullWidth={false} fullWidth={false}
{...props} {...props}
{...field} {...field}
@@ -0,0 +1,26 @@
import {useField} from "formik";
import {NumberInput as HeroUiNumberInput} from "@heroui/react";
// @ts-ignore
const NumberInput = ({label, showErrorUntouched = false, ...props}) => {
// @ts-ignore
const [field, meta, helpers] = useField(props);
return (
<HeroUiNumberInput
className="min-h-20 grow"
fullWidth={false}
{...props}
value={field.value}
onValueChange={(value) => helpers.setValue(value)}
onBlur={field.onBlur}
name={field.name}
id={label}
label={label}
isInvalid={(meta.touched || showErrorUntouched) && !!meta.error}
errorMessage={meta.initialError || meta.error}
/>
);
}
export default NumberInput;
@@ -9,7 +9,7 @@ const SelectInput = ({label, values, ...props}) => {
const items = values.map((v: string) => ({key: v, label: v})); const items = values.map((v: string) => ({key: v, label: v}));
return ( return (
<div className="min-h-20 flex-grow"> <div className="min-h-20 grow">
<Select <Select
fullWidth={true} fullWidth={true}
{...field} {...field}
@@ -0,0 +1,23 @@
import {useField} from "formik";
import {Slider as HeroUiSlider} from "@heroui/react";
// @ts-ignore
const SliderInput = ({label, showErrorUntouched = false, ...props}) => {
// @ts-ignore
const [field, meta, helpers] = useField(props);
return (
<HeroUiSlider
className="min-h-20 grow"
{...props}
value={field.value}
onChange={(value) => helpers.setValue(value)}
onBlur={field.onBlur}
name={field.name}
id={label}
label={label}
/>
);
}
export default SliderInput;
@@ -8,7 +8,7 @@ export default function TextAreaInput({label, showErrorUntouched = false, ...pro
return ( return (
<Textarea <Textarea
className={`flex-grow ${meta.initialError || meta.error ? "" : "mb-6"}`} className={`grow ${meta.initialError || meta.error ? "" : "mb-6"}`}
fullWidth={false} fullWidth={false}
{...props} {...props}
{...field} {...field}
@@ -1,5 +1,5 @@
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto"; import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import {Check} from "@phosphor-icons/react"; import {CheckIcon} from "@phosphor-icons/react";
import {addToast, Button} from "@heroui/react"; import {addToast, Button} from "@heroui/react";
import React from "react"; import React from "react";
import {Form, Formik} from "formik"; import {Form, Formik} from "formik";
@@ -11,6 +11,9 @@ import DirectoryMappingInput from "Frontend/components/general/input/DirectoryMa
import Section from "Frontend/components/general/Section"; import Section from "Frontend/components/general/Section";
import {useNavigate} from "react-router"; import {useNavigate} from "react-router";
import * as Yup from "yup"; import * as Yup from "yup";
import ArrayInputAutocomplete from "Frontend/components/general/input/ArrayInputAutocomplete";
import {useSnapshot} from "valtio/react";
import {platformState} from "Frontend/state/PlatformState";
interface LibraryManagementDetailsProps { interface LibraryManagementDetailsProps {
library: LibraryDto; library: LibraryDto;
@@ -19,6 +22,7 @@ interface LibraryManagementDetailsProps {
export default function LibraryManagementDetails({library}: LibraryManagementDetailsProps) { export default function LibraryManagementDetails({library}: LibraryManagementDetailsProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const [librarySaved, setLibrarySaved] = React.useState(false); const [librarySaved, setLibrarySaved] = React.useState(false);
const availablePlatforms = useSnapshot(platformState).available;
async function handleSubmit(values: LibraryDto): Promise<void> { async function handleSubmit(values: LibraryDto): Promise<void> {
const changed = deepDiff(library, values) as LibraryUpdateDto; const changed = deepDiff(library, values) as LibraryUpdateDto;
@@ -66,7 +70,7 @@ export default function LibraryManagementDetails({library}: LibraryManagementDet
> >
{(formik) => ( {(formik) => (
<Form> <Form>
<div className="flex flex-row flex-grow justify-between mb-4"> <div className="flex flex-row grow justify-between mb-4">
<h1 className="text-2xl font-bold">Edit library details</h1> <h1 className="text-2xl font-bold">Edit library details</h1>
<Button <Button
color="primary" color="primary"
@@ -74,12 +78,14 @@ export default function LibraryManagementDetails({library}: LibraryManagementDet
isDisabled={formik.isSubmitting || librarySaved || !formik.dirty} isDisabled={formik.isSubmitting || librarySaved || !formik.dirty}
type="submit" type="submit"
> >
{formik.isSubmitting ? "" : librarySaved ? <Check/> : "Save"} {formik.isSubmitting ? "" : librarySaved ? <CheckIcon/> : "Save"}
</Button> </Button>
</div> </div>
<Input label="Library name" name="name"/> <Input label="Library name" name="name"/>
<ArrayInputAutocomplete options={Array.from(availablePlatforms)} name="platforms" label="Platforms"/>
<DirectoryMappingInput name="directories"/> <DirectoryMappingInput name="directories"/>
<Section title="Danger zone"/> <Section title="Danger zone"/>
@@ -17,7 +17,7 @@ import {
Tooltip, Tooltip,
useDisclosure useDisclosure
} from "@heroui/react"; } from "@heroui/react";
import {CheckCircle, MagnifyingGlass, Pencil, Trash} from "@phosphor-icons/react"; import {CheckCircleIcon, MagnifyingGlassIcon, PencilIcon, TrashIcon} from "@phosphor-icons/react";
import {useSnapshot} from "valtio/react"; import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState"; import {gameState} from "Frontend/state/GameState";
import {GameEndpoint} from "Frontend/generated/endpoints"; import {GameEndpoint} from "Frontend/generated/endpoints";
@@ -28,6 +28,7 @@ import MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
import {GameAdminDto} from "Frontend/dtos/GameDtos"; import {GameAdminDto} from "Frontend/dtos/GameDtos";
import MetadataCompletenessIndicator from "Frontend/components/general/MetadataCompletenessIndicator"; import MetadataCompletenessIndicator from "Frontend/components/general/MetadataCompletenessIndicator";
import {metadataCompleteness} from "Frontend/util/utils"; import {metadataCompleteness} from "Frontend/util/utils";
import ChipList from "Frontend/components/general/ChipList";
interface LibraryManagementGamesProps { interface LibraryManagementGamesProps {
library: LibraryDto; library: LibraryDto;
@@ -162,6 +163,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
}> }>
<TableHeader> <TableHeader>
<TableColumn key="title" allowsSorting>Game</TableColumn> <TableColumn key="title" allowsSorting>Game</TableColumn>
<TableColumn key="platforms">Platforms</TableColumn>
<TableColumn key="addedToLibrary" allowsSorting>Added to library</TableColumn> <TableColumn key="addedToLibrary" allowsSorting>Added to library</TableColumn>
<TableColumn key="downloadCount" allowsSorting>Download count</TableColumn> <TableColumn key="downloadCount" allowsSorting>Download count</TableColumn>
<TableColumn>Path</TableColumn> <TableColumn>Path</TableColumn>
@@ -179,6 +181,9 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
underline="hover">{item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"}) underline="hover">{item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"})
</Link> </Link>
</TableCell> </TableCell>
<TableCell>
<ChipList items={item.platforms} maxVisible={1} defaultContent="Unspecified"/>
</TableCell>
<TableCell> <TableCell>
{new Date(item.createdAt).toLocaleString()} {new Date(item.createdAt).toLocaleString()}
</TableCell> </TableCell>
@@ -196,10 +201,10 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
<Button isIconOnly size="sm" onPress={() => toggleMatchConfirmed(item)}> <Button isIconOnly size="sm" onPress={() => toggleMatchConfirmed(item)}>
{item.metadata.matchConfirmed ? {item.metadata.matchConfirmed ?
<Tooltip content="Unconfirm match"> <Tooltip content="Unconfirm match">
<CheckCircle weight="fill" className="fill-success"/> <CheckCircleIcon weight="fill" className="fill-success"/>
</Tooltip> : </Tooltip> :
<Tooltip content="Confirm match"> <Tooltip content="Confirm match">
<CheckCircle/> <CheckCircleIcon/>
</Tooltip>} </Tooltip>}
</Button> </Button>
<Button isIconOnly size="sm" onPress={() => { <Button isIconOnly size="sm" onPress={() => {
@@ -207,7 +212,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
editGameModal.onOpenChange(); editGameModal.onOpenChange();
}}> }}>
<Tooltip content="Edit metadata"> <Tooltip content="Edit metadata">
<Pencil/> <PencilIcon/>
</Tooltip> </Tooltip>
</Button> </Button>
<Button isIconOnly size="sm" onPress={() => { <Button isIconOnly size="sm" onPress={() => {
@@ -215,13 +220,13 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
matchGameModal.onOpenChange(); matchGameModal.onOpenChange();
}}> }}>
<Tooltip content="Match game"> <Tooltip content="Match game">
<MagnifyingGlass/> <MagnifyingGlassIcon/>
</Tooltip> </Tooltip>
</Button> </Button>
<Button isIconOnly size="sm" color="danger" <Button isIconOnly size="sm" color="danger"
onPress={() => deleteGame(item)}> onPress={() => deleteGame(item)}>
<Tooltip content="Remove from library"> <Tooltip content="Remove from library">
<Trash/> <TrashIcon/>
</Tooltip> </Tooltip>
</Button> </Button>
</div> </div>
@@ -12,35 +12,45 @@ import {
Tooltip, Tooltip,
useDisclosure useDisclosure
} from "@heroui/react"; } from "@heroui/react";
import {MagnifyingGlass, Trash} from "@phosphor-icons/react"; import {MagnifyingGlassIcon, TrashIcon} from "@phosphor-icons/react";
import {LibraryEndpoint} from "Frontend/generated/endpoints"; import {LibraryEndpoint} from "Frontend/generated/endpoints";
import {useMemo, useState} from "react"; import {useMemo, useState} from "react";
import LibraryUpdateDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryUpdateDto"; import LibraryUpdateDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryUpdateDto";
import {fileNameFromPath, hashCode} from "Frontend/util/utils"; import {fileNameFromPath} from "Frontend/util/utils";
import MatchGameModal from "Frontend/components/general/modals/MatchGameModal"; import MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto"; import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
import IgnoredPathDto from "Frontend/generated/org/gameyfin/app/libraries/dto/IgnoredPathDto";
import IgnoredPathSourceTypeDto from "Frontend/generated/org/gameyfin/app/libraries/dto/IgnoredPathSourceTypeDto";
import {useSnapshot} from "valtio/react";
import {pluginState} from "Frontend/state/PluginState";
import {userState} from "Frontend/state/UserState";
import PluginIcon from "Frontend/components/general/plugin/PluginIcon";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
interface LibraryManagementUnmatchedPathsProps { interface LibraryManagementIgnoredPathsProps {
library: LibraryAdminDto; library: LibraryAdminDto;
} }
export default function LibraryManagementUnmatchedPaths({library}: LibraryManagementUnmatchedPathsProps) { export default function LibraryManagementIgnoredPaths({library}: LibraryManagementIgnoredPathsProps) {
const plugins = useSnapshot(pluginState).state;
const users = useSnapshot(userState).state;
const matchGameModal = useDisclosure(); const matchGameModal = useDisclosure();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const rowsPerPage = 25; const rowsPerPage = 25;
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [selectedPath, setSelectedPath] = useState(library.unmatchedPaths ? library.unmatchedPaths[0] : null); const [selectedPath, setSelectedPath] = useState(library.ignoredPaths ? library.ignoredPaths[0] : null);
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({column: "path", direction: "ascending"}); const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({column: "path", direction: "ascending"});
const pages = useMemo(() => { const pages = useMemo(() => {
return Math.ceil(getFilteredPaths().length / rowsPerPage); return Math.ceil(getFilteredPaths().length / rowsPerPage);
}, [library.unmatchedPaths, searchTerm]); }, [library.ignoredPaths, searchTerm]);
const filteredPaths = useMemo(() => { const filteredPaths = useMemo(() => {
return library.unmatchedPaths! return library.ignoredPaths!
.filter((path) => path.toLowerCase().includes(searchTerm.toLowerCase())) .filter((path) => path.path.toLowerCase().includes(searchTerm.toLowerCase()))
.map((path) => ({key: hashCode(path), path})); .map((path) => ({key: path.id, path}));
}, [library, searchTerm]); }, [library, searchTerm]);
const sortedPaths = useMemo(() => { const sortedPaths = useMemo(() => {
@@ -48,7 +58,7 @@ export default function LibraryManagementUnmatchedPaths({library}: LibraryManage
let cmp: number; let cmp: number;
switch (sortDescriptor.column) { switch (sortDescriptor.column) {
case "path": case "path":
cmp = a.path.localeCompare(b.path); cmp = a.path.path.localeCompare(b.path.path);
break; break;
default: default:
cmp = 0; cmp = 0;
@@ -66,22 +76,44 @@ export default function LibraryManagementUnmatchedPaths({library}: LibraryManage
return sortedPaths.slice(start, end); return sortedPaths.slice(start, end);
}, [page, sortedPaths]); }, [page, sortedPaths]);
async function deleteUnmatchedPath(unmatchedPath: string) { async function deleteIgnoredPath(ignoredPath: IgnoredPathDto) {
const libraryUpdateDto: LibraryUpdateDto = { const libraryUpdateDto: LibraryUpdateDto = {
id: library.id, id: library.id,
unmatchedPaths: library.unmatchedPaths!.filter((path) => path !== unmatchedPath) ignoredPaths: library.ignoredPaths!.filter((path) => path.id !== ignoredPath.id)
} }
await LibraryEndpoint.updateLibrary(libraryUpdateDto); await LibraryEndpoint.updateLibrary(libraryUpdateDto);
} }
function getFilteredPaths() { function getFilteredPaths() {
return library.unmatchedPaths!!.filter((path) => return library.ignoredPaths!!.filter((path) =>
path.toLowerCase().includes(searchTerm.toLowerCase()) path.path.toLowerCase().includes(searchTerm.toLowerCase())
) )
} }
function renderSource(ignoredPath: IgnoredPathDto) {
if (ignoredPath.sourceType === IgnoredPathSourceTypeDto.USER) {
const userId = Number(ignoredPath.source);
const user = users[userId];
return user ? `Manually added by user (${user.username})` : "Unknown user";
} else if (ignoredPath.sourceType === IgnoredPathSourceTypeDto.PLUGIN) {
const pluginIds: string[] = JSON.parse(ignoredPath.source)
return pluginIds ?
<div className="flex flex-row gap-2 items-center">
<p>Automatically added by plugins (</p>
{pluginIds.map(id => {
const p = plugins[id];
return p ? <PluginIcon key={id} plugin={p as PluginDto}/>
: "Unknown plugin";
})}
<p>)</p>
</div>
: "Unknown plugins"
}
return ignoredPath.source;
}
return <div className="flex flex-col gap-4"> return <div className="flex flex-col gap-4">
<h1 className="text-2xl font-bold">Manage unmatched paths</h1> <h1 className="text-2xl font-bold">Manage ignored paths</h1>
<Input <Input
className="w-96" className="w-96"
isClearable isClearable
@@ -109,13 +141,17 @@ export default function LibraryManagementUnmatchedPaths({library}: LibraryManage
}> }>
<TableHeader> <TableHeader>
<TableColumn key="path" allowsSorting>Path</TableColumn> <TableColumn key="path" allowsSorting>Path</TableColumn>
<TableColumn key="source">Source</TableColumn>
<TableColumn width={1}>Actions</TableColumn> <TableColumn width={1}>Actions</TableColumn>
</TableHeader> </TableHeader>
<TableBody emptyContent="This library has no unmatched paths." items={pagedPaths}> <TableBody emptyContent="This library has no ignored paths." items={pagedPaths}>
{(item) => ( {(item) => (
<TableRow key={item.key}> <TableRow key={item.key}>
<TableCell> <TableCell>
{item.path} {item.path.path}
</TableCell>
<TableCell>
{renderSource(item.path)}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">
@@ -124,12 +160,12 @@ export default function LibraryManagementUnmatchedPaths({library}: LibraryManage
setSelectedPath(item.path); setSelectedPath(item.path);
matchGameModal.onOpenChange(); matchGameModal.onOpenChange();
}}> }}>
<MagnifyingGlass/> <MagnifyingGlassIcon/>
</Button> </Button>
</Tooltip> </Tooltip>
<Tooltip content="Remove entry from list"> <Tooltip content="Remove entry from list">
<Button isIconOnly size="sm" color="danger" <Button isIconOnly size="sm" color="danger"
onPress={() => deleteUnmatchedPath(item.path)}><Trash/> onPress={() => deleteIgnoredPath(item.path)}><TrashIcon/>
</Button> </Button>
</Tooltip> </Tooltip>
</div> </div>
@@ -138,9 +174,9 @@ export default function LibraryManagementUnmatchedPaths({library}: LibraryManage
)} )}
</TableBody> </TableBody>
</Table> </Table>
{selectedPath && <MatchGameModal path={selectedPath} {selectedPath && <MatchGameModal path={selectedPath.path}
libraryId={library.id} libraryId={library.id}
initialSearchTerm={fileNameFromPath(selectedPath, false)} initialSearchTerm={fileNameFromPath(selectedPath.path, false)}
isOpen={matchGameModal.isOpen} isOpen={matchGameModal.isOpen}
onOpenChange={matchGameModal.onOpenChange}/> onOpenChange={matchGameModal.onOpenChange}/>
} }
@@ -83,7 +83,7 @@ export default function AssignRolesModal({isOpen, onOpenChange, user}: AssignRol
placeholder="Select roles" placeholder="Select roles"
renderValue={(items: SelectedItems<Role>) => { renderValue={(items: SelectedItems<Role>) => {
return ( return (
<div className="flex flex-grow flex-wrap gap-2"> <div className="flex grow flex-wrap gap-2">
{items.map((item) => ( {items.map((item) => (
<RoleChip key={item.key} role={item.textValue as string}/> <RoleChip key={item.key} role={item.textValue as string}/>
))} ))}
@@ -11,8 +11,9 @@ import {
} from "@heroui/react"; } from "@heroui/react";
import {Form, Formik} from "formik"; import {Form, Formik} from "formik";
import Input from "Frontend/components/general/input/Input"; import Input from "Frontend/components/general/input/Input";
import React from "react"; import React, {useEffect, useState} from "react";
import GameUpdateDto from "Frontend/generated/org/gameyfin/app/games/dto/GameUpdateDto"; import GameUpdateDto from "Frontend/generated/org/gameyfin/app/games/dto/GameUpdateDto";
import GameEnumPropertyValuesDto from "Frontend/generated/org/gameyfin/app/games/dto/GameEnumPropertyValuesDto";
import {deepDiff} from "Frontend/util/utils"; import {deepDiff} from "Frontend/util/utils";
import {GameEndpoint} from "Frontend/generated/endpoints"; import {GameEndpoint} from "Frontend/generated/endpoints";
import TextAreaInput from "Frontend/components/general/input/TextAreaInput"; import TextAreaInput from "Frontend/components/general/input/TextAreaInput";
@@ -21,6 +22,9 @@ import GameCoverPicker from "Frontend/components/general/input/GameCoverPicker";
import DatePickerInput from "Frontend/components/general/input/DatePickerInput"; import DatePickerInput from "Frontend/components/general/input/DatePickerInput";
import ArrayInput from "Frontend/components/general/input/ArrayInput"; import ArrayInput from "Frontend/components/general/input/ArrayInput";
import GameHeaderPicker from "Frontend/components/general/input/GameHeaderPicker"; import GameHeaderPicker from "Frontend/components/general/input/GameHeaderPicker";
import ArrayInputAutocomplete from "Frontend/components/general/input/ArrayInputAutocomplete";
import {useSnapshot} from "valtio/react";
import {platformState} from "Frontend/state/PlatformState";
interface EditGameMetadataModalProps { interface EditGameMetadataModalProps {
game: GameDto; game: GameDto;
@@ -29,7 +33,14 @@ interface EditGameMetadataModalProps {
} }
export default function EditGameMetadataModal({game, isOpen, onOpenChange}: EditGameMetadataModalProps) { export default function EditGameMetadataModal({game, isOpen, onOpenChange}: EditGameMetadataModalProps) {
return ( const availablePlatforms = useSnapshot(platformState).available;
const [propertyEnumValues, setPropertyEnumValues] = useState<GameEnumPropertyValuesDto>();
useEffect(() => {
GameEndpoint.getEnumPropertyValues().then(setPropertyEnumValues);
}, []);
return propertyEnumValues && (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="3xl"> <Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="3xl">
<ModalContent> <ModalContent>
{(onClose) => { {(onClose) => {
@@ -69,6 +80,8 @@ export default function EditGameMetadataModal({game, isOpen, onOpenChange}: Edit
<DatePickerInput key="release" name="release" label="Release" <DatePickerInput key="release" name="release" label="Release"
className="w-fit"/> className="w-fit"/>
</div> </div>
<ArrayInputAutocomplete options={Array.from(availablePlatforms)}
name="platforms" label="Platforms"/>
<TextAreaInput key="summary" name="summary" label="Summary (HTML)"/> <TextAreaInput key="summary" name="summary" label="Summary (HTML)"/>
<TextAreaInput key="comment" name="comment" label="Comment (Markdown)"/> <TextAreaInput key="comment" name="comment" label="Comment (Markdown)"/>
<Accordion variant="splitted" <Accordion variant="splitted"
@@ -81,14 +94,21 @@ export default function EditGameMetadataModal({game, isOpen, onOpenChange}: Edit
title="Additional Metadata"> title="Additional Metadata">
<ArrayInput key="developers" name="developers" label="Developers"/> <ArrayInput key="developers" name="developers" label="Developers"/>
<ArrayInput key="publishers" name="publishers" label="Publishers"/> <ArrayInput key="publishers" name="publishers" label="Publishers"/>
<ArrayInput key="genres" name="genres" label="Genres"/> <ArrayInputAutocomplete options={propertyEnumValues.genres}
<ArrayInput key="themes" name="themes" label="Themes"/> defaultSelected={game.genres}
key="genres" name="genres" label="Genres"/>
<ArrayInputAutocomplete options={propertyEnumValues.themes}
defaultSelected={game.themes}
key="themes" name="themes" label="Themes"/>
<ArrayInputAutocomplete options={propertyEnumValues.features}
defaultSelected={game.features}
key="features" name="features"
label="Features"/>
<ArrayInputAutocomplete options={propertyEnumValues.perspectives}
defaultSelected={game.perspectives}
key="perspectives" name="perspectives"
label="Perspectives"/>
<ArrayInput key="keywords" name="keywords" label="Keywords"/> <ArrayInput key="keywords" name="keywords" label="Keywords"/>
<ArrayInput key="features" name="features" label="Features"/>
<ArrayInput key="perspectives" name="perspectives"
label="Perspectives"/>
<ArrayInput key="keywords" name="keywords"
label="Keywords"/>
</AccordionItem> </AccordionItem>
</Accordion> </Accordion>
</ModalBody> </ModalBody>
@@ -3,7 +3,7 @@ import {Button, Image, Input, Modal, ModalBody, ModalContent, ModalHeader, Scrol
import React, {useEffect, useState} from "react"; import React, {useEffect, useState} from "react";
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto"; import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
import {GameEndpoint} from "Frontend/generated/endpoints"; import {GameEndpoint} from "Frontend/generated/endpoints";
import {ArrowRight, MagnifyingGlass} from "@phosphor-icons/react"; import {ArrowRightIcon, MagnifyingGlassIcon} from "@phosphor-icons/react";
import PluginIcon from "Frontend/components/general/plugin/PluginIcon"; import PluginIcon from "Frontend/components/general/plugin/PluginIcon";
import {useSnapshot} from "valtio/react"; import {useSnapshot} from "valtio/react";
import {pluginState} from "Frontend/state/PluginState"; import {pluginState} from "Frontend/state/PluginState";
@@ -33,7 +33,7 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
async function search() { async function search() {
setIsSearching(true); setIsSearching(true);
const results = await GameEndpoint.getPotentialMatches(searchTerm); const results = await GameEndpoint.getPotentialMatches(searchTerm, game.platforms);
let validResults = results.filter(result => result.coverUrls && result.coverUrls.length > 0); let validResults = results.filter(result => result.coverUrls && result.coverUrls.length > 0);
setSearchResults(validResults); setSearchResults(validResults);
setIsSearching(false); setIsSearching(false);
@@ -59,7 +59,7 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
setCoverUrl(coverUrl); setCoverUrl(coverUrl);
onClose(); onClose();
}}> }}>
<ArrowRight/> <ArrowRightIcon/>
</Button> </Button>
</div> </div>
<div className="flex flex-row gap-2 mb-4"> <div className="flex flex-row gap-2 mb-4">
@@ -74,7 +74,7 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
}} }}
/> />
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}> <Button isIconOnly onPress={search} color="primary" isLoading={isSearching}>
<MagnifyingGlass/> <MagnifyingGlassIcon/>
</Button> </Button>
</div> </div>
{searchResults.length === 0 && !isSearching && {searchResults.length === 0 && !isSearching &&
@@ -103,7 +103,7 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
> >
<Image <Image
alt={cover.title} alt={cover.title}
className="z-0 object-cover aspect-[12/17] group-hover:brightness-[25%]" className="z-0 object-cover aspect-12/17 group-hover:brightness-25"
src={cover.url} src={cover.url}
radius="none" radius="none"
height={216} height={216}
@@ -113,7 +113,7 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
<PluginIcon plugin={state[cover.source] as PluginDto} size={32} <PluginIcon plugin={state[cover.source] as PluginDto} size={32}
blurred={false} showTooltip={false}/> blurred={false} showTooltip={false}/>
<p className="text-s text-center">{cover.title}</p> <p className="text-s text-center">{cover.title}</p>
<ArrowRight/> <ArrowRightIcon/>
</div> </div>
</div> </div>
))} ))}
@@ -3,7 +3,7 @@ import {Button, Image, Input, Modal, ModalBody, ModalContent, ModalHeader, Scrol
import React, {useEffect, useState} from "react"; import React, {useEffect, useState} from "react";
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto"; import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
import {GameEndpoint} from "Frontend/generated/endpoints"; import {GameEndpoint} from "Frontend/generated/endpoints";
import {ArrowRight, MagnifyingGlass} from "@phosphor-icons/react"; import {ArrowRightIcon, MagnifyingGlassIcon} from "@phosphor-icons/react";
import PluginIcon from "Frontend/components/general/plugin/PluginIcon"; import PluginIcon from "Frontend/components/general/plugin/PluginIcon";
import {useSnapshot} from "valtio/react"; import {useSnapshot} from "valtio/react";
import {pluginState} from "Frontend/state/PluginState"; import {pluginState} from "Frontend/state/PluginState";
@@ -33,7 +33,7 @@ export function GameHeaderPickerModal({game, isOpen, onOpenChange, setHeaderUrl}
async function search() { async function search() {
setIsSearching(true); setIsSearching(true);
const results = await GameEndpoint.getPotentialMatches(searchTerm); const results = await GameEndpoint.getPotentialMatches(searchTerm, game.platforms);
let validResults = results.filter(result => result.headerUrls && result.headerUrls.length > 0); let validResults = results.filter(result => result.headerUrls && result.headerUrls.length > 0);
setSearchResults(validResults); setSearchResults(validResults);
setIsSearching(false); setIsSearching(false);
@@ -59,7 +59,7 @@ export function GameHeaderPickerModal({game, isOpen, onOpenChange, setHeaderUrl}
setHeaderUrl(headerUrl); setHeaderUrl(headerUrl);
onClose(); onClose();
}}> }}>
<ArrowRight/> <ArrowRightIcon/>
</Button> </Button>
</div> </div>
<div className="flex flex-row gap-2 mb-4"> <div className="flex flex-row gap-2 mb-4">
@@ -74,7 +74,7 @@ export function GameHeaderPickerModal({game, isOpen, onOpenChange, setHeaderUrl}
}} }}
/> />
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}> <Button isIconOnly onPress={search} color="primary" isLoading={isSearching}>
<MagnifyingGlass/> <MagnifyingGlassIcon/>
</Button> </Button>
</div> </div>
{searchResults.length === 0 && !isSearching && {searchResults.length === 0 && !isSearching &&
@@ -103,7 +103,7 @@ export function GameHeaderPickerModal({game, isOpen, onOpenChange, setHeaderUrl}
> >
<Image <Image
alt={header.title} alt={header.title}
className="z-0 object-cover group-hover:brightness-[25%]" className="z-0 object-cover group-hover:brightness-25"
src={header.url} src={header.url}
radius="none" radius="none"
/> />
@@ -112,7 +112,7 @@ export function GameHeaderPickerModal({game, isOpen, onOpenChange, setHeaderUrl}
<PluginIcon plugin={state[header.source] as PluginDto} size={32} <PluginIcon plugin={state[header.source] as PluginDto} size={32}
blurred={false} showTooltip={false}/> blurred={false} showTooltip={false}/>
<p className="text-s text-center">{header.title}</p> <p className="text-s text-center">{header.title}</p>
<ArrowRight/> <ArrowRightIcon/>
</div> </div>
</div> </div>
))} ))}
@@ -1,7 +1,7 @@
import React, {useEffect, useState} from "react"; import React, {useEffect, useState} from "react";
import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, Snippet} from "@heroui/react"; import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, Snippet} from "@heroui/react";
import {MessageEndpoint, RegistrationEndpoint, UserEndpoint} from "Frontend/generated/endpoints"; import {MessageEndpoint, RegistrationEndpoint, UserEndpoint} from "Frontend/generated/endpoints";
import TokenDto from "Frontend/generated/org/gameyfin/app/shared/token/TokenDto"; import TokenDto from "Frontend/generated/org/gameyfin/app/core/token/TokenDto";
import {Form, Formik, FormikErrors} from "formik"; import {Form, Formik, FormikErrors} from "formik";
import Input from "Frontend/components/general/input/Input"; import Input from "Frontend/components/general/input/Input";
import * as Yup from "yup"; import * as Yup from "yup";
@@ -7,21 +7,22 @@ import Input from "Frontend/components/general/input/Input";
import * as Yup from "yup"; import * as Yup from "yup";
import DirectoryMappingInput from "Frontend/components/general/input/DirectoryMappingInput"; import DirectoryMappingInput from "Frontend/components/general/input/DirectoryMappingInput";
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto"; import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
import ArrayInputAutocomplete from "Frontend/components/general/input/ArrayInputAutocomplete";
import {useSnapshot} from "valtio/react";
import {platformState} from "Frontend/state/PlatformState";
interface LibraryCreationModalProps { interface LibraryCreationModalProps {
libraries: LibraryDto[];
setLibraries: (libraries: LibraryDto[]) => void;
isOpen: boolean; isOpen: boolean;
onOpenChange: () => void; onOpenChange: () => void;
} }
export default function LibraryCreationModal({ export default function LibraryCreationModal({
libraries,
isOpen, isOpen,
onOpenChange onOpenChange
}: LibraryCreationModalProps) { }: LibraryCreationModalProps) {
const [scanAfterCreation, setScanAfterCreation] = useState<boolean>(true); const [scanAfterCreation, setScanAfterCreation] = useState<boolean>(true);
const availablePlatforms = useSnapshot(platformState).available;
async function createLibrary(library: LibraryDto) { async function createLibrary(library: LibraryDto) {
await LibraryEndpoint.createLibrary(library as LibraryAdminDto, scanAfterCreation); await LibraryEndpoint.createLibrary(library as LibraryAdminDto, scanAfterCreation);
@@ -33,12 +34,12 @@ export default function LibraryCreationModal({
}); });
} }
return ( return (availablePlatforms &&
<> <>
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="xl"> <Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="xl">
<ModalContent> <ModalContent>
{(onClose) => ( {(onClose) => (
<Formik initialValues={{name: "", directories: []}} <Formik initialValues={{name: "", directories: [], platforms: []}}
validationSchema={Yup.object({ validationSchema={Yup.object({
name: Yup.string() name: Yup.string()
.required("Library name is required") .required("Library name is required")
@@ -65,6 +66,11 @@ export default function LibraryCreationModal({
value={formik.values.name} value={formik.values.name}
required required
/> />
<ArrayInputAutocomplete options={Array.from(availablePlatforms)}
name="platforms"
label="Platforms"
placeholder="Platform(s) of the games in this library (leave empty for all platforms)"
/>
<DirectoryMappingInput name="directories"/> <DirectoryMappingInput name="directories"/>
</div> </div>
</ModalBody> </ModalBody>
@@ -13,13 +13,15 @@ import {
Tooltip Tooltip
} from "@heroui/react"; } from "@heroui/react";
import React, {useEffect, useState} from "react"; import React, {useEffect, useState} from "react";
import {ArrowRight, MagnifyingGlass} from "@phosphor-icons/react"; import {ArrowRightIcon, MagnifyingGlassIcon} from "@phosphor-icons/react";
import {GameEndpoint} from "Frontend/generated/endpoints"; import {GameEndpoint} from "Frontend/generated/endpoints";
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto"; import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
import PluginIcon from "../plugin/PluginIcon"; import PluginIcon from "../plugin/PluginIcon";
import {useSnapshot} from "valtio/react"; import {useSnapshot} from "valtio/react";
import {pluginState} from "Frontend/state/PluginState"; import {pluginState} from "Frontend/state/PluginState";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto"; import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
import {libraryState} from "Frontend/state/LibraryState";
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
interface MatchGameModalProps { interface MatchGameModalProps {
path: string; path: string;
@@ -44,6 +46,7 @@ export default function MatchGameModal({
const [isMatching, setIsMatching] = useState<string | null>(null); const [isMatching, setIsMatching] = useState<string | null>(null);
const state = useSnapshot(pluginState).state; const state = useSnapshot(pluginState).state;
const librariesState = useSnapshot(libraryState).state;
useEffect(() => { useEffect(() => {
setSearchTerm(initialSearchTerm); setSearchTerm(initialSearchTerm);
@@ -56,7 +59,7 @@ export default function MatchGameModal({
async function search() { async function search() {
setIsSearching(true); setIsSearching(true);
const results = await GameEndpoint.getPotentialMatches(searchTerm); const results = await GameEndpoint.getPotentialMatches(searchTerm, (librariesState[libraryId] as LibraryAdminDto).platforms);
setSearchResults(results); setSearchResults(results);
setIsSearching(false); setIsSearching(false);
} }
@@ -84,7 +87,7 @@ export default function MatchGameModal({
}} }}
/> />
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}> <Button isIconOnly onPress={search} color="primary" isLoading={isSearching}>
<MagnifyingGlass/> <MagnifyingGlassIcon/>
</Button> </Button>
</div> </div>
@@ -141,7 +144,7 @@ export default function MatchGameModal({
setIsMatching(null); setIsMatching(null);
onClose(); onClose();
}}> }}>
<ArrowRight/> <ArrowRightIcon/>
</Button> </Button>
</Tooltip> </Tooltip>
</TableCell> </TableCell>
@@ -1,7 +1,7 @@
import React, {useEffect, useState} from "react"; import React, {useEffect, useState} from "react";
import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react"; import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {Input as NextInput} from "@heroui/input"; import {Input as NextInput} from "@heroui/input";
import {WarningCircle} from "@phosphor-icons/react"; import { WarningCircleIcon } from "@phosphor-icons/react";
import {MessageEndpoint, PasswordResetEndpoint} from "Frontend/generated/endpoints"; import {MessageEndpoint, PasswordResetEndpoint} from "Frontend/generated/endpoints";
interface PasswordResetModalProps { interface PasswordResetModalProps {
@@ -47,7 +47,7 @@ export default function PasswordResetModal({
placeholder="Email" placeholder="Email"
/> : /> :
<div className="flex flex-row items-center gap-4 text-warning"> <div className="flex flex-row items-center gap-4 text-warning">
<WarningCircle size={40}/> <WarningCircleIcon size={40}/>
<p> <p>
Password self-service is disabled.<br/> Password self-service is disabled.<br/>
To reset your password please contact your administrator. To reset your password please contact your administrator.
@@ -1,6 +1,6 @@
import React, {useEffect, useState} from "react"; import React, {useEffect, useState} from "react";
import {Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, Snippet} from "@heroui/react"; import {Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, Snippet} from "@heroui/react";
import TokenDto from "Frontend/generated/org/gameyfin/app/shared/token/TokenDto"; import TokenDto from "Frontend/generated/org/gameyfin/app/core/token/TokenDto";
import {timeUntil} from "Frontend/util/utils"; import {timeUntil} from "Frontend/util/utils";
interface PasswordResetTokenModalProps { interface PasswordResetTokenModalProps {
@@ -4,7 +4,7 @@ import React, {useEffect, useState} from "react";
import Input from "Frontend/components/general/input/Input"; import Input from "Frontend/components/general/input/Input";
import FileTreeView from "Frontend/components/general/input/FileTreeView"; import FileTreeView from "Frontend/components/general/input/FileTreeView";
import DirectoryMappingDto from "Frontend/generated/org/gameyfin/app/libraries/dto/DirectoryMappingDto"; import DirectoryMappingDto from "Frontend/generated/org/gameyfin/app/libraries/dto/DirectoryMappingDto";
import {ArrowRight} from "@phosphor-icons/react"; import { ArrowRightIcon } from "@phosphor-icons/react";
interface PathPickerModalProps { interface PathPickerModalProps {
returnSelectedPath: (path: DirectoryMappingDto) => void; returnSelectedPath: (path: DirectoryMappingDto) => void;
@@ -45,7 +45,7 @@ export default function PathPickerModal({returnSelectedPath, isOpen, onOpenChang
isDisabled isDisabled
required required
/> />
<ArrowRight className="mb-8"/> <ArrowRightIcon className="mb-8"/>
<Input <Input
name="externalPath" name="externalPath"
label="External path (optional)" label="External path (optional)"
@@ -6,7 +6,7 @@ import Markdown from "react-markdown";
import remarkBreaks from "remark-breaks"; import remarkBreaks from "remark-breaks";
import {PluginEndpoint} from "Frontend/generated/endpoints"; import {PluginEndpoint} from "Frontend/generated/endpoints";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto"; import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
import {ArrowClockwise} from "@phosphor-icons/react"; import { ArrowClockwiseIcon } from "@phosphor-icons/react";
import PluginConfigMetadataDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginConfigMetadataDto"; import PluginConfigMetadataDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginConfigMetadataDto";
import PluginConfigFormField from "Frontend/components/general/plugin/PluginConfigFormField"; import PluginConfigFormField from "Frontend/components/general/plugin/PluginConfigFormField";
@@ -161,7 +161,7 @@ export default function PluginDetailsModal({plugin, isOpen, onOpenChange}: Plugi
} }
setTimeout(() => setConfigValidated(ValidationState.UNCHECKED), 5000); setTimeout(() => setConfigValidated(ValidationState.UNCHECKED), 5000);
}}> }}>
<ArrowClockwise/> <ArrowClockwiseIcon/>
</Button> </Button>
</Tooltip> </Tooltip>
</>} </>}
@@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import {addToast, Button, Chip, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react"; import {addToast, Button, Chip, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {ListBox, ListBoxItem, useDragAndDrop} from "react-aria-components"; import {ListBox, ListBoxItem, useDragAndDrop} from "react-aria-components";
import {CaretUpDown} from "@phosphor-icons/react"; import { CaretUpDownIcon } from "@phosphor-icons/react";
import {useListData} from "@react-stately/data"; import {useListData} from "@react-stately/data";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto"; import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
import {PluginEndpoint} from "Frontend/generated/endpoints"; import {PluginEndpoint} from "Frontend/generated/endpoints";
@@ -91,7 +91,7 @@ export default function PluginPrioritiesModal({plugins, isOpen, onOpenChange}: P
</Chip> </Chip>
<p className="font-normal text-small">{plugin.name}</p> <p className="font-normal text-small">{plugin.name}</p>
</div> </div>
<CaretUpDown/> <CaretUpDownIcon/>
</ListBoxItem> </ListBoxItem>
)} )}
</ListBox> </ListBox>
@@ -101,7 +101,7 @@ export default function PluginPrioritiesModal({plugins, isOpen, onOpenChange}: P
<Button variant="light" onPress={onClose}> <Button variant="light" onPress={onClose}>
Cancel Cancel
</Button> </Button>
<Button color="success" onPress={() => setPluginPriorities(onClose)}> <Button color="primary" onPress={() => setPluginPriorities(onClose)}>
Save Save
</Button> </Button>
</ModalFooter> </ModalFooter>
@@ -1,5 +1,7 @@
import { import {
addToast, addToast,
Autocomplete,
AutocompleteItem,
Button, Button,
Input, Input,
Modal, Modal,
@@ -14,7 +16,7 @@ import {
Tooltip Tooltip
} from "@heroui/react"; } from "@heroui/react";
import React, {useEffect, useState} from "react"; import React, {useEffect, useState} from "react";
import {ArrowRight, MagnifyingGlass} from "@phosphor-icons/react"; import {ArrowRightIcon, MagnifyingGlassIcon} from "@phosphor-icons/react";
import {GameEndpoint, GameRequestEndpoint} from "Frontend/generated/endpoints"; import {GameEndpoint, GameRequestEndpoint} from "Frontend/generated/endpoints";
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto"; import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
import PluginIcon from "../plugin/PluginIcon"; import PluginIcon from "../plugin/PluginIcon";
@@ -22,22 +24,29 @@ import {useSnapshot} from "valtio/react";
import {pluginState} from "Frontend/state/PluginState"; import {pluginState} from "Frontend/state/PluginState";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto"; import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
import GameRequestCreationDto from "Frontend/generated/org/gameyfin/app/requests/dto/GameRequestCreationDto"; import GameRequestCreationDto from "Frontend/generated/org/gameyfin/app/requests/dto/GameRequestCreationDto";
import Platform from "Frontend/generated/org/gameyfin/pluginapi/gamemetadata/Platform";
import {platformState} from "Frontend/state/PlatformState";
interface RequestGameModalProps { interface RequestGameModalProps {
isOpen: boolean; isOpen: boolean;
onOpenChange: () => void; onOpenChange: () => void;
} }
// TODO: Maybe make this configurable in the admin settings?
const DEFAULT_PLATFORM_FOR_NEW_REQUESTS = "PC (Microsoft Windows)";
export default function RequestGameModal({ export default function RequestGameModal({
isOpen, isOpen,
onOpenChange onOpenChange
}: RequestGameModalProps) { }: RequestGameModalProps) {
const [selectedPlatform, setSelectedPlatform] = useState<string>(DEFAULT_PLATFORM_FOR_NEW_REQUESTS);
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [searchResults, setSearchResults] = useState<GameSearchResultDto[]>([]); const [searchResults, setSearchResults] = useState<GameSearchResultDto[]>([]);
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
const [isRequesting, setIsRequesting] = useState<string | null>(null); const [isRequesting, setIsRequesting] = useState<string | null>(null);
const plugins = useSnapshot(pluginState).state; const plugins = useSnapshot(pluginState).state;
const availablePlatforms = useSnapshot(platformState).available;
useEffect(() => { useEffect(() => {
setSearchTerm(""); setSearchTerm("");
@@ -47,7 +56,9 @@ export default function RequestGameModal({
async function requestGame(game: GameSearchResultDto) { async function requestGame(game: GameSearchResultDto) {
const request: GameRequestCreationDto = { const request: GameRequestCreationDto = {
title: game.title, title: game.title,
release: game.release release: game.release,
// Since we can only request for one platform at a time, just pick the first one
platform: game.platforms ? game.platforms[0] : DEFAULT_PLATFORM_FOR_NEW_REQUESTS as Platform
} }
try { try {
@@ -66,7 +77,7 @@ export default function RequestGameModal({
async function search() { async function search() {
setIsSearching(true); setIsSearching(true);
const results = await GameEndpoint.getPotentialMatches(searchTerm); const results = await GameEndpoint.getPotentialMatches(searchTerm, [selectedPlatform] as Platform[]);
setSearchResults(results); setSearchResults(results);
setIsSearching(false); setIsSearching(false);
} }
@@ -83,6 +94,18 @@ export default function RequestGameModal({
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<h2 className="text-xl font-semibold">Request a game</h2> <h2 className="text-xl font-semibold">Request a game</h2>
</div> </div>
<Autocomplete
label="Platform"
size="sm"
allowsCustomValue={false}
selectedKey={selectedPlatform}
//@ts-ignore
onSelectionChange={(newSelection) => newSelection && setSelectedPlatform(newSelection)}
>
{Array.from(availablePlatforms).map((platform) => (
<AutocompleteItem key={platform}>{platform}</AutocompleteItem>
))}
</Autocomplete>
<div className="flex flex-row gap-2 mb-4"> <div className="flex flex-row gap-2 mb-4">
<Input value={searchTerm} <Input value={searchTerm}
onValueChange={setSearchTerm} onValueChange={setSearchTerm}
@@ -93,8 +116,11 @@ export default function RequestGameModal({
} }
}} }}
/> />
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}> <Button isIconOnly
<MagnifyingGlass/> color="primary"
onPress={search}
isLoading={isSearching}>
<MagnifyingGlassIcon/>
</Button> </Button>
</div> </div>
@@ -151,7 +177,7 @@ export default function RequestGameModal({
setIsRequesting(null); setIsRequesting(null);
onClose(); onClose();
}}> }}>
<ArrowRight/> <ArrowRightIcon/>
</Button> </Button>
</Tooltip> </Tooltip>
</TableCell> </TableCell>
@@ -1,5 +1,5 @@
import {Image, Tooltip} from "@heroui/react"; import {Image, Tooltip} from "@heroui/react";
import {Plug} from "@phosphor-icons/react"; import { PlugIcon } from "@phosphor-icons/react";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto"; import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
interface PluginIconProps { interface PluginIconProps {
@@ -18,7 +18,7 @@ export default function PluginIcon({
const icon = plugin.hasLogo const icon = plugin.hasLogo
? ?
<Image isBlurred={blurred} src={`/images/plugins/${plugin.id}/logo`} width={size} height={size} radius="none"/> <Image isBlurred={blurred} src={`/images/plugins/${plugin.id}/logo`} width={size} height={size} radius="none"/>
: <Plug size={size} weight="fill"/>; : <PlugIcon size={size} weight="fill"/>;
return showTooltip return showTooltip
? <Tooltip content={plugin.name}>{icon}</Tooltip> ? <Tooltip content={plugin.name}>{icon}</Tooltip>
@@ -1,5 +1,5 @@
import {Button, Tooltip, useDisclosure} from "@heroui/react"; import {Button, Tooltip, useDisclosure} from "@heroui/react";
import {ListNumbers} from "@phosphor-icons/react"; import { ListNumbersIcon } from "@phosphor-icons/react";
import {PluginManagementCard} from "Frontend/components/general/cards/PluginManagementCard"; import {PluginManagementCard} from "Frontend/components/general/cards/PluginManagementCard";
import React from "react"; import React from "react";
import PluginPrioritiesModal from "Frontend/components/general/modals/PluginPrioritiesModal"; import PluginPrioritiesModal from "Frontend/components/general/modals/PluginPrioritiesModal";
@@ -16,7 +16,7 @@ export function PluginManagementSection({type, plugins = []}: PluginManagementSe
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex flex-row flex-grow justify-between"> <div className="flex flex-row grow justify-between">
<h2 className="text-xl font-bold">{camelCaseToTitle(type)}</h2> <h2 className="text-xl font-bold">{camelCaseToTitle(type)}</h2>
<Tooltip color="foreground" placement="left" content="Change plugin order"> <Tooltip color="foreground" placement="left" content="Change plugin order">
@@ -24,7 +24,7 @@ export function PluginManagementSection({type, plugins = []}: PluginManagementSe
variant="flat" variant="flat"
onPress={pluginPrioritiesModal.onOpen} onPress={pluginPrioritiesModal.onOpen}
isDisabled={plugins.length === 0}> isDisabled={plugins.length === 0}>
<ListNumbers/> <ListNumbersIcon/>
</Button> </Button>
</Tooltip> </Tooltip>
</div> </div>
@@ -1,44 +0,0 @@
import {Button, Link, Popover, PopoverContent, PopoverTrigger} from "@heroui/react";
import {Warning} from "@phosphor-icons/react";
// TODO: Remove this component before the release of version 2.2.0
export default function DockerHubDeprecationPopover() {
return (
<Popover placement="bottom-end" showArrow={true} color="warning">
<PopoverTrigger>
<Button isIconOnly color="warning" variant="flat">
<Warning/>
</Button>
</PopoverTrigger>
<PopoverContent>
<div className="m-4 text-sm leading-relaxed">
<h3 className="mb-2 font-bold">Image deprecation notice</h3>
<p>
Starting with version
<code className="font-semibold"> 2.2.0 </code>
the image{' '}
<Link href="https://hub.docker.com/r/grimsi/gameyfin"
isExternal
underline="always"
size="sm"
className="text-warning-contrast">
grimsi/gameyfin
</Link>
{' '}will no longer be published to Docker Hub.
</p>
<p>
Please switch to{' '}
<Link href="https://github.com/gameyfin/gameyfin/pkgs/container/gameyfin"
isExternal
underline="always"
size="sm"
className="text-warning-contrast">
ghcr.io/gameyfin/gameyfin
</Link>
{' '}if you are currently using the Docker Hub image.
</p>
</div>
</PopoverContent>
</Popover>
);
}
@@ -7,7 +7,7 @@ export default function ThemePreview({theme, isSelected}: {
}) { }) {
return ( return (
<Tooltip content={<p className="capitalize">{theme.name?.replace("-", " ")}</p>} placement="bottom"> <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 <div className={`flex flex-col grow aspect-square border-2 rounded-large overflow-hidden
${theme.name}-dark ${theme.name}-dark
${isSelected ? "border-foreground" : "border-foreground-200 hover:border-focus"}`}> ${isSelected ? "border-foreground" : "border-foreground-200 hover:border-focus"}`}>
<div className="flex-1 bg-primary"/> <div className="flex-1 bg-primary"/>
@@ -0,0 +1,208 @@
import type {ComponentProps} from "react";
import React from "react";
import type {ButtonProps} from "@heroui/react";
import {cn} from "@heroui/react";
import {useControlledState} from "@react-stately/utils";
import {domAnimation, LazyMotion, m} from "framer-motion"; // reintroduce LazyMotion & domAnimation
export type StepDescriptor = {
title?: React.ReactNode;
icon?: React.ReactNode;
className?: string;
};
export interface StepperProps extends React.HTMLAttributes<HTMLButtonElement> {
steps?: StepDescriptor[];
color?: ButtonProps["color"];
currentStep?: number;
defaultStep?: number;
hideProgressBars?: boolean;
className?: string;
stepClassName?: string;
onStepChange?: (stepIndex: number) => void;
allowFutureNavigation?: boolean;
}
function CheckIcon(props: ComponentProps<"svg">) {
return (
<svg {...props} fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<m.path
animate={{pathLength: 1}}
d="M5 13l4 4L19 7"
initial={{pathLength: 0}}
strokeLinecap="round"
strokeLinejoin="round"
transition={{
delay: 0.2,
type: "tween",
ease: "easeOut",
duration: 0.3,
}}
/>
</svg>
);
}
const Stepper = React.forwardRef<HTMLButtonElement, StepperProps>(
(
{
color = "primary",
steps = [],
defaultStep = 0,
onStepChange,
currentStep: currentStepProp,
hideProgressBars = false,
stepClassName,
className,
allowFutureNavigation = false,
...props
},
ref
) => {
const [currentStep, setCurrentStep] = useControlledState(currentStepProp, defaultStep, onStepChange);
const colors = React.useMemo(() => {
let userColor;
let fgColor;
const colorsVars = [
"[--active-fg-color:var(--step-fg-color)]",
"[--active-border-color:var(--step-color)]",
"[--active-color:var(--step-color)]",
"[--complete-background-color:var(--step-color)]",
"[--complete-border-color:var(--step-color)]",
"[--inactive-border-color:hsl(var(--heroui-default-300))]",
"[--inactive-color:hsl(var(--heroui-default-300))]"
];
switch (color) {
case "secondary":
userColor = "[--step-color:hsl(var(--heroui-secondary))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-secondary-foreground))]";
break;
case "success":
userColor = "[--step-color:hsl(var(--heroui-success))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-success-foreground))]";
break;
case "warning":
userColor = "[--step-color:hsl(var(--heroui-warning))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-warning-foreground))]";
break;
case "danger":
userColor = "[--step-color:hsl(var(--heroui-error))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-error-foreground))]";
break;
case "default":
userColor = "[--step-color:hsl(var(--heroui-default))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-default-foreground))]";
break;
case "primary":
default:
userColor = "[--step-color:hsl(var(--heroui-primary))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-primary-foreground))]";
break;
}
if (!className?.includes("--step-fg-color")) colorsVars.unshift(fgColor);
if (!className?.includes("--step-color")) colorsVars.unshift(userColor);
if (!className?.includes("--inactive-bar-color"))
colorsVars.push("[--inactive-bar-color:hsl(var(--heroui-default-300))]");
return colorsVars;
}, [color, className]);
// Compute statuses once
const statuses = steps.map((_, i) => (
currentStep === i ? "active" : currentStep < i ? "inactive" : "complete"
));
return (
<LazyMotion features={domAnimation}> {/* enable pathLength & variants animations */}
<nav aria-label="Progress" className={cn("relative w-full overflow-x-visible py-4", colors, className)}>
{/* Circles + connectors row */}
<div className="flex w-full items-center">
{steps.map((step, idx) => {
const status = statuses[idx];
const canNavigate = allowFutureNavigation || idx <= currentStep;
const isLast = idx === steps.length - 1;
return (
<div key={idx}
className={cn("flex items-center", !isLast && "flex-1")}> {/* flex-1 only if there is a connector after */}
<button
ref={ref}
aria-current={status === "active" ? "step" : undefined}
type="button"
onClick={() => canNavigate && setCurrentStep(idx)}
className={cn(
"group relative flex h-[38px] w-[38px] items-center justify-center rounded-full border-medium font-semibold text-large bg-content1 transition-colors duration-300",
!canNavigate && "pointer-events-none opacity-60",
step.className,
stepClassName,
status === "inactive" && "text-(--inactive-color) border-(--inactive-border-color)",
status === "active" && "text-(--active-color) border-(--active-border-color)",
status === "complete" && "border-(--complete-border-color) bg-(--complete-background-color) shadow-lg"
)}
{...props}
>
<m.div
animate={status}
initial={false}
variants={{
inactive: {scale: 1, opacity: 0.85},
active: {scale: 1.04, opacity: 1},
complete: {scale: 1, opacity: 1}
}}
transition={{type: "spring", stiffness: 260, damping: 20}}
className="flex items-center justify-center w-full h-full"
>
{status === "complete" ? (
<CheckIcon key={`check-${idx}`}
className="h-6 w-6 text-(--active-fg-color)"/>
) : step.icon ? (
step.icon
) : (
<span>{idx + 1}</span>
)}
</m.div>
</button>
{!isLast && !hideProgressBars && (
<div className="flex-1">
<div
className="mx-3 h-0.5 rounded-full bg-(--inactive-bar-color) relative">{/* gap so line does not touch circles */}
<m.div
className="absolute left-0 top-0 h-full rounded-full bg-(--active-border-color)"
animate={{width: idx < currentStep ? '100%' : 0}}
transition={{duration: 0.35, ease: 'easeInOut'}}
/>
</div>
</div>
)}
</div>
);
})}
</div>
{/* Titles row */}
<div className={cn("mt-2 grid w-full")}
style={{gridTemplateColumns: `repeat(${steps.length}, minmax(0,1fr))`}}>
{steps.map((step, idx) => {
const status = statuses[idx];
return (
<div key={idx} className="flex justify-center px-1 text-center">
{step.title && (
<span
className={cn(
"text-small lg:text-medium font-medium transition-[color,opacity] duration-300",
status === "inactive" ? "text-default-500" : "text-default-foreground"
)}
>
{step.title}
</span>
)}
</div>
);
})}
</div>
</nav>
</LazyMotion>
);
}
);
Stepper.displayName = "Stepper";
export default Stepper;
@@ -1,20 +1,22 @@
import React, {ReactNode, useState} from "react"; import React, {ReactNode, useState} from "react";
import {Form, Formik, FormikBag, FormikHelpers} from "formik"; import {Form, Formik, FormikBag, FormikHelpers} from "formik";
import {ArrowLeft, ArrowRight, Check} from "@phosphor-icons/react"; import {ArrowLeftIcon, ArrowRightIcon, CheckIcon} from "@phosphor-icons/react";
import {Button} from "@heroui/react"; import {Button} from "@heroui/react";
import {Step, Stepper} from "@material-tailwind/react"; import Stepper from "./Stepper";
const Wizard = ({children, initialValues, onSubmit}: { type WizardProps = {
children: ReactNode, children: ReactNode;
initialValues: any, initialValues: any;
onSubmit: (values: any, bag: FormikHelpers<any> | FormikBag<any, any>) => Promise<any> onSubmit: (values: any, bag: FormikHelpers<any> | FormikBag<any, any>) => Promise<any>;
}) => { };
const Wizard = ({children, initialValues, onSubmit}: WizardProps) => {
const allSteps = React.Children.toArray(children);
const [stepNumber, setStepNumber] = useState(0); const [stepNumber, setStepNumber] = useState(0);
const steps = React.Children.toArray(children);
const [snapshot, setSnapshot] = useState(initialValues); const [snapshot, setSnapshot] = useState(initialValues);
const step = steps[stepNumber]; const step = allSteps[stepNumber];
const totalSteps = steps.length; const totalSteps = allSteps.length;
const isFirstStep = stepNumber === 0; const isFirstStep = stepNumber === 0;
const isLastStep = stepNumber === totalSteps - 1; const isLastStep = stepNumber === totalSteps - 1;
@@ -28,10 +30,11 @@ const Wizard = ({children, initialValues, onSubmit}: {
setStepNumber(Math.max(stepNumber - 1, 0)); setStepNumber(Math.max(stepNumber - 1, 0));
}; };
const handleSubmit = async (values: any, bag: FormikBag<any, any> | FormikHelpers<any>) => { const handleSubmit = async (values: any, bag: FormikHelpers<any> | FormikBag<any, any>) => {
/*// @ts-ignore*/ // per-step custom submit if provided
// @ts-ignore
if (step.props.onSubmit) { if (step.props.onSubmit) {
/*// @ts-ignore*/ // @ts-ignore
await step.props.onSubmit(values, bag); await step.props.onSubmit(values, bag);
} }
if (isLastStep) { if (isLastStep) {
@@ -42,53 +45,44 @@ const Wizard = ({children, initialValues, onSubmit}: {
} }
}; };
const stepsMeta = allSteps.map((child: any) => ({
title: child.props?.title ?? child.props?.label,
icon: child.props?.icon
}));
return ( return (
<Formik <Formik
initialValues={snapshot} initialValues={snapshot}
onSubmit={handleSubmit} onSubmit={handleSubmit}
/*// @ts-ignore*/ // @ts-ignore
validationSchema={step.props.validationSchema} validationSchema={step.props?.validationSchema}
enableReinitialize={false}
> >
{(formik) => ( {(formik) => (
<Form className="flex flex-col h-full"> <Form className="flex flex-col h-full">
<div className="w-full mb-8"> <div className="w-full mb-8">
<Stepper activeStep={stepNumber} activeLineClassName="bg-primary" <Stepper
lineClassName="bg-foreground" steps={stepsMeta}
placeholder={undefined} currentStep={stepNumber}
onPointerEnterCapture={undefined} onStepChange={(idx) => {
onPointerLeaveCapture={undefined} // only allow backwards navigation to keep validation flow
onResize={undefined} if (idx <= stepNumber) setStepNumber(idx);
onResizeCapture={undefined}> }}
{steps.map((child, index) => ( hideProgressBars={false}
<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>
<div className="flex grow"> <div className="flex grow">{step}</div>
{step} <div className="left-8 right-8 absolute bottom-8">
</div>
<div className="left-8 right-8 absolute bottom-8 -z-1">
<div className="flex justify-between"> <div className="flex justify-between">
<Button color="primary" onClick={() => previous(formik.values)} isDisabled={isFirstStep}>
<ArrowLeft/>
</Button>
<Button <Button
color="primary" color="primary"
isLoading={formik.isSubmitting} onPress={() => previous(formik.values)}
type="submit" isDisabled={isFirstStep || formik.isSubmitting}
> >
{formik.isSubmitting ? "" : isLastStep ? <Check/> : <ArrowRight/>} <ArrowLeftIcon/>
</Button>
<Button color="primary" isLoading={formik.isSubmitting} type="submit">
{formik.isSubmitting ? "" : isLastStep ? <CheckIcon/> : <ArrowRightIcon/>}
</Button> </Button>
</div> </div>
</div> </div>
@@ -98,4 +92,4 @@ const Wizard = ({children, initialValues, onSubmit}: {
); );
}; };
export default Wizard; export default Wizard;
+10
View File
@@ -0,0 +1,10 @@
import {heroui, HeroUIPluginConfig} from "@heroui/react";
import {compileThemes, themes} from "./theming/themes"
export const HeroUIConfig: HeroUIPluginConfig = {
// TODO: Prefix disabled until bug in heroui is fixed: https://github.com/heroui-inc/heroui/issues/5403
// prefix: "gf",
themes: compileThemes(themes)
};
export default heroui(HeroUIConfig);
+53 -33
View File
@@ -1,44 +1,38 @@
@tailwind base; @import "tailwindcss";
@tailwind components;
@tailwind utilities;
@layer utilities { @plugin './heroui.ts';
.gradient-primary {
@apply bg-gradient-to-br from-primary-400 to-primary-700;
}
.button-secondary { @source "./index.html";
@apply bg-primary-300 text-background/80; @source "./**/*.{js,ts,jsx,tsx}";
} @source "../../../node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}";
/* Prevent TailwindCSS from purging the custom themes (since they are not referenced at compile-time) */
@source inline("{gameyfin-blue,gameyfin-violet,gameyfin-classic,neutral,slate,red,rose,orange,pink,blue,yellow,violet,colorblind}-{light,dark}");
@custom-variant dark (&:is(.dark *));
@theme {
--color-gf-primary: #2332c8;
--color-gf-secondary: #6441a5;
} }
/* Custom CSS */ /* Custom gridTemplateColumns */
@utility grid-cols-300px {
:root { grid-template-columns: repeat(auto-fit, 300px);
/* Overwrite default Hilla styles (e.g. loading indicator) */ }
--lumo-primary-color: theme(colors.primary); @utility grid-cols-auto-fill {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
/* 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 */ /* Re-added custom utilities (Tailwind v4 style) */
.react-aria-ListBoxItem { @utility gradient-primary {
&[data-dragging] { @apply bg-gradient-to-br from-primary-400 to-primary-700;
opacity: 0.6; }
} @utility button-secondary {
} @apply bg-primary-300 text-background/80;
.react-aria-DropIndicator[data-drop-target] {
outline: 1px solid theme(colors.primary);
} }
/* Shine animation for game covers */
.shine { .shine {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@@ -71,4 +65,30 @@
100% { 100% {
left: 125%; left: 125%;
} }
}
/* List box drag & drop */
.react-aria-ListBoxItem[data-dragging] {
opacity: 0.6;
}
.react-aria-DropIndicator[data-drop-target] {
outline: 1px solid hsl(var(--heroui-primary));
}
/* Root variable overrides */
:root {
/* Overwrite default Hilla styles (e.g. loading indicator) */
--lumo-primary-color: hsl(var(--heroui-primary));
/* Overwrite SwiperJS styles */
--swiper-theme-color: hsl(var(--heroui-primary));
--swiper-navigation-color: hsl(var(--heroui-primary));
--swiper-pagination-color: hsl(var(--heroui-primary));
/* Overwrite SwiperJS styles */
.swiper-pagination-bullet {
background-color: hsl(var(--heroui-primary));
}
} }
+6
View File
@@ -26,6 +26,7 @@ import {RouterConfigurationBuilder} from "@vaadin/hilla-file-router/runtime.js";
import ErrorView from "Frontend/views/ErrorView"; import ErrorView from "Frontend/views/ErrorView";
import GameRequestView from "Frontend/views/GameRequestView"; import GameRequestView from "Frontend/views/GameRequestView";
import {GameRequestManagement} from "Frontend/components/administration/GameRequestManagement"; import {GameRequestManagement} from "Frontend/components/administration/GameRequestManagement";
import {DownloadManagement} from "Frontend/components/administration/DownloadManagement";
export const {router, routes} = new RouterConfigurationBuilder() export const {router, routes} = new RouterConfigurationBuilder()
.withReactRoutes([ .withReactRoutes([
@@ -99,6 +100,11 @@ export const {router, routes} = new RouterConfigurationBuilder()
element: <GameRequestManagement/>, element: <GameRequestManagement/>,
handle: {title: 'Administration - Game Requests'} handle: {title: 'Administration - Game Requests'}
}, },
{
path: 'downloads',
element: <DownloadManagement/>,
handle: {title: 'Administration - Downloads'}
},
{ {
path: 'users', path: 'users',
element: <UserManagement/>, element: <UserManagement/>,
@@ -0,0 +1,81 @@
import {proxy} from 'valtio';
import {BandwidthMonitoringEndpoint} from "Frontend/generated/endpoints";
import SessionStatsDto from "Frontend/generated/org/gameyfin/app/core/download/bandwidth/SessionStatsDto";
import {Subscription} from "@vaadin/hilla-frontend";
import {convertBpsToMbps} from "Frontend/util/utils";
type DownloadSessionState = {
subscription?: Subscription<SessionStatsDto[][]>;
isLoaded: boolean;
all: SessionStatsDto[];
byId: Record<string, SessionStatsDto>;
activeSessions: number;
bandwidthInUse: number;
};
export const downloadSessionState = proxy<DownloadSessionState>({
get isLoaded() {
return this.subscription != null;
},
all: [],
byId: {},
get activeSessions() {
return this.all.filter((session: SessionStatsDto) => session.activeDownloads > 0).length;
},
get bandwidthInUse() {
return this.all.reduce((total: number, session: SessionStatsDto) => total + session.currentBytesPerSecond, 0);
}
});
/** Subscribe to and process download session updates from backend **/
export async function initializeDownloadSessionState() {
if (downloadSessionState.isLoaded) return;
// Fetch initial configuration data
const initialSessions = await BandwidthMonitoringEndpoint.getActiveSessions();
downloadSessionState.all = sortSessions(initialSessions);
initialSessions.forEach((session: SessionStatsDto) => {
downloadSessionState.byId[session.sessionId] = session;
});
// Subscribe to real-time updates
downloadSessionState.subscription = BandwidthMonitoringEndpoint.subscribe().onNext((downloadSessionUpdate: SessionStatsDto[][]) => {
downloadSessionUpdate.forEach((updateBatch: SessionStatsDto[]) => {
downloadSessionState.all = sortSessions(updateBatch);
updateBatch.forEach((session: SessionStatsDto) => {
downloadSessionState.byId[session.sessionId] = session;
});
});
});
}
/** Sort sessions: active sessions (by bandwidth, then oldest first), inactive sessions (newest first) **/
function sortSessions(sessions: SessionStatsDto[]): SessionStatsDto[] {
return [...sessions].sort((a, b) => {
const aIsActive = a.activeDownloads > 0;
const bIsActive = b.activeDownloads > 0;
// Active sessions come first
if (aIsActive !== bIsActive) {
return bIsActive ? 1 : -1;
}
// For active sessions: sort by bandwidth (highest first), then by age (oldest first)
if (aIsActive) {
const bandwidthDiff = convertBpsToMbps(b.currentBytesPerSecond, 0) - convertBpsToMbps(a.currentBytesPerSecond, 0);
if (bandwidthDiff !== 0) {
return bandwidthDiff;
}
// Tie breaker: oldest first
const aTime = new Date(a.startTime).getTime();
const bTime = new Date(b.startTime).getTime();
return aTime - bTime;
}
// For inactive sessions: sort by age (newest first)
const aTime = new Date(a.startTime).getTime();
const bTime = new Date(b.startTime).getTime();
return bTime - aTime;
});
}
@@ -0,0 +1,47 @@
import {proxy} from 'valtio';
import {PlatformEndpoint} from "Frontend/generated/endpoints";
import PlatformStatsDto from "Frontend/generated/org/gameyfin/app/platforms/dto/PlatformStatsDto";
import {Subscription} from "@vaadin/hilla-frontend";
type PlatformState = {
subscription?: Subscription<PlatformStatsDto[]>;
isLoaded: boolean;
available: Set<string>;
usedByGames: Set<string>;
usedByLibraries: Set<string>;
};
export const platformState = proxy<PlatformState>({
get isLoaded() {
return this.subscription != null;
},
available: new Set<string>,
usedByGames: new Set<string>,
usedByLibraries: new Set<string>
});
/** Subscribe to and process platform updates from backend **/
export async function initializePlatformState() {
if (platformState.isLoaded) return;
// Fetch initial configuration data
const initialPlatformStats = await PlatformEndpoint.getStats();
platformState.available = new Set(initialPlatformStats.available);
platformState.usedByGames = new Set(initialPlatformStats.inUseByGames);
platformState.usedByLibraries = new Set(initialPlatformStats.inUseByLibraries);
// Subscribe to real-time updates
platformState.subscription = PlatformEndpoint.subscribe().onNext((platformStats: Partial<PlatformStatsDto>[]) => {
platformStats.forEach((updateDto: Partial<PlatformStatsDto>) => {
if (updateDto.available !== undefined) {
platformState.available = new Set(updateDto.available);
}
if (updateDto.inUseByGames !== undefined) {
platformState.usedByGames = new Set(updateDto.inUseByGames);
}
if (updateDto.inUseByLibraries !== undefined) {
platformState.usedByLibraries = new Set(updateDto.inUseByLibraries);
}
})
});
}
+32
View File
@@ -0,0 +1,32 @@
import {proxy} from "valtio";
import {UserEndpoint} from "Frontend/generated/endpoints";
import ExtendedUserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/ExtendedUserInfoDto";
type UserState = {
isLoaded: boolean;
state: Record<number, ExtendedUserInfoDto>;
users: ExtendedUserInfoDto[];
};
export const userState = proxy<UserState>({
isLoaded: false,
state: {},
get users() {
return Object.values<ExtendedUserInfoDto>(this.state);
}
});
/** Fetch and cache all users **/
export async function initializeUserState() {
if (userState.isLoaded) return;
try {
const allUsers = await UserEndpoint.getAllUsers();
allUsers.forEach((user: ExtendedUserInfoDto) => {
userState.state[user.id] = user;
});
userState.isLoaded = true;
} catch (error) {
console.error("Failed to load users:", error);
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
import {GameyfinClassic} from "./themes/gameyfin-classic"; import {GameyfinClassic} from "./themes/gameyfin-classic";
import {GameyfinBlue} from "./themes/gameyfin-blue"; import {GameyfinBlue} from "./themes/gameyfin-blue";
import {GameyfinViolet} from "./themes/gameyfin-violet"; import {GameyfinViolet} from "./themes/gameyfin-violet";
import {Purple} from "./themes/purple"; import {Pink} from "./themes/pink";
import {Neutral} from "./themes/neutral"; import {Neutral} from "./themes/neutral";
import {Slate} from "./themes/slate"; import {Slate} from "./themes/slate";
import {Red} from "./themes/red"; import {Red} from "./themes/red";
@@ -44,4 +44,4 @@ export function themeNames(): string[] {
return Object.keys(compileThemes(themes)); return Object.keys(compileThemes(themes));
} }
export const themes: Theme[] = [GameyfinBlue, GameyfinViolet, GameyfinClassic, Neutral, Slate, Red, Rose, Orange, Purple, Blue, Yellow, Violet, Colorblind]; export const themes: Theme[] = [GameyfinBlue, GameyfinViolet, GameyfinClassic, Neutral, Slate, Red, Rose, Orange, Pink, Blue, Yellow, Violet, Colorblind];
@@ -1,7 +1,7 @@
import {Theme} from "../theme"; import {Theme} from "../theme";
export const Purple: Theme = { export const Pink: Theme = {
name: 'purple', name: 'pink',
colors: { colors: {
primary: { primary: {
DEFAULT: '#DD62ED', DEFAULT: '#DD62ED',
+69 -10
View File
@@ -37,13 +37,13 @@ export function hashCode(string: string) {
export function roleToColor(role: string) { export function roleToColor(role: string) {
switch (role) { switch (role) {
case "ROLE_SUPERADMIN": case "ROLE_SUPERADMIN":
return "red"; return "bg-red-500";
case "ROLE_ADMIN": case "ROLE_ADMIN":
return "orange"; return "bg-orange-500";
case "ROLE_USER": case "ROLE_USER":
return "blue"; return "bg-blue-500";
default: default:
return "gray"; return "bg-gray-500";
} }
} }
@@ -62,9 +62,10 @@ export async function fetchWithAuth(url: string, body: any = null, method = "POS
* Calculate the time difference between a given Instant and the current time in the user's timezone. * Calculate the time difference between a given Instant and the current time in the user's timezone.
* @param {string} instantString - The Instant string returned by the backend. * @param {string} instantString - The Instant string returned by the backend.
* @param {string} timeZone - The user's timezone. * @param {string} timeZone - The user's timezone.
* @param {boolean} timeOnly - Whether to exclude "ago" or "in" prefix/suffix.
* @returns {string} - The time difference in a human-readable format. * @returns {string} - The time difference in a human-readable format.
*/ */
export function timeUntil(instantString: string, timeZone: string = moment.tz.guess()): string { export function timeUntil(instantString: string, timeZone: string = moment.tz.guess(), timeOnly: boolean = false): string {
const givenDate = moment.tz(instantString, timeZone); const givenDate = moment.tz(instantString, timeZone);
const now = moment.tz(timeZone); const now = moment.tz(timeZone);
const diffInSeconds = givenDate.diff(now, 'seconds'); const diffInSeconds = givenDate.diff(now, 'seconds');
@@ -84,7 +85,10 @@ export function timeUntil(instantString: string, timeZone: string = moment.tz.gu
for (const unit of units) { for (const unit of units) {
const value = Math.floor(absDiffInSeconds / unit.seconds); const value = Math.floor(absDiffInSeconds / unit.seconds);
if (value >= 1) { if (value >= 1) {
return `${isPast ? '' : 'in'} ${value} ${unit.name}${value > 1 ? 's' : ''} ${isPast ? 'ago' : ''}`; if (timeOnly) {
return `${value} ${unit.name}${value > 1 ? 's' : ''}`;
}
return `${isPast ? '' : 'in '}${value} ${unit.name}${value > 1 ? 's' : ''} ${isPast ? 'ago' : ''}`;
} }
} }
@@ -217,7 +221,7 @@ export function fileNameFromPath(path: string, includeExtension: boolean = true)
*/ */
export function metadataCompleteness(game: GameDto) { export function metadataCompleteness(game: GameDto) {
// Total number of fields considered for completeness // Total number of fields considered for completeness
// Includes all fields except "comment" // Includes all fields except "comment" and "platforms"
const totalFields = 21; const totalFields = 21;
const filledFields = Object.values(game).filter(value => { const filledFields = Object.values(game).filter(value => {
@@ -227,7 +231,8 @@ export function metadataCompleteness(game: GameDto) {
return true; return true;
}).length; }).length;
return Math.round((filledFields / totalFields) * 100); const completeness = Math.round((filledFields / totalFields) * 100);
return Math.min(100, completeness); // Never exceed 100%
} }
/** /**
@@ -276,11 +281,65 @@ export function compoundRating(game: GameDto, scale = [0, 100]): number {
* @param game The GameDto object containing the ratings. * @param game The GameDto object containing the ratings.
* @returns A string representing the star rating out of 5, or "N/A" if no ratings are available. * @returns A string representing the star rating out of 5, or "N/A" if no ratings are available.
*/ */
export function starRatingAsString(game: GameDto) { export function starRatingAsString(game: GameDto): string {
const starRange = [1, 5]; const starRange = [1, 5];
const rating = compoundRating(game, starRange); const rating = compoundRating(game, starRange);
if (rating === 0) return "N/A"; if (rating === 0) return "N/A";
return rating.toFixed(1); return rating.toFixed(1);
} }
/**
* Convert bytes per second to megabits per second.
* @param bps
* @param fractionDigits
*/
export function convertBpsToMbps(bps: number, fractionDigits: number = 0): number {
// Formula: (bytes per second * 8) / 1,000,000 = megabits per second
const mbps = bps / 125000;
const multiplier = 10 ** fractionDigits;
return Math.round(mbps * multiplier) / multiplier;
}
/**
* Convert an HSL string to a hex RGB string.
* @param hslString HSL string in the format "H S% L%" (e.g., "339.2 90.36% 51.18%")
* @returns Hex RGB string in the format "#RRGGBB" (e.g., "#ff0080")
*/
export function hslToHex(hslString: string): string {
// Parse the HSL string
const parts = hslString.trim().split(/\s+/);
const h = parseFloat(parts[0]) / 360;
const s = parseFloat(parts[1]) / 100;
const l = parseFloat(parts[2]) / 100;
let r, g, b;
if (s === 0) {
r = g = b = l; // achromatic
} else {
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
// Convert to hex
const toHex = (x: number) => {
const hex = Math.round(x * 255).toString(16);
return hex.length === 1 ? '0' + hex : hex;
};
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
@@ -1,46 +1,61 @@
import {Disc, Envelope, GameController, LockKey, Log, Plug, Users, Wrench} from "@phosphor-icons/react"; import {
DiscIcon,
DownloadSimpleIcon,
EnvelopeIcon,
GameControllerIcon,
LockKeyIcon,
LogIcon,
PlugIcon,
UsersIcon,
WrenchIcon
} from "@phosphor-icons/react";
import withSideMenu, {MenuItem} from "Frontend/components/general/withSideMenu"; import withSideMenu, {MenuItem} from "Frontend/components/general/withSideMenu";
const menuItems: MenuItem[] = [ const menuItems: MenuItem[] = [
{ {
title: "Libraries", title: "Libraries",
url: "libraries", url: "libraries",
icon: <GameController/> icon: <GameControllerIcon/>
}, },
{ {
title: "Game Requests", title: "Game Requests",
url: "requests", url: "requests",
icon: <Disc/> icon: <DiscIcon/>
},
{
title: "Downloads",
url: "downloads",
icon: <DownloadSimpleIcon/>
}, },
{ {
title: "Users", title: "Users",
url: "users", url: "users",
icon: <Users/> icon: <UsersIcon/>
}, },
{ {
title: "SSO", title: "SSO",
url: "sso", url: "sso",
icon: <LockKey/> icon: <LockKeyIcon/>
}, },
{ {
title: "Messages", title: "Messages",
url: "messages", url: "messages",
icon: <Envelope/> icon: <EnvelopeIcon/>
}, },
{ {
title: "Plugins", title: "Plugins",
url: "plugins", url: "plugins",
icon: <Plug/> icon: <PlugIcon/>
}, },
{ {
title: "Logs", title: "Logs",
url: "logs", url: "logs",
icon: <Log/> icon: <LogIcon/>
}, },
{ {
title: "System", title: "System",
url: "system", url: "system",
icon: <Wrench/> icon: <WrenchIcon/>
} }
] ]
@@ -1,14 +1,14 @@
import {Card, CardBody, CardHeader} from "@heroui/react"; import {Card, CardBody, CardHeader} from "@heroui/react";
import {useNavigate, useSearchParams} from "react-router"; import {useNavigate, useSearchParams} from "react-router";
import React, {useEffect, useState} from "react"; import React, {useEffect, useState} from "react";
import {CheckCircle, Warning, WarningCircle} from "@phosphor-icons/react"; import {CheckCircleIcon, WarningCircleIcon, WarningIcon} from "@phosphor-icons/react";
import TokenValidationResult from "Frontend/generated/org/gameyfin/app/shared/token/TokenValidationResult"; import TokenValidationResult from "Frontend/generated/org/gameyfin/app/core/token/TokenValidationResult";
import {EmailConfirmationEndpoint} from "Frontend/generated/endpoints"; import {EmailConfirmationEndpoint} from "Frontend/generated/endpoints";
import {useAuth} from "Frontend/util/auth"; import {useAuth} from "Frontend/util/auth";
export default function EmailConfirmationView() { export default function EmailConfirmationView() {
const auth = useAuth(); const auth = useAuth();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
const [validationResult, setValidationResult] = useState<TokenValidationResult>(TokenValidationResult.INVALID); const [validationResult, setValidationResult] = useState<TokenValidationResult>(TokenValidationResult.INVALID);
@@ -34,7 +34,7 @@ export default function EmailConfirmationView() {
} }
return ( return (
<div className="flex flex-row flex-grow items-center justify-center size-full gradient-primary"> <div className="flex flex-row grow items-center justify-center size-full gradient-primary">
<Card className="p-4 min-w-[468px]"> <Card className="p-4 min-w-[468px]">
<CardHeader className="mb-4"> <CardHeader className="mb-4">
<img <img
@@ -46,7 +46,7 @@ export default function EmailConfirmationView() {
<CardBody className="flex flex-row justify-center"> <CardBody className="flex flex-row justify-center">
{validationResult === TokenValidationResult.VALID ? {validationResult === TokenValidationResult.VALID ?
<div className="flex flex-row items-center gap-4 text-success"> <div className="flex flex-row items-center gap-4 text-success">
<CheckCircle size={40}/> <CheckCircleIcon size={40}/>
<p> <p>
Email confirmed<br/> Email confirmed<br/>
You will be redirected shortly You will be redirected shortly
@@ -54,7 +54,7 @@ export default function EmailConfirmationView() {
</div> </div>
: validationResult === TokenValidationResult.EXPIRED ? : validationResult === TokenValidationResult.EXPIRED ?
<div className="flex flex-row items-center gap-4 text-warning"> <div className="flex flex-row items-center gap-4 text-warning">
<WarningCircle size={40}/> <WarningCircleIcon size={40}/>
<p> <p>
Expired token<br/> Expired token<br/>
Please request a new one Please request a new one
@@ -62,7 +62,7 @@ export default function EmailConfirmationView() {
</div> </div>
: :
<div className="flex flex-row items-center gap-4 text-danger"> <div className="flex flex-row items-center gap-4 text-danger">
<Warning size={40}/> <WarningIcon size={40}/>
<p> <p>
Invalid token<br/> Invalid token<br/>
Please try again Please try again
+15 -32
View File
@@ -1,23 +1,6 @@
import {Button} from "@heroui/react"; import {Button} from "@heroui/react";
import {useNavigate} from "react-router"; import {useNavigate} from "react-router";
import { import { AlienIcon, CompassIcon, CubeIcon, DiceFiveIcon, FlagCheckeredIcon, GameControllerIcon, GhostIcon, Icon, IconContext, JoystickIcon, MagicWandIcon, PuzzlePieceIcon, RocketLaunchIcon, SkullIcon, SmileyXEyesIcon, SwordIcon } from "@phosphor-icons/react";
Alien,
Compass,
Cube,
DiceFive,
FlagCheckered,
GameController,
Ghost,
Icon,
IconContext,
Joystick,
MagicWand,
PuzzlePiece,
RocketLaunch,
Skull,
SmileyXEyes,
Sword
} from "@phosphor-icons/react";
import React, {ReactElement, useState} from "react"; import React, {ReactElement, useState} from "react";
import GameyfinLogo from "Frontend/components/theming/GameyfinLogo"; import GameyfinLogo from "Frontend/components/theming/GameyfinLogo";
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern"; import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
@@ -37,85 +20,85 @@ export default function ErrorView() {
"title": "404 Level Not Found!", "title": "404 Level Not Found!",
"subtitle": "Youve wandered off the map. This level doesnt exist—or maybe its still in development.", "subtitle": "Youve wandered off the map. This level doesnt exist—or maybe its still in development.",
"buttonText": "Go back to the main menu", "buttonText": "Go back to the main menu",
"icon": <Joystick/> "icon": <JoystickIcon/>
}, },
{ {
"title": "404 Quest Failed", "title": "404 Quest Failed",
"subtitle": "The path you seek does not exist. Maybe it was just a side quest after all.", "subtitle": "The path you seek does not exist. Maybe it was just a side quest after all.",
"buttonText": "Return to the guild hall", "buttonText": "Return to the guild hall",
"icon": <Compass/> "icon": <CompassIcon/>
}, },
{ {
"title": "404 Youve encountered a glitch in the system!", "title": "404 Youve encountered a glitch in the system!",
"subtitle": "The page youre looking for couldnt load. Dont worry, no coins were lost.", "subtitle": "The page youre looking for couldnt load. Dont worry, no coins were lost.",
"buttonText": "Retry mission", "buttonText": "Retry mission",
"icon": <Alien/> "icon": <AlienIcon/>
}, },
{ {
"title": "404 Game Cartridge Not Inserted", "title": "404 Game Cartridge Not Inserted",
"subtitle": "This page failed to load. Did you blow on the cartridge and try again?", "subtitle": "This page failed to load. Did you blow on the cartridge and try again?",
"buttonText": "Reset the console", "buttonText": "Reset the console",
"icon": <DiceFive/> "icon": <DiceFiveIcon/>
}, },
{ {
"title": "404 You are in the wrong zone…", "title": "404 You are in the wrong zone…",
"subtitle": "This area is off-limits… or was never meant to be explored. Tread carefully.", "subtitle": "This area is off-limits… or was never meant to be explored. Tread carefully.",
"buttonText": "Find a safe path", "buttonText": "Find a safe path",
"icon": <SmileyXEyes/> "icon": <SmileyXEyesIcon/>
}, },
{ {
"title": "404 You Missed the Jump!", "title": "404 You Missed the Jump!",
"subtitle": "The platform you were trying to reach isnt here. Maybe it was a hidden level?", "subtitle": "The platform you were trying to reach isnt here. Maybe it was a hidden level?",
"buttonText": "Respawn at Start", "buttonText": "Respawn at Start",
"icon": <GameController/> "icon": <GameControllerIcon/>
}, },
{ {
"title": "404 Signal Lost in Deep Space", "title": "404 Signal Lost in Deep Space",
"subtitle": "We've lost contact with this page. All we have is static and void.", "subtitle": "We've lost contact with this page. All we have is static and void.",
"buttonText": "Return to Command Center", "buttonText": "Return to Command Center",
"icon": <RocketLaunch/> "icon": <RocketLaunchIcon/>
}, },
{ {
"title": "404 The Page Has Vanished in a Puff of Smoke", "title": "404 The Page Has Vanished in a Puff of Smoke",
"subtitle": "A forbidden spell may have erased the page from existence. Try another path.", "subtitle": "A forbidden spell may have erased the page from existence. Try another path.",
"buttonText": "Return to the Grimoire", "buttonText": "Return to the Grimoire",
"icon": <MagicWand/> "icon": <MagicWandIcon/>
}, },
{ {
"title": "404 Block Not Found", "title": "404 Block Not Found",
"subtitle": "The page you're looking for hasn't been crafted yet. Gather more resources and try again.", "subtitle": "The page you're looking for hasn't been crafted yet. Gather more resources and try again.",
"buttonText": "Back to Base", "buttonText": "Back to Base",
"icon": <Cube/> "icon": <CubeIcon/>
}, },
{ {
"title": "404 Puzzle Piece Missing", "title": "404 Puzzle Piece Missing",
"subtitle": "This page doesnt quite fit. Try rotating it… or just go back.", "subtitle": "This page doesnt quite fit. Try rotating it… or just go back.",
"buttonText": "Solve a different puzzle", "buttonText": "Solve a different puzzle",
"icon": <PuzzlePiece/> "icon": <PuzzlePieceIcon/>
}, },
{ {
"title": "404 You Took a Wrong Turn!", "title": "404 You Took a Wrong Turn!",
"subtitle": "You drifted off course and into the digital void.", "subtitle": "You drifted off course and into the digital void.",
"buttonText": "Return to the Starting Line", "buttonText": "Return to the Starting Line",
"icon": <FlagCheckered/> "icon": <FlagCheckeredIcon/>
}, },
{ {
"title": "404 This Page Didnt Survive", "title": "404 This Page Didnt Survive",
"subtitle": "Only ruins remain. Whatever was here is long gone.", "subtitle": "Only ruins remain. Whatever was here is long gone.",
"buttonText": "Search for safe house", "buttonText": "Search for safe house",
"icon": <Skull/> "icon": <SkullIcon/>
}, },
{ {
"title": "404 Instance Not Found", "title": "404 Instance Not Found",
"subtitle": "This dungeon has been removed or doesnt exist on this realm.", "subtitle": "This dungeon has been removed or doesnt exist on this realm.",
"buttonText": "Return to your stronghold", "buttonText": "Return to your stronghold",
"icon": <Sword/> "icon": <SwordIcon/>
}, },
{ {
"title": "404 The Page Was… Never Really Here…", "title": "404 The Page Was… Never Really Here…",
"subtitle": "You were warned not to look. But you clicked anyway.", "subtitle": "You were warned not to look. But you clicked anyway.",
"buttonText": "Turn Back Now", "buttonText": "Turn Back Now",
"icon": <Ghost/> "icon": <GhostIcon/>
} }
]; ];
@@ -16,7 +16,7 @@ import {
useDisclosure useDisclosure
} from "@heroui/react"; } from "@heroui/react";
import RequestGameModal from "Frontend/components/general/modals/RequestGameModal"; import RequestGameModal from "Frontend/components/general/modals/RequestGameModal";
import {ArrowUp, Check, Info, PlusCircle, Trash, X} from "@phosphor-icons/react"; import {ArrowUpIcon, CheckIcon, InfoIcon, PlusCircleIcon, TrashIcon, XIcon} from "@phosphor-icons/react";
import React, {useEffect, useMemo, useState} from "react"; import React, {useEffect, useMemo, useState} from "react";
import {useAuth} from "Frontend/util/auth"; import {useAuth} from "Frontend/util/auth";
import {ConfigEndpoint, GameRequestEndpoint} from "Frontend/generated/endpoints"; import {ConfigEndpoint, GameRequestEndpoint} from "Frontend/generated/endpoints";
@@ -145,9 +145,10 @@ export default function GameRequestView() {
switch (status) { switch (status) {
case GameRequestStatus.APPROVED: case GameRequestStatus.APPROVED:
return <Chip size="sm" radius="sm" return <Chip size="sm" radius="sm"
className="text-xs bg-success-300 text-success-foreground">Approved</Chip>; className="text-xs bg-success text-success-foreground">Approved</Chip>;
case GameRequestStatus.FULFILLED: case GameRequestStatus.FULFILLED:
return <Chip size="sm" radius="sm" className="text-xs bg-success">Fulfilled</Chip>; return <Chip size="sm" radius="sm"
className="text-xs bg-success-100 text-success-foreground">Fulfilled</Chip>;
case GameRequestStatus.REJECTED: case GameRequestStatus.REJECTED:
return <Chip size="sm" radius="sm" return <Chip size="sm" radius="sm"
className="text-xs bg-danger-300 text-danger-foreground">Rejected</Chip>; className="text-xs bg-danger-300 text-danger-foreground">Rejected</Chip>;
@@ -162,13 +163,13 @@ export default function GameRequestView() {
<h1 className="text-2xl font-bold">Game Requests</h1> <h1 className="text-2xl font-bold">Game Requests</h1>
<div className="flex flex-row items-center gap-4"> <div className="flex flex-row items-center gap-4">
{!areGameRequestsEnabled && {!areGameRequestsEnabled &&
<SmallInfoField icon={Info} <SmallInfoField icon={InfoIcon}
message="Request submission is disabled" message="Request submission is disabled"
className="text-default-500"/> className="text-default-500"/>
} }
<Button className="w-fit" <Button className="w-fit"
color="primary" color="primary"
startContent={<PlusCircle weight="fill"/>} startContent={<PlusCircleIcon weight="fill"/>}
onPress={requestGameModal.onOpen} onPress={requestGameModal.onOpen}
isDisabled={!areGameRequestsEnabled || (!auth.state.user && !areGuestsAllowedToRequestGames)}> isDisabled={!areGameRequestsEnabled || (!auth.state.user && !areGuestsAllowedToRequestGames)}>
Request a Game Request a Game
@@ -219,6 +220,7 @@ export default function GameRequestView() {
> >
<TableHeader> <TableHeader>
<TableColumn key="title" allowsSorting>Title & Release</TableColumn> <TableColumn key="title" allowsSorting>Title & Release</TableColumn>
<TableColumn key="platform">Platform</TableColumn>
<TableColumn>Submitted by</TableColumn> <TableColumn>Submitted by</TableColumn>
<TableColumn key="createdAt" allowsSorting>Submitted</TableColumn> <TableColumn key="createdAt" allowsSorting>Submitted</TableColumn>
<TableColumn key="updatedAt" allowsSorting>Updated</TableColumn> <TableColumn key="updatedAt" allowsSorting>Updated</TableColumn>
@@ -232,6 +234,9 @@ export default function GameRequestView() {
<TableCell> <TableCell>
{item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"}) {item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"})
</TableCell> </TableCell>
<TableCell>
<Chip size="sm" radius="sm" className="text-xs max-w-32 truncate">{item.platform}</Chip>
</TableCell>
<TableCell> <TableCell>
<p className="text-default-500"> <p className="text-default-500">
{item.requester ? {item.requester ?
@@ -259,7 +264,7 @@ export default function GameRequestView() {
variant={hasUserVotedForRequest(item as GameRequestDto) ? "solid" : "bordered"} variant={hasUserVotedForRequest(item as GameRequestDto) ? "solid" : "bordered"}
color={hasUserVotedForRequest(item as GameRequestDto) ? "primary" : "default"} color={hasUserVotedForRequest(item as GameRequestDto) ? "primary" : "default"}
isDisabled={!auth.state.user || item.status === GameRequestStatus.FULFILLED} isDisabled={!auth.state.user || item.status === GameRequestStatus.FULFILLED}
startContent={<ArrowUp/>} startContent={<ArrowUpIcon/>}
onPress={async () => await toggleVote(item.id)}> onPress={async () => await toggleVote(item.id)}>
{item.voters.length} {item.voters.length}
</Button> </Button>
@@ -272,7 +277,7 @@ export default function GameRequestView() {
color={item.status === GameRequestStatus.APPROVED ? "primary" : "default"} color={item.status === GameRequestStatus.APPROVED ? "primary" : "default"}
isDisabled={item.status === GameRequestStatus.FULFILLED} isDisabled={item.status === GameRequestStatus.FULFILLED}
onPress={async () => await toggleApprove(item as GameRequestDto)}> onPress={async () => await toggleApprove(item as GameRequestDto)}>
<Check/> <CheckIcon/>
</Button> </Button>
</Tooltip> </Tooltip>
<Tooltip content="Reject this request"> <Tooltip content="Reject this request">
@@ -281,7 +286,7 @@ export default function GameRequestView() {
color={item.status === GameRequestStatus.REJECTED ? "primary" : "default"} color={item.status === GameRequestStatus.REJECTED ? "primary" : "default"}
isDisabled={item.status === GameRequestStatus.FULFILLED} isDisabled={item.status === GameRequestStatus.FULFILLED}
onPress={async () => await toggleReject(item as GameRequestDto)}> onPress={async () => await toggleReject(item as GameRequestDto)}>
<X/> <XIcon/>
</Button> </Button>
</Tooltip> </Tooltip>
</div>} </div>}
@@ -290,7 +295,7 @@ export default function GameRequestView() {
<Button size="sm" isIconOnly <Button size="sm" isIconOnly
color="danger" color="danger"
onPress={async () => await deleteRequest(item.id)}> onPress={async () => await deleteRequest(item.id)}>
<Trash/> <TrashIcon/>
</Button> </Button>
</Tooltip> </Tooltip>
} }
+47 -27
View File
@@ -5,11 +5,19 @@ import {GameCover} from "Frontend/components/general/covers/GameCover";
import ComboButton, {ComboButtonOption} from "Frontend/components/general/input/ComboButton"; import ComboButton, {ComboButtonOption} from "Frontend/components/general/input/ComboButton";
import ImageCarousel from "Frontend/components/general/covers/ImageCarousel"; import ImageCarousel from "Frontend/components/general/covers/ImageCarousel";
import {Accordion, AccordionItem, addToast, Button, Chip, Link, Tooltip, useDisclosure} from "@heroui/react"; import {Accordion, AccordionItem, addToast, Button, Chip, Link, Tooltip, useDisclosure} from "@heroui/react";
import {humanFileSize, isAdmin, starRatingAsString, toTitleCase} from "Frontend/util/utils"; import {humanFileSize, isAdmin, starRatingAsString} from "Frontend/util/utils";
import {DownloadEndpoint} from "Frontend/endpoints/endpoints"; import {DownloadEndpoint} from "Frontend/endpoints/endpoints";
import {gameState} from "Frontend/state/GameState"; import {gameState} from "Frontend/state/GameState";
import {useSnapshot} from "valtio/react"; import {useSnapshot} from "valtio/react";
import {CheckCircle, Info, MagnifyingGlass, Pencil, Star, Trash, TriangleDashed} from "@phosphor-icons/react"; import {
CheckCircleIcon,
InfoIcon,
MagnifyingGlassIcon,
PencilIcon,
StarIcon,
TrashIcon,
TriangleDashedIcon
} from "@phosphor-icons/react";
import {useAuth} from "Frontend/util/auth"; import {useAuth} from "Frontend/util/auth";
import MatchGameModal from "Frontend/components/general/modals/MatchGameModal"; import MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
import EditGameMetadataModal from "Frontend/components/general/modals/EditGameMetadataModal"; import EditGameMetadataModal from "Frontend/components/general/modals/EditGameMetadataModal";
@@ -17,6 +25,7 @@ import GameUpdateDto from "Frontend/generated/org/gameyfin/app/games/dto/GameUpd
import Markdown from "react-markdown"; import Markdown from "react-markdown";
import remarkBreaks from "remark-breaks"; import remarkBreaks from "remark-breaks";
import {GameAdminDto} from "Frontend/dtos/GameDtos"; import {GameAdminDto} from "Frontend/dtos/GameDtos";
import ChipList from "Frontend/components/general/ChipList";
export default function GameView() { export default function GameView() {
const {gameId} = useParams(); const {gameId} = useParams();
@@ -93,12 +102,12 @@ export default function GameView() {
) : ( ) : (
<div className="w-full h-96 bg-secondary relative"/> <div className="w-full h-96 bg-secondary relative"/>
)} )}
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-background"/> <div className="absolute inset-0 bg-linear-to-b from-transparent to-background"/>
</div> </div>
<div className="flex flex-col gap-4 mx-24"> <div className="flex flex-col gap-4 mx-24">
<div className="flex flex-row justify-between"> <div className="flex flex-row justify-between">
<div className="flex flex-row gap-4"> <div className="flex flex-row gap-4">
<div className="mt-[-16.25rem]"> <div className="-mt-65">
<GameCover game={game} size={320} radius="none"/> <GameCover game={game} size={320} radius="none"/>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -107,7 +116,7 @@ export default function GameView() {
{game.title} {game.title}
</p> </p>
<div className="flex flex-row gap-1 mb-0.5 text-default-500"> <div className="flex flex-row gap-1 mb-0.5 text-default-500">
<Star weight="fill"/> <StarIcon weight="fill"/>
{starRatingAsString(game)} {starRatingAsString(game)}
</div> </div>
</div> </div>
@@ -116,10 +125,11 @@ export default function GameView() {
{game.release !== undefined ? new Date(game.release).getFullYear() : {game.release !== undefined ? new Date(game.release).getFullYear() :
<p className="text-default-500">no data</p>} <p className="text-default-500">no data</p>}
</p> </p>
<ChipList items={game.platforms} maxVisible={1}/>
<Tooltip <Tooltip
content={`Last update: ${new Date(game.updatedAt).toLocaleString()}`} content={`Last update: ${new Date(game.updatedAt).toLocaleString()}`}
placement="right"> placement="right">
<Info/> <InfoIcon/>
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
@@ -129,20 +139,20 @@ export default function GameView() {
<Button isIconOnly onPress={toggleMatchConfirmed}> <Button isIconOnly onPress={toggleMatchConfirmed}>
{game.metadata.matchConfirmed ? {game.metadata.matchConfirmed ?
<Tooltip content="Unconfirm match"> <Tooltip content="Unconfirm match">
<CheckCircle weight="fill" className="fill-success"/> <CheckCircleIcon weight="fill" className="fill-success"/>
</Tooltip> : </Tooltip> :
<Tooltip content="Confirm match"> <Tooltip content="Confirm match">
<CheckCircle/> <CheckCircleIcon/>
</Tooltip>} </Tooltip>}
</Button> </Button>
<Tooltip content="Edit metadata"> <Tooltip content="Edit metadata">
<Button isIconOnly onPress={editGameModal.onOpenChange}> <Button isIconOnly onPress={editGameModal.onOpenChange}>
<Pencil/> <PencilIcon/>
</Button> </Button>
</Tooltip> </Tooltip>
<Tooltip content="Search for metadata"> <Tooltip content="Search for metadata">
<Button isIconOnly onPress={matchGameModal.onOpenChange}> <Button isIconOnly onPress={matchGameModal.onOpenChange}>
<MagnifyingGlass/> <MagnifyingGlassIcon/>
</Button> </Button>
</Tooltip> </Tooltip>
<Tooltip content="Remove from library"> <Tooltip content="Remove from library">
@@ -151,7 +161,7 @@ export default function GameView() {
await deleteGame(); await deleteGame();
navigate("/"); navigate("/");
}}> }}>
<Trash/> <TrashIcon/>
</Button> </Button>
</Tooltip> </Tooltip>
</div>} </div>}
@@ -168,7 +178,7 @@ export default function GameView() {
<AccordionItem key="information" <AccordionItem key="information"
aria-label="Information" aria-label="Information"
title="Information" title="Information"
startContent={<Info weight="fill"/>}> startContent={<InfoIcon weight="fill"/>}>
<Markdown <Markdown
remarkPlugins={[remarkBreaks]} remarkPlugins={[remarkBreaks]}
components={{ components={{
@@ -195,11 +205,12 @@ export default function GameView() {
<p>No summary available</p> <p>No summary available</p>
} }
</div> </div>
<div className="flex flex-col flex-1 gap-2"> <div className="flex flex-col flex-1">
<p className="text-default-500">Details</p> <p className="text-default-500">Details</p>
<table className="text-left w-full table-auto"> <table
className="text-left w-full table-auto border-separate border-spacing-y-1">
<tbody> <tbody>
<tr className="h-6"> <tr>
<td className="text-default-500 w-0 min-w-32">Developed by</td> <td className="text-default-500 w-0 min-w-32">Developed by</td>
<td className="flex flex-row gap-1"> <td className="flex flex-row gap-1">
{game.developers && game.developers.length > 0 {game.developers && game.developers.length > 0
@@ -213,64 +224,73 @@ export default function GameView() {
</> </>
) )
: <Tooltip content="Missing data" color="foreground" placement="right"> : <Tooltip content="Missing data" color="foreground" placement="right">
<TriangleDashed className="fill-default-500 h-6 bottom-0"/> <TriangleDashedIcon className="fill-default-500 h-6 bottom-0"/>
</Tooltip> </Tooltip>
} }
</td> </td>
</tr> </tr>
<tr className="h-6"> <tr>
<td className="text-default-500 w-0 min-w-32">Published by</td> <td className="text-default-500 w-0 min-w-32">Published by</td>
<td className="flex flex-row gap-1"> <td className="flex flex-row gap-1">
{game.publishers && game.publishers.length > 0 {game.publishers && game.publishers.length > 0
? [...game.publishers].sort().join(" / ") ? [...game.publishers].sort().join(" / ")
: <Tooltip content="Missing data" color="foreground" placement="right"> : <Tooltip content="Missing data" color="foreground" placement="right">
<TriangleDashed className="fill-default-500 h-6 bottom-0"/> <TriangleDashedIcon className="fill-default-500 h-6 bottom-0"/>
</Tooltip> </Tooltip>
} }
</td> </td>
</tr> </tr>
<tr className="h-6"> <tr>
<td className="text-default-500 w-0 min-w-32">Genres</td> <td className="text-default-500 w-0 min-w-32">Genres</td>
<td className="flex flex-row gap-1"> <td className="flex flex-row gap-1">
{game.genres && game.genres.length > 0 {game.genres && game.genres.length > 0
? [...game.genres].sort().map(genre => ? [...game.genres].sort().map(genre =>
<Link key={genre} href={`/search?genre=${encodeURIComponent(genre)}`}> <Link key={genre} href={`/search?genre=${encodeURIComponent(genre)}`}>
<Chip radius="sm">{toTitleCase(genre)}</Chip> <Chip radius="sm" size="sm"
className="text-sm">
{genre}
</Chip>
</Link> </Link>
) )
: <Tooltip content="Missing data" color="foreground" placement="right"> : <Tooltip content="Missing data" color="foreground" placement="right">
<TriangleDashed className="fill-default-500 h-6 bottom-0"/> <TriangleDashedIcon className="fill-default-500 h-6 bottom-0"/>
</Tooltip> </Tooltip>
} }
</td> </td>
</tr> </tr>
<tr className="h-6"> <tr>
<td className="text-default-500 w-0 min-w-32">Themes</td> <td className="text-default-500 w-0 min-w-32">Themes</td>
<td className="flex flex-row gap-1"> <td className="flex flex-row gap-1">
{game.themes && game.themes.length > 0 {game.themes && game.themes.length > 0
? [...game.themes].sort().map(theme => ? [...game.themes].sort().map(theme =>
<Link key={theme} href={`/search?theme=${encodeURIComponent(theme)}`}> <Link key={theme} href={`/search?theme=${encodeURIComponent(theme)}`}>
<Chip radius="sm">{toTitleCase(theme)}</Chip> <Chip radius="sm" size="sm"
className="text-sm">
{theme}
</Chip>
</Link> </Link>
) )
: <Tooltip content="Missing data" color="foreground" placement="right"> : <Tooltip content="Missing data" color="foreground" placement="right">
<TriangleDashed className="fill-default-500 h-6 bottom-0"/> <TriangleDashedIcon className="fill-default-500 h-6 bottom-0"/>
</Tooltip> </Tooltip>
} }
</td> </td>
</tr> </tr>
<tr className="h-6"> <tr>
<td className="text-default-500 w-0 min-w-32">Features</td> <td className="text-default-500 w-0 min-w-32">Features</td>
<td className="flex flex-row gap-1"> <td className="flex flex-row gap-1">
{game.features && game.features.length > 0 {game.features && game.features.length > 0
? [...game.features].sort().map(feature => ? [...game.features].sort().map(feature =>
<Link key={feature} <Link key={feature}
href={`/search?feature=${encodeURIComponent(feature)}`}> href={`/search?feature=${encodeURIComponent(feature)}`}>
<Chip radius="sm">{toTitleCase(feature)}</Chip> <Chip radius="sm" size="sm"
className="text-sm">
{feature}
</Chip>
</Link> </Link>
) )
: <Tooltip content="Missing data" color="foreground" placement="right"> : <Tooltip content="Missing data" color="foreground" placement="right">
<TriangleDashed className="fill-default-500 h-6 bottom-0"/> <TriangleDashedIcon className="fill-default-500 h-6 bottom-0"/>
</Tooltip> </Tooltip>
} }
</td> </td>
@@ -5,7 +5,7 @@ import Input from "Frontend/components/general/input/Input";
import * as Yup from "yup"; import * as Yup from "yup";
import {RegistrationEndpoint} from "Frontend/generated/endpoints"; import {RegistrationEndpoint} from "Frontend/generated/endpoints";
import React, {useEffect, useState} from "react"; import React, {useEffect, useState} from "react";
import {Warning} from "@phosphor-icons/react"; import { WarningIcon } from "@phosphor-icons/react";
import UserInvitationAcceptanceResult import UserInvitationAcceptanceResult
from "Frontend/generated/org/gameyfin/app/users/enums/UserInvitationAcceptanceResult"; from "Frontend/generated/org/gameyfin/app/users/enums/UserInvitationAcceptanceResult";
@@ -63,7 +63,7 @@ export default function InvitationRegistrationView() {
} }
return ( return (
<div className="flex flex-row flex-grow items-center justify-center size-full gradient-primary"> <div className="flex flex-row grow items-center justify-center size-full gradient-primary">
<Card className="p-4 min-w-[468px]"> <Card className="p-4 min-w-[468px]">
<CardHeader className="mb-4"> <CardHeader className="mb-4">
<img <img
@@ -114,8 +114,8 @@ export default function InvitationRegistrationView() {
)} )}
</Formik> </Formik>
: :
<p className="flex flex-row flex-grow justify-center items-center gap-2 text-danger text-2xl font-bold"> <p className="flex flex-row grow justify-center items-center gap-2 text-danger text-2xl font-bold">
<Warning weight="fill"/> <WarningIcon weight="fill"/>
Invalid token Invalid token
</p> </p>
} }
@@ -2,12 +2,12 @@ import {useLocation, useNavigate, useParams} from "react-router";
import React, {useEffect} from "react"; import React, {useEffect} from "react";
import LibraryHeader from "Frontend/components/general/covers/LibraryHeader"; import LibraryHeader from "Frontend/components/general/covers/LibraryHeader";
import {Button, Tab, Tabs} from "@heroui/react"; import {Button, Tab, Tabs} from "@heroui/react";
import {ArrowLeft} from "@phosphor-icons/react"; import {ArrowLeftIcon} from "@phosphor-icons/react";
import LibraryManagementDetails from "Frontend/components/general/library/LibraryManagementDetails"; import LibraryManagementDetails from "Frontend/components/general/library/LibraryManagementDetails";
import LibraryManagementGames from "Frontend/components/general/library/LibraryManagementGames"; import LibraryManagementGames from "Frontend/components/general/library/LibraryManagementGames";
import {useSnapshot} from "valtio/react"; import {useSnapshot} from "valtio/react";
import {libraryState} from "Frontend/state/LibraryState"; import {libraryState} from "Frontend/state/LibraryState";
import LibraryManagementUnmatchedPaths from "Frontend/components/general/library/LibraryManagementUnmatchedPaths"; import LibraryManagementIgnoredPaths from "Frontend/components/general/library/LibraryManagementIgnoredPaths";
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto"; import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
@@ -26,7 +26,7 @@ export default function LibraryManagementView() {
return libraryId && state.state[parseInt(libraryId)] && <div className="flex flex-col gap-4"> return libraryId && state.state[parseInt(libraryId)] && <div className="flex flex-col gap-4">
<div className="flex flex-row gap-4 items-center"> <div className="flex flex-row gap-4 items-center">
<Button isIconOnly variant="light" onPress={() => navigate("/administration/libraries")}> <Button isIconOnly variant="light" onPress={() => navigate("/administration/libraries")}>
<ArrowLeft/> <ArrowLeftIcon/>
</Button> </Button>
<h1 className="text-2xl font-bold">Manage library</h1> <h1 className="text-2xl font-bold">Manage library</h1>
</div> </div>
@@ -40,8 +40,8 @@ export default function LibraryManagementView() {
<Tab key="#games" title="Games"> <Tab key="#games" title="Games">
<LibraryManagementGames library={state.state[parseInt(libraryId)] as LibraryAdminDto}/> <LibraryManagementGames library={state.state[parseInt(libraryId)] as LibraryAdminDto}/>
</Tab> </Tab>
<Tab key="#unmatched-paths" title="Unmatched paths"> <Tab key="#ignored-paths" title="Ignored paths">
<LibraryManagementUnmatchedPaths library={state.state[parseInt(libraryId)] as LibraryAdminDto}/> <LibraryManagementIgnoredPaths library={state.state[parseInt(libraryId)] as LibraryAdminDto}/>
</Tab> </Tab>
</Tabs> </Tabs>
</div>; </div>;
+1 -1
View File
@@ -47,7 +47,7 @@ export default function LoginView() {
alt="Gameyfin Logo" alt="Gameyfin Logo"
/> />
</CardHeader> </CardHeader>
<CardBody className="mt-8 mb-2 w-80 max-w-screen-lg sm:w-96"> <CardBody className="mt-8 mb-2 w-80 max-w-(--breakpoint-lg) sm:w-96">
<Formik <Formik
initialValues={{}} initialValues={{}}
onSubmit={tryLogin}> onSubmit={tryLogin}>
+17 -17
View File
@@ -5,7 +5,15 @@ import GameyfinLogo from "Frontend/components/theming/GameyfinLogo";
import * as PackageJson from "../../../../package.json"; import * as PackageJson from "../../../../package.json";
import {Outlet, useLocation, useNavigate} from "react-router"; import {Outlet, useLocation, useNavigate} from "react-router";
import {useAuth} from "Frontend/util/auth"; import {useAuth} from "Frontend/util/auth";
import {ArrowLeft, DiceSix, Disc, Heart, House, ListMagnifyingGlass, SignIn} from "@phosphor-icons/react"; import {
ArrowLeftIcon,
DiceSixIcon,
DiscIcon,
HeartIcon,
HouseIcon,
ListMagnifyingGlassIcon,
SignInIcon
} from "@phosphor-icons/react";
import Confetti, {ConfettiProps} from "react-confetti-boom"; import Confetti, {ConfettiProps} from "react-confetti-boom";
import {useTheme} from "next-themes"; import {useTheme} from "next-themes";
import {useUserPreferenceService} from "Frontend/util/user-preference-service"; import {useUserPreferenceService} from "Frontend/util/user-preference-service";
@@ -14,7 +22,6 @@ import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState"; import {gameState} from "Frontend/state/GameState";
import ScanProgressPopover from "Frontend/components/general/ScanProgressPopover"; import ScanProgressPopover from "Frontend/components/general/ScanProgressPopover";
import {isAdmin} from "Frontend/util/utils"; import {isAdmin} from "Frontend/util/utils";
import DockerHubDeprecationPopover from "Frontend/components/temp/DockerHubDeprecationPopover";
export default function MainLayout() { export default function MainLayout() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -71,10 +78,10 @@ export default function MainLayout() {
{isHomePage ? <GameyfinLogo className="h-10 fill-foreground"/> : {isHomePage ? <GameyfinLogo className="h-10 fill-foreground"/> :
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">
<Button isIconOnly onPress={() => history.back()} variant="light"> <Button isIconOnly onPress={() => history.back()} variant="light">
<ArrowLeft size={26} weight="bold"/> <ArrowLeftIcon size={26} weight="bold"/>
</Button> </Button>
<Button isIconOnly onPress={() => navigate("/")} variant="light"> <Button isIconOnly onPress={() => navigate("/")} variant="light">
<House size={26} weight="fill"/> <HouseIcon size={26} weight="fill"/>
</Button> </Button>
</div> </div>
} }
@@ -84,13 +91,13 @@ export default function MainLayout() {
<Button isIconOnly variant="light" <Button isIconOnly variant="light"
onPress={() => navigate("/game/" + getRandomGameId())} onPress={() => navigate("/game/" + getRandomGameId())}
isDisabled={gameState.games.length === 0}> isDisabled={gameState.games.length === 0}>
<DiceSix/> <DiceSixIcon/>
</Button> </Button>
</Tooltip> </Tooltip>
<SearchBar/> <SearchBar/>
<Tooltip content="Advanced search" placement="bottom"> <Tooltip content="Advanced search" placement="bottom">
<Button isIconOnly variant="light" onPress={() => navigate("/search")}> <Button isIconOnly variant="light" onPress={() => navigate("/search")}>
<ListMagnifyingGlass/> <ListMagnifyingGlassIcon/>
</Button> </Button>
</Tooltip> </Tooltip>
</NavbarContent>} </NavbarContent>}
@@ -100,20 +107,13 @@ export default function MainLayout() {
<Button color="primary" <Button color="primary"
isDisabled={window.location.pathname.startsWith("/requests")} isDisabled={window.location.pathname.startsWith("/requests")}
onPress={() => navigate("/requests")} onPress={() => navigate("/requests")}
startContent={<Disc weight="fill"/>}> startContent={<DiscIcon weight="fill"/>}>
Requests Requests
</Button> </Button>
</Tooltip> </Tooltip>
</NavbarItem> </NavbarItem>
{isAdmin(auth) && {isAdmin(auth) &&
<div className="flex flex-row"> <div className="flex flex-row">
<NavbarItem>
<Tooltip content="Important information" placement="bottom">
<div>
<DockerHubDeprecationPopover/>
</div>
</Tooltip>
</NavbarItem>
<NavbarItem> <NavbarItem>
<Tooltip content="View library scan results" placement="bottom"> <Tooltip content="View library scan results" placement="bottom">
<div> <div>
@@ -139,7 +139,7 @@ export default function MainLayout() {
This triggers Hilla to redirect to the correct login page (integrated or SSO) automatically. This triggers Hilla to redirect to the correct login page (integrated or SSO) automatically.
Otherwise, SSO login would not be possible if we redirect to "/login" directly */ Otherwise, SSO login would not be possible if we redirect to "/login" directly */
onPress={() => window.location.href = "/loginredirect"}> onPress={() => window.location.href = "/loginredirect"}>
<SignIn fill="text-background/80"/> <SignInIcon fill="text-background/80"/>
</Button> </Button>
</Tooltip> </Tooltip>
</NavbarItem> </NavbarItem>
@@ -147,7 +147,7 @@ export default function MainLayout() {
</NavbarContent> </NavbarContent>
</Navbar> </Navbar>
<div className="flex flex-col flex-grow 2xl:px-[12.5%] overflow-x-hidden mt-4"> <div className="flex flex-col grow 2xl:px-[12.5%] overflow-x-hidden mt-4">
<Outlet/> <Outlet/>
</div> </div>
@@ -157,7 +157,7 @@ export default function MainLayout() {
<p>Gameyfin {PackageJson.version}</p> <p>Gameyfin {PackageJson.version}</p>
<p className="flex flex-row gap-1 items-baseline"> <p className="flex flex-row gap-1 items-baseline">
Made with Made with
<Heart size={16} weight="fill" className="text-primary" onClick={easterEgg}/> <HeartIcon size={16} weight="fill" className="text-primary" onClick={easterEgg}/>
by by
<Link href="https://github.com/grimsi" target="_blank">grimsi</Link> and <Link href="https://github.com/grimsi" target="_blank">grimsi</Link> and
<Link href="https://github.com/gameyfin/gameyfin/graphs/contributors" target="_blank"> <Link href="https://github.com/gameyfin/gameyfin/graphs/contributors" target="_blank">
@@ -5,11 +5,11 @@ import Input from "Frontend/components/general/input/Input";
import * as Yup from "yup"; import * as Yup from "yup";
import {PasswordResetEndpoint} from "Frontend/generated/endpoints"; import {PasswordResetEndpoint} from "Frontend/generated/endpoints";
import React, {useEffect, useState} from "react"; import React, {useEffect, useState} from "react";
import {Warning} from "@phosphor-icons/react"; import {WarningIcon} from "@phosphor-icons/react";
import TokenValidationResult from "Frontend/generated/org/gameyfin/app/shared/token/TokenValidationResult"; import TokenValidationResult from "Frontend/generated/org/gameyfin/app/core/token/TokenValidationResult";
export default function PasswordResetView() { export default function PasswordResetView() {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [token, setToken] = useState<string>(); const [token, setToken] = useState<string>();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -50,7 +50,7 @@ export default function PasswordResetView() {
} }
return ( return (
<div className="flex flex-row flex-grow items-center justify-center size-full gradient-primary"> <div className="flex flex-row grow items-center justify-center size-full gradient-primary">
<Card className="p-4 min-w-[468px]"> <Card className="p-4 min-w-[468px]">
<CardHeader className="mb-4"> <CardHeader className="mb-4">
<img <img
@@ -91,8 +91,8 @@ export default function PasswordResetView() {
)} )}
</Formik> </Formik>
: :
<p className="flex flex-row flex-grow justify-center items-center gap-2 text-danger text-2xl font-bold"> <p className="flex flex-row grow justify-center items-center gap-2 text-danger text-2xl font-bold">
<Warning weight="fill"/> <WarningIcon weight="fill"/>
Invalid token Invalid token
</p> </p>
} }
+3 -3
View File
@@ -1,16 +1,16 @@
import {Palette, User} from "@phosphor-icons/react"; import { PaletteIcon, UserIcon } from "@phosphor-icons/react";
import withSideMenu from "Frontend/components/general/withSideMenu"; import withSideMenu from "Frontend/components/general/withSideMenu";
const menuItems = [ const menuItems = [
{ {
title: "My Profile", title: "My Profile",
url: "profile", url: "profile",
icon: <User/> icon: <UserIcon/>
}, },
{ {
title: "Appearance", title: "Appearance",
url: "appearance", url: "appearance",
icon: <Palette/> icon: <PaletteIcon/>
}, },
/* TODO: Implement account self management /* TODO: Implement account self management
{ {
+18 -12
View File
@@ -1,5 +1,11 @@
import {Button, Input, Select, SelectedItems, SelectItem, Tooltip} from "@heroui/react"; import {Button, Input, Select, SelectedItems, SelectItem, Tooltip} from "@heroui/react";
import {FunnelSimple, FunnelSimpleX, MagnifyingGlass, SortAscending, Star} from "@phosphor-icons/react"; import {
FunnelSimpleIcon,
FunnelSimpleXIcon,
MagnifyingGlassIcon,
SortAscendingIcon,
StarIcon
} from "@phosphor-icons/react";
import {useSnapshot} from "valtio/react"; import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState"; import {gameState} from "Frontend/state/GameState";
import {libraryState} from "Frontend/state/LibraryState"; import {libraryState} from "Frontend/state/LibraryState";
@@ -9,7 +15,7 @@ import {Fzf} from "fzf";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto"; import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto"; import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import CoverGrid from "Frontend/components/general/covers/CoverGrid"; import CoverGrid from "Frontend/components/general/covers/CoverGrid";
import {compoundRating, toTitleCase} from "Frontend/util/utils"; import {compoundRating} from "Frontend/util/utils";
export default function SearchView() { export default function SearchView() {
const games = useSnapshot(gameState).sortedAlphabetically as GameDto[]; const games = useSnapshot(gameState).sortedAlphabetically as GameDto[];
@@ -249,7 +255,7 @@ export default function SearchView() {
const stars = []; const stars = [];
for (let i = 0; i < total; i++) { for (let i = 0; i < total; i++) {
stars.push( stars.push(
<Star key={i} weight={i < filled ? "fill" : "regular"} className="inline-block"/> <StarIcon key={i} weight={i < filled ? "fill" : "regular"} className="inline-block"/>
); );
} }
return <div className="flex flex-row"> return <div className="flex flex-row">
@@ -261,13 +267,13 @@ export default function SearchView() {
<div className="flex w-full justify-between px-12 gap-4 flex-col lg:flex-row"> <div className="flex w-full justify-between px-12 gap-4 flex-col lg:flex-row">
<Input <Input
classNames={{ classNames={{
base: "w-full lg:w-96 flex-shrink-0", base: "w-full lg:w-96 shrink-0",
mainWrapper: "h-full", mainWrapper: "h-full",
inputWrapper: inputWrapper:
"h-full font-normal text-default-500 bg-default-400/20 dark:bg-default-500/20", "h-full font-normal text-default-500 bg-default-400/20 dark:bg-default-500/20",
}} }}
placeholder="Type to search..." placeholder="Type to search..."
startContent={<MagnifyingGlass/>} startContent={<MagnifyingGlassIcon/>}
type="search" type="search"
value={searchTerm} value={searchTerm}
isClearable isClearable
@@ -276,7 +282,7 @@ export default function SearchView() {
/> />
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">
<Select <Select
startContent={<SortAscending/>} startContent={<SortAscendingIcon/>}
selectedKeys={[sortBy]} selectedKeys={[sortBy]}
disallowEmptySelection disallowEmptySelection
selectionMode="single" selectionMode="single"
@@ -301,7 +307,7 @@ export default function SearchView() {
onPress={() => setShowFilters(!showFilters)} onPress={() => setShowFilters(!showFilters)}
aria-label="Toggle Filters" aria-label="Toggle Filters"
> >
<FunnelSimple/> <FunnelSimpleIcon/>
</Button> </Button>
</Tooltip> </Tooltip>
<Tooltip content="Clear All Filters"> <Tooltip content="Clear All Filters">
@@ -318,7 +324,7 @@ export default function SearchView() {
}} }}
aria-label="Clear All Filters" aria-label="Clear All Filters"
> >
<FunnelSimpleX/> <FunnelSimpleXIcon/>
</Button> </Button>
</Tooltip> </Tooltip>
</div> </div>
@@ -386,7 +392,7 @@ export default function SearchView() {
onSelectionChange={setSelectedGenres} onSelectionChange={setSelectedGenres}
> >
{Array.from(knownGenres).map((genre) => ( {Array.from(knownGenres).map((genre) => (
<SelectItem key={genre}>{toTitleCase(genre)}</SelectItem> <SelectItem key={genre}>{genre}</SelectItem>
))} ))}
</Select> </Select>
<Select <Select
@@ -399,7 +405,7 @@ export default function SearchView() {
onSelectionChange={setSelectedThemes} onSelectionChange={setSelectedThemes}
> >
{Array.from(knownThemes).map((theme) => ( {Array.from(knownThemes).map((theme) => (
<SelectItem key={theme}>{toTitleCase(theme)}</SelectItem> <SelectItem key={theme}>{theme}</SelectItem>
))} ))}
</Select> </Select>
<Select <Select
@@ -412,7 +418,7 @@ export default function SearchView() {
onSelectionChange={setSelectedFeatures} onSelectionChange={setSelectedFeatures}
> >
{Array.from(knownFeatures).map((feature) => ( {Array.from(knownFeatures).map((feature) => (
<SelectItem key={feature}>{toTitleCase(feature)}</SelectItem> <SelectItem key={feature}>{feature}</SelectItem>
))} ))}
</Select> </Select>
<Select <Select
@@ -425,7 +431,7 @@ export default function SearchView() {
onSelectionChange={setSelectedPerspectives} onSelectionChange={setSelectedPerspectives}
> >
{Array.from(knownPerspectives).map((perspective) => ( {Array.from(knownPerspectives).map((perspective) => (
<SelectItem key={perspective}>{toTitleCase(perspective)}</SelectItem> <SelectItem key={perspective}>{perspective}</SelectItem>
))} ))}
</Select> </Select>
<Select <Select
+6 -6
View File
@@ -3,7 +3,7 @@ import * as Yup from 'yup';
import Wizard from "Frontend/components/wizard/Wizard"; import Wizard from "Frontend/components/wizard/Wizard";
import WizardStep from "Frontend/components/wizard/WizardStep"; import WizardStep from "Frontend/components/wizard/WizardStep";
import Input from "Frontend/components/general/input/Input"; import Input from "Frontend/components/general/input/Input";
import {HandWaving, Palette, User} from "@phosphor-icons/react"; import { HandWavingIcon, PaletteIcon, UserIcon } from "@phosphor-icons/react";
import {addToast, Card} from "@heroui/react"; import {addToast, Card} from "@heroui/react";
import {SetupEndpoint} from "Frontend/generated/endpoints"; import {SetupEndpoint} from "Frontend/generated/endpoints";
import {ThemeSelector} from "Frontend/components/theming/ThemeSelector"; import {ThemeSelector} from "Frontend/components/theming/ThemeSelector";
@@ -35,7 +35,7 @@ function WelcomeStep() {
function ThemeStep() { function ThemeStep() {
return ( return (
<div className="flex flex-col flex-grow gap-6 items-center"> <div className="flex flex-col grow gap-6 items-center">
<p className="text-2xl font-bold">Choose your style</p> <p className="text-2xl font-bold">Choose your style</p>
<ThemeSelector/> <ThemeSelector/>
</div> </div>
@@ -44,7 +44,7 @@ function ThemeStep() {
function UserStep() { function UserStep() {
return ( return (
<div className="flex flex-row flex-grow justify-center"> <div className="flex flex-row grow justify-center">
<div className="flex flex-col w-1/3 min-w-96 gap-6 items-center"> <div className="flex flex-col w-1/3 min-w-96 gap-6 items-center">
<p className="text-2xl font-bold">Create your account</p> <p className="text-2xl font-bold">Create your account</p>
<p>This will set up the initial admin user account.</p> <p>This will set up the initial admin user account.</p>
@@ -108,10 +108,10 @@ function SetupView() {
} }
} }
> >
<WizardStep icon={<HandWaving/>}> <WizardStep icon={<HandWavingIcon/>}>
<WelcomeStep/> <WelcomeStep/>
</WizardStep> </WizardStep>
<WizardStep icon={<Palette/>}> <WizardStep icon={<PaletteIcon/>}>
<ThemeStep/> <ThemeStep/>
</WizardStep> </WizardStep>
<WizardStep <WizardStep
@@ -128,7 +128,7 @@ function SetupView() {
.equals([Yup.ref('password')], 'Passwords do not match') .equals([Yup.ref('password')], 'Passwords do not match')
.required('Required') .required('Required')
})} })}
icon={<User/>} icon={<UserIcon/>}
> >
<UserStep/> <UserStep/>
</WizardStep> </WizardStep>
@@ -2,7 +2,6 @@ package org.gameyfin.app
import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication import org.springframework.boot.runApplication
import org.springframework.scheduling.annotation.EnableAsync
import org.springframework.scheduling.annotation.EnableScheduling import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.transaction.annotation.EnableTransactionManagement import org.springframework.transaction.annotation.EnableTransactionManagement
@@ -10,7 +9,6 @@ import org.springframework.transaction.annotation.EnableTransactionManagement
@SpringBootApplication @SpringBootApplication
@EnableScheduling @EnableScheduling
@EnableTransactionManagement @EnableTransactionManagement
@EnableAsync
class GameyfinApplication class GameyfinApplication
fun main(args: Array<String>) { fun main(args: Array<String>) {

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