Release 2.3.0 (#804)

* chore: bump version to v2.3.0-preview

* Customize start page (#803)

* Update ConfigService to support complex Objects
Implemented tests for ConfigService

* Added DB migration for config table

* Fixed version in banner.txt not being displayed

* Implement Library ordering
Implement "Show recently added games on homepage"

* Fix build.gradle.kts

* FIx bug when creating libraries

* Fix TypeScript errors
Fix library sorting

* Bump actions/checkout from 5 to 6 (#811)

Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  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>

* Added automatic scanning using file system watchers (#813)

* Implement collections (#814)

* Backend implementation for collections

* Fix database schema and migration script

* Refactor some config values
Fix ArrayInput not being deactivatable

* Remove "AutoRegisterNewUsers" config option

* Fix bug when removing ignored paths

* Add UI for collections (WIP)

* Fix table actions not synced with state
Fix tests

* Finish implementation of collection feature

* Fix tests

* Bump actions/checkout from 5 to 6 (#815)

Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  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>

* Fix "allow guests to create game requests" not being enabled when guest access is activated

* Fix: Disable loading of EditGameMetadataModal and MatchGameModal in GameView when user is not admin

* Bump actions/checkout from 5 to 6 (#819)

Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  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>

* Overhaul startpage (#823)

* WIP: Update start page layout

* Performance improvements (lazy loading and virtualized grids/lists)
Fix various smaller issues

* Implement use of blurhash for all images in backend and covers in frontend

* Fix bugs and test

* Fix code analysis issues

* Remove "UI settings" since they have been made obsolete

* Remove length limit from "image.originalUrl" (#824)

* Remove alpine based image (#825)

* Fix bug when games from library are still in a collection, thus prevention deletion of said library

* Delete image files in background

* Fix layout

---------

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-12-10 12:58:14 +01:00
committed by GitHub
parent 608a0b5ac1
commit 09953a3f78
142 changed files with 5249 additions and 937 deletions
+8 -22
View File
@@ -13,36 +13,25 @@ runs:
username: ${{ inputs.ghcr_username }} username: ${{ inputs.ghcr_username }}
password: ${{ inputs.ghcr_token }} password: ${{ inputs.ghcr_token }}
- name: Prepare Ubuntu tags - name: Prepare combined tags (default + ubuntu suffix)
id: ubuntu_tags id: combined_tags
if: ${{ inputs.variant != 'alpine' }}
shell: bash shell: bash
run: | run: |
TAGS="${{ inputs.tags }}" TAGS="${{ inputs.tags }}"
# Generate both default tags and ubuntu-suffixed tags
DEFAULT_TAGS="$TAGS"
UBUNTU_TAGS=$(echo "$TAGS" | awk -F, '{for(i=1;i<=NF;i++){split($i,a,":"); printf "%s:%s-ubuntu", a[1], a[2]; if(i<NF) printf ","}}') UBUNTU_TAGS=$(echo "$TAGS" | awk -F, '{for(i=1;i<=NF;i++){split($i,a,":"); printf "%s:%s-ubuntu", a[1], a[2]; if(i<NF) printf ","}}')
echo "ubuntu_tags=$UBUNTU_TAGS" >> $GITHUB_OUTPUT COMBINED_TAGS="$DEFAULT_TAGS,$UBUNTU_TAGS"
echo "combined_tags=$COMBINED_TAGS" >> $GITHUB_OUTPUT
- name: Build and push Docker image (Alpine)
if: ${{ inputs.variant != 'ubuntu' }}
uses: docker/build-push-action@v5
with:
context: ${{ inputs.context }}
file: ${{ inputs.dockerfile }}
platforms: ${{ inputs.platforms }}
push: true
tags: ${{ inputs.tags }}
cache-from: 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 }}
file: docker/Dockerfile.ubuntu file: docker/Dockerfile.ubuntu
platforms: ${{ inputs.platforms }} platforms: ${{ inputs.platforms }}
push: true push: true
tags: ${{ steps.ubuntu_tags.outputs.ubuntu_tags }} tags: ${{ steps.combined_tags.outputs.combined_tags }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha cache-to: type=gha
@@ -65,7 +54,4 @@ 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'
+3 -8
View File
@@ -14,7 +14,7 @@ jobs:
checks: write checks: write
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v6
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v5 uses: actions/setup-java@v5
@@ -46,13 +46,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
packages: write packages: write
strategy:
fail-fast: false
matrix:
variant: [ alpine, ubuntu ]
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v6
- name: Download build outputs - name: Download build outputs
uses: actions/download-artifact@v5 uses: actions/download-artifact@v5
@@ -67,7 +63,7 @@ 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 (${{ matrix.variant }}) - name: Build and push Docker image
uses: ./.github/actions/docker-build-push uses: ./.github/actions/docker-build-push
with: with:
ghcr_username: ${{ github.actor }} ghcr_username: ${{ github.actor }}
@@ -76,4 +72,3 @@ jobs:
dockerfile: docker/Dockerfile dockerfile: docker/Dockerfile
platforms: linux/arm64/v8,linux/amd64 platforms: linux/arm64/v8,linux/amd64
tags: ghcr.io/gameyfin/gameyfin:${{ steps.extract_tag.outputs.tag }} tags: ghcr.io/gameyfin/gameyfin:${{ steps.extract_tag.outputs.tag }}
variant: ${{ matrix.variant }}
+3 -8
View File
@@ -16,7 +16,7 @@ jobs:
version: ${{ steps.extract_version.outputs.version }} version: ${{ steps.extract_version.outputs.version }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -76,13 +76,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
packages: write packages: write
strategy:
fail-fast: false
matrix:
variant: [ alpine, ubuntu ]
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -92,7 +88,7 @@ jobs:
name: build-outputs name: build-outputs
path: . path: .
- name: Build and push Docker image (${{ matrix.variant }}) - name: Build and push Docker image
uses: ./.github/actions/docker-build-push uses: ./.github/actions/docker-build-push
with: with:
ghcr_username: ${{ github.actor }} ghcr_username: ${{ github.actor }}
@@ -101,4 +97,3 @@ jobs:
dockerfile: docker/Dockerfile dockerfile: docker/Dockerfile
platforms: linux/arm64/v8,linux/amd64 platforms: linux/arm64/v8,linux/amd64
tags: ghcr.io/gameyfin/gameyfin:${{ needs.build.outputs.version }} tags: ghcr.io/gameyfin/gameyfin:${{ needs.build.outputs.version }}
variant: ${{ matrix.variant }}
+6 -11
View File
@@ -23,7 +23,7 @@ jobs:
release_version: ${{ steps.get_version.outputs.release_version }} release_version: ${{ steps.get_version.outputs.release_version }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -66,7 +66,7 @@ jobs:
checks: write checks: write
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -107,13 +107,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
packages: write packages: write
strategy:
fail-fast: false
matrix:
variant: [ alpine, ubuntu ]
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -142,7 +138,7 @@ jobs:
TAGS="$GHCR_TAGS" TAGS="$GHCR_TAGS"
echo "tags=$TAGS" >> $GITHUB_OUTPUT echo "tags=$TAGS" >> $GITHUB_OUTPUT
- name: Build and push Docker image (${{ matrix.variant }}) - name: Build and push Docker image
uses: ./.github/actions/docker-build-push uses: ./.github/actions/docker-build-push
with: with:
ghcr_username: ${{ github.actor }} ghcr_username: ${{ github.actor }}
@@ -151,14 +147,13 @@ jobs:
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
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -189,7 +184,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
+14 -2
View File
@@ -1,3 +1,5 @@
import org.apache.tools.ant.filters.ReplaceTokens
group = "org.gameyfin" group = "org.gameyfin"
val appMainClass = "org.gameyfin.app.GameyfinApplicationKt" val appMainClass = "org.gameyfin.app.GameyfinApplicationKt"
@@ -31,6 +33,7 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-aop")
implementation("org.springframework.cloud:spring-cloud-starter") implementation("org.springframework.cloud:spring-cloud-starter")
implementation("jakarta.validation:jakarta.validation-api:3.1.0") implementation("jakarta.validation:jakarta.validation-api:3.1.0")
@@ -70,8 +73,9 @@ dependencies {
implementation(project(":plugin-api")) implementation(project(":plugin-api"))
// Utils // Utils
implementation("org.apache.tika:tika-core:3.1.0") implementation("org.apache.tika:tika-core:3.2.3")
implementation("me.xdrop:fuzzywuzzy:1.4.0") implementation("me.xdrop:fuzzywuzzy:1.4.0")
implementation("com.vanniktech:blurhash:0.3.0")
// Development // Development
developmentOnly("org.springframework.boot:spring-boot-devtools") developmentOnly("org.springframework.boot:spring-boot-devtools")
@@ -98,4 +102,12 @@ dependencyManagement {
tasks.withType<Test> { tasks.withType<Test> {
useJUnitPlatform() useJUnitPlatform()
} }
tasks.named<ProcessResources>("processResources") {
val projectVersion = rootProject.version.toString()
filesMatching("application.yml") {
filter<ReplaceTokens>("tokens" to mapOf("project.version" to projectVersion))
}
}
+126 -16
View File
@@ -1,12 +1,12 @@
{ {
"name": "gameyfin", "name": "gameyfin",
"version": "2.2.0-preview", "version": "2.3.0-preview",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "gameyfin", "name": "gameyfin",
"version": "2.2.0-preview", "version": "2.3.0-preview",
"dependencies": { "dependencies": {
"@heroui/react": "^2.8.5", "@heroui/react": "^2.8.5",
"@phosphor-icons/react": "^2.1.7", "@phosphor-icons/react": "^2.1.7",
@@ -31,6 +31,7 @@
"@vaadin/vaadin-material-styles": "24.9.4", "@vaadin/vaadin-material-styles": "24.9.4",
"@vaadin/vaadin-themable-mixin": "24.9.4", "@vaadin/vaadin-themable-mixin": "24.9.4",
"@vaadin/vaadin-usage-statistics": "2.1.3", "@vaadin/vaadin-usage-statistics": "2.1.3",
"blurhash": "^2.0.5",
"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",
@@ -54,10 +55,10 @@
"react-player": "^2.16.0", "react-player": "^2.16.0",
"react-realtime-chart": "^0.8.1", "react-realtime-chart": "^0.8.1",
"react-router": "7.6.3", "react-router": "7.6.3",
"react-window": "^2.2.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",
"valtio-reactive": "^0.1.2",
"yup": "^1.6.1" "yup": "^1.6.1"
}, },
"devDependencies": { "devDependencies": {
@@ -69,6 +70,7 @@
"@types/node": "^22.4.0", "@types/node": "^22.4.0",
"@types/react": "19.1.17", "@types/react": "19.1.17",
"@types/react-dom": "19.1.11", "@types/react-dom": "19.1.11",
"@types/react-window": "^1.8.8",
"@vaadin/hilla-generator-cli": "24.9.4", "@vaadin/hilla-generator-cli": "24.9.4",
"@vaadin/hilla-generator-core": "24.9.4", "@vaadin/hilla-generator-core": "24.9.4",
"@vaadin/hilla-generator-plugin-backbone": "24.9.4", "@vaadin/hilla-generator-plugin-backbone": "24.9.4",
@@ -200,6 +202,7 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3", "@babel/generator": "^7.28.3",
@@ -3364,6 +3367,7 @@
"resolved": "https://registry.npmjs.org/@heroui/system/-/system-2.4.23.tgz", "resolved": "https://registry.npmjs.org/@heroui/system/-/system-2.4.23.tgz",
"integrity": "sha512-kgYvfkIOQKM6CCBIlNSE2tXMtNrS1mvEUbvwnaU3pEYbMlceBtwA5v7SlpaJy/5dqKcTbfmVMUCmXnY/Kw4vaQ==", "integrity": "sha512-kgYvfkIOQKM6CCBIlNSE2tXMtNrS1mvEUbvwnaU3pEYbMlceBtwA5v7SlpaJy/5dqKcTbfmVMUCmXnY/Kw4vaQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@heroui/react-utils": "2.1.14", "@heroui/react-utils": "2.1.14",
"@heroui/system-rsc": "2.3.20", "@heroui/system-rsc": "2.3.20",
@@ -3449,6 +3453,7 @@
"resolved": "https://registry.npmjs.org/@heroui/theme/-/theme-2.4.23.tgz", "resolved": "https://registry.npmjs.org/@heroui/theme/-/theme-2.4.23.tgz",
"integrity": "sha512-5hoaRWG+/d/t06p7Pfhz70DUP0Uggjids7/z2Ytgup4A8KAOvDIXxvHUDlk6rRHKiN1wDMNA5H+EWsSXB/m03Q==", "integrity": "sha512-5hoaRWG+/d/t06p7Pfhz70DUP0Uggjids7/z2Ytgup4A8KAOvDIXxvHUDlk6rRHKiN1wDMNA5H+EWsSXB/m03Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@heroui/shared-utils": "2.1.12", "@heroui/shared-utils": "2.1.12",
"clsx": "^1.2.1", "clsx": "^1.2.1",
@@ -3997,7 +4002,8 @@
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/@open-wc/dedupe-mixin/-/dedupe-mixin-1.4.0.tgz", "resolved": "https://registry.npmjs.org/@open-wc/dedupe-mixin/-/dedupe-mixin-1.4.0.tgz",
"integrity": "sha512-Sj7gKl1TLcDbF7B6KUhtvr+1UCxdhMbNY5KxdU5IfMFWqL8oy1ZeAcCANjoB1TL0AJTcPmcCFsCbHf8X2jGDUA==", "integrity": "sha512-Sj7gKl1TLcDbF7B6KUhtvr+1UCxdhMbNY5KxdU5IfMFWqL8oy1ZeAcCANjoB1TL0AJTcPmcCFsCbHf8X2jGDUA==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/@phosphor-icons/react": { "node_modules/@phosphor-icons/react": {
"version": "2.1.10", "version": "2.1.10",
@@ -4017,6 +4023,7 @@
"resolved": "https://registry.npmjs.org/@polymer/polymer/-/polymer-3.5.2.tgz", "resolved": "https://registry.npmjs.org/@polymer/polymer/-/polymer-3.5.2.tgz",
"integrity": "sha512-fWwImY/UH4bb2534DVSaX+Azs2yKg8slkMBHOyGeU2kKx7Xmxp6Lee0jP8p6B3d7c1gFUPB2Z976dTUtX81pQA==", "integrity": "sha512-fWwImY/UH4bb2534DVSaX+Azs2yKg8slkMBHOyGeU2kKx7Xmxp6Lee0jP8p6B3d7c1gFUPB2Z976dTUtX81pQA==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"peer": true,
"dependencies": { "dependencies": {
"@webcomponents/shadycss": "^1.9.1" "@webcomponents/shadycss": "^1.9.1"
} }
@@ -6946,6 +6953,7 @@
"integrity": "sha512-pAZSHMiagDR7cARo/cch1f3rXy0AEXwsVsVH09FcyeJVAzCnGgmYis7P3JidtTUjyadhTeSo8TgRPswstghDaw==", "integrity": "sha512-pAZSHMiagDR7cARo/cch1f3rXy0AEXwsVsVH09FcyeJVAzCnGgmYis7P3JidtTUjyadhTeSo8TgRPswstghDaw==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -6955,6 +6963,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz",
"integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
@@ -6965,10 +6974,21 @@
"integrity": "sha512-3BKc/yGdNTYQVVw4idqHtSOcFsgGuBbMveKCOgF8wQ5QtrYOc3jDIlzg3jef04zcXFIHLelyGlj0T+BJ8+KN+w==", "integrity": "sha512-3BKc/yGdNTYQVVw4idqHtSOcFsgGuBbMveKCOgF8wQ5QtrYOc3jDIlzg3jef04zcXFIHLelyGlj0T+BJ8+KN+w==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.0.0" "@types/react": "^19.0.0"
} }
}, },
"node_modules/@types/react-window": {
"version": "1.8.8",
"resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz",
"integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/resolve": { "node_modules/@types/resolve": {
"version": "1.20.2", "version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
@@ -7005,6 +7025,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/a11y-base/-/a11y-base-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/a11y-base/-/a11y-base-24.9.4.tgz",
"integrity": "sha512-y8Rrq84MOyCYJ5rbzWtm7rqP3UNX5r5aspKFVYDNATLVcqFMFqUopz5Tn+YMRMb0MJ3K3GU3vy+xWVcF3WyzAg==", "integrity": "sha512-y8Rrq84MOyCYJ5rbzWtm7rqP3UNX5r5aspKFVYDNATLVcqFMFqUopz5Tn+YMRMb0MJ3K3GU3vy+xWVcF3WyzAg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -7017,6 +7038,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/accordion/-/accordion-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/accordion/-/accordion-24.9.4.tgz",
"integrity": "sha512-AkeNGWA7TOVM8hrh0JzMNdWrRScIS9HN4t4UJ8DFSQVsYwDkixZqDOCkoCGoNJ+iRbKzrTcA2a9rHIuk+qjh+A==", "integrity": "sha512-AkeNGWA7TOVM8hrh0JzMNdWrRScIS9HN4t4UJ8DFSQVsYwDkixZqDOCkoCGoNJ+iRbKzrTcA2a9rHIuk+qjh+A==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -7034,6 +7056,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/app-layout/-/app-layout-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/app-layout/-/app-layout-24.9.4.tgz",
"integrity": "sha512-VhFOyQSLV5ALm9C9YNmRw7TrsPZ3R/lwIYhowoYIDHE4o7NbW6EdbgdUYkEBa3EPI2oC5IYZzLkXz+kLYgvCIg==", "integrity": "sha512-VhFOyQSLV5ALm9C9YNmRw7TrsPZ3R/lwIYhowoYIDHE4o7NbW6EdbgdUYkEBa3EPI2oC5IYZzLkXz+kLYgvCIg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -7051,6 +7074,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/avatar/-/avatar-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/avatar/-/avatar-24.9.4.tgz",
"integrity": "sha512-/CzAHhwjGC8fXpsBlm6oUpj1PKZh7a0Nz451R1XGkNF4We2RK76yEwTkZSEDlJR+6Fx1mDH+nnXU1wplGUVynw==", "integrity": "sha512-/CzAHhwjGC8fXpsBlm6oUpj1PKZh7a0Nz451R1XGkNF4We2RK76yEwTkZSEDlJR+6Fx1mDH+nnXU1wplGUVynw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -7068,6 +7092,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/avatar-group/-/avatar-group-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/avatar-group/-/avatar-group-24.9.4.tgz",
"integrity": "sha512-ao2J8wsubP/HOV18ftl5a9C5gDQ41NYAskQOc+B2Mjv9K+Gt/Nsp+I7vl/vli0gTWYVCbpTAm8oXVSqaOYyRWQ==", "integrity": "sha512-ao2J8wsubP/HOV18ftl5a9C5gDQ41NYAskQOc+B2Mjv9K+Gt/Nsp+I7vl/vli0gTWYVCbpTAm8oXVSqaOYyRWQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -7423,6 +7448,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/button/-/button-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/button/-/button-24.9.4.tgz",
"integrity": "sha512-Wg+gnrQ3LT9WpJGT91WCXFpX9HOpReyoq77K2jjsnzx2ZN6d3iHmPImVIdA658fLE6U3RCyRazQymoWQ01VEuA==", "integrity": "sha512-Wg+gnrQ3LT9WpJGT91WCXFpX9HOpReyoq77K2jjsnzx2ZN6d3iHmPImVIdA658fLE6U3RCyRazQymoWQ01VEuA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -7439,6 +7465,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/card/-/card-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/card/-/card-24.9.4.tgz",
"integrity": "sha512-DzHGUjA9ESkAC6oJrAUx28OunwSs33lwNWQzq0Xh64mWafUWMjc0k6QvGc45rdXOY/MZXkG21SWqahAVi7qWVA==", "integrity": "sha512-DzHGUjA9ESkAC6oJrAUx28OunwSs33lwNWQzq0Xh64mWafUWMjc0k6QvGc45rdXOY/MZXkG21SWqahAVi7qWVA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@vaadin/component-base": "~24.9.4", "@vaadin/component-base": "~24.9.4",
"@vaadin/vaadin-lumo-styles": "~24.9.4", "@vaadin/vaadin-lumo-styles": "~24.9.4",
@@ -7452,6 +7479,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/checkbox/-/checkbox-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/checkbox/-/checkbox-24.9.4.tgz",
"integrity": "sha512-B381QSKkmi0bpnmw+BV/oMGrEAkLVHxrL75mrrl0jEzA9nzc+vTsb72cONnbp6XCO17hb+sVQ6gdAxBrSsr59A==", "integrity": "sha512-B381QSKkmi0bpnmw+BV/oMGrEAkLVHxrL75mrrl0jEzA9nzc+vTsb72cONnbp6XCO17hb+sVQ6gdAxBrSsr59A==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -7469,6 +7497,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/checkbox-group/-/checkbox-group-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/checkbox-group/-/checkbox-group-24.9.4.tgz",
"integrity": "sha512-LpqbUpzWLlC3lXGRNrbMPAb6rOA7sOQYPrfonw1aVM3u4IiS+T9ffKCQHeTrxeV+S0Ddmiu0rCA2q7j4EcMEPw==", "integrity": "sha512-LpqbUpzWLlC3lXGRNrbMPAb6rOA7sOQYPrfonw1aVM3u4IiS+T9ffKCQHeTrxeV+S0Ddmiu0rCA2q7j4EcMEPw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -7487,6 +7516,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/combo-box/-/combo-box-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/combo-box/-/combo-box-24.9.4.tgz",
"integrity": "sha512-DrAxKvu6rF/o2t1rP5zEWEj2A4R0OqkbkdaqbFOJDMdVSTHYOTHzmu/M38jRB9owSsTGJW9q9L2ATJcio2NECA==", "integrity": "sha512-DrAxKvu6rF/o2t1rP5zEWEj2A4R0OqkbkdaqbFOJDMdVSTHYOTHzmu/M38jRB9owSsTGJW9q9L2ATJcio2NECA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -7520,6 +7550,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/component-base/-/component-base-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/component-base/-/component-base-24.9.4.tgz",
"integrity": "sha512-bS28kpICJodSHTEJQwQexNGkhdXbPtPhmUJGWk6gx+9Dmuzeq5C/xlnkYpuRbGnvR4lGUL2SdK6ZlhcPzKXncQ==", "integrity": "sha512-bS28kpICJodSHTEJQwQexNGkhdXbPtPhmUJGWk6gx+9Dmuzeq5C/xlnkYpuRbGnvR4lGUL2SdK6ZlhcPzKXncQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -7533,6 +7564,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/confirm-dialog/-/confirm-dialog-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/confirm-dialog/-/confirm-dialog-24.9.4.tgz",
"integrity": "sha512-RTkr9k8HaYYnV4a/DoPFuDWSO3+LjubM8+FhOio8RgGyMWyyEXIlhdFqsCwV7EmyEZ3rje8Mk5zpUSabBN9fVQ==", "integrity": "sha512-RTkr9k8HaYYnV4a/DoPFuDWSO3+LjubM8+FhOio8RgGyMWyyEXIlhdFqsCwV7EmyEZ3rje8Mk5zpUSabBN9fVQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -7551,6 +7583,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/context-menu/-/context-menu-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/context-menu/-/context-menu-24.9.4.tgz",
"integrity": "sha512-/ESoTcMP6EGpyVYoueEKwCSspbnOLlMFYChLiPreCTPJddvPAX1Wvip9VA9JHzEPqYlJ8f9sf0fCPCFYF+WNfQ==", "integrity": "sha512-/ESoTcMP6EGpyVYoueEKwCSspbnOLlMFYChLiPreCTPJddvPAX1Wvip9VA9JHzEPqYlJ8f9sf0fCPCFYF+WNfQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -7571,6 +7604,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/custom-field/-/custom-field-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/custom-field/-/custom-field-24.9.4.tgz",
"integrity": "sha512-hIcs5V7u6HnlL4t7/JIUTrCRTI0bcupH6Yo5gyO+8g9TojVLH9XJvg148P4pRD/aoSEaC7N2GjrB86051hdqhg==", "integrity": "sha512-hIcs5V7u6HnlL4t7/JIUTrCRTI0bcupH6Yo5gyO+8g9TojVLH9XJvg148P4pRD/aoSEaC7N2GjrB86051hdqhg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -7588,6 +7622,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/date-picker/-/date-picker-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/date-picker/-/date-picker-24.9.4.tgz",
"integrity": "sha512-CiPt0KKPfmQvdijzP+J8nZmv/Gg1o2segUeeSovwX0y2zi9CDGPhBOEh7X+ZqoouRBc/XzaCaziZv5U1asExqg==", "integrity": "sha512-CiPt0KKPfmQvdijzP+J8nZmv/Gg1o2segUeeSovwX0y2zi9CDGPhBOEh7X+ZqoouRBc/XzaCaziZv5U1asExqg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.2.0", "@polymer/polymer": "^3.2.0",
@@ -7608,6 +7643,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/date-time-picker/-/date-time-picker-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/date-time-picker/-/date-time-picker-24.9.4.tgz",
"integrity": "sha512-GQsxm0h5vjam1KrM2g6AN7/fb9ZV3JiL8asW4t5FczvefjRUapSZCyqQem8z/AAqea/Nifyy/67tH8PwbEEISg==", "integrity": "sha512-GQsxm0h5vjam1KrM2g6AN7/fb9ZV3JiL8asW4t5FczvefjRUapSZCyqQem8z/AAqea/Nifyy/67tH8PwbEEISg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -7628,6 +7664,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/details/-/details-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/details/-/details-24.9.4.tgz",
"integrity": "sha512-bEQsVGBCtTc49g9jpVUCHw/JfkZxHlHZ6N9y2CsYb3cCfpOoXPnhQsDPBmOlnCFSGOwwgQC2F2gi69P3rdZR0Q==", "integrity": "sha512-bEQsVGBCtTc49g9jpVUCHw/JfkZxHlHZ6N9y2CsYb3cCfpOoXPnhQsDPBmOlnCFSGOwwgQC2F2gi69P3rdZR0Q==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -7645,6 +7682,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/dialog/-/dialog-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/dialog/-/dialog-24.9.4.tgz",
"integrity": "sha512-p+MXYfn1/OliqElQKy31/Wopo133HH5b/JoYt6dd2+Kn2YYXUR1LzDBxsMXumiEdkoowiUary7xFNvaSteStlw==", "integrity": "sha512-p+MXYfn1/OliqElQKy31/Wopo133HH5b/JoYt6dd2+Kn2YYXUR1LzDBxsMXumiEdkoowiUary7xFNvaSteStlw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -7662,6 +7700,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/email-field/-/email-field-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/email-field/-/email-field-24.9.4.tgz",
"integrity": "sha512-GWf20H4j8Mad2+cm/2pViAKllW3kIweNjoIR64rqUvooSKrEHzoAmi+iKuCKTkaW3ljub2BVTQ3WCqYkAiA4jA==", "integrity": "sha512-GWf20H4j8Mad2+cm/2pViAKllW3kIweNjoIR64rqUvooSKrEHzoAmi+iKuCKTkaW3ljub2BVTQ3WCqYkAiA4jA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
"@vaadin/component-base": "~24.9.4", "@vaadin/component-base": "~24.9.4",
@@ -7677,6 +7716,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/field-base/-/field-base-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/field-base/-/field-base-24.9.4.tgz",
"integrity": "sha512-ANUMWe08i0BTv9DoMcStdtxy8gKnvt7ERCLSINrIPAI19tAhrooifHXYxr6ig7ebK4gmjIc1TKqLeelWjesBhA==", "integrity": "sha512-ANUMWe08i0BTv9DoMcStdtxy8gKnvt7ERCLSINrIPAI19tAhrooifHXYxr6ig7ebK4gmjIc1TKqLeelWjesBhA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -7690,6 +7730,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/field-highlighter/-/field-highlighter-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/field-highlighter/-/field-highlighter-24.9.4.tgz",
"integrity": "sha512-8hFSAjXyKUR5Y+fSytJTp9wiKxDAjXHoUd1TxsSUvOMmG7p9tYQ25xT9tnFCR3O9bFUdb0TPF8Uo2+6oFUjYNA==", "integrity": "sha512-8hFSAjXyKUR5Y+fSytJTp9wiKxDAjXHoUd1TxsSUvOMmG7p9tYQ25xT9tnFCR3O9bFUdb0TPF8Uo2+6oFUjYNA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
"@vaadin/a11y-base": "~24.9.4", "@vaadin/a11y-base": "~24.9.4",
@@ -7706,6 +7747,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/form-layout/-/form-layout-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/form-layout/-/form-layout-24.9.4.tgz",
"integrity": "sha512-o0HO2hrOiXT0Pdqf/DxqsckRTtoIyUjDZX+RC+A4h9KxyUKtIDGHNmJEFfZq9p9iZeJNCz/Yejvq8tb8BtwfwA==", "integrity": "sha512-o0HO2hrOiXT0Pdqf/DxqsckRTtoIyUjDZX+RC+A4h9KxyUKtIDGHNmJEFfZq9p9iZeJNCz/Yejvq8tb8BtwfwA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -7722,6 +7764,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/grid/-/grid-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/grid/-/grid-24.9.4.tgz",
"integrity": "sha512-Dxr+hB4cjr3AKGE7bhn7V5TTjIU2aAvsDHx2canxUj823hTrJ6OuVY7auuDyxEwZCduTUVK4dSlY6C0GLSGqwA==", "integrity": "sha512-Dxr+hB4cjr3AKGE7bhn7V5TTjIU2aAvsDHx2canxUj823hTrJ6OuVY7auuDyxEwZCduTUVK4dSlY6C0GLSGqwA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -8133,6 +8176,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/horizontal-layout/-/horizontal-layout-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/horizontal-layout/-/horizontal-layout-24.9.4.tgz",
"integrity": "sha512-DHQZTcLnOCMMl9kJn2DXOTFhY2N3m6eVS8hx6ehnFE1ySNNxk7M2SgCAZRiAmAiQGUzaCWIlPdeo6OBOdRZiFg==", "integrity": "sha512-DHQZTcLnOCMMl9kJn2DXOTFhY2N3m6eVS8hx6ehnFE1ySNNxk7M2SgCAZRiAmAiQGUzaCWIlPdeo6OBOdRZiFg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
"@vaadin/component-base": "~24.9.4", "@vaadin/component-base": "~24.9.4",
@@ -8147,6 +8191,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/icon/-/icon-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/icon/-/icon-24.9.4.tgz",
"integrity": "sha512-0JTSsi/U3z2I/gDZisLqzwJSHYeem3hQcMhNiul9gn1iWVnrfzehpZRAG9L3UhlAKssvh+ttYRjHyeiXR/Rqhg==", "integrity": "sha512-0JTSsi/U3z2I/gDZisLqzwJSHYeem3hQcMhNiul9gn1iWVnrfzehpZRAG9L3UhlAKssvh+ttYRjHyeiXR/Rqhg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -8161,6 +8206,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/icons/-/icons-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/icons/-/icons-24.9.4.tgz",
"integrity": "sha512-aps89oGV+SrDwGDlr6/cO2kt+4gaz0oU4nnLBhJE9p7ZNDSjD6O91mLednKlK/RbpdMDLZ6S9gFgtLrTnrE7Lw==", "integrity": "sha512-aps89oGV+SrDwGDlr6/cO2kt+4gaz0oU4nnLBhJE9p7ZNDSjD6O91mLednKlK/RbpdMDLZ6S9gFgtLrTnrE7Lw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
"@vaadin/icon": "~24.9.4" "@vaadin/icon": "~24.9.4"
@@ -8171,6 +8217,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/input-container/-/input-container-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/input-container/-/input-container-24.9.4.tgz",
"integrity": "sha512-uS+gVgc4NPAR3YcXvJZGbeN48G1tviQZ1n7ap3CAHxai5iCxBYUOwao3FLRkWtDG5KHrEKLFtAw6YCquM+P3NA==", "integrity": "sha512-uS+gVgc4NPAR3YcXvJZGbeN48G1tviQZ1n7ap3CAHxai5iCxBYUOwao3FLRkWtDG5KHrEKLFtAw6YCquM+P3NA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
"@vaadin/component-base": "~24.9.4", "@vaadin/component-base": "~24.9.4",
@@ -8185,6 +8232,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/integer-field/-/integer-field-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/integer-field/-/integer-field-24.9.4.tgz",
"integrity": "sha512-a1iNJyIp6fGjIguHkZ6aV6e9PiEhZVCtSNItkoqmXCgmtNuhlpp0TwWniq5SoFSYFM/uExyv+JAXkxk7BbDuIw==", "integrity": "sha512-a1iNJyIp6fGjIguHkZ6aV6e9PiEhZVCtSNItkoqmXCgmtNuhlpp0TwWniq5SoFSYFM/uExyv+JAXkxk7BbDuIw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
"@vaadin/component-base": "~24.9.4", "@vaadin/component-base": "~24.9.4",
@@ -8198,6 +8246,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/item/-/item-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/item/-/item-24.9.4.tgz",
"integrity": "sha512-jSOA3SUrGVTYuPD/KqoL5lxVDoxwde5hzC+nDv6BtDvwNmnVMFUB7DKSKSuiPrult2DuUjqNGEJrTXTRhzodFg==", "integrity": "sha512-jSOA3SUrGVTYuPD/KqoL5lxVDoxwde5hzC+nDv6BtDvwNmnVMFUB7DKSKSuiPrult2DuUjqNGEJrTXTRhzodFg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -8214,6 +8263,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/list-box/-/list-box-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/list-box/-/list-box-24.9.4.tgz",
"integrity": "sha512-idi7Cag09/4snCvVdmrfnB5iPnpvDXzOT6I3SBkFY3mx7l8kceV5/Bk2/1ZqYgWHs52UGwnAeXpT5AGeseGdqQ==", "integrity": "sha512-idi7Cag09/4snCvVdmrfnB5iPnpvDXzOT6I3SBkFY3mx7l8kceV5/Bk2/1ZqYgWHs52UGwnAeXpT5AGeseGdqQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -8231,6 +8281,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/lit-renderer/-/lit-renderer-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/lit-renderer/-/lit-renderer-24.9.4.tgz",
"integrity": "sha512-fXoM9XS0nWqUTO0GQVe5m2Oaa1Ae5Rhc1HwzIiX2nTiRuFlX3tfHPeaZJpcG+MuQLn3qtc6rAzHiGl1Y+Tovkw==", "integrity": "sha512-fXoM9XS0nWqUTO0GQVe5m2Oaa1Ae5Rhc1HwzIiX2nTiRuFlX3tfHPeaZJpcG+MuQLn3qtc6rAzHiGl1Y+Tovkw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"lit": "^3.0.0" "lit": "^3.0.0"
} }
@@ -8240,6 +8291,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/login/-/login-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/login/-/login-24.9.4.tgz",
"integrity": "sha512-IVNUZl7Gm50Jm2KNgV7EznL2blGroaYoZDj4vrnrd2mAPMgGfKqEEEMvQn1RNeFTguBblWMAABKXJ65q2dFzHg==", "integrity": "sha512-IVNUZl7Gm50Jm2KNgV7EznL2blGroaYoZDj4vrnrd2mAPMgGfKqEEEMvQn1RNeFTguBblWMAABKXJ65q2dFzHg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -8259,6 +8311,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/markdown/-/markdown-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/markdown/-/markdown-24.9.4.tgz",
"integrity": "sha512-afhIEWc6RbbCoNy5WkP8U0GxwMrKpghHC/k0JEntJ6DrcT1zAhrhasLzHRL7GcXTDbTtbQUKGXlAhjebwPXTvA==", "integrity": "sha512-afhIEWc6RbbCoNy5WkP8U0GxwMrKpghHC/k0JEntJ6DrcT1zAhrhasLzHRL7GcXTDbTtbQUKGXlAhjebwPXTvA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@vaadin/component-base": "~24.9.4", "@vaadin/component-base": "~24.9.4",
@@ -8275,6 +8328,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/master-detail-layout/-/master-detail-layout-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/master-detail-layout/-/master-detail-layout-24.9.4.tgz",
"integrity": "sha512-XKL9ucABTdJDvHgAJRs64Wzot8NgPj/RpnuISoozcvHK6NtQTje+TjJgVehCcJnyefbabed00UCT/YsCOTnA3Q==", "integrity": "sha512-XKL9ucABTdJDvHgAJRs64Wzot8NgPj/RpnuISoozcvHK6NtQTje+TjJgVehCcJnyefbabed00UCT/YsCOTnA3Q==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@vaadin/a11y-base": "~24.9.4", "@vaadin/a11y-base": "~24.9.4",
"@vaadin/component-base": "~24.9.4", "@vaadin/component-base": "~24.9.4",
@@ -8289,6 +8343,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/menu-bar/-/menu-bar-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/menu-bar/-/menu-bar-24.9.4.tgz",
"integrity": "sha512-Fkto0kNLkuEfqaVTMm3VtRbUvawOVMsrSbyAe3zRSO72uXQ32Ir2OFHGWy3UD+xytAGz3yj5pqeESNAsoTIRsA==", "integrity": "sha512-Fkto0kNLkuEfqaVTMm3VtRbUvawOVMsrSbyAe3zRSO72uXQ32Ir2OFHGWy3UD+xytAGz3yj5pqeESNAsoTIRsA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -8310,6 +8365,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/message-input/-/message-input-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/message-input/-/message-input-24.9.4.tgz",
"integrity": "sha512-YZWmZgVBxZLasGL9ni0TTqY3UilcjrpKyJvBnziUq3W2NiXUvnQOGskXwpVkD5sQi2f/D5d18IfCaW2FSOR7Rg==", "integrity": "sha512-YZWmZgVBxZLasGL9ni0TTqY3UilcjrpKyJvBnziUq3W2NiXUvnQOGskXwpVkD5sQi2f/D5d18IfCaW2FSOR7Rg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -8327,6 +8383,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/message-list/-/message-list-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/message-list/-/message-list-24.9.4.tgz",
"integrity": "sha512-8uY6/Z7rBYzeLTe6ASB8Ub7dmIWiM6hzcwL1U2k/FXGZYyQ1MK3BGlqbH/QX0B0rR+3+F//ORZmh8efg5gTH9A==", "integrity": "sha512-8uY6/Z7rBYzeLTe6ASB8Ub7dmIWiM6hzcwL1U2k/FXGZYyQ1MK3BGlqbH/QX0B0rR+3+F//ORZmh8efg5gTH9A==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -8345,6 +8402,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/multi-select-combo-box/-/multi-select-combo-box-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/multi-select-combo-box/-/multi-select-combo-box-24.9.4.tgz",
"integrity": "sha512-fH0WKt93ucm6cSTwuIAVH4e/Qm1oLspuO0em3OfgrEoZN7b5gPAAaqI/gyUDs5+PTRyqJ7YyIu1m5VI8fLJkmg==", "integrity": "sha512-fH0WKt93ucm6cSTwuIAVH4e/Qm1oLspuO0em3OfgrEoZN7b5gPAAaqI/gyUDs5+PTRyqJ7YyIu1m5VI8fLJkmg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -8367,6 +8425,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/notification/-/notification-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/notification/-/notification-24.9.4.tgz",
"integrity": "sha512-ZRqPo0PZo/VqExs0DKQ3kGjUEvUgm2DPsHAPI0LzQXv1OS7Roet9T2tL7y9//CK3JGlzfTmhqlH7XxUR/DvbCw==", "integrity": "sha512-ZRqPo0PZo/VqExs0DKQ3kGjUEvUgm2DPsHAPI0LzQXv1OS7Roet9T2tL7y9//CK3JGlzfTmhqlH7XxUR/DvbCw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -8384,6 +8443,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/number-field/-/number-field-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/number-field/-/number-field-24.9.4.tgz",
"integrity": "sha512-Z9kmiJTZvoWPWt7TQJOLu8jcI4J0F+dHhjBoeTfMtLkrMTHiKiWj1oKbamtsS1+6DfOMkcrsY2C/cfsPFlA45g==", "integrity": "sha512-Z9kmiJTZvoWPWt7TQJOLu8jcI4J0F+dHhjBoeTfMtLkrMTHiKiWj1oKbamtsS1+6DfOMkcrsY2C/cfsPFlA45g==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -8402,6 +8462,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/overlay/-/overlay-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/overlay/-/overlay-24.9.4.tgz",
"integrity": "sha512-0oYDV//7rCxzt2tXPVyJYqUtFn4EiHXrC1lDmbUzWtbGIQZwP3KALiYSk3ylP+bqxv2KTA0m6FjObj8wTj5s8g==", "integrity": "sha512-0oYDV//7rCxzt2tXPVyJYqUtFn4EiHXrC1lDmbUzWtbGIQZwP3KALiYSk3ylP+bqxv2KTA0m6FjObj8wTj5s8g==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -8418,6 +8479,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/password-field/-/password-field-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/password-field/-/password-field-24.9.4.tgz",
"integrity": "sha512-LfVsS3lSGqVyNbTJ5dVp8qe5pHYF1//8wg7IXw/AZqnqGbcqkI8gezUHaNnW0yFY1Wx0ZVmee5akZisB4z0H2g==", "integrity": "sha512-LfVsS3lSGqVyNbTJ5dVp8qe5pHYF1//8wg7IXw/AZqnqGbcqkI8gezUHaNnW0yFY1Wx0ZVmee5akZisB4z0H2g==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -8437,6 +8499,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/polymer-legacy-adapter/-/polymer-legacy-adapter-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/polymer-legacy-adapter/-/polymer-legacy-adapter-24.9.4.tgz",
"integrity": "sha512-5j38OaD+M44c6qBnrrt84ZiTTDbjHW5/8RsJBkqmvuZs5jQ9KXBqLqCV1WCZKAWr/aGcPS2NPBTpuFLNn0czRw==", "integrity": "sha512-5j38OaD+M44c6qBnrrt84ZiTTDbjHW5/8RsJBkqmvuZs5jQ9KXBqLqCV1WCZKAWr/aGcPS2NPBTpuFLNn0czRw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
"@vaadin/vaadin-themable-mixin": "~24.9.4", "@vaadin/vaadin-themable-mixin": "~24.9.4",
@@ -8448,6 +8511,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/popover/-/popover-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/popover/-/popover-24.9.4.tgz",
"integrity": "sha512-v5iZAukyG4V1TV6OTJ4feo8yuLn3fyu7QYEvhIQFhTp5THrlmtpjlx+FXIU8JWm/uoJ0RjRUeJXUkp+Zg6sdlA==", "integrity": "sha512-v5iZAukyG4V1TV6OTJ4feo8yuLn3fyu7QYEvhIQFhTp5THrlmtpjlx+FXIU8JWm/uoJ0RjRUeJXUkp+Zg6sdlA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@vaadin/a11y-base": "~24.9.4", "@vaadin/a11y-base": "~24.9.4",
@@ -8465,6 +8529,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/progress-bar/-/progress-bar-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/progress-bar/-/progress-bar-24.9.4.tgz",
"integrity": "sha512-RBxOG5D0lsbUl0WreFDrKCVrtZSHEwrjfr7Wx+7QMI2c26TpElSF2yffF7Z+IRqqtF0Q71MbtQyEx23ej0AQ1A==", "integrity": "sha512-RBxOG5D0lsbUl0WreFDrKCVrtZSHEwrjfr7Wx+7QMI2c26TpElSF2yffF7Z+IRqqtF0Q71MbtQyEx23ej0AQ1A==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -8480,6 +8545,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/radio-group/-/radio-group-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/radio-group/-/radio-group-24.9.4.tgz",
"integrity": "sha512-qQ4VlIEsq4p5SNaSg8EglHAmmLnVpz53cMp91qb7fCLPD0zlaoC3wvQKiISpSDnYKlskUf7r6yyYjDi5yQL9vQ==", "integrity": "sha512-qQ4VlIEsq4p5SNaSg8EglHAmmLnVpz53cMp91qb7fCLPD0zlaoC3wvQKiISpSDnYKlskUf7r6yyYjDi5yQL9vQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -8581,6 +8647,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/scroller/-/scroller-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/scroller/-/scroller-24.9.4.tgz",
"integrity": "sha512-yOfKlwbItnzD9XR51/WThvjyFUIBVeIKu75FHpmlmUnjjAnwj4kyw0AjnUu9itGX+R5D83ZidondbcWtqhFexQ==", "integrity": "sha512-yOfKlwbItnzD9XR51/WThvjyFUIBVeIKu75FHpmlmUnjjAnwj4kyw0AjnUu9itGX+R5D83ZidondbcWtqhFexQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -8597,6 +8664,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/select/-/select-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/select/-/select-24.9.4.tgz",
"integrity": "sha512-Ii70lQGcys1alONgIKo2ufithPzFI4s0K5uDFFSH3e12NHFAZu8VxA8tYbWwLh1H5q209ib1MnfUuUevHghVUA==", "integrity": "sha512-Ii70lQGcys1alONgIKo2ufithPzFI4s0K5uDFFSH3e12NHFAZu8VxA8tYbWwLh1H5q209ib1MnfUuUevHghVUA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.2.0", "@polymer/polymer": "^3.2.0",
@@ -8620,6 +8688,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/side-nav/-/side-nav-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/side-nav/-/side-nav-24.9.4.tgz",
"integrity": "sha512-pLAcoZzzMA+vQHuqo6jy8N4G0bz6qFtIM+BpJ9kcHYiulUg/Y7NPa4K2YsHF5gChZwE9cbxGMc0lPoN6p8QeKg==", "integrity": "sha512-pLAcoZzzMA+vQHuqo6jy8N4G0bz6qFtIM+BpJ9kcHYiulUg/Y7NPa4K2YsHF5gChZwE9cbxGMc0lPoN6p8QeKg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@vaadin/a11y-base": "~24.9.4", "@vaadin/a11y-base": "~24.9.4",
@@ -8635,6 +8704,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/split-layout/-/split-layout-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/split-layout/-/split-layout-24.9.4.tgz",
"integrity": "sha512-oTL0IT7Cm6flCOtMg3odZo+qEczSmL2OS61Mf67BIAYvOG08OMNZElKJ57tVe50NL5DIGMmMtcfyC5Mni/mWuw==", "integrity": "sha512-oTL0IT7Cm6flCOtMg3odZo+qEczSmL2OS61Mf67BIAYvOG08OMNZElKJ57tVe50NL5DIGMmMtcfyC5Mni/mWuw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -8650,6 +8720,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/tabs/-/tabs-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/tabs/-/tabs-24.9.4.tgz",
"integrity": "sha512-YfFZ+SV+2HtHIvdhHf0jTFc0spUjt3gQK8ckdw12DrOR2OPnShx1BFEDkor0NQqVLmLgvksbPxYJPhVf8wvj1w==", "integrity": "sha512-YfFZ+SV+2HtHIvdhHf0jTFc0spUjt3gQK8ckdw12DrOR2OPnShx1BFEDkor0NQqVLmLgvksbPxYJPhVf8wvj1w==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -8667,6 +8738,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/tabsheet/-/tabsheet-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/tabsheet/-/tabsheet-24.9.4.tgz",
"integrity": "sha512-Yy37yU2p2zazxqVx2rj2ljjrrZVq/jqu01HhgwCrnA7kpi21p9uVV8EvuAg17qU9uKFc8LQseNRKMtis9K7TcA==", "integrity": "sha512-Yy37yU2p2zazxqVx2rj2ljjrrZVq/jqu01HhgwCrnA7kpi21p9uVV8EvuAg17qU9uKFc8LQseNRKMtis9K7TcA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -8684,6 +8756,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/text-area/-/text-area-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/text-area/-/text-area-24.9.4.tgz",
"integrity": "sha512-pihekoVk4wIeoqykvNyb8S81PS8NFHLKRZqcnsDgtMyIEg7UlRPSIgUZEbFAGzrU9WnY+NJehzhVnPFKBuePxw==", "integrity": "sha512-pihekoVk4wIeoqykvNyb8S81PS8NFHLKRZqcnsDgtMyIEg7UlRPSIgUZEbFAGzrU9WnY+NJehzhVnPFKBuePxw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -8702,6 +8775,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/text-field/-/text-field-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/text-field/-/text-field-24.9.4.tgz",
"integrity": "sha512-1Yr8Hn5o4r3mBA0jXgRiMiAlfIOe2H/3tkWHj8KBdxH2PfwDGjzXvf5995g6kO4/b2E2sWNtMTQYufEr1HLlWw==", "integrity": "sha512-1Yr8Hn5o4r3mBA0jXgRiMiAlfIOe2H/3tkWHj8KBdxH2PfwDGjzXvf5995g6kO4/b2E2sWNtMTQYufEr1HLlWw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -8720,6 +8794,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/time-picker/-/time-picker-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/time-picker/-/time-picker-24.9.4.tgz",
"integrity": "sha512-4agGlkzbEfCXPtwiTNrG22r8ayA6R1U/jq0HeaXGFb6toLDZZyPsxKlZfA0FEvOF3EmyGHIKuEG6sR5X+GyCTg==", "integrity": "sha512-4agGlkzbEfCXPtwiTNrG22r8ayA6R1U/jq0HeaXGFb6toLDZZyPsxKlZfA0FEvOF3EmyGHIKuEG6sR5X+GyCTg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -8740,6 +8815,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/tooltip/-/tooltip-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/tooltip/-/tooltip-24.9.4.tgz",
"integrity": "sha512-p+zGTb5Q0HsCjbF8rbNU5SoadH9vC1Yz1qrvsgji61cDDcQ8VRQYbYjq85YHVQ9l0GHIhIJTIkC8MvqjPeGCtg==", "integrity": "sha512-p+zGTb5Q0HsCjbF8rbNU5SoadH9vC1Yz1qrvsgji61cDDcQ8VRQYbYjq85YHVQ9l0GHIhIJTIkC8MvqjPeGCtg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -8758,6 +8834,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/upload/-/upload-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/upload/-/upload-24.9.4.tgz",
"integrity": "sha512-71yoqOL3p5mbuBZvMh9qzZLpEbbjXRUd+Q7kQsNLHaZHC+ca2usFvXRk9XK8GmFE9in9/+0fsMw9a33D1OF3Vw==", "integrity": "sha512-71yoqOL3p5mbuBZvMh9qzZLpEbbjXRUd+Q7kQsNLHaZHC+ca2usFvXRk9XK8GmFE9in9/+0fsMw9a33D1OF3Vw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -8775,13 +8852,15 @@
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@vaadin/vaadin-development-mode-detector/-/vaadin-development-mode-detector-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@vaadin/vaadin-development-mode-detector/-/vaadin-development-mode-detector-2.0.7.tgz",
"integrity": "sha512-9FhVhr0ynSR3X2ao+vaIEttcNU5XfzCbxtmYOV8uIRnUCtNgbvMOIcyGBvntsX9I5kvIP2dV3cFAOG9SILJzEA==", "integrity": "sha512-9FhVhr0ynSR3X2ao+vaIEttcNU5XfzCbxtmYOV8uIRnUCtNgbvMOIcyGBvntsX9I5kvIP2dV3cFAOG9SILJzEA==",
"license": "Apache-2.0" "license": "Apache-2.0",
"peer": true
}, },
"node_modules/@vaadin/vaadin-lumo-styles": { "node_modules/@vaadin/vaadin-lumo-styles": {
"version": "24.9.4", "version": "24.9.4",
"resolved": "https://registry.npmjs.org/@vaadin/vaadin-lumo-styles/-/vaadin-lumo-styles-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/vaadin-lumo-styles/-/vaadin-lumo-styles-24.9.4.tgz",
"integrity": "sha512-g6qx0Fp9VOvjKUnYyxgE42Jl5c+LdiA9T6KxPCzOF8EiaHKsCLLKecnvwmV6SelQ45AbVLg4YiROFJ27k38d/w==", "integrity": "sha512-g6qx0Fp9VOvjKUnYyxgE42Jl5c+LdiA9T6KxPCzOF8EiaHKsCLLKecnvwmV6SelQ45AbVLg4YiROFJ27k38d/w==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
"@vaadin/component-base": "~24.9.4", "@vaadin/component-base": "~24.9.4",
@@ -8805,6 +8884,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/vaadin-themable-mixin/-/vaadin-themable-mixin-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/vaadin-themable-mixin/-/vaadin-themable-mixin-24.9.4.tgz",
"integrity": "sha512-XfxSq/pgVDAbGXKLJuDqR5uFT0zRac6UfY8Q+aNzv2R53PAm3vrt/S6XQ8VHDMCV/+8mVT2dZSnl22pafSEDcw==", "integrity": "sha512-XfxSq/pgVDAbGXKLJuDqR5uFT0zRac6UfY8Q+aNzv2R53PAm3vrt/S6XQ8VHDMCV/+8mVT2dZSnl22pafSEDcw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"lit": "^3.0.0", "lit": "^3.0.0",
@@ -8817,6 +8897,7 @@
"integrity": "sha512-8r4TNknD7OJQADe3VygeofFR7UNAXZ2/jjBFP5dgI8+2uMfnuGYgbuHivasKr9WSQ64sPej6m8rDoM1uSllXjQ==", "integrity": "sha512-8r4TNknD7OJQADe3VygeofFR7UNAXZ2/jjBFP5dgI8+2uMfnuGYgbuHivasKr9WSQ64sPej6m8rDoM1uSllXjQ==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@vaadin/vaadin-development-mode-detector": "^2.0.0" "@vaadin/vaadin-development-mode-detector": "^2.0.0"
}, },
@@ -8829,6 +8910,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/vertical-layout/-/vertical-layout-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/vertical-layout/-/vertical-layout-24.9.4.tgz",
"integrity": "sha512-kiVhwIGUXSA+fi6klIzLXauVF4TfYu07ME2BkwnC0I4ymQqN4SZiAemUP+0/Gp5XRJ23H4q5cSTYws5u8C4cwA==", "integrity": "sha512-kiVhwIGUXSA+fi6klIzLXauVF4TfYu07ME2BkwnC0I4ymQqN4SZiAemUP+0/Gp5XRJ23H4q5cSTYws5u8C4cwA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
"@vaadin/component-base": "~24.9.4", "@vaadin/component-base": "~24.9.4",
@@ -8843,6 +8925,7 @@
"resolved": "https://registry.npmjs.org/@vaadin/virtual-list/-/virtual-list-24.9.4.tgz", "resolved": "https://registry.npmjs.org/@vaadin/virtual-list/-/virtual-list-24.9.4.tgz",
"integrity": "sha512-Jc7puX8ln83CrVUQ2t3pK9B1crq+MJATEkKTwYZNZ1u4VfpwDWE3srN8ZTidIjUSOWWNXPHbBlnlAJSaOmyk/Q==", "integrity": "sha512-Jc7puX8ln83CrVUQ2t3pK9B1crq+MJATEkKTwYZNZ1u4VfpwDWE3srN8ZTidIjUSOWWNXPHbBlnlAJSaOmyk/Q==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0", "@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0", "@polymer/polymer": "^3.0.0",
@@ -8948,6 +9031,7 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@@ -9201,6 +9285,12 @@
"baseline-browser-mapping": "dist/cli.js" "baseline-browser-mapping": "dist/cli.js"
} }
}, },
"node_modules/blurhash": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.5.tgz",
"integrity": "sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==",
"license": "MIT"
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
@@ -9238,6 +9328,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.9", "baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746", "caniuse-lite": "^1.0.30001746",
@@ -9490,7 +9581,8 @@
"version": "2.5.1", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/client-only": { "node_modules/client-only": {
"version": "0.0.1", "version": "0.0.1",
@@ -10087,6 +10179,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC", "license": "ISC",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@@ -10390,6 +10483,7 @@
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
"license": "(MPL-2.0 OR Apache-2.0)", "license": "(MPL-2.0 OR Apache-2.0)",
"peer": true,
"optionalDependencies": { "optionalDependencies": {
"@types/trusted-types": "^2.0.7" "@types/trusted-types": "^2.0.7"
} }
@@ -10900,6 +10994,7 @@
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.22.tgz", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.22.tgz",
"integrity": "sha512-ZgGvdxXCw55ZYvhoZChTlG6pUuehecgvEAJz0BHoC5pQKW1EC5xf1Mul1ej5+ai+pVY0pylyFfdl45qnM1/GsA==", "integrity": "sha512-ZgGvdxXCw55ZYvhoZChTlG6pUuehecgvEAJz0BHoC5pQKW1EC5xf1Mul1ej5+ai+pVY0pylyFfdl45qnM1/GsA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"motion-dom": "^12.23.21", "motion-dom": "^12.23.21",
"motion-utils": "^12.23.6", "motion-utils": "^12.23.6",
@@ -12385,6 +12480,7 @@
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.1.tgz", "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.1.tgz",
"integrity": "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==", "integrity": "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"peer": true,
"dependencies": { "dependencies": {
"@lit/reactive-element": "^2.1.0", "@lit/reactive-element": "^2.1.0",
"lit-element": "^4.2.0", "lit-element": "^4.2.0",
@@ -12489,6 +12585,7 @@
"resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz",
"integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"marked": "bin/marked.js" "marked": "bin/marked.js"
}, },
@@ -13444,7 +13541,8 @@
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/own-keys": { "node_modules/own-keys": {
"version": "1.0.1", "version": "1.0.1",
@@ -13686,6 +13784,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -13789,6 +13888,7 @@
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.4.0", "loose-envify": "^1.4.0",
"object-assign": "^4.1.1", "object-assign": "^4.1.1",
@@ -13871,6 +13971,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
"integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -14000,6 +14101,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz",
"integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.26.0" "scheduler": "^0.26.0"
}, },
@@ -14096,6 +14198,7 @@
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.3.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.3.tgz",
"integrity": "sha512-zf45LZp5skDC6I3jDLXQUu0u26jtuP4lEGbc7BbdyxenBN1vJSTA18czM2D+h5qyMBuMrD+9uB+mU37HIoKGRA==", "integrity": "sha512-zf45LZp5skDC6I3jDLXQUu0u26jtuP4lEGbc7BbdyxenBN1vJSTA18czM2D+h5qyMBuMrD+9uB+mU37HIoKGRA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cookie": "^1.0.1", "cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0" "set-cookie-parser": "^2.6.0"
@@ -14167,6 +14270,16 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/react-window": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-window/-/react-window-2.2.3.tgz",
"integrity": "sha512-gTRqQYC8ojbiXyd9duYFiSn2TJw0ROXCgYjenOvNKITWzK0m0eCvkUsEUM08xvydkMh7ncp+LE0uS3DeNGZxnQ==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/read-cache": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -14416,6 +14529,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz",
"integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.8"
}, },
@@ -15260,6 +15374,7 @@
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz",
"integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==", "integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/dcastil" "url": "https://github.com/sponsors/dcastil"
@@ -15376,6 +15491,7 @@
"integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==",
"devOptional": true, "devOptional": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"dependencies": { "dependencies": {
"@jridgewell/source-map": "^0.3.3", "@jridgewell/source-map": "^0.3.3",
"acorn": "^8.15.0", "acorn": "^8.15.0",
@@ -15625,6 +15741,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -15955,15 +16072,6 @@
} }
} }
}, },
"node_modules/valtio-reactive": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/valtio-reactive/-/valtio-reactive-0.1.2.tgz",
"integrity": "sha512-9Zv/tFiFWQWEBzfDikJgY9lkQ6CXf4T+Rsk08AKQMMZVmI5YvkAS7qFnRtwd1uVPNT/wsK+QcKiFHBvjCRohYQ==",
"license": "MIT",
"peerDependencies": {
"valtio": ">=2.0.0"
}
},
"node_modules/vfile": { "node_modules/vfile": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
@@ -15997,6 +16105,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "fdir": "^6.4.4",
@@ -16474,6 +16583,7 @@
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"rollup": "dist/bin/rollup" "rollup": "dist/bin/rollup"
}, },
+9 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "gameyfin", "name": "gameyfin",
"version": "2.2.1", "version": "2.3.0-preview",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@heroui/react": "^2.8.5", "@heroui/react": "^2.8.5",
@@ -26,6 +26,7 @@
"@vaadin/vaadin-material-styles": "24.9.4", "@vaadin/vaadin-material-styles": "24.9.4",
"@vaadin/vaadin-themable-mixin": "24.9.4", "@vaadin/vaadin-themable-mixin": "24.9.4",
"@vaadin/vaadin-usage-statistics": "2.1.3", "@vaadin/vaadin-usage-statistics": "2.1.3",
"blurhash": "^2.0.5",
"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",
@@ -49,10 +50,10 @@
"react-player": "^2.16.0", "react-player": "^2.16.0",
"react-realtime-chart": "^0.8.1", "react-realtime-chart": "^0.8.1",
"react-router": "7.6.3", "react-router": "7.6.3",
"react-window": "^2.2.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",
"valtio-reactive": "^0.1.2",
"yup": "^1.6.1" "yup": "^1.6.1"
}, },
"devDependencies": { "devDependencies": {
@@ -64,6 +65,7 @@
"@types/node": "^22.4.0", "@types/node": "^22.4.0",
"@types/react": "19.1.17", "@types/react": "19.1.17",
"@types/react-dom": "19.1.11", "@types/react-dom": "19.1.11",
"@types/react-window": "^1.8.8",
"@vaadin/hilla-generator-cli": "24.9.4", "@vaadin/hilla-generator-cli": "24.9.4",
"@vaadin/hilla-generator-core": "24.9.4", "@vaadin/hilla-generator-core": "24.9.4",
"@vaadin/hilla-generator-plugin-backbone": "24.9.4", "@vaadin/hilla-generator-plugin-backbone": "24.9.4",
@@ -137,7 +139,6 @@
"react-markdown": "$react-markdown", "react-markdown": "$react-markdown",
"remark-breaks": "$remark-breaks", "remark-breaks": "$remark-breaks",
"valtio": "$valtio", "valtio": "$valtio",
"valtio-reactive": "$valtio-reactive",
"fzf": "$fzf", "fzf": "$fzf",
"@vaadin/router": "2.0.0", "@vaadin/router": "2.0.0",
"@tailwindcss/vite": "$@tailwindcss/vite", "@tailwindcss/vite": "$@tailwindcss/vite",
@@ -202,7 +203,9 @@
"@vaadin/upload": "24.9.4", "@vaadin/upload": "24.9.4",
"@vaadin/vertical-layout": "24.9.4", "@vaadin/vertical-layout": "24.9.4",
"@vaadin/virtual-list": "24.9.4", "@vaadin/virtual-list": "24.9.4",
"react-realtime-chart": "$react-realtime-chart" "react-realtime-chart": "$react-realtime-chart",
"react-window": "$react-window",
"blurhash": "$blurhash"
}, },
"vaadin": { "vaadin": {
"dependencies": { "dependencies": {
@@ -264,6 +267,6 @@
"workbox-precaching": "7.3.0" "workbox-precaching": "7.3.0"
}, },
"disableUsageStatistics": true, "disableUsageStatistics": true,
"hash": "45fe1cd9320d2da603b811b433279d79b37370c9732e877490fc304807ef6163" "hash": "d06c4b56ae3a7bc3c4356d3669fc1ed559d83e5285df4e8b3e99bff46869f939"
} }
} }
Binary file not shown.
+3 -1
View File
@@ -19,6 +19,7 @@ import {initializeGameRequestState} from "Frontend/state/GameRequestState";
import {initializePlatformState} from "Frontend/state/PlatformState"; import {initializePlatformState} from "Frontend/state/PlatformState";
import {initializeDownloadSessionState} from "Frontend/state/DownloadSessionState"; import {initializeDownloadSessionState} from "Frontend/state/DownloadSessionState";
import {initializeUserState} from "Frontend/state/UserState"; import {initializeUserState} from "Frontend/state/UserState";
import {initializeCollectionState} from "Frontend/state/CollectionState";
export default function App() { export default function App() {
client.middlewares = [ErrorHandlingMiddleware]; client.middlewares = [ErrorHandlingMiddleware];
@@ -48,10 +49,11 @@ function ViewWithAuth() {
if (auth.state.initializing || auth.state.loading) return; if (auth.state.initializing || auth.state.loading) return;
initializeLibraryState(); initializeLibraryState();
initializeGameState(); initializeCollectionState();
initializePlatformState(); initializePlatformState();
initializeGameRequestState(); initializeGameRequestState();
initializePluginState(); initializePluginState();
initializeGameState();
if (isAdmin(auth)) { if (isAdmin(auth)) {
initializeScanState(); initializeScanState();
@@ -1,5 +1,5 @@
import {useAuth} from "Frontend/util/auth"; import {useAuth} from "Frontend/util/auth";
import { GearFineIcon, QuestionIcon, SignOutIcon, UserIcon } 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";
@@ -19,7 +19,7 @@ export default function ProfileMenu() {
{ {
label: "Administration", label: "Administration",
icon: <GearFineIcon/>, icon: <GearFineIcon/>,
onClick: () => navigate("/administration/libraries"), onClick: () => navigate("/administration/games"),
showIf: isAdmin(auth) showIf: isAdmin(auth)
}, },
{ {
@@ -12,7 +12,7 @@ import {DownloadSessionCard} from "Frontend/components/general/cards/DownloadSes
import {humanFileSize} from "Frontend/util/utils"; import {humanFileSize} from "Frontend/util/utils";
function DownloadManagementLayout({getConfig, formik}: any) { function DownloadManagementLayout({getConfig, formik}: any) {
const sessions = useSnapshot(downloadSessionState).all as SessionStatsDto[]; const sessions = useSnapshot(downloadSessionState).all;
const [lastDaySum, setLastDaySum] = React.useState<number>(0); const [lastDaySum, setLastDaySum] = React.useState<number>(0);
React.useEffect(() => { React.useEffect(() => {
@@ -0,0 +1,149 @@
import React from "react";
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import withConfigPage from "Frontend/components/administration/withConfigPage";
import Section from "Frontend/components/general/Section";
import * as Yup from 'yup';
import "Frontend/util/yup-extensions";
import {Button, Divider, Tooltip, useDisclosure} from "@heroui/react";
import {ListNumbersIcon, PlusIcon} from "@phosphor-icons/react";
import {LibraryOverviewCard} from "Frontend/components/general/cards/LibraryOverviewCard";
import LibraryCreationModal from "Frontend/components/general/modals/LibraryCreationModal";
import {useSnapshot} from "valtio/react";
import {libraryState} from "Frontend/state/LibraryState";
import LibraryPrioritiesModal from "Frontend/components/general/modals/LibraryPrioritiesModal";
import {collectionState} from "Frontend/state/CollectionState";
import {CollectionOverviewCard} from "Frontend/components/general/cards/CollectionOverviewCard";
import CollectionCreationModal from "Frontend/components/general/modals/CollectionCreationModal";
import CollectionPrioritiesModal from "Frontend/components/general/modals/CollectionPrioritiesModal";
function GameManagementLayout({getConfig, formik}: any) {
const libraries = useSnapshot(libraryState);
const libraryCreationModal = useDisclosure();
const libraryOrderModal = useDisclosure();
const collections = useSnapshot(collectionState);
const collectionCreationModal = useDisclosure();
const collectionOrderModal = useDisclosure();
return (
<div className="flex flex-col">
<div className="flex flex-row items-baseline justify-between">
<h2 className="text-xl font-bold mt-8 mb-1">Libraries</h2>
<div className="flex flex-row gap-2">
<Tooltip content="Change library order">
<Button isIconOnly variant="flat" onPress={libraryOrderModal.onOpen}>
<ListNumbersIcon/>
</Button>
</Tooltip>
<Tooltip content="Add new library">
<Button isIconOnly variant="flat" onPress={libraryCreationModal.onOpen}>
<PlusIcon/>
</Button>
</Tooltip>
</div>
</div>
<Divider className="mb-4"/>
{libraries.sorted.length > 0 ?
// Aspect ratio of cover = 12/17 -> 5 covers = 60/17 -> 353px * 100px
<div id="library-cards" className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(353px,1fr))]">
{libraries.sorted.map((library) =>
// @ts-ignore
<LibraryOverviewCard library={library} key={library.name}/>
)}
</div> :
<p className="mt-4 text-center text-default-500">No libraries found</p>
}
<div className="flex flex-row items-baseline justify-between">
<h2 className="text-xl font-bold mt-8 mb-1">Collections</h2>
<div className="flex flex-row gap-2">
<Tooltip content="Change collection order">
<Button isIconOnly variant="flat" onPress={collectionOrderModal.onOpen}>
<ListNumbersIcon/>
</Button>
</Tooltip>
<Tooltip content="Create new collection">
<Button isIconOnly variant="flat" onPress={collectionCreationModal.onOpen}>
<PlusIcon/>
</Button>
</Tooltip>
</div>
</div>
<Divider className="mb-4"/>
{collections.sorted.length > 0 ?
// Aspect ratio of cover = 12/17 -> 5 covers = 60/17 -> 353px * 100px
<div id="collection-cards" className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(353px,1fr))]">
{collections.sorted.map((collection) =>
// @ts-ignore
<CollectionOverviewCard collection={collection} key={collection.name}/>
)}
</div> :
<p className="mt-4 text-center text-default-500">No collections found</p>
}
<Section title="Scanning"/>
<div className="flex flex-col gap-4">
<ConfigFormField configElement={getConfig("library.scan.enable-filesystem-watcher")}/>
<ConfigFormField configElement={getConfig("library.scan.scan-empty-directories")}/>
<div className="flex flex-row gap-4 items-baseline">
<ConfigFormField configElement={getConfig("library.scan.extract-title-using-regex")}/>
<ConfigFormField configElement={getConfig("library.scan.title-extraction-regex")}
isDisabled={!formik.values.library.scan["extract-title-using-regex"]}/>
</div>
<ConfigFormField configElement={getConfig("library.scan.title-match-min-ratio")}/>
<ConfigFormField configElement={getConfig("library.scan.game-file-extensions")}/>
</div>
<Section title="Metadata"/>
<div className="flex flex-row items-baseline">
<ConfigFormField configElement={getConfig("library.metadata.update.enabled")}/>
<ConfigFormField configElement={getConfig("library.metadata.update.schedule")}
isDisabled={!formik.values.library.metadata.update.enabled}/>
</div>
<LibraryCreationModal
isOpen={libraryCreationModal.isOpen}
onOpenChange={libraryCreationModal.onOpenChange}
/>
<LibraryPrioritiesModal
isOpen={libraryOrderModal.isOpen}
onOpenChange={libraryOrderModal.onOpenChange}
/>
<CollectionCreationModal
isOpen={collectionCreationModal.isOpen}
onOpenChange={collectionCreationModal.onOpenChange}
/>
<CollectionPrioritiesModal
isOpen={collectionOrderModal.isOpen}
onOpenChange={collectionOrderModal.onOpenChange}/>
</div>
);
}
const validationSchema = Yup.object({
library: Yup.object({
metadata: Yup.object({
update: Yup.object({
enabled: Yup.boolean(),
schedule: Yup.string().when("enabled", {
is: true,
then: (schema) => schema.cron()
}),
})
}),
scan: Yup.object({
"extract-title-using-regex": Yup.boolean(),
"title-extraction-regex": Yup.string().when("extract-title-using-regex", {
is: true,
then: (schema) => schema.trim().required("Title extraction regex is required when enabled")
}),
"title-match-min-ratio": Yup.number().min(1, "Must be between 1-100").max(100, "Must be between 1-100")
})
})
});
export const GameManagement = withConfigPage(GameManagementLayout, "Games", validationSchema);
@@ -20,7 +20,7 @@ function GameRequestManagementLayout({getConfig, formik}: any) {
<div className="flex flex-row items-center gap-4"> <div className="flex flex-row items-center gap-4">
<ConfigFormField <ConfigFormField
configElement={getConfig("requests.games.allow-guests-to-request-games")} configElement={getConfig("requests.games.allow-guests-to-request-games")}
isDisabled={!formik.values.library["allow-public-access"]}/> isDisabled={!formik.values.security["allow-public-access"]}/>
<ConfigFormField configElement={getConfig("requests.games.max-open-requests-per-user")}/> <ConfigFormField configElement={getConfig("requests.games.max-open-requests-per-user")}/>
</div> </div>
@@ -1,115 +0,0 @@
import React from "react";
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import withConfigPage from "Frontend/components/administration/withConfigPage";
import Section from "Frontend/components/general/Section";
import * as Yup from 'yup';
import "Frontend/util/yup-extensions";
import {addToast, Button, Divider, Tooltip, useDisclosure} from "@heroui/react";
import {PlusIcon} from "@phosphor-icons/react";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import {LibraryOverviewCard} from "Frontend/components/general/cards/LibraryOverviewCard";
import LibraryCreationModal from "Frontend/components/general/modals/LibraryCreationModal";
import LibraryUpdateDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryUpdateDto";
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import {useSnapshot} from "valtio/react";
import {libraryState} from "Frontend/state/LibraryState";
function LibraryManagementLayout({getConfig, formik}: any) {
const libraryCreationModal = useDisclosure();
const state = useSnapshot(libraryState);
async function updateLibrary(library: LibraryUpdateDto) {
await LibraryEndpoint.updateLibrary(library);
addToast({
title: "Library updated",
description: `Library ${library.name} has been updated.`,
color: "success"
})
}
async function removeLibrary(library: LibraryDto) {
await LibraryEndpoint.deleteLibrary(library.id);
addToast({
title: "Library removed",
description: `Library ${library.name} has been removed.`,
color: "success"
})
}
return (
<div className="flex flex-col">
<Section title="Permissions"/>
<ConfigFormField configElement={getConfig("library.allow-public-access")}/>
<Section title="Scanning"/>
<div className="flex flex-col gap-4">
<ConfigFormField configElement={getConfig("library.scan.enable-filesystem-watcher")} isDisabled/>
<ConfigFormField configElement={getConfig("library.scan.scan-empty-directories")}/>
<div className="flex flex-row gap-4 items-baseline">
<ConfigFormField configElement={getConfig("library.scan.extract-title-using-regex")}/>
<ConfigFormField configElement={getConfig("library.scan.title-extraction-regex")}
isDisabled={!formik.values.library.scan["extract-title-using-regex"]}/>
</div>
<ConfigFormField configElement={getConfig("library.scan.title-match-min-ratio")}/>
<ConfigFormField configElement={getConfig("library.scan.game-file-extensions")}/>
</div>
<Section title="Metadata"/>
<div className="flex flex-row items-baseline">
<ConfigFormField configElement={getConfig("library.metadata.update.enabled")}/>
<ConfigFormField configElement={getConfig("library.metadata.update.schedule")}
isDisabled={!formik.values.library.metadata.update.enabled}/>
</div>
<div className="flex flex-row items-baseline justify-between">
<h2 className="text-xl font-bold mt-8 mb-1">Libraries</h2>
<Tooltip content="Add new library">
<Button isIconOnly variant="flat" onPress={libraryCreationModal.onOpen}>
<PlusIcon/>
</Button>
</Tooltip>
</div>
<Divider className="mb-4"/>
{state.sorted.length > 0 ?
// Aspect ratio of cover = 12/17 -> 5 covers = 60/17 -> 353px * 100px
<div id="library-cards" className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(353px,1fr))]">
{state.sorted.map((library) =>
// @ts-ignore
<LibraryOverviewCard library={library} updateLibrary={updateLibrary}
removeLibrary={removeLibrary} key={library.name}/>
)}
</div> :
<p className="mt-4 text-center text-default-500">No libraries found</p>
}
<LibraryCreationModal
isOpen={libraryCreationModal.isOpen}
onOpenChange={libraryCreationModal.onOpenChange}
/>
</div>
);
}
const validationSchema = Yup.object({
library: Yup.object({
metadata: Yup.object({
update: Yup.object({
enabled: Yup.boolean(),
schedule: Yup.string().when("enabled", {
is: true,
then: (schema) => schema.cron()
}),
})
}),
scan: Yup.object({
"extract-title-using-regex": Yup.boolean(),
"title-extraction-regex": Yup.string().when("extract-title-using-regex", {
is: true,
then: (schema) => schema.trim().required("Title extraction regex is required when enabled")
}),
"title-match-min-ratio": Yup.number().min(1, "Must be between 1-100").max(100, "Must be between 1-100")
})
})
});
export const LibraryManagement = withConfigPage(LibraryManagementLayout, "Library Management", validationSchema);
@@ -121,13 +121,13 @@ function MessageManagementLayout({getConfig, formik}: any) {
<EditTemplateModal <EditTemplateModal
isOpen={editorModal.isOpen} isOpen={editorModal.isOpen}
onOpenChange={editorModal.onOpenChange} onOpenChange={editorModal.onOpenChange}
selectedTemplate={selectedTemplate!!} selectedTemplate={selectedTemplate!}
/> />
<SendTestNotificationModal <SendTestNotificationModal
isOpen={testNotificationModal.isOpen} isOpen={testNotificationModal.isOpen}
onOpenChange={testNotificationModal.onOpenChange} onOpenChange={testNotificationModal.onOpenChange}
selectedTemplate={selectedTemplate!!} selectedTemplate={selectedTemplate!}
/> />
</div> </div>
); );
@@ -19,7 +19,7 @@ export default function PluginManagement() {
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
{pluginTypes.map(type => {pluginTypes.map(type =>
// @ts-ignore // @ts-ignore
<PluginManagementSection key={type} type={type} plugins={state.pluginsByType[type]}/> <PluginManagementSection key={type} type={type}/>
)} )}
</div> </div>
</div> </div>
@@ -3,14 +3,14 @@ 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 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} from "@heroui/react";
import { MagicWandIcon, WarningIcon } from "@phosphor-icons/react"; import {MagicWandIcon} from "@phosphor-icons/react";
function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) { function SecurityManagementLayout({getConfig, formik, setSaveMessage}: any) {
useEffect(() => { useEffect(() => {
if (formik.dirty) { if (formik.dirty) {
setSaveMessage("Gameyfin must be restarted for the changes to take effect"); setSaveMessage("Gameyfin must be restarted for changes in the SSO configuration to take effect");
} else { } else {
setSaveMessage(null); setSaveMessage(null);
} }
@@ -43,41 +43,26 @@ function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) {
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex flex-row">
<Section title="Permissions"/>
<ConfigFormField configElement={getConfig("security.allow-public-access")}/>
<Section title="Single Sign-On"/>
<div className="flex flex-row items-start gap-8">
<div className="flex flex-col">
<h2 className="text-xl font-bold mb-4">General configuration</h2>
<ConfigFormField className="mb-4"
configElement={getConfig("sso.oidc.enabled")}/>
<ConfigFormField configElement={getConfig("sso.oidc.match-existing-users-by")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.roles-claim")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.oauth-scopes")}
isDisabled={!formik.values.sso.oidc.enabled}/>
</div>
<div className="flex flex-col flex-1"> <div className="flex flex-col flex-1">
<Section title="SSO configuration"/> <h2 className="text-xl font-bold mb-4">SSO Provider Configuration</h2>
<ConfigFormField configElement={getConfig("sso.oidc.enabled")}/>
<Section title="SSO user handling"/>
<div className="flex flex-row items-baseline mb-4">
<CheckboxGroup className="flex flex-col flex-1 items-baseline gap-2"
value={["auto-register-new-users"]}>
<div className="flex flex-row gap-2">
<Checkbox className="items-baseline" value="auto-register-new-users" isDisabled>
Automatically create new users after registration
</Checkbox>
<Tooltip content={"Currently not configurable (always enabled)"} placement="right">
<WarningIcon weight="fill"/>
</Tooltip>
</div>
</CheckboxGroup>
{/*TODO: enable when the issues with unregistered SSO users are sorted
<ConfigFormField configElement={getConfig("sso.oidc.auto-register-new-users")} isDisabled={!formik.values.sso.oidc.enabled}/>
*/}
<ConfigFormField configElement={getConfig("sso.oidc.match-existing-users-by")}
isDisabled={!formik.values.sso.oidc.enabled ||
!formik.values.sso.oidc["auto-register-new-users"]}/>
</div>
<div className="flex flex-row items-center gap-4">
<ConfigFormField configElement={getConfig("sso.oidc.roles-claim")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.oauth-scopes")}
isDisabled={!formik.values.sso.oidc.enabled}/>
</div>
<Section title="SSO provider configuration"/>
<ConfigFormField configElement={getConfig("sso.oidc.client-id")} <ConfigFormField configElement={getConfig("sso.oidc.client-id")}
isDisabled={!formik.values.sso.oidc.enabled}/> isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.client-secret")} <ConfigFormField configElement={getConfig("sso.oidc.client-secret")}
@@ -111,7 +96,6 @@ const validationSchema = Yup.object({
sso: Yup.object({ sso: Yup.object({
oidc: Yup.object({ oidc: Yup.object({
enabled: Yup.boolean(), enabled: Yup.boolean(),
"auto-register-new-users": Yup.boolean().required(),
"match-existing-users-by": Yup.string().required(), "match-existing-users-by": Yup.string().required(),
"client-id": Yup.string().when("enabled", ([enabled], schema) => "client-id": Yup.string().when("enabled", ([enabled], schema) =>
enabled ? schema.required("Client ID is required") : schema enabled ? schema.required("Client ID is required") : schema
@@ -141,4 +125,4 @@ const validationSchema = Yup.object({
}) })
}); });
export const SsoManagement = withConfigPage(SsoManagementLayout, "Single Sign-On", validationSchema); export const SecurityManagement = withConfigPage(SecurityManagementLayout, "Security", validationSchema);
@@ -4,8 +4,7 @@ import withConfigPage from "Frontend/components/administration/withConfigPage";
import Section from "Frontend/components/general/Section"; 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 {UserPlusIcon} 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";
@@ -32,10 +31,6 @@ 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 &&
<SmallInfoField className="mb-4 text-warning" icon={InfoIcon}
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}>
<UserPlusIcon/> <UserPlusIcon/>
@@ -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 { CheckIcon, InfoIcon } 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";
@@ -32,7 +32,7 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
} }
function getConfig(key: string): ConfigEntryDto | undefined { function getConfig(key: string): ConfigEntryDto | undefined {
return state.state[key] as ConfigEntryDto | undefined; return state.state[key];
} }
function getChangedValues(initial: NestedConfig, current: NestedConfig): Record<string, any> { function getChangedValues(initial: NestedConfig, current: NestedConfig): Record<string, any> {
@@ -11,16 +11,15 @@ import {
} from "@heroui/react"; } from "@heroui/react";
import {useSnapshot} from "valtio/react"; 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 {libraryState} from "Frontend/state/LibraryState"; import {libraryState} from "Frontend/state/LibraryState";
import { TargetIcon, WarningIcon } 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";
export default function ScanProgressPopover() { export default function ScanProgressPopover() {
const libraries = useSnapshot(libraryState).state; const libraries = useSnapshot(libraryState).state;
const scans = useSnapshot(scanState).sortedByStartTime as LibraryScanProgress[]; const scans = useSnapshot(scanState).sortedByStartTime;
const scanInProgress = useSnapshot(scanState).isScanning; const scanInProgress = useSnapshot(scanState).isScanning;
// Add state to track current time and force re-renders // Add state to track current time and force re-renders
@@ -50,7 +49,7 @@ export default function ScanProgressPopover() {
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent> <PopoverContent>
<div className="flex flex-col gap-2 m-2 min-w-96 w-fit"> <div className="flex flex-col gap-2 m-2 min-w-md">
{scans.length === 0 ? {scans.length === 0 ?
<p className="flex h-12 items-center justify-center text-sm text-default-500"> <p className="flex h-12 items-center justify-center text-sm text-default-500">
No scans in progress or in history. No scans in progress or in history.
@@ -59,12 +58,12 @@ export default function ScanProgressPopover() {
{scans.map((scan, index) => {scans.map((scan, index) =>
<div className="flex flex-col" key={scan.scanId}> <div className="flex flex-col" key={scan.scanId}>
<div <div
className="flex flex-row justify-between items-center text-default-500 mb-1"> className="flex flex-row gap-4 justify-between items-center text-default-500 mb-1">
<p>{toTitleCase(scan.type)} scan for library&nbsp; <p>{toTitleCase(scan.type)} scan for library&nbsp;
<Link underline="always" <Link underline="always"
color="foreground" color="foreground"
size="sm" size="sm"
href={`/administration/libraries/library/${scan.libraryId}`}> href={`/administration/games/library/${scan.libraryId}`}>
{libraries[scan.libraryId].name} {libraries[scan.libraryId].name}
</Link> </Link>
</p> </p>
@@ -1,8 +1,7 @@
import {Autocomplete, AutocompleteItem} from "@heroui/react"; import {Autocomplete, AutocompleteItem} from "@heroui/react";
import { CaretRightIcon, MagnifyingGlassIcon } 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 {useNavigate} from "react-router"; import {useNavigate} from "react-router";
import {GameCover} from "Frontend/components/general/covers/GameCover"; import {GameCover} from "Frontend/components/general/covers/GameCover";
@@ -10,7 +9,7 @@ export default function SearchBar() {
const navigate = useNavigate(); const navigate = useNavigate();
const state = useSnapshot(gameState); const state = useSnapshot(gameState);
const games = state.games as GameDto[]; const games = state.games;
return <Autocomplete return <Autocomplete
aria-label="Search for games" aria-label="Search for games"
@@ -0,0 +1,75 @@
import {Button, Card, Tooltip} from "@heroui/react";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import React, {useEffect, useState} from "react";
import {GameCover} from "Frontend/components/general/covers/GameCover";
import CollectionAdminDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionAdminDto";
import {SlidersHorizontalIcon} from "@phosphor-icons/react";
import {useNavigate} from "react-router";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
import ChipList from "Frontend/components/general/ChipList";
interface CollectionOverviewCardProps {
collection: CollectionAdminDto;
}
export function CollectionOverviewCard({collection}: CollectionOverviewCardProps) {
const MAX_COVER_COUNT = 5;
const navigate = useNavigate();
const state = useSnapshot(gameState);
const [randomGames, setRandomGames] = useState<GameDto[]>([]);
useEffect(() => {
if (!state.randomlyOrderedGamesByCollectionId) return;
setRandomGames(getRandomGames());
}, [state]);
function getRandomGames() {
if (!state.randomlyOrderedGamesByCollectionId[collection.id]) return [];
const games = state.randomlyOrderedGamesByCollectionId[collection.id]
.filter(game => game.cover?.id != null);
if (!games) return [];
return games.slice(0, MAX_COVER_COUNT);
}
return (
<Card className="flex flex-col justify-between w-[353px]">
<div className="flex flex-1 justify-center items-center">
<div className="flex flex-1 opacity-10 min-h-[100px]">
<IconBackgroundPattern/>
{randomGames.length > 0 &&
<div className="absolute flex flex-row">
{randomGames.map((game) => (
<GameCover game={game} size={100} radius="none" key={game.cover?.id}/>
))}
</div>
}
</div>
<p className="absolute text-2xl font-bold">{collection.name}</p>
<div className="absolute right-0 top-0 flex flex-row">
<Tooltip content="Configuration" placement="bottom" color="foreground">
<Button isIconOnly variant="light" onPress={() => navigate('collection/' + collection.id)}>
<SlidersHorizontalIcon/>
</Button>
</Tooltip>
</div>
</div>
{collection.stats &&
<div className="grid grid-rows-2 grid-cols-3 justify-items-center items-center p-2 pt-4">
<p>Games</p>
<p>Downloads</p>
<p>Platforms</p>
<p className="font-bold">{collection.stats.gamesCount}</p>
<p className="font-bold">{collection.stats.downloadCount}</p>
<ChipList items={collection.stats.gamePlatforms} maxVisible={0}
defaultContent={collection.stats.gamesCount > 0 ? "All" : "None"}/>
</div>
}
</Card>
);
}
@@ -1,5 +1,4 @@
import {Button, Card, Tooltip} from "@heroui/react"; import {Button, Card, Tooltip} from "@heroui/react";
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";
@@ -23,7 +22,9 @@ export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
const randomGames = getRandomGames(); const randomGames = getRandomGames();
function getRandomGames() { function getRandomGames() {
const games = state.randomlyOrderedGamesByLibraryId[library.id] as GameDto[]; if (!state.randomlyOrderedGamesByLibraryId[library.id]) return [];
const games = state.randomlyOrderedGamesByLibraryId[library.id]
.filter(game => game.cover?.id != null);
if (!games) return []; if (!games) return [];
return games.slice(0, MAX_COVER_COUNT); return games.slice(0, MAX_COVER_COUNT);
} }
@@ -40,7 +41,7 @@ export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
{randomGames.length > 0 && {randomGames.length > 0 &&
<div className="absolute flex flex-row"> <div className="absolute flex flex-row">
{randomGames.map((game) => ( {randomGames.map((game) => (
<GameCover game={game} size={100} radius="none" key={game.coverId}/> <GameCover game={game} size={100} radius="none" key={game.cover?.id}/>
))} ))}
</div> </div>
} }
@@ -1,5 +1,20 @@
import {Button, Card, Chip, Tooltip, useDisclosure} from "@heroui/react"; import {Button, Card, Chip, Tooltip, useDisclosure} from "@heroui/react";
import { CheckCircleIcon, IconContext, PauseCircleIcon, PlayCircleIcon, PowerIcon, QuestionIcon, QuestionMarkIcon, SealCheckIcon, SealQuestionIcon, SealWarningIcon, SlidersHorizontalIcon, StopCircleIcon, WarningCircleIcon, XCircleIcon } from "@phosphor-icons/react"; import {
CheckCircleIcon,
IconContext,
PauseCircleIcon,
PlayCircleIcon,
PowerIcon,
QuestionIcon,
QuestionMarkIcon,
SealCheckIcon,
SealQuestionIcon,
SealWarningIcon,
SlidersHorizontalIcon,
StopCircleIcon,
WarningCircleIcon,
XCircleIcon
} 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";
@@ -105,11 +120,11 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
return state === PluginState.DISABLED; return state === PluginState.DISABLED;
} }
function togglePluginEnabled() { async function togglePluginEnabled() {
if (isDisabled(plugin.state)) { if (isDisabled(plugin.state)) {
PluginEndpoint.enablePlugin(plugin.id); await PluginEndpoint.enablePlugin(plugin.id);
} else { } else {
PluginEndpoint.disablePlugin(plugin.id); await PluginEndpoint.disablePlugin(plugin.id);
} }
} }
@@ -0,0 +1,84 @@
import {Card, Chip, Image} from "@heroui/react";
import React, {useMemo} from "react";
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import CollectionDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionDto";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import Rand from "rand-seed";
import {useNavigate} from "react-router";
interface StartPageDisplayCardProps {
item: LibraryDto | CollectionDto;
}
export function StartPageDisplayCard({item}: StartPageDisplayCardProps) {
const navigate = useNavigate();
const isCollection = (libraryOrCollection: LibraryDto | CollectionDto): libraryOrCollection is CollectionDto => {
return 'description' in libraryOrCollection;
};
const isLibrary = (libraryOrCollection: LibraryDto | CollectionDto): libraryOrCollection is LibraryDto => {
return !('description' in libraryOrCollection);
};
const gamesState = useSnapshot(gameState);
const randomImageId = useMemo<number | null>(() => getRandomImageId(), [item]);
const link = useMemo<string>(() => getLink(), [item]);
const type = isCollection(item) ? 'Collection' : 'Library';
/**
* Gets a random cover ID from the games in the specified library or collection.
* Since the Random class is seeded with the game ID, the same game and image will always be selected for a given library/collection (unless the games inside change).
* @return {number | null} The random cover ID or null if none found.
*/
function getRandomImageId(): number | null {
let games: GameDto[] = [];
if (isCollection(item)) {
games = gamesState.randomlyOrderedGamesByCollectionId[item.id] as GameDto[];
} else if (isLibrary(item)) {
games = gamesState.randomlyOrderedGamesByLibraryId[item.id] as GameDto[];
}
if (!games || games.length == 0) return null;
// Find the first game that has at least one screenshot available
let game: GameDto | undefined = games.find(game => game.images && game.images.length > 0);
if (!game) return null;
const random = new Rand(`${item.id}-${game.id}`);
const randomImageIndex = Math.floor(random.next() * game.images!.length);
return game.images![randomImageIndex].id;
}
function getLink(): string {
if (isCollection(item)) {
return `/collection/${item.id}`;
} else if (isLibrary(item)) {
return `/library/${item.id}`;
}
return '#';
}
return randomImageId && (
<Card isPressable={true}
onPress={() => navigate(link)}
className="h-48 w-96 relative overflow-hidden scale-95 hover:scale-100 shine transition-all select-none">
<Image
src={`images/cover/${randomImageId}`}
className="absolute inset-0 w-full h-full object-cover brightness-40 z-0"
removeWrapper
/>
<div className="flex flex-col gap-1 relative z-10 items-center justify-center h-full">
<h2 className="text-white text-2xl font-bold text-center px-4">
{item.name}
</h2>
<Chip size="sm" radius="sm">{type}</Chip>
</div>
</Card>
);
}
@@ -0,0 +1,57 @@
import CollectionAdminDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionAdminDto";
import React, {useEffect, useState} from "react";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
import {Card} from "@heroui/react";
interface CollectionHeaderProps {
collection: CollectionAdminDto;
className?: string;
}
export default function CollectionHeader({collection, className}: CollectionHeaderProps) {
const MAX_COVER_COUNT = 5;
const state = useSnapshot(gameState);
const [randomGames, setRandomGames] = useState<GameDto[]>([]);
useEffect(() => {
if (!state.randomlyOrderedGamesByCollectionId) return;
setRandomGames(getRandomGames());
}, [state]);
function getRandomGames() {
if (!state.randomlyOrderedGamesByCollectionId[collection.id]) return [];
const games = state.randomlyOrderedGamesByCollectionId[collection.id]
.filter(game => game.images && game.images.length > 0);
if (!games) return [];
return games.slice(0, MAX_COVER_COUNT);
}
return (
<Card className={`overflow-hidden rounded-lg relative pointer-events-none select-none ${className}`}>
<IconBackgroundPattern/>
<div className="flex flex-row items-center w-full h-full brightness-50">
{randomGames.map((game, idx) => (
<div
key={idx}
className="flex-none overflow-hidden -ml-[10%]"
style={{
width: `calc(100% / ${MAX_COVER_COUNT - 2})`,
clipPath: 'polygon(15% 0, 100% 0, 85% 100%, 0% 100%)',
}}
>
<img
src={`/images/screenshot/${game.images![0].id}`}
alt={`Image ${idx}`}
/>
</div>
))}
</div>
<div className="absolute inset-0 flex items-center justify-center">
<h2 className="text-white text-3xl font-bold">{collection.name}</h2>
</div>
</Card>
);
}
@@ -1,16 +1,110 @@
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto"; import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {GameCover} from "Frontend/components/general/covers/GameCover"; import {GameCover} from "Frontend/components/general/covers/GameCover";
import type {CellComponentProps} from "react-window";
import {Grid} from "react-window";
import {useEffect, useRef, useState} from "react";
interface CoverGridProps { interface CoverGridProps {
games: GameDto[]; games: GameDto[];
} }
// Constants for grid layout
const MIN_COLUMN_WIDTH = 180; // Minimum width per item (minmax value from original)
const MAX_COLUMN_WIDTH = 212; // Maximum width per item (minmax value from original)
const GAP = 16; // gap-4 = 1rem = 16px
const ASPECT_RATIO = 12 / 17; // Game cover aspect ratio (width/height)
export default function CoverGrid({games}: CoverGridProps) { export default function CoverGrid({games}: CoverGridProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(0);
// Update container width on resize
useEffect(() => {
const updateDimensions = () => {
if (containerRef.current) {
setContainerWidth(containerRef.current.offsetWidth);
}
};
const resizeObserver = new ResizeObserver(updateDimensions);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
updateDimensions();
return () => resizeObserver.disconnect();
}, []);
// Calculate how many columns can fit
const columnCount = Math.max(1, Math.floor((containerWidth + GAP) / (MIN_COLUMN_WIDTH + GAP)));
// Calculate actual column width to distribute space evenly (up to MAX_COLUMN_WIDTH)
const actualColumnWidth = Math.min(
MAX_COLUMN_WIDTH,
Math.floor((containerWidth - (columnCount - 1) * GAP) / columnCount)
);
// Calculate cover height based on width and aspect ratio
// GameCover's size prop is the height, so we need to calculate height from width
const coverHeight = Math.floor(actualColumnWidth / ASPECT_RATIO);
// Calculate row count
const rowCount = Math.ceil(games.length / columnCount);
// Cell renderer for react-window Grid
const Cell = ({
columnIndex,
rowIndex,
style
}: CellComponentProps<{}>) => {
const gameIndex = rowIndex * columnCount + columnIndex;
// Return empty cell if we're past the end of the games array
if (gameIndex >= games.length) {
return <div style={style}/>;
}
const game = games[gameIndex];
return (
<div
style={{
...style,
paddingBottom: GAP,
display: 'flex',
justifyContent: 'center',
boxSizing: 'border-box'
}}
>
<GameCover game={game} interactive={true} size={coverHeight} lazy={true}/>
</div>
);
};
// Column width function to handle the last column differently
const getColumnWidth = (index: number) => {
// Last column doesn't need gap after it
if (index === columnCount - 1) {
return actualColumnWidth;
}
return actualColumnWidth + GAP;
};
return ( return (
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,212px))] gap-4 justify-center"> <div ref={containerRef} className="w-full">
{games.map((game) => ( {containerWidth > 0 && (
<GameCover key={game.id} game={game} interactive={true}/> <Grid<{}>
))} columnCount={columnCount}
columnWidth={getColumnWidth}
rowCount={rowCount}
rowHeight={coverHeight + GAP}
defaultWidth={containerWidth}
cellComponent={Cell}
cellProps={{}}
style={{overflowX: 'hidden'}}
/>
)}
</div> </div>
); );
} }
@@ -1,66 +1,166 @@
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 {ArrowRightIcon} from "@phosphor-icons/react"; import {CaretLeftIcon, CaretRightIcon} from "@phosphor-icons/react";
import {Button, Link} from "@heroui/react";
import {Grid, GridImperativeAPI} from "react-window";
interface CoverRowProps { interface CoverRowProps {
games: GameDto[]; games: GameDto[];
title: string; title: string;
onPressShowMore: () => void; link: string;
} }
const aspectRatio = 12 / 17; // aspect ratio of the game cover const aspectRatio = 12 / 17; // aspect ratio of the game cover
const defaultImageHeight = 300; // default height for the image const defaultImageHeight = 300; // default height for the image
const defaultImageWidth = aspectRatio * defaultImageHeight; // default width for the image const defaultImageWidth = aspectRatio * defaultImageHeight; // default width for the image
const gap = 8; // gap between items in pixels (gap-2 = 0.5rem = 8px)
export function CoverRow({games, title, onPressShowMore}: CoverRowProps) { export function CoverRow({games, title, link}: CoverRowProps) {
const gridRef = useRef<GridImperativeAPI | null>(null);
const [scrollPosition, setScrollPosition] = useState(0);
const [containerWidth, setContainerWidth] = useState(0);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(games.length);
// Update container width on resize
useEffect(() => { useEffect(() => {
const calculateVisible = () => { const updateWidth = () => {
if (containerRef.current) { if (containerRef.current) {
const containerWidth = containerRef.current.offsetWidth; setContainerWidth(containerRef.current.offsetWidth);
const maxFit = Math.floor((containerWidth - defaultImageWidth) / defaultImageWidth) + 1;
setVisibleCount(maxFit < games.length ? maxFit : games.length);
} }
}; };
const resizeObserver = new ResizeObserver(calculateVisible); const resizeObserver = new ResizeObserver(updateWidth);
if (containerRef.current) { if (containerRef.current) {
resizeObserver.observe(containerRef.current); resizeObserver.observe(containerRef.current);
} }
calculateVisible(); // initial calculation updateWidth();
return () => resizeObserver.disconnect(); return () => resizeObserver.disconnect();
}, [games.length]); }, []);
const showMore = visibleCount < games.length; // Handle scroll updates - track scroll position from the grid element
useEffect(() => {
let gridElement: HTMLDivElement | null = null;
const handleScroll = () => {
if (gridElement) {
setScrollPosition(gridElement.scrollLeft);
}
};
// Small delay to ensure grid is mounted
const timer = setTimeout(() => {
gridElement = gridRef.current?.element ?? null;
if (gridElement) {
gridElement.addEventListener('scroll', handleScroll);
// Initial scroll position
setScrollPosition(gridElement.scrollLeft);
}
}, 100);
return () => {
clearTimeout(timer);
if (gridElement) {
gridElement.removeEventListener('scroll', handleScroll);
}
};
}, [containerWidth, games.length]);
const totalWidth = games.length * (defaultImageWidth + gap);
const maxScroll = Math.max(0, totalWidth - containerWidth);
const scrollLeft = () => {
const gridElement = gridRef.current?.element;
if (gridElement) {
const itemWidth = defaultImageWidth + gap;
const scrollAmount = itemWidth * 3; // Scroll exactly 3 items
const newPosition = Math.max(0, scrollPosition - scrollAmount);
gridElement.scrollTo({
left: newPosition,
behavior: "smooth"
});
}
};
const scrollRight = () => {
const gridElement = gridRef.current?.element;
if (gridElement) {
const itemWidth = defaultImageWidth + gap;
const scrollAmount = itemWidth * 3; // Scroll exactly 3 items
const newPosition = Math.min(maxScroll, scrollPosition + scrollAmount);
gridElement.scrollTo({
left: newPosition,
behavior: "smooth"
});
}
};
const canScrollLeft = scrollPosition > 1; // Allow small margin for floating point issues
const canScrollRight = scrollPosition < maxScroll - 1 && maxScroll > 0;
// Cell renderer for react-window Grid
const Cell = ({columnIndex, style}: {
ariaAttributes: { "aria-colindex": number; role: "gridcell" };
columnIndex: number;
rowIndex: number;
style: React.CSSProperties;
}) => {
const game = games[columnIndex];
return (
<div style={{...style, paddingRight: gap}}>
<GameCover game={game} radius="sm" interactive={true}/>
</div>
);
};
return ( return (
<div className="flex flex-col mb-4"> <div className="flex flex-col mb-4">
<p className="text-2xl font-bold mb-4">{title}</p> <div className="flex flex-row justify-between items-baseline mb-4">
<div className="w-full relative"> <Link href={link} className="flex flex-row gap-1 w-fit items-baseline" color="foreground"
<div ref={containerRef} className="flex flex-row gap-2 rounded-md bg-transparent"> underline="hover">
{games.slice(0, visibleCount).map((game, index) => ( <p className="text-2xl font-bold">{title}</p>
<GameCover key={index} game={game} radius="sm" interactive={true}/> <CaretRightIcon weight="bold" size={16}/>
))} </Link>
<div className="flex flex-row gap-2">
<Button
isIconOnly
size="sm"
variant="flat"
onPress={scrollLeft}
isDisabled={!canScrollLeft}
aria-label="Scroll left"
>
<CaretLeftIcon weight="bold" size={20}/>
</Button>
<Button
isIconOnly
size="sm"
variant="flat"
onPress={scrollRight}
isDisabled={!canScrollRight}
aria-label="Scroll right"
>
<CaretRightIcon weight="bold" size={20}/>
</Button>
</div> </div>
</div>
{showMore && ( <div ref={containerRef} className="w-full relative overflow-hidden">
<div className="flex flex-row items-center justify-end cursor-pointer" {containerWidth > 0 && (
onClick={onPressShowMore}> <Grid<{}>
<div className="absolute h-full w-1/4 right-0 bottom-0 gridRef={gridRef}
bg-linear-to-r from-transparent to-background columnCount={games.length}
transition-all duration-300 ease-in-out hover:opacity-80"/> columnWidth={defaultImageWidth + gap}
<div rowCount={1}
className="absolute h-full right-0 bottom-0 flex flex-row items-center gap-2 pointer-events-none"> rowHeight={defaultImageHeight}
<p className="text-xl font-semibold">Show more</p> defaultHeight={defaultImageHeight}
<ArrowRightIcon weight="bold"/> defaultWidth={containerWidth}
</div> cellComponent={Cell}
</div> cellProps={{}}
className="scrollbar-hide"
style={{overflow: 'auto'}}
/>
)} )}
</div> </div>
</div> </div>
@@ -1,21 +1,105 @@
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto"; import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {Image} from "@heroui/react"; import {Image} from "@heroui/react";
import {GameCoverFallback} from "Frontend/components/general/covers/GameCoverFallback"; import {GameCoverFallback} from "Frontend/components/general/covers/GameCoverFallback";
import {useEffect, useRef, useState} from "react";
import {decode} from "blurhash";
// Cache to track which images have been loaded across component remounts
const loadedImagesCache = new Set<number>();
interface GameCoverProps { interface GameCoverProps {
game: GameDto; game: GameDto;
size?: number; size?: number;
radius?: "none" | "sm" | "md" | "lg"; radius?: "none" | "sm" | "md" | "lg";
interactive?: boolean; interactive?: boolean;
lazy?: boolean;
} }
export function GameCover({game, size = 300, radius = "sm", interactive = false}: GameCoverProps) { export function GameCover({game, size = 300, radius = "sm", interactive = false, lazy = false}: GameCoverProps) {
const coverContent = Number.isInteger(game.coverId) ? ( const [shouldLoad, setShouldLoad] = useState(!lazy);
<div className={`${interactive ? "rounded-md scale-95 hover:scale-100 shine transition-all" : ""}`}> // Check cache to see if this image has already been loaded
const [isImageLoaded, setIsImageLoaded] = useState(
game.cover ? loadedImagesCache.has(game.cover.id) : false
);
const [blurhashUrl, setBlurhashUrl] = useState<string | undefined>(undefined);
const containerRef = useRef<HTMLDivElement>(null);
// Generate blurhash placeholder image
useEffect(() => {
if (game.cover?.blurhash) {
try {
// Decode blurhash to pixel data
const pixels = decode(game.cover.blurhash, 32, 45); // Small size for placeholder
// Create canvas and draw pixels
const canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 45;
const ctx = canvas.getContext('2d');
if (ctx) {
const imageData = ctx.createImageData(32, 45);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
// Convert canvas to data URL
setBlurhashUrl(canvas.toDataURL());
}
} catch (e) {
console.error('Failed to decode blurhash:', e);
}
}
}, [game.cover?.blurhash]);
useEffect(() => {
if (!lazy || shouldLoad) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setShouldLoad(true);
observer.disconnect();
}
});
},
{
rootMargin: '200px', // Start loading 200px before the element enters viewport
}
);
if (containerRef.current) {
observer.observe(containerRef.current);
}
return () => observer.disconnect();
}, [lazy, shouldLoad]);
// Preload the real image when shouldLoad becomes true
useEffect(() => {
if (!shouldLoad || !game.cover || isImageLoaded) return;
const img = document.createElement('img');
img.src = `images/cover/${game.cover.id}`;
img.onload = () => {
loadedImagesCache.add(game.cover!.id);
setIsImageLoaded(true);
};
img.onerror = () => {
// If image fails to load, we'll just show the fallback
setIsImageLoaded(true);
};
}, [shouldLoad, game.cover, isImageLoaded]);
const coverContent = game.cover ? (
<div
ref={containerRef}
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={shouldLoad && isImageLoaded ? `images/cover/${game.cover.id}` : blurhashUrl}
radius={radius} radius={radius}
height={size} height={size}
fallbackSrc={<GameCoverFallback title={game.title} size={size} radius={radius}/>} fallbackSrc={<GameCoverFallback title={game.title} size={size} radius={radius}/>}
@@ -1,6 +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 React from "react"; import React from "react";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
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";
@@ -17,7 +16,9 @@ export default function LibraryHeader({library, className}: LibraryHeaderProps)
const randomGames = getRandomGames(); const randomGames = getRandomGames();
function getRandomGames() { function getRandomGames() {
const games = state.randomlyOrderedGamesByLibraryId[library.id] as GameDto[]; if (!state.randomlyOrderedGamesByLibraryId[library.id]) return [];
const games = state.randomlyOrderedGamesByLibraryId[library.id]
.filter(game => game.images && game.images.length > 0);
if (!games) return []; if (!games) return [];
return games.slice(0, MAX_COVER_COUNT); return games.slice(0, MAX_COVER_COUNT);
} }
@@ -36,7 +37,7 @@ export default function LibraryHeader({library, className}: LibraryHeaderProps)
}} }}
> >
<img <img
src={`/images/screenshot/${game.imageIds![0]}`} src={`/images/screenshot/${game.images![0].id}`}
alt={`Image ${idx}`} alt={`Image ${idx}`}
/> />
</div> </div>
@@ -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 { PlusIcon } from "@phosphor-icons/react"; import {PlusIcon} from "@phosphor-icons/react";
// @ts-ignore // @ts-ignore
const ArrayInput = ({label, ...props}) => { const ArrayInput = ({label, ...props}) => {
@@ -35,13 +35,23 @@ const ArrayInput = ({label, ...props}) => {
<div className="flex flex-row flex-wrap gap-2 items-center"> <div className="flex flex-row flex-wrap gap-2 items-center">
{field.value.map((element: any, index: number) => ( {field.value.map((element: any, index: number) => (
<Chip key={index} onClose={() => arrayHelpers.remove(index)}> <Chip key={index}
onClose={() => arrayHelpers.remove(index)}
isDisabled={props.isDisabled}
>
{element} {element}
</Chip> </Chip>
))} ))}
<Popover placement="bottom" showArrow={true}> <Popover placement="bottom" showArrow={true}>
<PopoverTrigger> <PopoverTrigger>
<Button isIconOnly size="sm" variant="light" radius="full"><PlusIcon/></Button> <Button isIconOnly
size="sm"
variant="light"
radius="full"
isDisabled={props.isDisabled}
>
<PlusIcon/>
</Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent> <PopoverContent>
<Input <Input
@@ -77,7 +77,7 @@ export default function FileTreeView({onPathChange}: { onPathChange: (file: stri
if (subDirectories === undefined) return; if (subDirectories === undefined) return;
const newNodes = fileDtosToNodes(subDirectories as FileDto[]); const newNodes = fileDtosToNodes(subDirectories as FileDto[]);
const updatedTree = updateTreeWithNewNodes(fileTree!!, element.id, newNodes); const updatedTree = updateTreeWithNewNodes(fileTree!, element.id, newNodes);
setFileTree(updatedTree); setFileTree(updatedTree);
setFlattenedFileTree(flattenTree(updatedTree)); setFlattenedFileTree(flattenTree(updatedTree));
@@ -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 { ImageBrokenIcon, PencilIcon } from "@phosphor-icons/react"; import {ImageBrokenIcon, PencilIcon} from "@phosphor-icons/react";
// @ts-ignore // @ts-ignore
@@ -16,12 +16,12 @@ export default function GameCoverPicker({game, showErrorUntouched = false, ...pr
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.cover?.id ?
<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.cover?.id}`}
{...props} {...props}
{...field} {...field}
radius="none" radius="none"
@@ -16,12 +16,12 @@ export default function GameHeaderPicker({game, showErrorUntouched = false, ...p
return (<> return (<>
<div className="relative group size-full cursor-pointer bg-background/50" <div className="relative group size-full cursor-pointer bg-background/50"
onClick={gameHeaderPickerModal.onOpenChange}> onClick={gameHeaderPickerModal.onOpenChange}>
{field.value || game.headerId ? {field.value || game.header?.id ?
<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.header?.id}`}
{...props} {...props}
{...field} {...field}
radius="none" radius="none"
@@ -14,6 +14,7 @@ import * as Yup from "yup";
import ArrayInputAutocomplete from "Frontend/components/general/input/ArrayInputAutocomplete"; import ArrayInputAutocomplete from "Frontend/components/general/input/ArrayInputAutocomplete";
import {useSnapshot} from "valtio/react"; import {useSnapshot} from "valtio/react";
import {platformState} from "Frontend/state/PlatformState"; import {platformState} from "Frontend/state/PlatformState";
import CheckboxInput from "Frontend/components/general/input/CheckboxInput";
interface LibraryManagementDetailsProps { interface LibraryManagementDetailsProps {
library: LibraryDto; library: LibraryDto;
@@ -45,7 +46,7 @@ export default function LibraryManagementDetails({library}: LibraryManagementDet
color: "success" color: "success"
}); });
navigate("/administration/libraries"); navigate("/administration/games");
} catch (e) { } catch (e) {
addToast({ addToast({
title: "Error deleting library", title: "Error deleting library",
@@ -84,6 +85,8 @@ export default function LibraryManagementDetails({library}: LibraryManagementDet
<Input label="Library name" name="name"/> <Input label="Library name" name="name"/>
<CheckboxInput label="Display on homepage" name="metadata.displayOnHomepage" className="mb-4"/>
<ArrayInputAutocomplete options={Array.from(availablePlatforms)} name="platforms" label="Platforms"/> <ArrayInputAutocomplete options={Array.from(availablePlatforms)} name="platforms" label="Platforms"/>
<DirectoryMappingInput name="directories"/> <DirectoryMappingInput name="directories"/>
@@ -38,12 +38,12 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
const rowsPerPage = 25; const rowsPerPage = 25;
const state = useSnapshot(gameState); const state = useSnapshot(gameState);
const games = state.gamesByLibraryId[library.id] ? state.gamesByLibraryId[library.id] as GameAdminDto[] : []; const games = state.gamesByLibraryId[library.id] ? state.gamesByLibraryId[library.id] : [];
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [filter, setFilter] = useState<"all" | "confirmed" | "nonConfirmed">("all"); const [filter, setFilter] = useState<"all" | "confirmed" | "nonConfirmed">("all");
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({column: "title", direction: "ascending"}); const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({column: "title", direction: "ascending"});
const [selectedGame, setSelectedGame] = useState<GameAdminDto>(games[0]); const [selectedGame, setSelectedGame] = useState<GameAdminDto>(games[0] as GameAdminDto);
const editGameModal = useDisclosure(); const editGameModal = useDisclosure();
const matchGameModal = useDisclosure(); const matchGameModal = useDisclosure();
@@ -94,7 +94,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
function getFilteredGames() { function getFilteredGames() {
let filteredGames = (games as GameAdminDto[]).filter((game) => let filteredGames = (games as GameAdminDto[]).filter((game) =>
game.metadata.path!!.toLowerCase().includes(searchTerm.toLowerCase()) || game.metadata.path!.toLowerCase().includes(searchTerm.toLowerCase()) ||
game.title.toLowerCase().includes(searchTerm.toLowerCase()) || game.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
game.publishers?.some(publisher => publisher.toLowerCase().includes(searchTerm.toLowerCase())) || game.publishers?.some(publisher => publisher.toLowerCase().includes(searchTerm.toLowerCase())) ||
game.developers?.some(developer => developer.toLowerCase().includes(searchTerm.toLowerCase())) game.developers?.some(developer => developer.toLowerCase().includes(searchTerm.toLowerCase()))
@@ -102,10 +102,10 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
if (filter === "confirmed") { if (filter === "confirmed") {
return filteredGames.filter(g => g.metadata.matchConfirmed); return filteredGames.filter(g => g.metadata.matchConfirmed);
} } else if (filter === "nonConfirmed") {
if (filter === "nonConfirmed") {
return filteredGames.filter(g => !g.metadata.matchConfirmed); return filteredGames.filter(g => !g.metadata.matchConfirmed);
} }
return filteredGames; return filteredGames;
} }
@@ -178,7 +178,8 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
<Link href={`/game/${item.id}`} <Link href={`/game/${item.id}`}
color="foreground" color="foreground"
className="text-sm" className="text-sm"
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> <TableCell>
@@ -238,7 +239,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
<EditGameMetadataModal game={selectedGame} <EditGameMetadataModal game={selectedGame}
isOpen={editGameModal.isOpen} isOpen={editGameModal.isOpen}
onOpenChange={editGameModal.onOpenChange}/> onOpenChange={editGameModal.onOpenChange}/>
<MatchGameModal path={selectedGame.metadata.path!!} <MatchGameModal path={selectedGame.metadata.path!}
libraryId={library.id} libraryId={library.id}
replaceGameId={selectedGame.id} replaceGameId={selectedGame.id}
initialSearchTerm={selectedGame.title} initialSearchTerm={selectedGame.title}
@@ -85,7 +85,7 @@ export default function LibraryManagementIgnoredPaths({library}: LibraryManageme
} }
function getFilteredPaths() { function getFilteredPaths() {
return library.ignoredPaths!!.filter((path) => return library.ignoredPaths!.filter((path) =>
path.path.toLowerCase().includes(searchTerm.toLowerCase()) path.path.toLowerCase().includes(searchTerm.toLowerCase())
) )
} }
@@ -165,7 +165,10 @@ export default function LibraryManagementIgnoredPaths({library}: LibraryManageme
</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={() => deleteIgnoredPath(item.path)}><TrashIcon/> onPress={() => deleteIgnoredPath(item.path)}
isDisabled={item.path.sourceType !== IgnoredPathSourceTypeDto.USER}
>
<TrashIcon/>
</Button> </Button>
</Tooltip> </Tooltip>
</div> </div>
@@ -0,0 +1,90 @@
import React from "react";
import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {Form, Formik} from "formik";
import Input from "Frontend/components/general/input/Input";
import {CollectionEndpoint} from "Frontend/generated/endpoints";
import CollectionCreateDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionCreateDto";
import * as Yup from "yup";
import TextAreaInput from "Frontend/components/general/input/TextAreaInput";
interface CollectionCreationModalProps {
isOpen: boolean;
onOpenChange: () => void;
}
export default function CollectionCreationModal({
isOpen,
onOpenChange
}: CollectionCreationModalProps) {
async function createCollection(collection: CollectionCreateDto) {
await CollectionEndpoint.createCollection(collection);
addToast({
title: "New collection created",
description: `Collection ${collection.name} created!`,
color: "success"
});
}
return (<>
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="xl">
<ModalContent>
{(onClose) => (
<Formik
initialValues={{
name: "",
description: ""
}}
validationSchema={Yup.object({
name: Yup.string()
.required("Collection name is required")
.max(255, "Collection name must be 255 characters or less")
})}
isInitialValid={false}
onSubmit={async (values: any) => {
await createCollection(values);
onClose();
}}
>
{(formik) =>
<Form>
<ModalHeader className="flex flex-col gap-1">Create a new collection</ModalHeader>
<ModalBody>
<div className="flex flex-col gap-2">
<Input
name="name"
label="Collection Name"
placeholder="Enter collection name"
value={formik.values.name}
required
/>
<TextAreaInput
name="description"
label="Collection Description"
placeholder="Enter collection description"
value={formik.values.description}
/>
</div>
</ModalBody>
<ModalFooter className="flex flex-row justify-end">
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary"
isLoading={formik.isSubmitting}
isDisabled={formik.isSubmitting}
type="submit"
>
{formik.isSubmitting ? "" : "Add"}
</Button>
</ModalFooter>
</Form>
}
</Formik>
)}
</ModalContent>
</Modal>
</>
);
}
@@ -0,0 +1,181 @@
import {useSnapshot} from "valtio/react";
import {
Button,
Input,
Link,
Select,
SelectItem,
SortDescriptor,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
Tooltip
} from "@heroui/react";
import React, {useMemo, useState} from "react";
import {GameAdminDto} from "Frontend/dtos/GameDtos";
import {CollectionEndpoint} from "Frontend/generated/endpoints";
import {MinusIcon, PlusIcon} from "@phosphor-icons/react";
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
import {libraryState} from "Frontend/state/LibraryState";
import {gameState} from "Frontend/state/GameState";
import {collectionState} from "Frontend/state/CollectionState";
interface CollectionGamesTableProps {
collectionId: number;
}
export default function CollectionGamesTable({collectionId}: CollectionGamesTableProps) {
const gamesState = useSnapshot(gameState);
const games = gamesState.games as GameAdminDto[];
const librariesState = useSnapshot(libraryState);
const libraries = librariesState.state as Record<number, LibraryAdminDto>;
const collectionsState = useSnapshot(collectionState);
const collection = collectionsState.state[collectionId];
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({column: "path", direction: "ascending"});
const [searchTerm, setSearchTerm] = useState("");
const [filter, setFilter] = useState<"all" | "inCollection" | "notInCollection">("all");
function libraryName(game: GameAdminDto) {
return libraries[game.libraryId]?.name || "Unknown";
}
const gameInCollectionMap = useMemo(() => {
const map = new Map<number, boolean>();
games.forEach(game => {
map.set(game.id, collection.gameIds!.includes(game.id));
});
return map;
}, [games, collection.gameIds]);
function isGameInCollection(game: GameAdminDto) {
return gameInCollectionMap.get(game.id) ?? false;
}
const filteredGames = useMemo(() => {
return games
.filter((game) => game.title.toLowerCase().includes(searchTerm.toLowerCase()))
.filter(game => {
if (filter === "inCollection") {
return isGameInCollection(game);
} else if (filter === "notInCollection") {
return !isGameInCollection(game);
}
return true;
});
}, [games, searchTerm, filter, gameInCollectionMap]);
const sortedGames = useMemo(() => {
return filteredGames
.slice()
.sort((a, b) => {
let cmp: number;
switch (sortDescriptor.column) {
case "title":
cmp = a.title.localeCompare(b.title);
break;
case "library":
cmp = (libraryName(a)).localeCompare(libraryName(b));
break;
default:
cmp = 0;
}
if (sortDescriptor.direction === "descending") {
cmp *= -1;
}
return cmp;
})
.map(game => ({...game, _inCollection: isGameInCollection(game)}));
}, [filteredGames, sortDescriptor, libraries, gameInCollectionMap]);
async function addGameToCollection(game: GameAdminDto) {
await CollectionEndpoint.addGameToCollection(collectionId, game.id);
}
async function removeGameFromCollection(game: GameAdminDto) {
await CollectionEndpoint.removeGameFromCollection(collectionId, game.id);
}
return (
<div className="flex flex-col gap-2">
<div className="flex flex-row gap-2 justify-between">
<Input
className="w-96"
isClearable
placeholder="Search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onClear={() => setSearchTerm("")}
/>
<Select
selectedKeys={[filter]}
disallowEmptySelection
onSelectionChange={keys => setFilter(Array.from(keys)[0] as any)}
className="w-64"
>
<SelectItem key="all">Show all games</SelectItem>
<SelectItem key="inCollection">Show only games in collection</SelectItem>
<SelectItem key="notInCollection">Show only games not in collection</SelectItem>
</Select>
</div>
<Table isStriped isHeaderSticky
sortDescriptor={sortDescriptor}
onSortChange={setSortDescriptor}
classNames={{
base: "h-96"
}}>
<TableHeader>
<TableColumn key="title" allowsSorting>Title</TableColumn>
<TableColumn key="library" allowsSorting>Library</TableColumn>
<TableColumn width={1}>Actions</TableColumn>
</TableHeader>
<TableBody
emptyContent="Your filters did not match any games."
items={sortedGames}>
{(game) => (
// Key includes _inCollection to force re-render when that value changes
<TableRow key={`${game.id}-${game._inCollection}`}>
<TableCell>
<Link href={`/game/${game.id}`}
color="foreground"
className="text-sm"
underline="hover">
{game.title} ({game.release ? new Date(game.release).getFullYear() : "unknown"})
</Link>
</TableCell>
<TableCell>
<Link href={`/administration/games/library/${game.libraryId}`}
color="foreground"
className="text-sm"
underline="hover">
{libraryName(game)}
</Link>
</TableCell>
<TableCell>
<div className="flex flex-row gap-2">
<Tooltip content="Add game to collection">
<Button isIconOnly size="sm"
onPress={() => addGameToCollection(game)}
isDisabled={game._inCollection}>
<PlusIcon/>
</Button>
</Tooltip>
<Tooltip content="Remove game from collection">
<Button isIconOnly size="sm"
onPress={() => removeGameFromCollection(game)}
isDisabled={!game._inCollection}>
<MinusIcon/>
</Button>
</Tooltip>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}
@@ -0,0 +1,42 @@
import React from "react";
import {CollectionEndpoint} from "Frontend/generated/endpoints";
import {useSnapshot} from "valtio/react";
import {collectionState} from "Frontend/state/CollectionState";
import CollectionDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionDto";
import CollectionUpdateDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionUpdateDto";
import PrioritiesModal from "./PrioritiesModal";
interface CollectionPrioritiesModalProps {
isOpen: boolean;
onOpenChange: () => void;
}
export default function CollectionPrioritiesModal({isOpen, onOpenChange}: CollectionPrioritiesModalProps) {
const collections = useSnapshot(collectionState).sorted;
const updateCollections = async (reorderedCollections: any[]) => {
const updateDtos: CollectionUpdateDto[] = reorderedCollections.map((collection, index): CollectionUpdateDto => {
return {
id: collection.id,
metadata: {
displayOnHomepage: collection.metadata!.displayOnHomepage,
displayOrder: index
}
};
});
await CollectionEndpoint.updateCollections(updateDtos);
};
return (
<PrioritiesModal
title="Edit collection order"
subtitle="Collections higher on the list are displayed at the start"
items={collections as CollectionDto[]}
updateItems={updateCollections}
isOpen={isOpen}
onOpenChange={onOpenChange}
/>
);
}
@@ -7,7 +7,6 @@ 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";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
interface GameCoverPickerModalProps { interface GameCoverPickerModalProps {
game: GameDto; game: GameDto;
@@ -110,7 +109,7 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
/> />
<div <div
className="absolute inset-0 flex flex-col gap-4 items-center justify-center opacity-0 group-hover:opacity-100"> className="absolute inset-0 flex flex-col gap-4 items-center justify-center opacity-0 group-hover:opacity-100">
<PluginIcon plugin={state[cover.source] as PluginDto} size={32} <PluginIcon plugin={state[cover.source]} 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>
<ArrowRightIcon/> <ArrowRightIcon/>
@@ -7,7 +7,6 @@ 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";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
interface GameHeaderPickerModalProps { interface GameHeaderPickerModalProps {
game: GameDto; game: GameDto;
@@ -109,7 +108,7 @@ export function GameHeaderPickerModal({game, isOpen, onOpenChange, setHeaderUrl}
/> />
<div <div
className="absolute inset-0 flex flex-col gap-4 items-center justify-center opacity-0 group-hover:opacity-100"> className="absolute inset-0 flex flex-col gap-4 items-center justify-center opacity-0 group-hover:opacity-100">
<PluginIcon plugin={state[header.source] as PluginDto} size={32} <PluginIcon plugin={state[header.source]} 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>
<ArrowRightIcon/> <ArrowRightIcon/>
@@ -1,15 +1,14 @@
import React, {useState} from "react"; import React, {useState} from "react";
import {addToast, Button, Checkbox, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react"; import {addToast, Button, Checkbox, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {Form, Formik} from "formik"; import {Form, Formik} from "formik";
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import {LibraryEndpoint} from "Frontend/generated/endpoints"; import {LibraryEndpoint} from "Frontend/generated/endpoints";
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";
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 ArrayInputAutocomplete from "Frontend/components/general/input/ArrayInputAutocomplete"; import ArrayInputAutocomplete from "Frontend/components/general/input/ArrayInputAutocomplete";
import {useSnapshot} from "valtio/react"; import {useSnapshot} from "valtio/react";
import {platformState} from "Frontend/state/PlatformState"; import {platformState} from "Frontend/state/PlatformState";
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
interface LibraryCreationModalProps { interface LibraryCreationModalProps {
isOpen: boolean; isOpen: boolean;
@@ -24,8 +23,8 @@ export default function LibraryCreationModal({
const [scanAfterCreation, setScanAfterCreation] = useState<boolean>(true); const [scanAfterCreation, setScanAfterCreation] = useState<boolean>(true);
const availablePlatforms = useSnapshot(platformState).available; const availablePlatforms = useSnapshot(platformState).available;
async function createLibrary(library: LibraryDto) { async function createLibrary(library: LibraryAdminDto) {
await LibraryEndpoint.createLibrary(library as LibraryAdminDto, scanAfterCreation); await LibraryEndpoint.createLibrary(library, scanAfterCreation);
addToast({ addToast({
title: "New library created", title: "New library created",
@@ -39,20 +38,25 @@ export default function LibraryCreationModal({
<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: [], platforms: []}} <Formik
validationSchema={Yup.object({ initialValues={{
name: Yup.string() name: "",
.required("Library name is required") directories: [],
.max(255, "Library name must be 255 characters or less"), platforms: []
directories: Yup.array() }}
.of(Yup.object()) validationSchema={Yup.object({
.min(1, "At least one directory is required") name: Yup.string()
})} .required("Library name is required")
isInitialValid={false} .max(255, "Library name must be 255 characters or less"),
onSubmit={async (values: any) => { directories: Yup.array()
await createLibrary(values); .of(Yup.object())
onClose(); .min(1, "At least one directory is required")
}} })}
isInitialValid={false}
onSubmit={async (values: any) => {
await createLibrary(values);
onClose();
}}
> >
{(formik) => {(formik) =>
<Form> <Form>
@@ -0,0 +1,41 @@
import React from "react";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import {useSnapshot} from "valtio/react";
import {libraryState} from "Frontend/state/LibraryState";
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import LibraryUpdateDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryUpdateDto";
import PrioritiesModal from "./PrioritiesModal";
interface LibraryPrioritiesModalProps {
isOpen: boolean;
onOpenChange: () => void;
}
export default function LibraryPrioritiesModal({isOpen, onOpenChange}: LibraryPrioritiesModalProps) {
const libraries = useSnapshot(libraryState).sorted;
const updateLibraries = async (reorderedLibraries: LibraryDto[]) => {
const updateDtos: LibraryUpdateDto[] = reorderedLibraries.map((library, index): LibraryUpdateDto => {
return {
id: library.id,
metadata: {
displayOnHomepage: library.metadata!.displayOnHomepage,
displayOrder: index
}
};
});
await LibraryEndpoint.updateLibraries(updateDtos);
};
return (
<PrioritiesModal
title="Edit library order"
subtitle="Libraries higher on the list are displayed at the start"
items={libraries}
updateItems={updateLibraries}
isOpen={isOpen}
onOpenChange={onOpenChange}
/>
);
}
@@ -19,7 +19,6 @@ import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/G
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 {libraryState} from "Frontend/state/LibraryState"; import {libraryState} from "Frontend/state/LibraryState";
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto"; import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
@@ -129,7 +128,7 @@ export default function MatchGameModal({
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">
{Object.values(item.originalIds).map( {Object.values(item.originalIds).map(
originalId => <PluginIcon originalId => <PluginIcon
plugin={state[originalId.pluginId] as PluginDto}/> plugin={state[originalId.pluginId]}/>
)} )}
</div> </div>
</TableCell> </TableCell>
@@ -1,113 +1,39 @@
import React from "react"; import React from "react";
import {addToast, Button, Chip, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {ListBox, ListBoxItem, useDragAndDrop} from "react-aria-components";
import { CaretUpDownIcon } from "@phosphor-icons/react";
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";
import PrioritiesModal from "./PrioritiesModal";
import {useSnapshot} from "valtio/react";
import {pluginState} from "Frontend/state/PluginState";
interface PluginPrioritiesModalProps { interface PluginPrioritiesModalProps {
plugins: PluginDto[];
isOpen: boolean; isOpen: boolean;
onOpenChange: () => void; onOpenChange: () => void;
type: string;
} }
export default function PluginPrioritiesModal({plugins, isOpen, onOpenChange}: PluginPrioritiesModalProps) { export default function PluginPrioritiesModal({isOpen, onOpenChange, type}: PluginPrioritiesModalProps) {
const plugins = useSnapshot(pluginState).sortedByType[type];
const sortedPlugins = useListData({ const updatePlugins = async (reorderedPlugins: PluginDto[]) => {
initialItems: plugins, // Already sorted in parent const prioritiesMap: Record<string, number> = {};
getKey: (plugin) => plugin.id const totalPlugins = reorderedPlugins.length;
});
let {dragAndDropHooks} = useDragAndDrop({ reorderedPlugins.forEach((plugin, index) => {
getItems: (keys) => // Reverse order: first item gets highest priority
[...keys].map((key) => ({'text/plain': sortedPlugins.getItem(key)!.name})), prioritiesMap[plugin.id] = totalPlugins - index;
onReorder(e) {
if (e.keys.has(e.target.key)) return;
if (e.target.dropPosition === 'before' || e.target.dropPosition === 'on') {
sortedPlugins.moveBefore(e.target.key, e.keys);
} else if (e.target.dropPosition === 'after') {
sortedPlugins.moveAfter(e.target.key, e.keys);
}
// Recalculate priority based on new position (reversed)
sortedPlugins.items.forEach((plugin, index) => {
const reversedPriority = sortedPlugins.items.length - index;
sortedPlugins.update(plugin.id, {...plugin, priority: reversedPriority});
});
}
});
function generatePrioritiesMap(): Record<string, number> {
let map: Record<string, number> = {};
const totalPlugins = sortedPlugins.items.length;
sortedPlugins.items.forEach((plugin, index) => {
map[plugin.id] = totalPlugins - index; // Reverse order
}); });
return map;
}
async function setPluginPriorities(onClose: () => void) { await PluginEndpoint.setPluginPriorities(prioritiesMap);
try { };
const prioritiesMap = generatePrioritiesMap();
await PluginEndpoint.setPluginPriorities(prioritiesMap);
addToast({
title: "Plugin order updated",
description: "Plugin order has been updated successfully.",
color: "success"
});
onClose();
} catch (e) {
addToast({
title: "Error",
description: "An error occurred while updating plugin order.",
color: "warning"
});
}
}
return ( return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg"> <PrioritiesModal
<ModalContent> title="Edit plugin order"
{(onClose) => ( subtitle="Plugins higher on the list are preferred"
<> items={plugins}
<ModalHeader className="flex flex-col gap-1"> updateItems={updatePlugins}
<p>Edit plugin order</p> isOpen={isOpen}
<p className="text-small font-normal">Plugins higher on the list are preferred</p> onOpenChange={onOpenChange}
</ModalHeader> />
<ModalBody>
<ListBox items={sortedPlugins.items}
dragAndDropHooks={dragAndDropHooks}
className="flex flex-col gap-2">
{(plugin: PluginDto) => (
<ListBoxItem
key={plugin.id}
className="flex flex-row p-2 rounded-lg justify-between items-center bg-foreground/5">
<div className="flex flex-row gap-2 items-center">
<Chip size="sm" color="primary">
{sortedPlugins.items.findIndex(p => p.id === plugin.id) + 1}
</Chip>
<p className="font-normal text-small">{plugin.name}</p>
</div>
<CaretUpDownIcon/>
</ListBoxItem>
)}
</ListBox>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary" onPress={() => setPluginPriorities(onClose)}>
Save
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
); );
} }
@@ -0,0 +1,127 @@
import React, {useEffect, useState} from "react";
import {addToast, Button, Chip, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {ListBox, ListBoxItem, useDragAndDrop} from "react-aria-components";
import {CaretUpDownIcon} from "@phosphor-icons/react";
import {useListData} from "@react-stately/data";
export interface PrioritizableItem {
id: number | string;
name: string;
}
interface PrioritiesModalProps<T extends PrioritizableItem> {
title: string;
subtitle: string;
items: T[];
updateItems: (items: T[]) => Promise<void>;
isOpen: boolean;
onOpenChange: () => void;
}
export default function PrioritiesModal<T extends PrioritizableItem>({
items,
isOpen,
onOpenChange,
title,
subtitle,
updateItems
}: PrioritiesModalProps<T>) {
const sortedItems = useListData<T>({
initialItems: items,
getKey: (item) => item.id
});
// Track order changes to trigger re-renders
const [orderVersion, setOrderVersion] = useState(0);
// Update sortedItems when items change
useEffect(() => {
sortedItems.setSelectedKeys(new Set());
sortedItems.items.forEach(item => sortedItems.remove(item.id));
items.forEach(item => sortedItems.append(item));
setOrderVersion(prev => prev + 1);
}, [items]);
let {dragAndDropHooks} = useDragAndDrop({
getItems: (keys) =>
[...keys].map((key) => ({'text/plain': sortedItems.getItem(key)!.name})),
onReorder(e) {
if (e.keys.has(e.target.key)) return;
if (e.target.dropPosition === 'before' || e.target.dropPosition === 'on') {
sortedItems.moveBefore(e.target.key, e.keys);
} else if (e.target.dropPosition === 'after') {
sortedItems.moveAfter(e.target.key, e.keys);
}
// Trigger re-render after reorder
setOrderVersion(prev => prev + 1);
}
});
async function updateItemOrder(onClose: () => void) {
try {
// Pass the reordered items directly to the update function
// The parent component will handle the actual transformation
await updateItems(sortedItems.items);
addToast({
title: "Order updated",
description: "Item order has been updated successfully.",
color: "success"
});
onClose();
} catch (e) {
addToast({
title: "Error",
description: "An error occurred while updating item order.",
color: "warning"
});
}
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<p>{title}</p>
<p className="text-small font-normal">{subtitle}</p>
</ModalHeader>
<ModalBody>
<ListBox items={sortedItems.items}
dragAndDropHooks={dragAndDropHooks}
className="flex flex-col gap-2"
key={orderVersion}>
{(item: T) => (
<ListBoxItem
key={item.id}
className="flex flex-row p-2 rounded-lg justify-between items-center bg-foreground/5">
<div className="flex flex-row gap-2 items-center">
<Chip size="sm" color="primary">
{sortedItems.items.findIndex(p => p.id === item.id) + 1}
</Chip>
<p className="font-normal text-small">{item.name}</p>
</div>
<CaretUpDownIcon/>
</ListBoxItem>
)}
</ListBox>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary" onPress={() => updateItemOrder(onClose)}>
Save
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -1,17 +1,19 @@
import {Button, Tooltip, useDisclosure} from "@heroui/react"; import {Button, Tooltip, useDisclosure} from "@heroui/react";
import { ListNumbersIcon } 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";
import {camelCaseToTitle} from "Frontend/util/utils"; import {camelCaseToTitle} from "Frontend/util/utils";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto"; import {useSnapshot} from "valtio/react";
import {pluginState} from "Frontend/state/PluginState";
interface PluginManagementSectionProps { interface PluginManagementSectionProps {
type: string; type: string;
plugins: PluginDto[];
} }
export function PluginManagementSection({type, plugins = []}: PluginManagementSectionProps) { export function PluginManagementSection({type}: PluginManagementSectionProps) {
const plugins = useSnapshot(pluginState).sortedByType[type];
const pluginPrioritiesModal = useDisclosure(); const pluginPrioritiesModal = useDisclosure();
return ( return (
@@ -40,10 +42,9 @@ export function PluginManagementSection({type, plugins = []}: PluginManagementSe
</div>} </div>}
<PluginPrioritiesModal <PluginPrioritiesModal
key={plugins.map(p => p.id + p.priority).join(',')} // force re-mount if plugin order changes
plugins={[...plugins].sort((a, b) => b.priority - a.priority)}
isOpen={pluginPrioritiesModal.isOpen} isOpen={pluginPrioritiesModal.isOpen}
onOpenChange={pluginPrioritiesModal.onOpenChange} onOpenChange={pluginPrioritiesModal.onOpenChange}
type={type}
/> />
</div>); </div>);
} }
+4
View File
@@ -6,6 +6,10 @@ import {router} from './routes';
const container = document.getElementById('outlet')!; const container = document.getElementById('outlet')!;
const root = createRoot(container); const root = createRoot(container);
declare module 'valtio' {
function useSnapshot<T extends object>(p: T): T
}
root.render( root.render(
<StrictMode> <StrictMode>
<RouterProvider router={router}/> <RouterProvider router={router}/>
+20 -15
View File
@@ -4,10 +4,10 @@ import HomeView from "Frontend/views/HomeView";
import SetupView from "Frontend/views/SetupView"; import SetupView from "Frontend/views/SetupView";
import {ThemeSelector} from "Frontend/components/theming/ThemeSelector"; import {ThemeSelector} from "Frontend/components/theming/ThemeSelector";
import App from "Frontend/App"; import App from "Frontend/App";
import {LibraryManagement} from "Frontend/components/administration/LibraryManagement"; import {GameManagement} from "Frontend/components/administration/GameManagement";
import {UserManagement} from "Frontend/components/administration/UserManagement"; import {UserManagement} from "Frontend/components/administration/UserManagement";
import ProfileManagement from "Frontend/components/administration/ProfileManagement"; import ProfileManagement from "Frontend/components/administration/ProfileManagement";
import {SsoManagement} from "Frontend/components/administration/SsoManagement"; import {SecurityManagement} from "Frontend/components/administration/SecurityManagement";
import {AdministrationView} from "Frontend/views/AdministrationView"; import {AdministrationView} from "Frontend/views/AdministrationView";
import {ProfileView} from "Frontend/views/ProfileView"; import {ProfileView} from "Frontend/views/ProfileView";
import {MessageManagement} from "Frontend/components/administration/MessageManagement"; import {MessageManagement} from "Frontend/components/administration/MessageManagement";
@@ -20,13 +20,14 @@ import {SystemManagement} from "Frontend/components/administration/SystemManagem
import GameView from "Frontend/views/GameView"; import GameView from "Frontend/views/GameView";
import LibraryManagementView from "Frontend/views/LibraryManagementView"; import LibraryManagementView from "Frontend/views/LibraryManagementView";
import SearchView from "Frontend/views/SearchView"; import SearchView from "Frontend/views/SearchView";
import RecentlyAddedView from "Frontend/views/RecentlyAddedView";
import LibraryView from "Frontend/views/LibraryView"; import LibraryView from "Frontend/views/LibraryView";
import {RouterConfigurationBuilder} from "@vaadin/hilla-file-router/runtime.js"; 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"; import {DownloadManagement} from "Frontend/components/administration/DownloadManagement";
import CollectionManagementView from "Frontend/views/CollectionManagementView";
import CollectionView from "Frontend/views/CollectionView";
export const {router, routes} = new RouterConfigurationBuilder() export const {router, routes} = new RouterConfigurationBuilder()
.withReactRoutes([ .withReactRoutes([
@@ -45,11 +46,6 @@ export const {router, routes} = new RouterConfigurationBuilder()
element: <SearchView/>, element: <SearchView/>,
handle: {title: 'Search'} handle: {title: 'Search'}
}, },
{
path: 'recently-added',
element: <RecentlyAddedView/>,
handle: {title: 'Recently Added'}
},
{ {
path: '/requests', path: '/requests',
element: <GameRequestView/>, element: <GameRequestView/>,
@@ -59,6 +55,10 @@ export const {router, routes} = new RouterConfigurationBuilder()
path: 'library/:libraryId', path: 'library/:libraryId',
element: <LibraryView/> element: <LibraryView/>
}, },
{
path: 'collection/:collectionId',
element: <CollectionView/>
},
{ {
path: 'game/:gameId', path: 'game/:gameId',
element: <GameView/> element: <GameView/>
@@ -86,15 +86,20 @@ export const {router, routes} = new RouterConfigurationBuilder()
handle: {title: 'Administration'}, handle: {title: 'Administration'},
children: [ children: [
{ {
path: 'libraries', path: 'games',
element: <LibraryManagement/>, element: <GameManagement/>,
handle: {title: 'Administration - Libraries'} handle: {title: 'Administration - Games'}
}, },
{ {
path: 'libraries/library/:libraryId', path: 'games/library/:libraryId',
element: <LibraryManagementView/>, element: <LibraryManagementView/>,
handle: {title: 'Administration - Library'} handle: {title: 'Administration - Library'}
}, },
{
path: 'games/collection/:collectionId',
element: <CollectionManagementView/>,
handle: {title: 'Administration - Collection'}
},
{ {
path: 'requests', path: 'requests',
element: <GameRequestManagement/>, element: <GameRequestManagement/>,
@@ -111,9 +116,9 @@ export const {router, routes} = new RouterConfigurationBuilder()
handle: {title: 'Administration - Users'} handle: {title: 'Administration - Users'}
}, },
{ {
path: 'sso', path: 'security',
element: <SsoManagement/>, element: <SecurityManagement/>,
handle: {title: 'Administration - SSO'} handle: {title: 'Administration - Security'}
}, },
{ {
path: 'messages', path: 'messages',
@@ -0,0 +1,70 @@
import {Subscription} from "@vaadin/hilla-frontend";
import {proxy} from "valtio/index";
import {CollectionEndpoint} from "Frontend/generated/endpoints";
import CollectionDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionDto";
import CollectionEvent from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionEvent";
type CollectionState = {
subscription?: Subscription<CollectionEvent[]>;
isLoaded: boolean;
state: Record<number, CollectionDto>;
collections: CollectionDto[];
sorted: CollectionDto[];
};
export const collectionState = proxy<CollectionState>({
get isLoaded() {
return this.subscription != null;
},
state: {},
get collections() {
return Object.values<CollectionDto>(this.state);
},
get sorted() {
return Object.values<CollectionDto>(this.state).sort((a: any, b: any) => {
const orderA = a.metadata?.displayOrder ?? -1;
const orderB = b.metadata?.displayOrder ?? -1;
// Handle -1 as "end of list"
const effectiveOrderA = orderA === -1 ? Number.MAX_SAFE_INTEGER : orderA;
const effectiveOrderB = orderB === -1 ? Number.MAX_SAFE_INTEGER : orderB;
const orderDiff = effectiveOrderA - effectiveOrderB;
if (orderDiff !== 0) {
return orderDiff;
}
// Fallback to creation date (newer first)
return new Date(a.createdAt!).getTime() - new Date(b.createdAt!).getTime();
});
}
});
/** Subscribe to and process state updates from backend **/
export async function initializeCollectionState() {
if (collectionState.isLoaded) return;
// Fetch initial collection list
const initialEntries = await CollectionEndpoint.getAll();
initialEntries.forEach((collection: CollectionDto) => {
collectionState.state[collection.id] = collection;
});
// Subscribe to real-time updates
collectionState.subscription = CollectionEndpoint.subscribeToCollectionEvents().onNext((collectionEvents: CollectionEvent[]) => {
collectionEvents.forEach((collectionEvent: CollectionEvent) => {
switch (collectionEvent.type) {
case "created":
case "updated":
//@ts-ignore
collectionState.state[collectionEvent.collection.id] = collectionEvent.collection;
break;
case "deleted":
//@ts-ignore
delete collectionState.state[collectionEvent.collectionId];
break;
}
})
});
}
+21 -14
View File
@@ -11,10 +11,10 @@ type GameState = {
state: Record<number, GameDto>; state: Record<number, GameDto>;
games: GameDto[]; games: GameDto[];
gamesByLibraryId: Record<number, GameDto[]>; gamesByLibraryId: Record<number, GameDto[]>;
gamesByCollectionId: Record<number, GameDto[]>;
sortedAlphabetically: GameDto[]; sortedAlphabetically: GameDto[];
recentlyAdded: GameDto[];
recentlyUpdated: GameDto[];
randomlyOrderedGamesByLibraryId: Record<number, GameDto[]>; randomlyOrderedGamesByLibraryId: Record<number, GameDto[]>;
randomlyOrderedGamesByCollectionId: Record<number, GameDto[]>;
knownPublishers: Set<string>; knownPublishers: Set<string>;
knownDevelopers: Set<string>; knownDevelopers: Set<string>;
knownGenres: Set<string>; knownGenres: Set<string>;
@@ -38,26 +38,33 @@ export const gameState = proxy<GameState>({
return acc; return acc;
}, {}); }, {});
}, },
get gamesByCollectionId() {
return this.sortedAlphabetically.reduce((acc: Record<number, GameDto[]>, game: GameDto) => {
game.collectionIds?.forEach((collectionId: number) => {
(acc[collectionId] ||= []).push(game);
});
return acc;
}, {});
},
get sortedAlphabetically() { get sortedAlphabetically() {
return this.games return this.games
.sort((a: GameDto, b: GameDto) => a.title.localeCompare(b.title, undefined, {sensitivity: 'base'})); .sort((a: GameDto, b: GameDto) => a.title.localeCompare(b.title, undefined, {sensitivity: 'base'}));
}, },
get recentlyAdded() {
return this.games
.sort((a: GameDto, b: GameDto) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 25);
},
get recentlyUpdated() {
return this.games
.sort((a: GameDto, b: GameDto) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
.slice(0, 25);
},
get randomlyOrderedGamesByLibraryId() { get randomlyOrderedGamesByLibraryId() {
const result: Record<number, GameDto[]> = {}; const result: Record<number, GameDto[]> = {};
for (const libraryId in this.gamesByLibraryId) { for (const libraryId in this.gamesByLibraryId) {
const rand = new Rand(libraryId.toString()); const rand = new Rand(`library-${libraryId}`);
result[libraryId] = this.gamesByLibraryId[libraryId] result[libraryId] = this.gamesByLibraryId[libraryId]
.filter((g: GameDto) => g.coverId && g.imageIds && g.imageIds.length > 0) .sort((a: GameDto, b: GameDto) => a.id - b.id)
.sort(() => rand.next() - 0.5);
}
return result;
},
get randomlyOrderedGamesByCollectionId() {
const result: Record<number, GameDto[]> = {};
for (const collectionId in this.gamesByCollectionId) {
const rand = new Rand(`collection-${collectionId}`);
result[collectionId] = this.gamesByCollectionId[collectionId]
.sort((a: GameDto, b: GameDto) => a.id - b.id) .sort((a: GameDto, b: GameDto) => a.id - b.id)
.sort(() => rand.next() - 0.5); .sort(() => rand.next() - 0.5);
} }
+14 -2
View File
@@ -23,8 +23,20 @@ export const libraryState = proxy<LibraryState>({
}, },
get sorted() { get sorted() {
return Object.values<LibraryDto>(this.state).sort((a, b) => { return Object.values<LibraryDto>(this.state).sort((a, b) => {
if (a.name === undefined || b.name === undefined) return 0; const orderA = a.metadata!.displayOrder;
return a.name.localeCompare(b.name); const orderB = b.metadata!.displayOrder;
// Handle -1 as "end of list"
const effectiveOrderA = orderA === -1 ? Number.MAX_SAFE_INTEGER : orderA;
const effectiveOrderB = orderB === -1 ? Number.MAX_SAFE_INTEGER : orderB;
const orderDiff = effectiveOrderA - effectiveOrderB;
if (orderDiff !== 0) {
return orderDiff;
}
// Fallback to creation date (newer first)
return new Date(a.createdAt!).getTime() - new Date(b.createdAt!).getTime();
}); });
} }
}); });
+9 -4
View File
@@ -9,7 +9,7 @@ type PluginState = {
isLoaded: boolean; isLoaded: boolean;
state: Record<string, PluginDto>; state: Record<string, PluginDto>;
plugins: PluginDto[]; plugins: PluginDto[];
pluginsByType: Record<string, PluginDto[]>; sortedByType: Record<string, PluginDto[]>;
}; };
export const pluginState = proxy<PluginState>({ export const pluginState = proxy<PluginState>({
@@ -20,8 +20,8 @@ export const pluginState = proxy<PluginState>({
get plugins() { get plugins() {
return Object.values<PluginDto>(this.state); return Object.values<PluginDto>(this.state);
}, },
get pluginsByType() { get sortedByType() {
return groupPluginsByType(this.state); return sortPluginsByType(this.state);
} }
}); });
@@ -52,7 +52,7 @@ export async function initializePluginState() {
/** Computed **/ /** Computed **/
function groupPluginsByType(pluginsMap: Record<string, PluginDto>): Record<string, PluginDto[]> { function sortPluginsByType(pluginsMap: Record<string, PluginDto>): Record<string, PluginDto[]> {
const pluginsByType: Record<string, PluginDto[]> = {}; const pluginsByType: Record<string, PluginDto[]> = {};
// Convert map to array of plugins // Convert map to array of plugins
@@ -72,5 +72,10 @@ function groupPluginsByType(pluginsMap: Record<string, PluginDto>): Record<strin
} }
} }
// Sort plugins within each type by priority (descending order - higher priority first)
for (const type in pluginsByType) {
pluginsByType[type].sort((a, b) => b.priority - a.priority);
}
return pluginsByType; return pluginsByType;
} }
+2 -3
View File
@@ -1,6 +1,5 @@
import {Middleware, MiddlewareContext, MiddlewareNext} from '@vaadin/hilla-frontend'; import {Middleware, MiddlewareContext, MiddlewareNext} from '@vaadin/hilla-frontend';
import {addToast} from "@heroui/react"; import {addToast} from "@heroui/react";
import {getReasonPhrase} from "http-status-codes";
export const ErrorHandlingMiddleware: Middleware = async function ( export const ErrorHandlingMiddleware: Middleware = async function (
context: MiddlewareContext, context: MiddlewareContext,
@@ -22,13 +21,13 @@ export const ErrorHandlingMiddleware: Middleware = async function (
if (json.type == "dev.hilla.exception.EndpointException" || json.type == "com.vaadin.hilla.exception.EndpointException") { if (json.type == "dev.hilla.exception.EndpointException" || json.type == "com.vaadin.hilla.exception.EndpointException") {
addToast({ addToast({
title: getReasonPhrase(response.status), title: "Error",
description: json.message, description: json.message,
color: "danger" color: "danger"
}) })
} else { } else {
addToast({ addToast({
title: getReasonPhrase(response.status), title: "Error",
description: `${endpoint}.${method}`, description: `${endpoint}.${method}`,
color: "danger" color: "danger"
}) })
@@ -13,8 +13,8 @@ import withSideMenu, {MenuItem} from "Frontend/components/general/withSideMenu";
const menuItems: MenuItem[] = [ const menuItems: MenuItem[] = [
{ {
title: "Libraries", title: "Games",
url: "libraries", url: "games",
icon: <GameControllerIcon/> icon: <GameControllerIcon/>
}, },
{ {
@@ -33,8 +33,8 @@ const menuItems: MenuItem[] = [
icon: <UsersIcon/> icon: <UsersIcon/>
}, },
{ {
title: "SSO", title: "Security",
url: "sso", url: "security",
icon: <LockKeyIcon/> icon: <LockKeyIcon/>
}, },
{ {
@@ -0,0 +1,127 @@
import {useNavigate, useParams} from "react-router";
import React, {useEffect} from "react";
import {addToast, Button} from "@heroui/react";
import {ArrowLeftIcon, CheckIcon} from "@phosphor-icons/react";
import {useSnapshot} from "valtio/react";
import CollectionAdminDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionAdminDto";
import {collectionState} from "Frontend/state/CollectionState";
import {Form, Formik} from "formik";
import * as Yup from "yup";
import Input from "Frontend/components/general/input/Input";
import Section from "Frontend/components/general/Section";
import {deepDiff} from "Frontend/util/utils";
import {CollectionEndpoint} from "Frontend/generated/endpoints";
import CollectionUpdateDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionUpdateDto";
import TextAreaInput from "Frontend/components/general/input/TextAreaInput";
import CollectionHeader from "Frontend/components/general/covers/CollectionHeader";
import CollectionGamesTable from "Frontend/components/general/modals/CollectionGamesTable";
import CheckboxInput from "Frontend/components/general/input/CheckboxInput";
export default function CollectionManagementView() {
const {collectionId} = useParams();
const navigate = useNavigate();
const [collectionSaved, setCollectionSaved] = React.useState(false);
const collections = useSnapshot(collectionState);
// Parse and validate collectionId early
const collectionIdNum = collectionId ? parseInt(collectionId) : null;
// Early return if invalid collection ID
useEffect(() => {
if (!collectionIdNum || (collections.isLoaded && !collections.state[collectionIdNum])) {
navigate("/administration/games");
}
}, [collections, collectionIdNum, navigate]);
// If collectionId is invalid, return null (will redirect via useEffect)
if (!collectionIdNum) {
return null;
}
// At this point, collectionIdNum is guaranteed to be a number
const collection = collections.state[collectionIdNum] as CollectionAdminDto;
async function handleSubmit(values: CollectionUpdateDto): Promise<void> {
const changed = deepDiff(collection, values) as CollectionUpdateDto;
if (Object.keys(changed).length === 0) return;
changed.id = collection.id;
await CollectionEndpoint.updateCollection(changed);
setCollectionSaved(true);
setTimeout(() => setCollectionSaved(false), 2000);
}
async function deleteCollection(): Promise<void> {
try {
await CollectionEndpoint.deleteCollection(collection.id);
addToast({
title: "Collection deleted",
description: `Collection ${collection.name} deleted!`,
color: "success"
});
navigate("/administration/games");
} catch (e) {
addToast({
title: "Error deleting collection",
description: `Collection ${collection.name} could not be deleted!`,
color: "warning"
});
}
}
return collection && (
<div className="flex flex-col gap-4">
<div className="flex flex-row gap-4 items-center">
<Button isIconOnly variant="light" onPress={() => history.back()}>
<ArrowLeftIcon/>
</Button>
<h1 className="text-2xl font-bold">Manage Collection</h1>
</div>
<CollectionHeader collection={collection} className="h-32"/>
<Formik
initialValues={collection}
onSubmit={handleSubmit}
enableReinitialize={true}
validationSchema={Yup.object({
name: Yup.string()
.required("Collection name is required")
.max(255, "Collection name must be 255 characters or less")
})}
>
{(formik) => (
<Form>
<div className="flex flex-row grow justify-between mb-4">
<h1 className="text-2xl font-bold">Edit collection details</h1>
<Button
color="primary"
isLoading={formik.isSubmitting}
isDisabled={formik.isSubmitting || collectionSaved || !formik.dirty}
type="submit"
>
{formik.isSubmitting ? "" : collectionSaved ? <CheckIcon/> : "Save"}
</Button>
</div>
<Input label="Collection name" name="name"/>
<TextAreaInput label="Collection description" name="description"/>
<CheckboxInput label="Display on homepage" name="metadata.displayOnHomepage" className="mb-4"/>
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-bold">Manage games in collection</h1>
<CollectionGamesTable collectionId={collectionIdNum}/>
</div>
<Section title="Danger zone"/>
<Button color="danger" onPress={deleteCollection}>
Delete collection
</Button>
</Form>
)}
</Formik>
</div>
);
}
@@ -0,0 +1,32 @@
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import React, {useEffect} from "react";
import {useNavigate, useParams} from "react-router";
import CoverGrid from "Frontend/components/general/covers/CoverGrid";
import {collectionState} from "Frontend/state/CollectionState";
export default function CollectionView() {
const {collectionId} = useParams();
const navigate = useNavigate();
const collections = useSnapshot(collectionState);
const games = collectionId ? useSnapshot(gameState).gamesByCollectionId[parseInt(collectionId!)] || [] : [];
useEffect(() => {
window.scrollTo(0, 0)
}, [])
useEffect(() => {
if (collections.isLoaded && (!collectionId || !collections.state[parseInt(collectionId)])) {
navigate("/", {replace: true});
}
document.title = collections.state[parseInt(collectionId!)]?.name || "Gameyfin";
}, [collectionId, collections]);
return (
<div className="flex flex-col gap-6">
<p className="text-4xl font-bold text-center">{collections.state[parseInt(collectionId!)]?.name}</p>
<CoverGrid games={games}/>
{games.length === 0 && <p className="text-center text-gray-500">This collection is empty.</p>}
</div>
);
}
+42 -19
View File
@@ -24,8 +24,9 @@ import EditGameMetadataModal from "Frontend/components/general/modals/EditGameMe
import GameUpdateDto from "Frontend/generated/org/gameyfin/app/games/dto/GameUpdateDto"; import GameUpdateDto from "Frontend/generated/org/gameyfin/app/games/dto/GameUpdateDto";
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 ChipList from "Frontend/components/general/ChipList"; import ChipList from "Frontend/components/general/ChipList";
import {collectionState} from "Frontend/state/CollectionState";
import {GameMetadataAdminDto} from "Frontend/dtos/GameDtos";
export default function GameView() { export default function GameView() {
const {gameId} = useParams(); const {gameId} = useParams();
@@ -37,7 +38,8 @@ export default function GameView() {
const matchGameModal = useDisclosure(); const matchGameModal = useDisclosure();
const state = useSnapshot(gameState); const state = useSnapshot(gameState);
const game = gameId ? state.state[parseInt(gameId)] as GameAdminDto : undefined; const game = gameId ? state.state[parseInt(gameId)] : undefined;
const collections = useSnapshot(collectionState).state;
const [downloadOptions, setDownloadOptions] = useState<Record<string, ComboButtonOption>>(); const [downloadOptions, setDownloadOptions] = useState<Record<string, ComboButtonOption>>();
@@ -69,7 +71,7 @@ export default function GameView() {
await GameEndpoint.updateGame( await GameEndpoint.updateGame(
{ {
id: game.id, id: game.id,
metadata: {matchConfirmed: !game.metadata.matchConfirmed} metadata: {matchConfirmed: !(game.metadata as GameMetadataAdminDto).matchConfirmed}
} as GameUpdateDto } as GameUpdateDto
) )
} }
@@ -87,17 +89,17 @@ export default function GameView() {
return game && ( return game && (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="overflow-hidden relative rounded-t-lg"> <div className="overflow-hidden relative rounded-t-lg">
{game.headerId ? ( {game.header?.id ? (
<img <img
className="w-full h-96 object-cover brightness-50 blur-sm scale-110" className="w-full h-96 object-cover brightness-50 blur-sm scale-110"
alt="Game header" alt="Game header"
src={`/images/header/${game.headerId}`} src={`/images/header/${game.header?.id}`}
/> />
) : game.imageIds && game.imageIds.length > 0 ? ( ) : game.images && game.images.length > 0 ? (
<img <img
className="w-full h-96 object-cover brightness-50 blur-sm scale-110" className="w-full h-96 object-cover brightness-50 blur-sm scale-110"
alt="Game screenshot" alt="Game screenshot"
src={`/images/screenshot/${game.imageIds[0]}`} src={`/images/screenshot/${game.images[0].id}`}
/> />
) : ( ) : (
<div className="w-full h-96 bg-secondary relative"/> <div className="w-full h-96 bg-secondary relative"/>
@@ -137,7 +139,7 @@ export default function GameView() {
<div className="flex flex-row items-center gap-8"> <div className="flex flex-row items-center gap-8">
{isAdmin(auth) && <div className="flex flex-row gap-2"> {isAdmin(auth) && <div className="flex flex-row gap-2">
<Button isIconOnly onPress={toggleMatchConfirmed}> <Button isIconOnly onPress={toggleMatchConfirmed}>
{game.metadata.matchConfirmed ? {(game.metadata as GameMetadataAdminDto).matchConfirmed ?
<Tooltip content="Unconfirm match"> <Tooltip content="Unconfirm match">
<CheckCircleIcon weight="fill" className="fill-success"/> <CheckCircleIcon weight="fill" className="fill-success"/>
</Tooltip> : </Tooltip> :
@@ -220,7 +222,7 @@ export default function GameView() {
color="foreground" underline="hover"> color="foreground" underline="hover">
{dev} {dev}
</Link> </Link>
{index !== game.developers!!.length - 1 && <p>/</p>} {index !== game.developers!.length - 1 && <p>/</p>}
</> </>
) )
: <Tooltip content="Missing data" color="foreground" placement="right"> : <Tooltip content="Missing data" color="foreground" placement="right">
@@ -295,6 +297,25 @@ export default function GameView() {
} }
</td> </td>
</tr> </tr>
{game.collectionIds.length > 0 &&
<tr>
<td className="text-default-500 w-0 min-w-32">Collections</td>
<td className="flex flex-row gap-1">
{[...game.collectionIds]
.map((collectionId) => collections[collectionId])
.sort((a, b) => a.id - b.id)
.map((collection, index) =>
<>
<Link key={collection.id} href={`/collection/${collection.id}`}
color="foreground" underline="hover">
{collection.name}
</Link>
{index !== game.collectionIds!.length - 1 && <p>/</p>}
</>
)}
</td>
</tr>
}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -302,22 +323,24 @@ export default function GameView() {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<p className="text-default-500">Media</p> <p className="text-default-500">Media</p>
<ImageCarousel <ImageCarousel
imageUrls={game.imageIds?.map(id => `/images/screenshot/${id}`)} imageUrls={game.images?.map(image => `/images/screenshot/${image.id}`)}
videosUrls={game.videoUrls} videosUrls={game.videoUrls}
className="-mx-24" className="-mx-24"
/> />
</div> </div>
</div> </div>
</div> </div>
<EditGameMetadataModal game={game} {isAdmin(auth) && <>
isOpen={editGameModal.isOpen} <EditGameMetadataModal game={game}
onOpenChange={editGameModal.onOpenChange}/> isOpen={editGameModal.isOpen}
<MatchGameModal path={game.metadata.path!!} onOpenChange={editGameModal.onOpenChange}/>
libraryId={game.libraryId} <MatchGameModal path={(game.metadata as GameMetadataAdminDto).path!}
replaceGameId={game.id} libraryId={game.libraryId}
initialSearchTerm={game.title} replaceGameId={game.id}
isOpen={matchGameModal.isOpen} initialSearchTerm={game.title}
onOpenChange={matchGameModal.onOpenChange}/> isOpen={matchGameModal.isOpen}
onOpenChange={matchGameModal.onOpenChange}/>
</>}
</div> </div>
); );
} }
+62 -10
View File
@@ -1,26 +1,78 @@
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {CoverRow} from "Frontend/components/general/covers/CoverRow"; import {CoverRow} from "Frontend/components/general/covers/CoverRow";
import {useSnapshot} from "valtio/react"; import {useSnapshot} from "valtio/react";
import {libraryState} from "Frontend/state/LibraryState"; import {libraryState} from "Frontend/state/LibraryState";
import {gameState} from "Frontend/state/GameState"; import {gameState} from "Frontend/state/GameState";
import {useNavigate} from "react-router"; import React, {useEffect, useState} from "react";
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import {collectionState} from "Frontend/state/CollectionState";
import CollectionDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionDto";
import {StartPageDisplayCard} from "Frontend/components/general/cards/StartPageDisplayCard";
import {Link} from "@heroui/react";
import {CaretRightIcon} from "@phosphor-icons/react";
export default function HomeView() { export default function HomeView() {
const navigate = useNavigate();
const librariesState = useSnapshot(libraryState); const librariesState = useSnapshot(libraryState);
const collectionsState = useSnapshot(collectionState);
const gamesState = useSnapshot(gameState); const gamesState = useSnapshot(gameState);
const recentlyAddedGames = gamesState.recentlyAdded as GameDto[]; const gamesByLibrary = gamesState.gamesByLibraryId;
const gamesByLibrary = gamesState.gamesByLibraryId as Record<number, GameDto[]>; const gamesByCollection = gamesState.gamesByCollectionId;
const [filteredAndSortedLibraries, setFilteredAndSortedLibraries] = useState<LibraryDto[]>([]);
const [filteredAndSortedCollections, setFilteredAndSortedCollections] = useState<CollectionDto[]>([]);
useEffect(() => {
const libraries = librariesState.sorted
.filter(library => library.metadata!.displayOnHomepage)
.filter(library =>
gamesByLibrary[library.id] && gamesByLibrary[library.id].length > 0
);
setFilteredAndSortedLibraries(libraries);
const collections = collectionsState.sorted
.filter(collection => collection.metadata!.displayOnHomepage)
.filter(collection =>
gamesByCollection[collection.id] && gamesByCollection[collection.id].length > 0
);
setFilteredAndSortedCollections(collections);
}, [librariesState.sorted, collectionsState.sorted, gamesByLibrary, gamesByCollection]);
return ( return (
<div className="w-full"> <div className="w-full">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-4">
<CoverRow title="Recently added" games={recentlyAddedGames} {(filteredAndSortedLibraries.length + filteredAndSortedCollections.length > 0) &&
onPressShowMore={() => navigate("/recently-added")}/> <div className="flex flex-col gap-2">
{librariesState.libraries.map((library) => ( <Link href="/search" className="flex flex-row gap-1 w-fit items-baseline" color="foreground"
underline="hover">
<p className="text-2xl font-bold mb-4">Your games</p>
<CaretRightIcon weight="bold" size={16}/>
</Link>
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(353px,1fr))]">
{filteredAndSortedLibraries.length > 0 &&
filteredAndSortedLibraries.map((library: LibraryDto) => (
<StartPageDisplayCard key={library.id} item={library}/>
))
}
{filteredAndSortedCollections.length > 0 &&
filteredAndSortedCollections.map((collection: CollectionDto) => (
<StartPageDisplayCard key={collection.id} item={collection}/>
))
}
</div>
</div>
}
{filteredAndSortedLibraries.map((library) => (
<CoverRow key={library.id} title={library.name} <CoverRow key={library.id} title={library.name}
games={gamesByLibrary[library.id] || []} games={gamesByLibrary[library.id] || []}
onPressShowMore={() => navigate("/library/" + library.id)} link={"/library/" + library.id}
/>
))}
{filteredAndSortedCollections.map((collection) => (
<CoverRow key={collection.id} title={collection.name}
games={gamesByCollection[collection.id] || []}
link={"/collection/" + collection.id}
/> />
))} ))}
</div> </div>
@@ -19,18 +19,18 @@ export default function LibraryManagementView() {
useEffect(() => { useEffect(() => {
if (state.isLoaded && (!libraryId || !state.state[parseInt(libraryId)])) { if (state.isLoaded && (!libraryId || !state.state[parseInt(libraryId)])) {
navigate("/administration/libraries"); navigate("/administration/games");
} }
}, [state, libraryId]); }, [state, libraryId]);
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={() => history.back()}>
<ArrowLeftIcon/> <ArrowLeftIcon/>
</Button> </Button>
<h1 className="text-2xl font-bold">Manage library</h1> <h1 className="text-2xl font-bold">Manage library</h1>
</div> </div>
<LibraryHeader library={state.state[parseInt(libraryId)] as LibraryAdminDto} className="h-32"/> <LibraryHeader library={state.state[parseInt(libraryId)]} className="h-32"/>
<Tabs color="primary" fullWidth <Tabs color="primary" fullWidth
selectedKey={hash.length > 0 ? hash : "#details"} selectedKey={hash.length > 0 ? hash : "#details"}
onSelectionChange={(newKey) => navigate(newKey.toString(), {replace: true})}> onSelectionChange={(newKey) => navigate(newKey.toString(), {replace: true})}>
+8 -4
View File
@@ -4,25 +4,29 @@ import {gameState} from "Frontend/state/GameState";
import React, {useEffect} from "react"; import React, {useEffect} from "react";
import {useNavigate, useParams} from "react-router"; import {useNavigate, useParams} from "react-router";
import CoverGrid from "Frontend/components/general/covers/CoverGrid"; import CoverGrid from "Frontend/components/general/covers/CoverGrid";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
export default function LibraryView() { export default function LibraryView() {
const {libraryId} = useParams(); const {libraryId} = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const libraries = useSnapshot(libraryState); const libraries = useSnapshot(libraryState);
const games = (libraryId ? useSnapshot(gameState).gamesByLibraryId[parseInt(libraryId!!)] || [] : []) as GameDto[]; const games = useSnapshot(gameState).gamesByLibraryId[parseInt(libraryId!)] || [];
useEffect(() => {
window.scrollTo(0, 0)
}, [])
useEffect(() => { useEffect(() => {
if (libraries.isLoaded && (!libraryId || !libraries.state[parseInt(libraryId)])) { if (libraries.isLoaded && (!libraryId || !libraries.state[parseInt(libraryId)])) {
navigate("/", {replace: true}); navigate("/", {replace: true});
} }
document.title = libraries.state[parseInt(libraryId!!)]?.name || "Gameyfin"; document.title = libraries.state[parseInt(libraryId!)]?.name || "Gameyfin";
}, [libraryId, libraries]); }, [libraryId, libraries]);
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<p className="text-4xl font-bold text-center">{libraries.state[parseInt(libraryId!!)]?.name}</p> <p className="text-4xl font-bold text-center">{libraries.state[parseInt(libraryId!)]?.name}</p>
<CoverGrid games={games}/> <CoverGrid games={games}/>
{games.length === 0 && <p className="text-center text-gray-500">This library is empty.</p>}
</div> </div>
); );
} }
@@ -1,16 +0,0 @@
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import React from "react";
import CoverGrid from "Frontend/components/general/covers/CoverGrid";
export default function RecentlyAddedView() {
const games = useSnapshot(gameState).recentlyAdded as GameDto[];
return (
<div className="flex flex-col gap-4">
<p className="text-4xl font-bold text-center">Recently added</p>
<CoverGrid games={games}/>
</div>
);
}
+6 -4
View File
@@ -13,19 +13,18 @@ import {useSearchParams} from "react-router";
import React, {useEffect, useMemo, useState} from "react"; import React, {useEffect, useMemo, useState} from "react";
import {Fzf} from "fzf"; 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 CoverGrid from "Frontend/components/general/covers/CoverGrid"; import CoverGrid from "Frontend/components/general/covers/CoverGrid";
import {compoundRating} 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;
const knownDevelopers = useSnapshot(gameState).knownDevelopers as Set<string>; const knownDevelopers = useSnapshot(gameState).knownDevelopers;
const knownGenres = useSnapshot(gameState).knownGenres; const knownGenres = useSnapshot(gameState).knownGenres;
const knownThemes = useSnapshot(gameState).knownThemes; const knownThemes = useSnapshot(gameState).knownThemes;
const knownFeatures = useSnapshot(gameState).knownFeatures; const knownFeatures = useSnapshot(gameState).knownFeatures;
const knownPerspectives = useSnapshot(gameState).knownPerspectives; const knownPerspectives = useSnapshot(gameState).knownPerspectives;
const knownKeywords = useSnapshot(gameState).knownKeywords; const knownKeywords = useSnapshot(gameState).knownKeywords;
const libraries = useSnapshot(libraryState).libraries as LibraryDto[]; const libraries = useSnapshot(libraryState).libraries;
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [initialLoadComplete, setInitialLoadComplete] = useState(false); const [initialLoadComplete, setInitialLoadComplete] = useState(false);
@@ -46,6 +45,9 @@ export default function SearchView() {
// Load initial filter values from URL parameters on component mount // Load initial filter values from URL parameters on component mount
useEffect(() => { useEffect(() => {
// Scroll to top on load
window.scrollTo(0, 0)
// Get all parameters from the URL // Get all parameters from the URL
const term = searchParams.get("term") || ""; const term = searchParams.get("term") || "";
const libs = searchParams.getAll("lib"); const libs = searchParams.getAll("lib");
@@ -0,0 +1,62 @@
package org.gameyfin.app.collections
import com.vaadin.flow.server.auth.AnonymousAllowed
import com.vaadin.hilla.Endpoint
import jakarta.annotation.security.RolesAllowed
import org.gameyfin.app.collections.dto.*
import org.gameyfin.app.collections.extensions.toAdminDto
import org.gameyfin.app.collections.extensions.toDto
import org.gameyfin.app.collections.extensions.toUserDto
import org.gameyfin.app.core.Role
import org.gameyfin.app.core.annotations.DynamicPublicAccess
import org.gameyfin.app.core.security.isCurrentUserAdmin
import reactor.core.publisher.Flux
@Endpoint
@DynamicPublicAccess
@AnonymousAllowed
class CollectionEndpoint(
private val collectionService: CollectionService
) {
fun subscribeToCollectionEvents(): Flux<out List<CollectionEvent>> {
return if (isCurrentUserAdmin()) {
CollectionService.subscribeAdmin()
} else {
CollectionService.subscribeUser()
}
}
fun getAll(): List<CollectionDto> = collectionService.getAll()
fun getById(id: Long): CollectionDto = collectionService.getById(id).toDto()
@RolesAllowed(Role.Names.ADMIN)
fun createCollection(dto: CollectionCreateDto) = collectionService.create(dto)
@RolesAllowed(Role.Names.ADMIN)
fun updateCollection(dto: CollectionUpdateDto) = collectionService.update(dto)
@RolesAllowed(Role.Names.ADMIN)
fun updateCollections(collections: List<CollectionUpdateDto>) = collectionService.update(collections)
@RolesAllowed(Role.Names.ADMIN)
fun addGameToCollection(collectionId: Long, gameId: Long) =
collectionService.addGame(collectionId, gameId)
@RolesAllowed(Role.Names.ADMIN)
fun removeGameFromCollection(collectionId: Long, gameId: Long) =
collectionService.removeGame(collectionId, gameId)
@RolesAllowed(Role.Names.ADMIN)
fun deleteCollection(collectionId: Long) = collectionService.delete(collectionId)
/* Unused endpoints for Hilla to generate typescript classes */
@Suppress("Unused", "FunctionName")
@RolesAllowed(Role.Names.ADMIN)
fun _getAdminDto(id: Long): CollectionAdminDto = collectionService.getById(id).toAdminDto()
@Suppress("Unused", "FunctionName")
@RolesAllowed(Role.Names.ADMIN)
fun _getUserDto(id: Long): CollectionUserDto = collectionService.getById(id).toUserDto()
}
@@ -0,0 +1,158 @@
package org.gameyfin.app.collections
import io.github.oshai.kotlinlogging.KotlinLogging
import org.gameyfin.app.collections.dto.*
import org.gameyfin.app.collections.entities.Collection
import org.gameyfin.app.collections.entities.CollectionMetadata
import org.gameyfin.app.collections.extensions.toDto
import org.gameyfin.app.collections.extensions.toEntity
import org.gameyfin.app.collections.repositories.CollectionRepository
import org.gameyfin.app.games.GameService
import org.gameyfin.app.games.entities.Game
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import reactor.core.publisher.Flux
import reactor.core.publisher.Sinks
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.toJavaDuration
@Service
class CollectionService(
private val collectionRepository: CollectionRepository,
private val gameService: GameService
) {
companion object {
private val log = KotlinLogging.logger {}
private val collectionUserEvents =
Sinks.many().multicast().onBackpressureBuffer<CollectionUserEvent>(1024, false)
private val collectionAdminEvents =
Sinks.many().multicast().onBackpressureBuffer<CollectionAdminEvent>(1024, false)
fun subscribeUser(): Flux<List<CollectionUserEvent>> {
log.debug { "New user subscription for collectionUserEvents" }
return collectionUserEvents.asFlux()
.buffer(100.milliseconds.toJavaDuration())
.doOnSubscribe {
log.debug { "Subscriber added to user collectionUserEvents [${collectionUserEvents.currentSubscriberCount()}]" }
}
.doFinally {
log.debug { "Subscriber removed from user collectionUserEvents with signal type $it [${collectionUserEvents.currentSubscriberCount()}]" }
}
}
fun subscribeAdmin(): Flux<List<CollectionAdminEvent>> {
log.debug { "New admin subscription for collectionAdminEvents" }
return collectionAdminEvents.asFlux()
.buffer(100.milliseconds.toJavaDuration())
.doOnSubscribe {
log.debug { "Subscriber added to admin collectionAdminEvents [${collectionAdminEvents.currentSubscriberCount()}]" }
}
.doFinally {
log.debug { "Subscriber removed from admin collectionAdminEvents with signal type $it [${collectionAdminEvents.currentSubscriberCount()}]" }
}
}
fun emitUser(event: CollectionUserEvent) {
collectionUserEvents.tryEmitNext(event)
}
fun emitAdmin(event: CollectionAdminEvent) {
collectionAdminEvents.tryEmitNext(event)
}
}
fun getAll(): List<CollectionDto> = collectionRepository.findAll().map { it.toDto() }
fun getById(id: Long): Collection = collectionRepository.findByIdOrNull(id)
?: throw IllegalArgumentException("Collection with id $id not found")
@Transactional
fun create(dto: CollectionCreateDto) {
if (collectionRepository.findByName(dto.name) != null) {
throw IllegalArgumentException("Collection with name '${dto.name}' already exists")
}
val entity = dto.toEntity()
dto.gameIds?.let { ids ->
ids.distinct().forEach { gameId ->
val game = gameService.getById(gameId)
entity.addGame(game)
}
}
collectionRepository.save(entity)
}
@Transactional
fun update(dto: CollectionUpdateDto): CollectionDto {
val collection = getById(dto.id)
dto.name?.let { newName ->
if (newName != collection.name && collectionRepository.findByName(newName) != null) {
throw IllegalArgumentException("Collection with name '$newName' already exists")
}
collection.name = newName
}
dto.description?.let { collection.description = it }
dto.gameIds?.let { ids ->
// Replace entire set of games
val newGames: MutableList<Game> = mutableListOf()
ids.distinct().forEach { gameId ->
val game = gameService.getById(gameId)
newGames.add(game)
}
// Remove old backrefs
collection.games.forEach { it.collections.remove(collection) }
collection.games.clear()
newGames.forEach { collection.addGame(it) }
}
dto.metadata?.let {
collection.metadata = CollectionMetadata(
it.displayOnHomepage ?: collection.metadata.displayOnHomepage,
it.displayOrder ?: collection.metadata.displayOrder,
collection.metadata.gamesAddedAt
)
}
val saved = collectionRepository.save(collection)
return saved.toDto()
}
/**
* Updates multiple collections in the repository.
*/
@Transactional
fun update(collections: List<CollectionUpdateDto>) {
collections.forEach { update(it) }
}
@Transactional
fun addGame(collectionId: Long, gameId: Long): CollectionDto {
val collection = getById(collectionId)
val game = gameService.getById(gameId)
collection.addGame(game)
gameService.update(game)
val saved = collectionRepository.save(collection)
return saved.toDto()
}
@Transactional
fun removeGame(collectionId: Long, gameId: Long): CollectionDto {
val collection = getById(collectionId)
val game = gameService.getById(gameId)
collection.removeGame(game)
gameService.update(game)
val saved = collectionRepository.save(collection)
return saved.toDto()
}
fun delete(collectionId: Long) {
collectionRepository.deleteById(collectionId)
}
}
@@ -0,0 +1,62 @@
package org.gameyfin.app.collections.dto
import com.fasterxml.jackson.annotation.JsonInclude
import org.gameyfin.pluginapi.gamemetadata.Platform
import java.time.Instant
interface CollectionDto {
val id: Long
val createdAt: Instant
val updatedAt: Instant
val name: String
val description: String?
val gameIds: List<Long>?
val metadata: CollectionMetadataDto?
}
@JsonInclude(JsonInclude.Include.NON_NULL)
data class CollectionUserDto(
override val id: Long,
override val createdAt: Instant,
override val updatedAt: Instant,
override val name: String,
override val description: String?,
override val gameIds: List<Long> = emptyList(),
override val metadata: CollectionMetadataDto?,
) : CollectionDto
@JsonInclude(JsonInclude.Include.NON_NULL)
data class CollectionAdminDto(
override val id: Long,
override val createdAt: Instant,
override val updatedAt: Instant,
override val name: String,
override val description: String?,
override val gameIds: List<Long> = emptyList(),
override val metadata: CollectionMetadataDto?,
val stats: CollectionStatsDto?,
) : CollectionDto
@JsonInclude(JsonInclude.Include.NON_NULL)
data class CollectionStatsDto(
val gamesCount: Int,
val downloadCount: Int,
val gamePlatforms: Set<Platform>
)
@JsonInclude(JsonInclude.Include.NON_NULL)
data class CollectionCreateDto(
val name: String,
val description: String? = null,
val gameIds: List<Long>? = null
)
@JsonInclude(JsonInclude.Include.NON_NULL)
data class CollectionUpdateDto(
val id: Long,
val name: String? = null,
val description: String? = null,
val gameIds: List<Long>? = null,
val metadata: CollectionMetadataUpdateDto? = null
)
@@ -0,0 +1,22 @@
package org.gameyfin.app.collections.dto
sealed interface CollectionEvent {
val type: String
}
sealed class CollectionUserEvent : CollectionEvent {
data class Created(val collection: CollectionUserDto, override val type: String = "created") : CollectionUserEvent()
data class Updated(val collection: CollectionUserDto, override val type: String = "updated") : CollectionUserEvent()
data class Deleted(val collectionId: Long, override val type: String = "deleted") : CollectionUserEvent()
}
sealed class CollectionAdminEvent : CollectionEvent {
data class Created(val collection: CollectionAdminDto, override val type: String = "created") :
CollectionAdminEvent()
data class Updated(val collection: CollectionAdminDto, override val type: String = "updated") :
CollectionAdminEvent()
data class Deleted(val collectionId: Long, override val type: String = "deleted") : CollectionAdminEvent()
}
@@ -0,0 +1,14 @@
package org.gameyfin.app.collections.dto
import java.time.Instant
data class CollectionMetadataDto(
val displayOnHomepage: Boolean,
val displayOrder: Int,
val gamesAddedAt: Map<Long, Instant>
)
data class CollectionMetadataUpdateDto(
val displayOnHomepage: Boolean?,
val displayOrder: Int?
)
@@ -0,0 +1,59 @@
package org.gameyfin.app.collections.entities
import jakarta.persistence.*
import org.gameyfin.app.games.entities.Game
import org.hibernate.annotations.CreationTimestamp
import org.hibernate.annotations.UpdateTimestamp
import java.time.Instant
@Entity
@EntityListeners(CollectionEntityListener::class)
class Collection(
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
var id: Long? = null,
@CreationTimestamp
@Column(nullable = false, updatable = false)
var createdAt: Instant? = null,
@UpdateTimestamp
@Column(nullable = false)
var updatedAt: Instant? = null,
@Column(nullable = false, unique = true)
var name: String,
@Lob
var description: String? = null,
@ManyToMany(fetch = FetchType.EAGER)
var games: MutableSet<Game> = mutableSetOf(),
@Embedded
var metadata: CollectionMetadata = CollectionMetadata()
) {
fun addGame(game: Game) {
games.add(game)
if (!game.collections.contains(this)) {
game.collections.add(this)
}
// Track when the game was added
game.id?.let { gameId ->
metadata.gamesAddedAt[gameId] = Instant.now()
}
// Force update to trigger @PostUpdate callback
updatedAt = Instant.now()
}
fun removeGame(game: Game) {
games.remove(game)
game.collections.remove(this)
// Remove the timestamp tracking for this game
game.id?.let { gameId ->
metadata.gamesAddedAt.remove(gameId)
}
// Force update to trigger @PostUpdate callback
updatedAt = Instant.now()
}
}
@@ -0,0 +1,37 @@
package org.gameyfin.app.collections.entities
import jakarta.persistence.PostPersist
import jakarta.persistence.PostRemove
import jakarta.persistence.PostUpdate
import org.gameyfin.app.collections.CollectionService
import org.gameyfin.app.collections.dto.CollectionAdminEvent
import org.gameyfin.app.collections.dto.CollectionUserEvent
import org.gameyfin.app.collections.extensions.toAdminDto
import org.gameyfin.app.collections.extensions.toUserDto
import org.gameyfin.app.core.events.CollectionCreatedEvent
import org.gameyfin.app.core.events.CollectionDeletedEvent
import org.gameyfin.app.core.events.CollectionUpdatedEvent
import org.gameyfin.app.util.EventPublisherHolder
class CollectionEntityListener {
@PostPersist
fun created(collection: Collection) {
CollectionService.emitUser(CollectionUserEvent.Created(collection.toUserDto()))
CollectionService.emitAdmin(CollectionAdminEvent.Created(collection.toAdminDto()))
EventPublisherHolder.publish(CollectionCreatedEvent(this, collection))
}
@PostUpdate
fun updated(collection: Collection) {
CollectionService.emitUser(CollectionUserEvent.Updated(collection.toUserDto()))
CollectionService.emitAdmin(CollectionAdminEvent.Updated(collection.toAdminDto()))
EventPublisherHolder.publish(CollectionUpdatedEvent(this, collection))
}
@PostRemove
fun deleted(collection: Collection) {
CollectionService.emitUser(CollectionUserEvent.Deleted(collection.id!!))
CollectionService.emitAdmin(CollectionAdminEvent.Deleted(collection.id!!))
EventPublisherHolder.publish(CollectionDeletedEvent(this, collection))
}
}
@@ -0,0 +1,15 @@
package org.gameyfin.app.collections.entities
import jakarta.persistence.ElementCollection
import jakarta.persistence.Embeddable
import jakarta.persistence.FetchType
import java.time.Instant
@Embeddable
class CollectionMetadata(
val displayOnHomepage: Boolean = true,
val displayOrder: Int = -1,
@ElementCollection(fetch = FetchType.EAGER)
val gamesAddedAt: MutableMap<Long, Instant> = mutableMapOf()
)
@@ -0,0 +1,54 @@
package org.gameyfin.app.collections.extensions
import org.gameyfin.app.collections.dto.*
import org.gameyfin.app.collections.entities.Collection
import org.gameyfin.app.collections.entities.CollectionMetadata
import org.gameyfin.app.core.security.isCurrentUserAdmin
fun Collection.toDto(): CollectionDto = if (isCurrentUserAdmin()) this.toAdminDto() else this.toUserDto()
fun Collection.toAdminDto(): CollectionAdminDto = CollectionAdminDto(
id = id!!,
createdAt = createdAt!!,
updatedAt = updatedAt!!,
name = name,
description = description,
gameIds = games.mapNotNull { it.id },
metadata = this.metadata.toDto(),
stats = CollectionStatsDto(
gamesCount = games.size,
downloadCount = games.sumOf { it.metadata.downloadCount },
gamePlatforms = games.flatMap { it.platforms }.toSet()
)
)
fun Collection.toUserDto(): CollectionUserDto = CollectionUserDto(
id = id!!,
createdAt = createdAt!!,
updatedAt = updatedAt!!,
name = name,
description = description,
gameIds = games.mapNotNull { it.id },
metadata = this.metadata.toDto()
)
fun CollectionCreateDto.toEntity(): Collection = Collection(
name = name,
description = description
)
fun CollectionMetadata.toDto(): CollectionMetadataDto {
return CollectionMetadataDto(
displayOnHomepage = this.displayOnHomepage,
displayOrder = this.displayOrder,
gamesAddedAt = this.gamesAddedAt.toMap()
)
}
fun CollectionMetadataDto.toEntity(): CollectionMetadata {
return CollectionMetadata(
displayOnHomepage = this.displayOnHomepage,
displayOrder = this.displayOrder,
gamesAddedAt = this.gamesAddedAt.toMutableMap()
)
}
@@ -0,0 +1,9 @@
package org.gameyfin.app.collections.repositories
import org.gameyfin.app.collections.entities.Collection
import org.springframework.data.jpa.repository.JpaRepository
interface CollectionRepository : JpaRepository<Collection, Long> {
fun findByName(name: String): Collection?
}
@@ -15,20 +15,23 @@ sealed class ConfigProperties<T : Serializable>(
val step: Number? = null val step: Number? = null
) { ) {
/** Libraries */ /** Security */
sealed class Libraries { sealed class Security {
data object AllowPublicAccess : ConfigProperties<Boolean>( data object AllowPublicAccess : ConfigProperties<Boolean>(
Boolean::class, Boolean::class,
"library.allow-public-access", "security.allow-public-access",
"Allow access to Gameyfin without login", "Allow access to Gameyfin without login",
false false
) )
}
/** Libraries */
sealed class Libraries {
sealed class Scan { sealed class Scan {
data object EnableFilesystemWatcher : ConfigProperties<Boolean>( data object EnableFilesystemWatcher : ConfigProperties<Boolean>(
Boolean::class, Boolean::class,
"library.scan.enable-filesystem-watcher", "library.scan.enable-filesystem-watcher",
"Enable automatic library scanning using file system watchers (coming soon™)", "Enable automatic library scanning using file system watchers",
false false
) )
@@ -189,13 +192,6 @@ sealed class ConfigProperties<T : Serializable>(
MatchUsersBy.entries MatchUsersBy.entries
) )
data object AutoRegisterNewUsers : ConfigProperties<Boolean>(
Boolean::class,
"sso.oidc.auto-register-new-users",
"Automatically create new users after registration",
true
)
data object RolesClaim : ConfigProperties<String>( data object RolesClaim : ConfigProperties<String>(
String::class, String::class,
"sso.oidc.roles-claim", "sso.oidc.roles-claim",
@@ -1,5 +1,7 @@
package org.gameyfin.app.config package org.gameyfin.app.config
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.ObjectMapper
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.gameyfin.app.config.dto.ConfigEntryDto import org.gameyfin.app.config.dto.ConfigEntryDto
import org.gameyfin.app.config.dto.ConfigUpdateDto import org.gameyfin.app.config.dto.ConfigUpdateDto
@@ -15,7 +17,8 @@ import kotlin.time.toJavaDuration
@Service @Service
class ConfigService( class ConfigService(
private val appConfigRepository: ConfigRepository private val appConfigRepository: ConfigRepository,
private val objectMapper: ObjectMapper
) { ) {
companion object { companion object {
private val log = KotlinLogging.logger {} private val log = KotlinLogging.logger {}
@@ -50,7 +53,7 @@ class ConfigService(
val appConfig = appConfigRepository.findByIdOrNull(configProperty.key) val appConfig = appConfigRepository.findByIdOrNull(configProperty.key)
return if (appConfig != null) { return if (appConfig != null) {
getValue(appConfig.value, configProperty) deserializeValue(appConfig.value, configProperty)
} else { } else {
configProperty.default ?: return null configProperty.default ?: return null
} }
@@ -101,6 +104,18 @@ class ConfigService(
} }
} }
/**
* Set the value for a specified key in a type-safe way.
*
* @param configProperty: The target config property
* @param value: Value to set the config property to
* @throws IllegalArgumentException if the value can't be cast to the type defined for the config property
*/
fun <T : Serializable> set(configProperty: ConfigProperties<T>, value: T) {
return set(configProperty.key, value)
}
/** /**
* Set the value for a specified key. * Set the value for a specified key.
* Checks if the value can be cast to the type defined for the config property. * Checks if the value can be cast to the type defined for the config property.
@@ -117,16 +132,12 @@ class ConfigService(
var configEntry = appConfigRepository.findByIdOrNull(key) var configEntry = appConfigRepository.findByIdOrNull(key)
val parsedValue = val serializedValue = serializeValue(value, key)
if (value.javaClass.isArray) {
(value as Array<Serializable>).joinToString(",")
} else
value.toString()
if (configEntry == null) { if (configEntry == null) {
configEntry = ConfigEntry(configProperty.key, parsedValue) configEntry = ConfigEntry(configProperty.key, serializedValue)
} else { } else {
configEntry.value = parsedValue configEntry.value = serializedValue
} }
appConfigRepository.save(configEntry) appConfigRepository.save(configEntry)
@@ -149,17 +160,6 @@ class ConfigService(
emit(update) emit(update)
} }
/**
* Set the value for a specified key in a type-safe way.
*
* @param configProperty: The target config property
* @param value: Value to set the config property to
* @throws IllegalArgumentException if the value can't be cast to the type defined for the config property
*/
fun <T : Serializable> set(configProperty: ConfigProperties<T>, value: T) {
return set(configProperty.key, value)
}
/** /**
* Remove a config property from the database. * Remove a config property from the database.
* This will also cause it to reset to its default value. * This will also cause it to reset to its default value.
@@ -175,41 +175,45 @@ class ConfigService(
} }
/** /**
* Get the value of the config property in a type-safe way. * Deserialize a value from the database to its proper type.
*
* @param value: The serialized value from the database
* @param configProperty: The config property containing type information
* @return The deserialized value
*/ */
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
private fun <T : Serializable> getValue(value: Serializable, configProperty: ConfigProperties<T>): T { private fun <T : Serializable> deserializeValue(value: Serializable, configProperty: ConfigProperties<T>): T {
val value = value.toString() return try {
return when { val typeReference = objectMapper.typeFactory.constructType(configProperty.type.java)
configProperty.type == String::class -> value as T objectMapper.readValue(value.toString(), typeReference) as T
configProperty.type == Boolean::class -> value.toBoolean() as T } catch (e: JsonProcessingException) {
configProperty.type == Int::class -> value.toFloat().toInt() as T throw IllegalArgumentException(
configProperty.type == Float::class -> value.toFloat() as T "Failed to deserialize value '$value' for key '${configProperty.key}' to type ${configProperty.type.simpleName}: ${e.message}",
e
)
} catch (e: Exception) {
throw IllegalArgumentException(
"Failed to deserialize value '$value' for key '${configProperty.key}' to type ${configProperty.type.simpleName}: ${e.message}",
e
)
}
}
configProperty.type.java.isEnum -> { /**
val enumConstants = configProperty.type.java.enumConstants * Serialize a value to be stored in the database.
enumConstants.firstOrNull { it.toString() == value } *
?: throw IllegalArgumentException("Unknown enum value '$value' for key ${configProperty.key}") * @param value: The value to serialize
} * @param key: The config key (for error messages)
* @return The serialized value as a string
configProperty.type.java.isArray -> { */
val componentType = configProperty.type.java.componentType private fun <T : Serializable> serializeValue(value: T, key: String): String {
// Remove the brackets and split the string by commas return try {
val elements = value objectMapper.writeValueAsString(value)
.removeSurrounding("[", "]") } catch (e: JsonProcessingException) {
.split(",") throw IllegalArgumentException(
.filter { it.isNotBlank() } "Failed to serialize value for key '$key': ${e.message}",
e
when (componentType) { )
String::class.java -> elements.toTypedArray() as T
Boolean::class.java -> elements.map { it.toBoolean() }.toTypedArray() as T
Int::class.java -> elements.map { it.toInt() }.toTypedArray() as T
Float::class.java -> elements.map { it.toFloat() }.toTypedArray() as T
else -> throw IllegalArgumentException("Unsupported array type: ${componentType.name}")
}
}
else -> throw IllegalArgumentException("Unknown config type ${configProperty.type}: '$value' for key ${configProperty.key}")
} }
} }
@@ -4,6 +4,7 @@ import jakarta.persistence.PostPersist
import jakarta.persistence.PostRemove import jakarta.persistence.PostRemove
import jakarta.persistence.PostUpdate import jakarta.persistence.PostUpdate
import org.gameyfin.app.config.ConfigProperties import org.gameyfin.app.config.ConfigProperties
import org.gameyfin.app.core.events.LibraryFilesystemWatcherConfigUpdatedEvent
import org.gameyfin.app.core.events.LibraryScanScheduleUpdatedEvent import org.gameyfin.app.core.events.LibraryScanScheduleUpdatedEvent
import org.gameyfin.app.util.EventPublisherHolder import org.gameyfin.app.util.EventPublisherHolder
@@ -19,7 +20,12 @@ class ConfigEntryEntityListener {
} }
ConfigProperties.Libraries.Scan.EnableFilesystemWatcher.key -> { ConfigProperties.Libraries.Scan.EnableFilesystemWatcher.key -> {
TODO() EventPublisherHolder.publish(
LibraryFilesystemWatcherConfigUpdatedEvent(
this,
configEntry.value.toBoolean()
)
)
} }
} }
} }
@@ -27,7 +27,7 @@ class DynamicAccessInterceptor(
clazz.isAnnotationPresent(DynamicPublicAccess::class.java) clazz.isAnnotationPresent(DynamicPublicAccess::class.java)
if (hasDynamicPublicAccess) { if (hasDynamicPublicAccess) {
if (request.userPrincipal != null || config.get(ConfigProperties.Libraries.AllowPublicAccess) == true) { if (request.userPrincipal != null || config.get(ConfigProperties.Security.AllowPublicAccess) == true) {
return true return true
} }
response.status = HttpServletResponse.SC_UNAUTHORIZED response.status = HttpServletResponse.SC_UNAUTHORIZED
@@ -1,5 +1,6 @@
package org.gameyfin.app.core.events package org.gameyfin.app.core.events
import org.gameyfin.app.collections.entities.Collection
import org.gameyfin.app.core.token.Token import org.gameyfin.app.core.token.Token
import org.gameyfin.app.core.token.TokenType import org.gameyfin.app.core.token.TokenType
import org.gameyfin.app.games.entities.Game import org.gameyfin.app.games.entities.Game
@@ -24,6 +25,7 @@ class PasswordResetRequestEvent(source: Any, val token: Token<TokenType.Password
ApplicationEvent(source) ApplicationEvent(source)
class LibraryScanScheduleUpdatedEvent(source: Any) : ApplicationEvent(source) class LibraryScanScheduleUpdatedEvent(source: Any) : ApplicationEvent(source)
class LibraryFilesystemWatcherConfigUpdatedEvent(source: Any, val isEnabled: Boolean) : ApplicationEvent(source)
class UserDeletedEvent(source: Any, val user: User, val baseUrl: String) : ApplicationEvent(source) class UserDeletedEvent(source: Any, val user: User, val baseUrl: String) : ApplicationEvent(source)
class UserUpdatedEvent(source: Any, val previousState: User, val currentState: User) : ApplicationEvent(source) class UserUpdatedEvent(source: Any, val previousState: User, val currentState: User) : ApplicationEvent(source)
@@ -34,4 +36,8 @@ class GameDeletedEvent(source: Any, val game: Game) : ApplicationEvent(source)
class LibraryCreatedEvent(source: Any, val library: Library) : ApplicationEvent(source) class LibraryCreatedEvent(source: Any, val library: Library) : ApplicationEvent(source)
class LibraryUpdatedEvent(source: Any, val currentState: Library) : ApplicationEvent(source) class LibraryUpdatedEvent(source: Any, val currentState: Library) : ApplicationEvent(source)
class LibraryDeletedEvent(source: Any, val library: Library) : ApplicationEvent(source) class LibraryDeletedEvent(source: Any, val library: Library) : ApplicationEvent(source)
class CollectionCreatedEvent(source: Any, val collection: Collection) : ApplicationEvent(source)
class CollectionUpdatedEvent(source: Any, val currentState: Collection) : ApplicationEvent(source)
class CollectionDeletedEvent(source: Any, val collection: Collection) : ApplicationEvent(source)
@@ -0,0 +1,41 @@
package org.gameyfin.app.core.exceptions
import com.vaadin.hilla.exception.EndpointException
import io.github.oshai.kotlinlogging.KotlinLogging
import org.aspectj.lang.ProceedingJoinPoint
import org.aspectj.lang.annotation.Around
import org.aspectj.lang.annotation.Aspect
import org.springframework.stereotype.Component
/**
* Aspect that intercepts all Vaadin Hilla endpoint method calls.
* Catches all exceptions thrown from endpoint methods, logs them with full stack trace,
* and re-throws them as EndpointException to be displayed nicely in the frontend.
*/
@Aspect
@Component
class EndpointExceptionHandler {
companion object {
private val log = KotlinLogging.logger {}
}
@Around("@within(com.vaadin.hilla.Endpoint)")
@Throws(Throwable::class)
fun handleEndpointException(joinPoint: ProceedingJoinPoint): Any? {
return try {
joinPoint.proceed()
} catch (ex: EndpointException) {
// If it's already an EndpointException, just log and re-throw
log.error(ex) { "Endpoint exception: ${ex.message}" }
throw ex
} catch (ex: Exception) {
// Log the original exception with full stack trace
log.error(ex) { "Exception in endpoint method ${joinPoint.signature.declaringType.simpleName}.${joinPoint.signature.name}: ${ex.message}" }
// Re-throw as EndpointException with the original message but no stack trace
throw EndpointException(ex.message ?: "An error occurred")
}
}
}
@@ -3,7 +3,7 @@ package org.gameyfin.app.core.interceptors
import org.gameyfin.app.core.events.GameUpdatedEvent import org.gameyfin.app.core.events.GameUpdatedEvent
import org.gameyfin.app.core.events.UserUpdatedEvent import org.gameyfin.app.core.events.UserUpdatedEvent
import org.gameyfin.app.games.entities.Game import org.gameyfin.app.games.entities.Game
import org.gameyfin.app.games.entities.Image import org.gameyfin.app.media.Image
import org.gameyfin.app.users.entities.User import org.gameyfin.app.users.entities.User
import org.gameyfin.app.util.EventPublisherHolder import org.gameyfin.app.util.EventPublisherHolder
import org.gameyfin.pluginapi.gamemetadata.Platform import org.gameyfin.pluginapi.gamemetadata.Platform
@@ -20,7 +20,7 @@ class DynamicPublicAccessAuthorizationManager(
): AuthorizationDecision { ): AuthorizationDecision {
val auth = authentication?.get() val auth = authentication?.get()
val allow = (auth?.isAuthenticated == true && auth.principal != "anonymousUser") || val allow = (auth?.isAuthenticated == true && auth.principal != "anonymousUser") ||
config.get(ConfigProperties.Libraries.AllowPublicAccess) == true config.get(ConfigProperties.Security.AllowPublicAccess) == true
return AuthorizationDecision(allow) return AuthorizationDecision(allow)
} }
@@ -23,7 +23,9 @@ import org.gameyfin.app.games.entities.*
import org.gameyfin.app.games.extensions.toDtos import org.gameyfin.app.games.extensions.toDtos
import org.gameyfin.app.games.repositories.GameRepository import org.gameyfin.app.games.repositories.GameRepository
import org.gameyfin.app.libraries.entities.Library import org.gameyfin.app.libraries.entities.Library
import org.gameyfin.app.media.Image
import org.gameyfin.app.media.ImageService import org.gameyfin.app.media.ImageService
import org.gameyfin.app.media.ImageType
import org.gameyfin.app.users.UserService import org.gameyfin.app.users.UserService
import org.gameyfin.pluginapi.gamemetadata.* import org.gameyfin.pluginapi.gamemetadata.*
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
@@ -34,6 +36,7 @@ import org.springframework.transaction.annotation.Transactional
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
import reactor.core.publisher.Sinks import reactor.core.publisher.Sinks
import java.nio.file.Path import java.nio.file.Path
import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset import java.time.ZoneOffset
import java.util.concurrent.Executors import java.util.concurrent.Executors
@@ -236,7 +239,7 @@ class GameService(
} }
@Transactional @Transactional
fun update(game: Game): Game? { fun updateMetadata(game: Game): Game? {
var wasGameUpdated = false var wasGameUpdated = false
val game = getById(game.id!!) val game = getById(game.id!!)
@@ -447,6 +450,11 @@ class GameService(
return if (wasGameUpdated) game else null return if (wasGameUpdated) game else null
} }
fun update(game: Game): Game {
game.updatedAt = Instant.now()
return gameRepository.save(game)
}
fun delete(gameId: Long) { fun delete(gameId: Long) {
gameRepository.deleteById(gameId) gameRepository.deleteById(gameId)
} }
@@ -680,7 +688,7 @@ class GameService(
.toMap() .toMap()
if (metadataResults.isEmpty()) { if (metadataResults.isEmpty()) {
log.info { "Could not identify game at path '$path'" } log.debug { "Could not identify game at path '$path'" }
return null return null
} }
@@ -1,6 +1,7 @@
package org.gameyfin.app.games.dto package org.gameyfin.app.games.dto
import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.annotation.JsonInclude
import org.gameyfin.app.media.ImageDto
import org.gameyfin.pluginapi.gamemetadata.* import org.gameyfin.pluginapi.gamemetadata.*
import java.time.Instant import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
@@ -10,10 +11,11 @@ sealed interface GameDto {
val createdAt: Instant val createdAt: Instant
val updatedAt: Instant val updatedAt: Instant
val libraryId: Long val libraryId: Long
val collectionIds: List<Long>
val title: String val title: String
val platforms: List<Platform> val platforms: List<Platform>
val coverId: Long? val cover: ImageDto?
val headerId: Long? val header: ImageDto?
val comment: String? val comment: String?
val summary: String? val summary: String?
val release: LocalDate? val release: LocalDate?
@@ -26,7 +28,7 @@ sealed interface GameDto {
val keywords: List<String>? val keywords: List<String>?
val features: List<GameFeature>? val features: List<GameFeature>?
val perspectives: List<PlayerPerspective>? val perspectives: List<PlayerPerspective>?
val imageIds: List<Long>? val images: List<ImageDto>?
val videoUrls: List<String>? val videoUrls: List<String>?
val metadata: GameMetadataDto val metadata: GameMetadataDto
} }
@@ -37,10 +39,11 @@ data class GameUserDto(
override val createdAt: Instant, override val createdAt: Instant,
override val updatedAt: Instant, override val updatedAt: Instant,
override val libraryId: Long, override val libraryId: Long,
override val collectionIds: List<Long>,
override val title: String, override val title: String,
override val platforms: List<Platform>, override val platforms: List<Platform>,
override val coverId: Long?, override val cover: ImageDto?,
override val headerId: Long?, override val header: ImageDto?,
override val comment: String?, override val comment: String?,
override val summary: String?, override val summary: String?,
override val release: LocalDate?, override val release: LocalDate?,
@@ -53,7 +56,7 @@ data class GameUserDto(
override val keywords: List<String>?, override val keywords: List<String>?,
override val features: List<GameFeature>?, override val features: List<GameFeature>?,
override val perspectives: List<PlayerPerspective>?, override val perspectives: List<PlayerPerspective>?,
override val imageIds: List<Long>?, override val images: List<ImageDto>?,
override val videoUrls: List<String>?, override val videoUrls: List<String>?,
override val metadata: GameMetadataUserDto override val metadata: GameMetadataUserDto
) : GameDto ) : GameDto
@@ -64,10 +67,11 @@ data class GameAdminDto(
override val createdAt: Instant, override val createdAt: Instant,
override val updatedAt: Instant, override val updatedAt: Instant,
override val libraryId: Long, override val libraryId: Long,
override val collectionIds: List<Long>,
override val title: String, override val title: String,
override val platforms: List<Platform>, override val platforms: List<Platform>,
override val coverId: Long?, override val cover: ImageDto?,
override val headerId: Long?, override val header: ImageDto?,
override val comment: String?, override val comment: String?,
override val summary: String?, override val summary: String?,
override val release: LocalDate?, override val release: LocalDate?,
@@ -80,7 +84,7 @@ data class GameAdminDto(
override val keywords: List<String>?, override val keywords: List<String>?,
override val features: List<GameFeature>?, override val features: List<GameFeature>?,
override val perspectives: List<PlayerPerspective>?, override val perspectives: List<PlayerPerspective>?,
override val imageIds: List<Long>?, override val images: List<ImageDto>?,
override val videoUrls: List<String>?, override val videoUrls: List<String>?,
override val metadata: GameMetadataAdminDto override val metadata: GameMetadataAdminDto
) : GameDto ) : GameDto
@@ -2,7 +2,9 @@ package org.gameyfin.app.games.entities
import jakarta.persistence.* import jakarta.persistence.*
import jakarta.persistence.CascadeType.* import jakarta.persistence.CascadeType.*
import org.gameyfin.app.collections.entities.Collection
import org.gameyfin.app.libraries.entities.Library import org.gameyfin.app.libraries.entities.Library
import org.gameyfin.app.media.Image
import org.gameyfin.pluginapi.gamemetadata.* import org.gameyfin.pluginapi.gamemetadata.*
import org.hibernate.annotations.CreationTimestamp import org.hibernate.annotations.CreationTimestamp
import org.hibernate.annotations.UpdateTimestamp import org.hibernate.annotations.UpdateTimestamp
@@ -79,6 +81,9 @@ class Game(
@ElementCollection @ElementCollection
var videoUrls: List<URI> = emptyList(), var videoUrls: List<URI> = emptyList(),
@ManyToMany(mappedBy = "games", fetch = FetchType.EAGER)
var collections: MutableList<Collection> = mutableListOf(),
@Embedded @Embedded
var metadata: GameMetadata var metadata: GameMetadata
) { ) {
@@ -3,6 +3,7 @@ package org.gameyfin.app.games.entities
import jakarta.persistence.PostPersist import jakarta.persistence.PostPersist
import jakarta.persistence.PostRemove import jakarta.persistence.PostRemove
import jakarta.persistence.PostUpdate import jakarta.persistence.PostUpdate
import jakarta.persistence.PreRemove
import org.gameyfin.app.core.events.GameCreatedEvent import org.gameyfin.app.core.events.GameCreatedEvent
import org.gameyfin.app.core.events.GameDeletedEvent import org.gameyfin.app.core.events.GameDeletedEvent
import org.gameyfin.app.games.GameService import org.gameyfin.app.games.GameService
@@ -28,6 +29,13 @@ class GameEntityListener {
// GameUpdateEvent triggered via {@link org.gameyfin.app.core.interceptors.EntityUpdateInterceptor#onFlushDirty} // GameUpdateEvent triggered via {@link org.gameyfin.app.core.interceptors.EntityUpdateInterceptor#onFlushDirty}
} }
@PreRemove
fun preDelete(game: Game) {
val collectionsToUpdate = game.collections.toList()
collectionsToUpdate.forEach { it.removeGame(game) }
game.collections.clear()
}
@PostRemove @PostRemove
fun deleted(game: Game) { fun deleted(game: Game) {
GameService.emitUser(GameUserEvent.Deleted(game.id!!)) GameService.emitUser(GameUserEvent.Deleted(game.id!!))
@@ -3,6 +3,7 @@ package org.gameyfin.app.games.extensions
import org.gameyfin.app.core.security.isCurrentUserAdmin import org.gameyfin.app.core.security.isCurrentUserAdmin
import org.gameyfin.app.games.dto.* import org.gameyfin.app.games.dto.*
import org.gameyfin.app.games.entities.* import org.gameyfin.app.games.entities.*
import org.gameyfin.app.media.toDto
import java.time.ZoneOffset import java.time.ZoneOffset
@@ -28,10 +29,11 @@ fun Game.toAdminDto(): GameAdminDto {
createdAt = createdAt!!, createdAt = createdAt!!,
updatedAt = updatedAt!!, updatedAt = updatedAt!!,
libraryId = this.library.id!!, libraryId = this.library.id!!,
collectionIds = this.collections.mapNotNull { it.id },
title = title!!, title = title!!,
platforms = this.platforms, platforms = this.platforms,
coverId = this.coverImage?.id, cover = this.coverImage?.toDto(),
headerId = this.headerImage?.id, header = this.headerImage?.toDto(),
comment = this.comment, comment = this.comment,
summary = this.summary, summary = this.summary,
release = this.release?.atZone(ZoneOffset.UTC)?.toLocalDate(), release = this.release?.atZone(ZoneOffset.UTC)?.toLocalDate(),
@@ -44,7 +46,7 @@ fun Game.toAdminDto(): GameAdminDto {
keywords = this.keywords.toList(), keywords = this.keywords.toList(),
features = this.features, features = this.features,
perspectives = this.perspectives, perspectives = this.perspectives,
imageIds = this.images.mapNotNull { it.id }, images = this.images.map { it.toDto() },
videoUrls = this.videoUrls.map { it.toString() }, videoUrls = this.videoUrls.map { it.toString() },
metadata = this.metadata.toAdminDto() metadata = this.metadata.toAdminDto()
) )
@@ -56,10 +58,11 @@ fun Game.toUserDto(): GameUserDto {
createdAt = createdAt!!, createdAt = createdAt!!,
updatedAt = updatedAt!!, updatedAt = updatedAt!!,
libraryId = this.library.id!!, libraryId = this.library.id!!,
collectionIds = this.collections.mapNotNull { it.id },
title = title!!, title = title!!,
platforms = this.platforms, platforms = this.platforms,
coverId = this.coverImage?.id, cover = this.coverImage?.toDto(),
headerId = this.headerImage?.id, header = this.headerImage?.toDto(),
comment = this.comment, comment = this.comment,
summary = this.summary, summary = this.summary,
release = this.release?.atZone(ZoneOffset.UTC)?.toLocalDate(), release = this.release?.atZone(ZoneOffset.UTC)?.toLocalDate(),
@@ -72,7 +75,7 @@ fun Game.toUserDto(): GameUserDto {
keywords = this.keywords.toList(), keywords = this.keywords.toList(),
features = this.features, features = this.features,
perspectives = this.perspectives, perspectives = this.perspectives,
imageIds = this.images.mapNotNull { it.id }, images = this.images.map { it.toDto() },
videoUrls = this.videoUrls.map { it.toString() }, videoUrls = this.videoUrls.map { it.toString() },
metadata = this.metadata.toUserDto() metadata = this.metadata.toUserDto()
) )
@@ -1,6 +1,6 @@
package org.gameyfin.app.games.repositories package org.gameyfin.app.games.repositories
import org.gameyfin.app.games.entities.Image import org.gameyfin.app.media.Image
import org.springframework.content.commons.store.ContentStore import org.springframework.content.commons.store.ContentStore
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
@@ -1,6 +1,6 @@
package org.gameyfin.app.games.repositories package org.gameyfin.app.games.repositories
import org.gameyfin.app.games.entities.Image import org.gameyfin.app.media.Image
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
interface ImageRepository : JpaRepository<Image, Long> { interface ImageRepository : JpaRepository<Image, Long> {
@@ -46,6 +46,9 @@ class LibraryEndpoint(
@RolesAllowed(Role.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
fun updateLibrary(library: LibraryUpdateDto) = libraryService.update(library) fun updateLibrary(library: LibraryUpdateDto) = libraryService.update(library)
@RolesAllowed(Role.Names.ADMIN)
fun updateLibraries(libraries: Collection<LibraryUpdateDto>) = libraryService.update(libraries)
@RolesAllowed(Role.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
fun deleteLibrary(libraryId: Long) = libraryService.delete(libraryId) fun deleteLibrary(libraryId: Long) = libraryService.delete(libraryId)
} }
@@ -102,60 +102,37 @@ class LibraryScanService(
emit(progress) emit(progress)
try { try {
val scanResult = filesystemService.scanLibraryForGamefiles(library) val scanData = performFilesystemScan(library)
val newPaths = scanResult.newPaths
val removedGamePaths = scanResult.removedGamePaths.map { it.toString() }
val removedIgnoredPaths = scanResult.removedIgnoredPaths
// Get plugin-generated (system) ignored paths to re-scan
val pluginIgnoredPathsToRescan = library.ignoredPaths
.filter { it.getType() == IgnoredPathSourceType.PLUGIN }
.map { Path.of(it.path) }
progress.currentStep = LibraryScanStep(
description = "Processing new games",
current = 0,
total = newPaths.size + pluginIgnoredPathsToRescan.size
)
emit(progress)
// 1. Process each new game independently (including re-scanned plugin ignored paths) // 1. Process each new game independently (including re-scanned plugin ignored paths)
val allPathsToProcess = newPaths + pluginIgnoredPathsToRescan val (newUnmatchedPaths, persistedNewGames) = processNewGamesWithProgress(
val (newUnmatchedPaths, persistedNewGames) = processNewGames(library, allPathsToProcess, progress) library,
scanData.allPathsToProcess,
progress
)
// 2. Update library (removed games/ignored paths, and add persisted new ones) // 2. Update library (removed games/ignored paths, and add persisted new ones)
val (removedGames) = updateLibrary( val (removedGames) = updateLibrary(
library, library,
removedIgnoredPaths, scanData.removedIgnoredPaths,
newUnmatchedPaths, newUnmatchedPaths,
removedGamePaths scanData.removedGamePaths
) )
// 3. Finish scan: persist library changes and report // 3. Finish scan: persist library changes and report
progress.currentStep = LibraryScanStep( finishScanWithProgress(persistedNewGames, library, progress)
description = "Finishing up",
current = 0,
total = persistedNewGames.size
)
emit(progress)
finishScanPersisted(persistedNewGames, library, progress) // 4. Send final progress update
completeScan(
progress.currentStep = LibraryScanStep(description = "Finished") progress,
progress.finishedAt = Instant.now() QuickScanResult(
progress.status = LibraryScanStatus.COMPLETED new = persistedNewGames.size,
progress.result = QuickScanResult( removed = removedGames.size,
new = persistedNewGames.size, unmatched = newUnmatchedPaths.size
removed = removedGames.size, )
unmatched = newUnmatchedPaths.size
) )
emit(progress)
} catch (e: Exception) { } catch (e: Exception) {
log.error { "Error during quick scan for library ${library.id}: ${e.message}" } handleScanError(e, library, progress, "quick scan")
log.debug(e) {}
progress.status = LibraryScanStatus.FAILED
progress.finishedAt = Instant.now()
emit(progress)
} }
} }
@@ -170,16 +147,7 @@ class LibraryScanService(
emit(progress) emit(progress)
try { try {
val scanResult = filesystemService.scanLibraryForGamefiles(library) val scanData = performFilesystemScan(library)
val newPaths = scanResult.newPaths
val removedGamePaths = scanResult.removedGamePaths.map { it.toString() }
val removedIgnoredPaths = scanResult.removedIgnoredPaths
// Get plugin-generated (system) ignored paths to re-scan
val pluginIgnoredPathsToRescan = library.ignoredPaths
.filter { it.getType() == IgnoredPathSourceType.PLUGIN }
.map { Path.of(it.path) }
// 1. Update existing games (individually) // 1. Update existing games (individually)
progress.currentStep = LibraryScanStep( progress.currentStep = LibraryScanStep(
@@ -192,54 +160,109 @@ class LibraryScanService(
val (updatedGames) = updateExistingGames(library.games, progress) val (updatedGames) = updateExistingGames(library.games, progress)
// 2. Process new games (individually, including re-scanned plugin ignored paths) // 2. Process new games (individually, including re-scanned plugin ignored paths)
val allPathsToProcess = newPaths + pluginIgnoredPathsToRescan val (newUnmatchedPaths, persistedNewGames) = processNewGamesWithProgress(
progress.currentStep = LibraryScanStep( library,
description = "Processing new games", scanData.allPathsToProcess,
current = 0, progress
total = allPathsToProcess.size
) )
emit(progress)
val (newUnmatchedPaths, persistedNewGames) = processNewGames(library, allPathsToProcess, progress)
val (removedGames) = updateLibrary( val (removedGames) = updateLibrary(
library, library,
removedIgnoredPaths, scanData.removedIgnoredPaths,
newUnmatchedPaths, newUnmatchedPaths,
removedGamePaths scanData.removedGamePaths
) )
// 3. Finish scan // 3. Finish scan
progress.currentStep = LibraryScanStep( finishScanWithProgress(persistedNewGames, library, progress)
description = "Finishing up",
current = 0,
total = persistedNewGames.size
)
emit(progress)
finishScanPersisted(persistedNewGames, library, progress)
// 4. Send final progress update // 4. Send final progress update
progress.currentStep = LibraryScanStep(description = "Finished") completeScan(
progress.finishedAt = Instant.now() progress,
progress.status = LibraryScanStatus.COMPLETED FullScanResult(
progress.result = FullScanResult( new = persistedNewGames.size,
new = persistedNewGames.size, removed = removedGames.size,
removed = removedGames.size, unmatched = newUnmatchedPaths.size,
unmatched = newUnmatchedPaths.size, updated = updatedGames.size
updated = updatedGames.size )
) )
emit(progress)
} catch (e: Exception) { } catch (e: Exception) {
log.error { "Error during full scan for library ${library.id}: ${e.message}" } handleScanError(e, library, progress, "full scan")
log.debug(e) {}
progress.status = LibraryScanStatus.FAILED
progress.finishedAt = Instant.now()
emit(progress)
return
} }
} }
private data class FilesystemScanData(
val allPathsToProcess: List<Path>,
val removedGamePaths: List<String>,
val removedIgnoredPaths: List<IgnoredPath>
)
private fun performFilesystemScan(library: Library): FilesystemScanData {
val scanResult = filesystemService.scanLibraryForGamefiles(library)
val newPaths = scanResult.newPaths
val removedGamePaths = scanResult.removedGamePaths.map { it.toString() }
val removedIgnoredPaths = scanResult.removedIgnoredPaths
// Get plugin-generated (system) ignored paths to re-scan
val pluginIgnoredPathsToRescan = library.ignoredPaths
.filter { it.getType() == IgnoredPathSourceType.PLUGIN }
.map { Path.of(it.path) }
val allPathsToProcess = newPaths + pluginIgnoredPathsToRescan
return FilesystemScanData(
allPathsToProcess = allPathsToProcess,
removedGamePaths = removedGamePaths,
removedIgnoredPaths = removedIgnoredPaths
)
}
private fun processNewGamesWithProgress(
library: Library,
gamePaths: List<Path>,
progress: LibraryScanProgress
): MatchNewGamesResult {
progress.currentStep = LibraryScanStep(
description = "Processing new games",
current = 0,
total = gamePaths.size
)
emit(progress)
return processNewGames(library, gamePaths, progress)
}
private fun finishScanWithProgress(
persistedNewGames: List<Game>,
library: Library,
progress: LibraryScanProgress
) {
progress.currentStep = LibraryScanStep(
description = "Finishing up",
current = 0,
total = persistedNewGames.size
)
emit(progress)
finishScanPersisted(persistedNewGames, library, progress)
}
private fun completeScan(progress: LibraryScanProgress, result: LibraryScanResult) {
progress.currentStep = LibraryScanStep(description = "Finished")
progress.finishedAt = Instant.now()
progress.status = LibraryScanStatus.COMPLETED
progress.result = result
emit(progress)
}
private fun handleScanError(e: Exception, library: Library, progress: LibraryScanProgress, scanType: String) {
log.error { "Error during $scanType for library ${library.id}: ${e.message}" }
log.debug(e) {}
progress.status = LibraryScanStatus.FAILED
progress.finishedAt = Instant.now()
emit(progress)
}
private fun processNewGames( private fun processNewGames(
library: Library, library: Library,
gamePaths: List<Path>, gamePaths: List<Path>,
@@ -5,6 +5,7 @@ import io.github.oshai.kotlinlogging.KotlinLogging
import org.gameyfin.app.core.security.getCurrentAuth import org.gameyfin.app.core.security.getCurrentAuth
import org.gameyfin.app.libraries.dto.* import org.gameyfin.app.libraries.dto.*
import org.gameyfin.app.libraries.entities.DirectoryMapping import org.gameyfin.app.libraries.entities.DirectoryMapping
import org.gameyfin.app.libraries.entities.IgnoredPathSourceType
import org.gameyfin.app.libraries.entities.Library import org.gameyfin.app.libraries.entities.Library
import org.gameyfin.app.libraries.enums.ScanType import org.gameyfin.app.libraries.enums.ScanType
import org.gameyfin.app.libraries.extensions.toDtos import org.gameyfin.app.libraries.extensions.toDtos
@@ -34,7 +35,7 @@ class LibraryService(
private val libraryAdminEvents = Sinks.many().multicast().onBackpressureBuffer<LibraryAdminEvent>(1024, false) private val libraryAdminEvents = Sinks.many().multicast().onBackpressureBuffer<LibraryAdminEvent>(1024, false)
fun subscribeUser(): Flux<List<LibraryUserEvent>> { fun subscribeUser(): Flux<List<LibraryUserEvent>> {
log.debug { "New user subscription for libraryEvents" } log.debug { "New user subscription for libraryUserEvents" }
return libraryUserEvents.asFlux() return libraryUserEvents.asFlux()
.buffer(100.milliseconds.toJavaDuration()) .buffer(100.milliseconds.toJavaDuration())
.doOnSubscribe { .doOnSubscribe {
@@ -46,7 +47,7 @@ class LibraryService(
} }
fun subscribeAdmin(): Flux<List<LibraryAdminEvent>> { fun subscribeAdmin(): Flux<List<LibraryAdminEvent>> {
log.debug { "New admin subscription for libraryEvents" } log.debug { "New admin subscription for libraryAdminEvents" }
return libraryAdminEvents.asFlux() return libraryAdminEvents.asFlux()
.buffer(100.milliseconds.toJavaDuration()) .buffer(100.milliseconds.toJavaDuration())
.doOnSubscribe { .doOnSubscribe {
@@ -166,15 +167,16 @@ class LibraryService(
library.platforms.addAll(it) library.platforms.addAll(it)
} }
// Only allow updating USER sourced ignored paths; preserve PLUGIN sourced ones
libraryUpdateDto.ignoredPaths libraryUpdateDto.ignoredPaths
?.filter { it.sourceType == IgnoredPathSourceTypeDto.USER } // Only USER source type is supported for updates ?.filter { it.sourceType == IgnoredPathSourceTypeDto.USER } // Only USER source type is supported for updates
?.let { dtos -> ?.let { dtos ->
// Get current user for USER source type paths
val currentUser = getCurrentAuth()?.let { auth -> userService.getByUsername(auth.name) } val currentUser = getCurrentAuth()?.let { auth -> userService.getByUsername(auth.name) }
library.ignoredPaths.clear() // Remove existing USER-sourced ignored paths, keep PLUGIN-sourced ones intact
library.ignoredPaths.removeIf { it.getType() == IgnoredPathSourceType.USER }
// Check for existing paths and reuse them if they exist // Recreate user-sourced paths (reuse existing entity if same path already present globally)
val pathsToAdd = dtos.map { dto -> val pathsToAdd = dtos.map { dto ->
val existingPath = ignoredPathRepository.findByPath(dto.path) val existingPath = ignoredPathRepository.findByPath(dto.path)
existingPath ?: dto.toEntity(currentUser) existingPath ?: dto.toEntity(currentUser)
@@ -183,10 +185,21 @@ class LibraryService(
library.ignoredPaths.addAll(pathsToAdd) library.ignoredPaths.addAll(pathsToAdd)
} }
libraryUpdateDto.metadata?.let {
library.metadata = it.toEntity()
}
library.updatedAt = Instant.now() library.updatedAt = Instant.now()
libraryRepository.save(library) libraryRepository.save(library)
} }
/**
* Updates multiple libraries in the repository.
*/
fun update(libraries: Collection<LibraryUpdateDto>) {
libraries.forEach { update(it) }
}
/** /**
* Deletes a library from the repository. * Deletes a library from the repository.
* *
@@ -0,0 +1,363 @@
package org.gameyfin.app.libraries
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import org.gameyfin.app.config.ConfigProperties
import org.gameyfin.app.config.ConfigService
import org.gameyfin.app.core.events.LibraryCreatedEvent
import org.gameyfin.app.core.events.LibraryDeletedEvent
import org.gameyfin.app.core.events.LibraryFilesystemWatcherConfigUpdatedEvent
import org.gameyfin.app.core.events.LibraryUpdatedEvent
import org.gameyfin.app.core.filesystem.FilesystemService
import org.gameyfin.app.games.repositories.GameRepository
import org.gameyfin.app.libraries.entities.Library
import org.gameyfin.app.libraries.enums.ScanType
import org.springframework.context.event.EventListener
import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Service
import java.nio.file.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.io.path.isDirectory
/**
* Service that monitors library directories for file system changes and automatically
* updates games and libraries when files are added, removed, or modified.
*/
@Service
class LibraryWatcherService(
private val libraryRepository: LibraryRepository,
private val libraryScanService: LibraryScanService,
private val gameRepository: GameRepository,
private val filesystemService: FilesystemService,
private val configService: ConfigService
) {
companion object {
private val log = KotlinLogging.logger {}
}
data class LibraryWatchInfo(
val libraryId: Long,
val path: Path
)
private var watchService: WatchService? = null
private val watchKeys = ConcurrentHashMap<WatchKey, LibraryWatchInfo>()
private val libraryWatchers = ConcurrentHashMap<Long, MutableList<WatchKey>>()
private var executor = Executors.newSingleThreadExecutor { r ->
Thread(r, "library-watcher-thread").apply { isDaemon = true }
}
private val running = AtomicBoolean(false)
@PostConstruct
fun start() {
// Check if filesystem watcher is enabled in config
val isEnabled = configService.get(ConfigProperties.Libraries.Scan.EnableFilesystemWatcher) ?: false
if (!isEnabled) {
log.debug { "Library Watcher Service is disabled in configuration" }
return
}
log.debug { "Starting Library Watcher Service" }
// Create a new watch service if needed
if (watchService == null) {
watchService = FileSystems.getDefault().newWatchService()
}
// Recreate executor if it was previously shut down
if (executor.isShutdown) {
executor = Executors.newSingleThreadExecutor { r ->
Thread(r, "library-watcher-thread").apply { isDaemon = true }
}
}
running.set(true)
// Start watching all existing libraries
val libraries = libraryRepository.findAll()
libraries.forEach { library ->
startWatchingLibrary(library)
}
// Start the watch service thread
executor.submit {
watchForChanges()
}
log.info { "Library Watcher Service started, monitoring ${libraries.size} libraries" }
}
@PreDestroy
fun stop() {
log.debug { "Stopping Library Watcher Service" }
running.set(false)
// Close all watch keys
watchKeys.keys.forEach { it.cancel() }
watchKeys.clear()
libraryWatchers.clear()
// Shutdown executor
executor.shutdown()
try {
if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
executor.shutdownNow()
}
} catch (_: InterruptedException) {
executor.shutdownNow()
Thread.currentThread().interrupt()
}
// Close watch service
watchService?.close()
watchService = null
log.info { "Library Watcher Service stopped" }
}
@Async
@EventListener(LibraryCreatedEvent::class)
fun onLibraryCreated(event: LibraryCreatedEvent) {
if (!running.get()) {
log.debug { "Library created event received but watcher is not running, skipping" }
return
}
log.debug { "Library created event received for library ${event.library.id}" }
startWatchingLibrary(event.library)
}
@Async
@EventListener(LibraryUpdatedEvent::class)
fun onLibraryUpdated(event: LibraryUpdatedEvent) {
if (!running.get()) {
log.debug { "Library updated event received but watcher is not running, skipping" }
return
}
log.debug { "Library updated event received for library ${event.currentState.id}" }
// Stop watching the old directories
stopWatchingLibrary(event.currentState.id!!)
// Start watching the new directories
startWatchingLibrary(event.currentState)
}
@Async
@EventListener(LibraryDeletedEvent::class)
fun onLibraryDeleted(event: LibraryDeletedEvent) {
if (!running.get()) {
log.debug { "Library deleted event received but watcher is not running, skipping" }
return
}
log.debug { "Library deleted event received for library ${event.library.id}" }
stopWatchingLibrary(event.library.id!!)
}
@Async
@EventListener(LibraryFilesystemWatcherConfigUpdatedEvent::class)
fun onFilesystemWatcherConfigUpdated(event: LibraryFilesystemWatcherConfigUpdatedEvent) {
log.debug { "Filesystem watcher configuration updated" }
if (event.isEnabled && !running.get()) {
// Configuration changed to enabled and watcher is not running - start it
log.debug { "Filesystem watcher enabled, starting watchers" }
start()
} else if (!event.isEnabled && running.get()) {
// Configuration changed to disabled and watcher is running - stop it
log.debug { "Filesystem watcher disabled, stopping watchers" }
stop()
}
}
private fun startWatchingLibrary(library: Library) {
val libraryId = library.id ?: return
log.debug { "Starting to watch library '${library.name}' (ID: $libraryId)" }
library.directories.forEach { directoryMapping ->
try {
val path = Paths.get(directoryMapping.internalPath)
if (!path.isDirectory()) {
log.warn { "Path is not a directory: $path" }
return@forEach
}
// Register the directory with the watch service
val service = watchService
if (service == null) {
log.warn { "Watch service is not initialized, cannot watch directory: $path" }
return@forEach
}
val watchKey = path.register(
service,
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_DELETE,
StandardWatchEventKinds.ENTRY_MODIFY
)
val watchInfo = LibraryWatchInfo(libraryId, path)
watchKeys[watchKey] = watchInfo
libraryWatchers.computeIfAbsent(libraryId) { mutableListOf() }.add(watchKey)
log.debug { "Registered watcher for directory: $path in library $libraryId" }
} catch (e: Exception) {
log.error(e) { "Failed to register watcher for directory: ${directoryMapping.internalPath}" }
}
}
}
private fun stopWatchingLibrary(libraryId: Long) {
log.debug { "Stopping watchers for library $libraryId" }
libraryWatchers[libraryId]?.forEach { watchKey ->
watchKey.cancel()
watchKeys.remove(watchKey)
}
libraryWatchers.remove(libraryId)
log.debug { "Stopped all watchers for library $libraryId" }
}
private fun watchForChanges() {
log.debug { "Watch service thread started" }
while (running.get()) {
try {
val watchKey = watchService?.poll(1, TimeUnit.SECONDS) ?: continue
val watchInfo = watchKeys[watchKey] ?: continue
val events = watchKey.pollEvents()
if (events.isEmpty()) {
watchKey.reset()
continue
}
log.debug { "Detected ${events.size} file system events in library ${watchInfo.libraryId}" }
// Group events by type
val hasCreates = events.any { it.kind() == StandardWatchEventKinds.ENTRY_CREATE }
val hasDeletes = events.any { it.kind() == StandardWatchEventKinds.ENTRY_DELETE }
val hasModifies = events.any { it.kind() == StandardWatchEventKinds.ENTRY_MODIFY }
// Process the events
processFileSystemEvents(watchInfo, events, hasCreates, hasDeletes, hasModifies)
// Reset the watch key
if (!watchKey.reset()) {
log.warn { "Watch key no longer valid for path: ${watchInfo.path}" }
watchKeys.remove(watchKey)
libraryWatchers[watchInfo.libraryId]?.remove(watchKey)
}
} catch (_: InterruptedException) {
log.debug { "Watch service thread interrupted" }
Thread.currentThread().interrupt()
break
} catch (e: Exception) {
log.error(e) { "Error processing file system events" }
}
}
log.debug { "Watch service thread stopped" }
}
private fun processFileSystemEvents(
watchInfo: LibraryWatchInfo,
events: List<WatchEvent<*>>,
hasCreates: Boolean,
hasDeletes: Boolean,
hasModifies: Boolean
) {
try {
val library = libraryRepository.findById(watchInfo.libraryId).orElse(null)
if (library == null) {
log.warn { "Library ${watchInfo.libraryId} not found, stopping watcher" }
stopWatchingLibrary(watchInfo.libraryId)
return
}
if (events.isEmpty()) {
log.debug { "No relevant game file changes detected" }
return
}
log.debug {
"Processing ${events.size} relevant file changes in library '${library.name}' " +
"(creates: $hasCreates, deletes: $hasDeletes, modifies: $hasModifies)"
}
// Handle creates (new games)
if (hasCreates) {
handleCreates(library, events.filter {
it.kind() == StandardWatchEventKinds.ENTRY_CREATE
})
}
// Handle deletes (removed games)
if (hasDeletes) {
handleDeletes(library, events.filter {
it.kind() == StandardWatchEventKinds.ENTRY_DELETE
})
}
// Handle modifies (changed file sizes)
if (hasModifies) {
handleModifies(library, watchInfo, events.filter {
it.kind() == StandardWatchEventKinds.ENTRY_MODIFY
})
}
} catch (e: Exception) {
log.error(e) { "Error processing file system events for library ${watchInfo.libraryId}" }
}
}
private fun handleCreates(library: Library, events: List<WatchEvent<*>>) {
log.debug { "Handling ${events.size} create events for library ${library.id}" }
// Trigger a quick scan to add new games
// The scan service will handle the actual game creation
libraryScanService.triggerScan(ScanType.QUICK, listOf(library.id!!))
}
private fun handleDeletes(library: Library, events: List<WatchEvent<*>>) {
log.debug { "Handling ${events.size} delete events for library ${library.id}" }
// Trigger a quick scan to remove deleted games
// The scan service will handle the actual game deletion
libraryScanService.triggerScan(ScanType.QUICK, listOf(library.id!!))
}
private fun handleModifies(library: Library, watchInfo: LibraryWatchInfo, events: List<WatchEvent<*>>) {
log.debug { "Handling ${events.size} modify events for library ${library.id}" }
events.forEach { event ->
@Suppress("UNCHECKED_CAST")
val watchEvent = event as WatchEvent<Path>
val filename = watchEvent.context()
val fullPath = watchInfo.path.resolve(filename)
// Find games that match this path and update their file size
val gamesToUpdate = library.games.filter { game ->
game.metadata.path == fullPath.toString()
}
if (gamesToUpdate.isNotEmpty()) {
log.debug { "Updating file size for ${gamesToUpdate.size} games: $fullPath" }
gamesToUpdate.forEach { game ->
val newFileSize = filesystemService.calculateFileSize(game.metadata.path)
if (game.metadata.fileSize != newFileSize) {
game.metadata.fileSize = newFileSize
gameRepository.save(game)
log.debug { "Updated file size for game '${game.title}' from ${game.metadata.fileSize} to $newFileSize bytes" }
}
}
}
}
}
}
@@ -2,27 +2,34 @@ package org.gameyfin.app.libraries.dto
import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.annotation.JsonInclude
import org.gameyfin.pluginapi.gamemetadata.Platform import org.gameyfin.pluginapi.gamemetadata.Platform
import java.time.Instant
interface LibraryDto { interface LibraryDto {
val id: Long val id: Long
val name: String val name: String
val games: List<Long>? val createdAt: Instant?
val gameIds: List<Long>?
val metadata: LibraryMetadataDto?
} }
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)
data class LibraryUserDto( data class LibraryUserDto(
override val id: Long, override val id: Long,
override val name: String, override val name: String,
override val games: List<Long>? override val createdAt: Instant?,
override val gameIds: List<Long>?,
override val metadata: LibraryMetadataDto?
) : LibraryDto ) : LibraryDto
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)
data class LibraryAdminDto( data class LibraryAdminDto(
override val id: Long, override val id: Long,
override val name: String, override val name: String,
override val createdAt: Instant?,
val directories: List<DirectoryMappingDto>, val directories: List<DirectoryMappingDto>,
val platforms: List<Platform>, val platforms: List<Platform>,
override val games: List<Long>?, override val gameIds: List<Long>?,
val stats: LibraryStatsDto?, val stats: LibraryStatsDto?,
val ignoredPaths: List<IgnoredPathDto>? val ignoredPaths: List<IgnoredPathDto>?,
override val metadata: LibraryMetadataDto?
) : LibraryDto ) : LibraryDto
@@ -0,0 +1,6 @@
package org.gameyfin.app.libraries.dto
data class LibraryMetadataDto(
val displayOnHomepage: Boolean,
val displayOrder: Int
)
@@ -7,5 +7,6 @@ data class LibraryUpdateDto(
val name: String? = null, val name: String? = null,
val directories: List<DirectoryMappingDto>? = null, val directories: List<DirectoryMappingDto>? = null,
val platforms: List<Platform>? = null, val platforms: List<Platform>? = null,
val ignoredPaths: List<IgnoredPathDto>? = null val ignoredPaths: List<IgnoredPathDto>? = null,
val metadata: LibraryMetadataDto? = null
) )
@@ -35,5 +35,8 @@ class Library(
var games: MutableList<Game> = ArrayList(), var games: MutableList<Game> = ArrayList(),
@OneToMany(fetch = FetchType.EAGER, orphanRemoval = true, cascade = [CascadeType.ALL]) @OneToMany(fetch = FetchType.EAGER, orphanRemoval = true, cascade = [CascadeType.ALL])
var ignoredPaths: MutableList<IgnoredPath> = ArrayList() var ignoredPaths: MutableList<IgnoredPath> = ArrayList(),
@Embedded
var metadata: LibraryMetadata = LibraryMetadata()
) )
@@ -0,0 +1,9 @@
package org.gameyfin.app.libraries.entities
import jakarta.persistence.Embeddable
@Embeddable
class LibraryMetadata(
val displayOnHomepage: Boolean = true,
val displayOrder: Int = -1
)
@@ -3,6 +3,7 @@ package org.gameyfin.app.libraries.extensions
import org.gameyfin.app.core.security.isCurrentUserAdmin import org.gameyfin.app.core.security.isCurrentUserAdmin
import org.gameyfin.app.libraries.dto.* import org.gameyfin.app.libraries.dto.*
import org.gameyfin.app.libraries.entities.Library import org.gameyfin.app.libraries.entities.Library
import org.gameyfin.app.libraries.entities.LibraryMetadata
fun Library.toDto(): LibraryDto { fun Library.toDto(): LibraryDto {
@@ -25,7 +26,9 @@ fun Library.toUserDto(): LibraryUserDto {
return LibraryUserDto( return LibraryUserDto(
id = this.id!!, id = this.id!!,
name = this.name, name = this.name,
games = this.games.mapNotNull { it.id } createdAt = this.createdAt!!,
gameIds = this.games.mapNotNull { it.id },
metadata = this.metadata.toDto()
) )
} }
@@ -33,13 +36,29 @@ fun Library.toAdminDto(): LibraryAdminDto {
return LibraryAdminDto( return LibraryAdminDto(
id = this.id!!, id = this.id!!,
name = this.name, name = this.name,
createdAt = this.createdAt!!,
directories = this.directories.map { DirectoryMappingDto(it.internalPath, it.externalPath) }, directories = this.directories.map { DirectoryMappingDto(it.internalPath, it.externalPath) },
platforms = this.platforms, platforms = this.platforms,
games = this.games.mapNotNull { it.id }, gameIds = this.games.mapNotNull { it.id },
stats = LibraryStatsDto( stats = LibraryStatsDto(
gamesCount = this.games.size, gamesCount = this.games.size,
downloadedGamesCount = this.games.sumOf { it.metadata.downloadCount } downloadedGamesCount = this.games.sumOf { it.metadata.downloadCount }
), ),
ignoredPaths = this.ignoredPaths.toDtos() ignoredPaths = this.ignoredPaths.toDtos(),
metadata = this.metadata.toDto()
) )
} }
fun LibraryMetadata.toDto(): LibraryMetadataDto {
return LibraryMetadataDto(
displayOnHomepage = this.displayOnHomepage,
displayOrder = this.displayOrder
)
}
fun LibraryMetadataDto.toEntity(): LibraryMetadata {
return LibraryMetadata(
displayOnHomepage = this.displayOnHomepage,
displayOrder = this.displayOrder
)
}
@@ -51,7 +51,7 @@ class LibraryGameProcessor(
// Note: GameService.update will load and save the managed entity inside this same transaction // Note: GameService.update will load and save the managed entity inside this same transaction
var updated: Game? = null var updated: Game? = null
try { try {
updated = gameService.update(game) updated = gameService.updateMetadata(game)
if (updated != null) { if (updated != null) {
// Download any images now associated with the game // Download any images now associated with the game
downloadImagesForGame(updated) downloadImagesForGame(updated)

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