mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
Overhaul startpage (#821)
* 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 * 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 --------- 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:
@@ -14,7 +14,7 @@ jobs:
|
||||
checks: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
variant: [ alpine, ubuntu ]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download build outputs
|
||||
uses: actions/download-artifact@v5
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
version: ${{ steps.extract_version.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
variant: [ alpine, ubuntu ]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
release_version: ${{ steps.get_version.outputs.release_version }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
checks: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
variant: [ alpine, ubuntu ]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -158,7 +158,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -189,7 +189,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
+13
-1
@@ -1,3 +1,5 @@
|
||||
import org.apache.tools.ant.filters.ReplaceTokens
|
||||
|
||||
group = "org.gameyfin"
|
||||
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-security")
|
||||
implementation("org.springframework.boot:spring-boot-starter-validation")
|
||||
implementation("org.springframework.boot:spring-boot-starter-aop")
|
||||
implementation("org.springframework.cloud:spring-cloud-starter")
|
||||
implementation("jakarta.validation:jakarta.validation-api:3.1.0")
|
||||
|
||||
@@ -70,8 +73,9 @@ dependencies {
|
||||
implementation(project(":plugin-api"))
|
||||
|
||||
// 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("com.vanniktech:blurhash:0.3.0")
|
||||
|
||||
// Development
|
||||
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
||||
@@ -99,3 +103,11 @@ dependencyManagement {
|
||||
tasks.withType<Test> {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
tasks.named<ProcessResources>("processResources") {
|
||||
val projectVersion = rootProject.version.toString()
|
||||
filesMatching("application.yml") {
|
||||
filter<ReplaceTokens>("tokens" to mapOf("project.version" to projectVersion))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+126
-16
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "gameyfin",
|
||||
"version": "2.2.0-preview",
|
||||
"version": "2.3.0-preview",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gameyfin",
|
||||
"version": "2.2.0-preview",
|
||||
"version": "2.3.0-preview",
|
||||
"dependencies": {
|
||||
"@heroui/react": "^2.8.5",
|
||||
"@phosphor-icons/react": "^2.1.7",
|
||||
@@ -31,6 +31,7 @@
|
||||
"@vaadin/vaadin-material-styles": "24.9.4",
|
||||
"@vaadin/vaadin-themable-mixin": "24.9.4",
|
||||
"@vaadin/vaadin-usage-statistics": "2.1.3",
|
||||
"blurhash": "^2.0.5",
|
||||
"classnames": "^2.5.1",
|
||||
"construct-style-sheets-polyfill": "3.1.0",
|
||||
"date-fns": "2.29.3",
|
||||
@@ -54,10 +55,10 @@
|
||||
"react-player": "^2.16.0",
|
||||
"react-realtime-chart": "^0.8.1",
|
||||
"react-router": "7.6.3",
|
||||
"react-window": "^2.2.3",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"swiper": "^11.2.6",
|
||||
"valtio": "^2.1.5",
|
||||
"valtio-reactive": "^0.1.2",
|
||||
"yup": "^1.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -69,6 +70,7 @@
|
||||
"@types/node": "^22.4.0",
|
||||
"@types/react": "19.1.17",
|
||||
"@types/react-dom": "19.1.11",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@vaadin/hilla-generator-cli": "24.9.4",
|
||||
"@vaadin/hilla-generator-core": "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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.3",
|
||||
@@ -3364,6 +3367,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@heroui/system/-/system-2.4.23.tgz",
|
||||
"integrity": "sha512-kgYvfkIOQKM6CCBIlNSE2tXMtNrS1mvEUbvwnaU3pEYbMlceBtwA5v7SlpaJy/5dqKcTbfmVMUCmXnY/Kw4vaQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@heroui/react-utils": "2.1.14",
|
||||
"@heroui/system-rsc": "2.3.20",
|
||||
@@ -3449,6 +3453,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@heroui/theme/-/theme-2.4.23.tgz",
|
||||
"integrity": "sha512-5hoaRWG+/d/t06p7Pfhz70DUP0Uggjids7/z2Ytgup4A8KAOvDIXxvHUDlk6rRHKiN1wDMNA5H+EWsSXB/m03Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@heroui/shared-utils": "2.1.12",
|
||||
"clsx": "^1.2.1",
|
||||
@@ -3997,7 +4002,8 @@
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@open-wc/dedupe-mixin/-/dedupe-mixin-1.4.0.tgz",
|
||||
"integrity": "sha512-Sj7gKl1TLcDbF7B6KUhtvr+1UCxdhMbNY5KxdU5IfMFWqL8oy1ZeAcCANjoB1TL0AJTcPmcCFsCbHf8X2jGDUA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@phosphor-icons/react": {
|
||||
"version": "2.1.10",
|
||||
@@ -4017,6 +4023,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@polymer/polymer/-/polymer-3.5.2.tgz",
|
||||
"integrity": "sha512-fWwImY/UH4bb2534DVSaX+Azs2yKg8slkMBHOyGeU2kKx7Xmxp6Lee0jP8p6B3d7c1gFUPB2Z976dTUtX81pQA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webcomponents/shadycss": "^1.9.1"
|
||||
}
|
||||
@@ -6946,6 +6953,7 @@
|
||||
"integrity": "sha512-pAZSHMiagDR7cARo/cch1f3rXy0AEXwsVsVH09FcyeJVAzCnGgmYis7P3JidtTUjyadhTeSo8TgRPswstghDaw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -6955,6 +6963,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz",
|
||||
"integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -6965,10 +6974,21 @@
|
||||
"integrity": "sha512-3BKc/yGdNTYQVVw4idqHtSOcFsgGuBbMveKCOgF8wQ5QtrYOc3jDIlzg3jef04zcXFIHLelyGlj0T+BJ8+KN+w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@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": {
|
||||
"version": "1.20.2",
|
||||
"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",
|
||||
"integrity": "sha512-y8Rrq84MOyCYJ5rbzWtm7rqP3UNX5r5aspKFVYDNATLVcqFMFqUopz5Tn+YMRMb0MJ3K3GU3vy+xWVcF3WyzAg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -7017,6 +7038,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/accordion/-/accordion-24.9.4.tgz",
|
||||
"integrity": "sha512-AkeNGWA7TOVM8hrh0JzMNdWrRScIS9HN4t4UJ8DFSQVsYwDkixZqDOCkoCGoNJ+iRbKzrTcA2a9rHIuk+qjh+A==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -7034,6 +7056,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/app-layout/-/app-layout-24.9.4.tgz",
|
||||
"integrity": "sha512-VhFOyQSLV5ALm9C9YNmRw7TrsPZ3R/lwIYhowoYIDHE4o7NbW6EdbgdUYkEBa3EPI2oC5IYZzLkXz+kLYgvCIg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -7051,6 +7074,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/avatar/-/avatar-24.9.4.tgz",
|
||||
"integrity": "sha512-/CzAHhwjGC8fXpsBlm6oUpj1PKZh7a0Nz451R1XGkNF4We2RK76yEwTkZSEDlJR+6Fx1mDH+nnXU1wplGUVynw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -7068,6 +7092,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/avatar-group/-/avatar-group-24.9.4.tgz",
|
||||
"integrity": "sha512-ao2J8wsubP/HOV18ftl5a9C5gDQ41NYAskQOc+B2Mjv9K+Gt/Nsp+I7vl/vli0gTWYVCbpTAm8oXVSqaOYyRWQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -7423,6 +7448,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/button/-/button-24.9.4.tgz",
|
||||
"integrity": "sha512-Wg+gnrQ3LT9WpJGT91WCXFpX9HOpReyoq77K2jjsnzx2ZN6d3iHmPImVIdA658fLE6U3RCyRazQymoWQ01VEuA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -7439,6 +7465,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/card/-/card-24.9.4.tgz",
|
||||
"integrity": "sha512-DzHGUjA9ESkAC6oJrAUx28OunwSs33lwNWQzq0Xh64mWafUWMjc0k6QvGc45rdXOY/MZXkG21SWqahAVi7qWVA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vaadin/component-base": "~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",
|
||||
"integrity": "sha512-B381QSKkmi0bpnmw+BV/oMGrEAkLVHxrL75mrrl0jEzA9nzc+vTsb72cONnbp6XCO17hb+sVQ6gdAxBrSsr59A==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -7469,6 +7497,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/checkbox-group/-/checkbox-group-24.9.4.tgz",
|
||||
"integrity": "sha512-LpqbUpzWLlC3lXGRNrbMPAb6rOA7sOQYPrfonw1aVM3u4IiS+T9ffKCQHeTrxeV+S0Ddmiu0rCA2q7j4EcMEPw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -7487,6 +7516,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/combo-box/-/combo-box-24.9.4.tgz",
|
||||
"integrity": "sha512-DrAxKvu6rF/o2t1rP5zEWEj2A4R0OqkbkdaqbFOJDMdVSTHYOTHzmu/M38jRB9owSsTGJW9q9L2ATJcio2NECA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -7520,6 +7550,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/component-base/-/component-base-24.9.4.tgz",
|
||||
"integrity": "sha512-bS28kpICJodSHTEJQwQexNGkhdXbPtPhmUJGWk6gx+9Dmuzeq5C/xlnkYpuRbGnvR4lGUL2SdK6ZlhcPzKXncQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -7533,6 +7564,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/confirm-dialog/-/confirm-dialog-24.9.4.tgz",
|
||||
"integrity": "sha512-RTkr9k8HaYYnV4a/DoPFuDWSO3+LjubM8+FhOio8RgGyMWyyEXIlhdFqsCwV7EmyEZ3rje8Mk5zpUSabBN9fVQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -7551,6 +7583,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/context-menu/-/context-menu-24.9.4.tgz",
|
||||
"integrity": "sha512-/ESoTcMP6EGpyVYoueEKwCSspbnOLlMFYChLiPreCTPJddvPAX1Wvip9VA9JHzEPqYlJ8f9sf0fCPCFYF+WNfQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -7571,6 +7604,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/custom-field/-/custom-field-24.9.4.tgz",
|
||||
"integrity": "sha512-hIcs5V7u6HnlL4t7/JIUTrCRTI0bcupH6Yo5gyO+8g9TojVLH9XJvg148P4pRD/aoSEaC7N2GjrB86051hdqhg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -7588,6 +7622,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/date-picker/-/date-picker-24.9.4.tgz",
|
||||
"integrity": "sha512-CiPt0KKPfmQvdijzP+J8nZmv/Gg1o2segUeeSovwX0y2zi9CDGPhBOEh7X+ZqoouRBc/XzaCaziZv5U1asExqg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.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",
|
||||
"integrity": "sha512-GQsxm0h5vjam1KrM2g6AN7/fb9ZV3JiL8asW4t5FczvefjRUapSZCyqQem8z/AAqea/Nifyy/67tH8PwbEEISg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -7628,6 +7664,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/details/-/details-24.9.4.tgz",
|
||||
"integrity": "sha512-bEQsVGBCtTc49g9jpVUCHw/JfkZxHlHZ6N9y2CsYb3cCfpOoXPnhQsDPBmOlnCFSGOwwgQC2F2gi69P3rdZR0Q==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -7645,6 +7682,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/dialog/-/dialog-24.9.4.tgz",
|
||||
"integrity": "sha512-p+MXYfn1/OliqElQKy31/Wopo133HH5b/JoYt6dd2+Kn2YYXUR1LzDBxsMXumiEdkoowiUary7xFNvaSteStlw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -7662,6 +7700,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/email-field/-/email-field-24.9.4.tgz",
|
||||
"integrity": "sha512-GWf20H4j8Mad2+cm/2pViAKllW3kIweNjoIR64rqUvooSKrEHzoAmi+iKuCKTkaW3ljub2BVTQ3WCqYkAiA4jA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
"@vaadin/component-base": "~24.9.4",
|
||||
@@ -7677,6 +7716,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/field-base/-/field-base-24.9.4.tgz",
|
||||
"integrity": "sha512-ANUMWe08i0BTv9DoMcStdtxy8gKnvt7ERCLSINrIPAI19tAhrooifHXYxr6ig7ebK4gmjIc1TKqLeelWjesBhA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -7690,6 +7730,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/field-highlighter/-/field-highlighter-24.9.4.tgz",
|
||||
"integrity": "sha512-8hFSAjXyKUR5Y+fSytJTp9wiKxDAjXHoUd1TxsSUvOMmG7p9tYQ25xT9tnFCR3O9bFUdb0TPF8Uo2+6oFUjYNA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
"@vaadin/a11y-base": "~24.9.4",
|
||||
@@ -7706,6 +7747,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/form-layout/-/form-layout-24.9.4.tgz",
|
||||
"integrity": "sha512-o0HO2hrOiXT0Pdqf/DxqsckRTtoIyUjDZX+RC+A4h9KxyUKtIDGHNmJEFfZq9p9iZeJNCz/Yejvq8tb8BtwfwA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -7722,6 +7764,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/grid/-/grid-24.9.4.tgz",
|
||||
"integrity": "sha512-Dxr+hB4cjr3AKGE7bhn7V5TTjIU2aAvsDHx2canxUj823hTrJ6OuVY7auuDyxEwZCduTUVK4dSlY6C0GLSGqwA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -8133,6 +8176,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/horizontal-layout/-/horizontal-layout-24.9.4.tgz",
|
||||
"integrity": "sha512-DHQZTcLnOCMMl9kJn2DXOTFhY2N3m6eVS8hx6ehnFE1ySNNxk7M2SgCAZRiAmAiQGUzaCWIlPdeo6OBOdRZiFg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
"@vaadin/component-base": "~24.9.4",
|
||||
@@ -8147,6 +8191,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/icon/-/icon-24.9.4.tgz",
|
||||
"integrity": "sha512-0JTSsi/U3z2I/gDZisLqzwJSHYeem3hQcMhNiul9gn1iWVnrfzehpZRAG9L3UhlAKssvh+ttYRjHyeiXR/Rqhg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -8161,6 +8206,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/icons/-/icons-24.9.4.tgz",
|
||||
"integrity": "sha512-aps89oGV+SrDwGDlr6/cO2kt+4gaz0oU4nnLBhJE9p7ZNDSjD6O91mLednKlK/RbpdMDLZ6S9gFgtLrTnrE7Lw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
"@vaadin/icon": "~24.9.4"
|
||||
@@ -8171,6 +8217,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/input-container/-/input-container-24.9.4.tgz",
|
||||
"integrity": "sha512-uS+gVgc4NPAR3YcXvJZGbeN48G1tviQZ1n7ap3CAHxai5iCxBYUOwao3FLRkWtDG5KHrEKLFtAw6YCquM+P3NA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
"@vaadin/component-base": "~24.9.4",
|
||||
@@ -8185,6 +8232,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/integer-field/-/integer-field-24.9.4.tgz",
|
||||
"integrity": "sha512-a1iNJyIp6fGjIguHkZ6aV6e9PiEhZVCtSNItkoqmXCgmtNuhlpp0TwWniq5SoFSYFM/uExyv+JAXkxk7BbDuIw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
"@vaadin/component-base": "~24.9.4",
|
||||
@@ -8198,6 +8246,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/item/-/item-24.9.4.tgz",
|
||||
"integrity": "sha512-jSOA3SUrGVTYuPD/KqoL5lxVDoxwde5hzC+nDv6BtDvwNmnVMFUB7DKSKSuiPrult2DuUjqNGEJrTXTRhzodFg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -8214,6 +8263,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/list-box/-/list-box-24.9.4.tgz",
|
||||
"integrity": "sha512-idi7Cag09/4snCvVdmrfnB5iPnpvDXzOT6I3SBkFY3mx7l8kceV5/Bk2/1ZqYgWHs52UGwnAeXpT5AGeseGdqQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -8231,6 +8281,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/lit-renderer/-/lit-renderer-24.9.4.tgz",
|
||||
"integrity": "sha512-fXoM9XS0nWqUTO0GQVe5m2Oaa1Ae5Rhc1HwzIiX2nTiRuFlX3tfHPeaZJpcG+MuQLn3qtc6rAzHiGl1Y+Tovkw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lit": "^3.0.0"
|
||||
}
|
||||
@@ -8240,6 +8291,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/login/-/login-24.9.4.tgz",
|
||||
"integrity": "sha512-IVNUZl7Gm50Jm2KNgV7EznL2blGroaYoZDj4vrnrd2mAPMgGfKqEEEMvQn1RNeFTguBblWMAABKXJ65q2dFzHg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -8259,6 +8311,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/markdown/-/markdown-24.9.4.tgz",
|
||||
"integrity": "sha512-afhIEWc6RbbCoNy5WkP8U0GxwMrKpghHC/k0JEntJ6DrcT1zAhrhasLzHRL7GcXTDbTtbQUKGXlAhjebwPXTvA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@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",
|
||||
"integrity": "sha512-XKL9ucABTdJDvHgAJRs64Wzot8NgPj/RpnuISoozcvHK6NtQTje+TjJgVehCcJnyefbabed00UCT/YsCOTnA3Q==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vaadin/a11y-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",
|
||||
"integrity": "sha512-Fkto0kNLkuEfqaVTMm3VtRbUvawOVMsrSbyAe3zRSO72uXQ32Ir2OFHGWy3UD+xytAGz3yj5pqeESNAsoTIRsA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -8310,6 +8365,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/message-input/-/message-input-24.9.4.tgz",
|
||||
"integrity": "sha512-YZWmZgVBxZLasGL9ni0TTqY3UilcjrpKyJvBnziUq3W2NiXUvnQOGskXwpVkD5sQi2f/D5d18IfCaW2FSOR7Rg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -8327,6 +8383,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/message-list/-/message-list-24.9.4.tgz",
|
||||
"integrity": "sha512-8uY6/Z7rBYzeLTe6ASB8Ub7dmIWiM6hzcwL1U2k/FXGZYyQ1MK3BGlqbH/QX0B0rR+3+F//ORZmh8efg5gTH9A==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.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",
|
||||
"integrity": "sha512-fH0WKt93ucm6cSTwuIAVH4e/Qm1oLspuO0em3OfgrEoZN7b5gPAAaqI/gyUDs5+PTRyqJ7YyIu1m5VI8fLJkmg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -8367,6 +8425,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/notification/-/notification-24.9.4.tgz",
|
||||
"integrity": "sha512-ZRqPo0PZo/VqExs0DKQ3kGjUEvUgm2DPsHAPI0LzQXv1OS7Roet9T2tL7y9//CK3JGlzfTmhqlH7XxUR/DvbCw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -8384,6 +8443,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/number-field/-/number-field-24.9.4.tgz",
|
||||
"integrity": "sha512-Z9kmiJTZvoWPWt7TQJOLu8jcI4J0F+dHhjBoeTfMtLkrMTHiKiWj1oKbamtsS1+6DfOMkcrsY2C/cfsPFlA45g==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -8402,6 +8462,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/overlay/-/overlay-24.9.4.tgz",
|
||||
"integrity": "sha512-0oYDV//7rCxzt2tXPVyJYqUtFn4EiHXrC1lDmbUzWtbGIQZwP3KALiYSk3ylP+bqxv2KTA0m6FjObj8wTj5s8g==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -8418,6 +8479,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/password-field/-/password-field-24.9.4.tgz",
|
||||
"integrity": "sha512-LfVsS3lSGqVyNbTJ5dVp8qe5pHYF1//8wg7IXw/AZqnqGbcqkI8gezUHaNnW0yFY1Wx0ZVmee5akZisB4z0H2g==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.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",
|
||||
"integrity": "sha512-5j38OaD+M44c6qBnrrt84ZiTTDbjHW5/8RsJBkqmvuZs5jQ9KXBqLqCV1WCZKAWr/aGcPS2NPBTpuFLNn0czRw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
"@vaadin/vaadin-themable-mixin": "~24.9.4",
|
||||
@@ -8448,6 +8511,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/popover/-/popover-24.9.4.tgz",
|
||||
"integrity": "sha512-v5iZAukyG4V1TV6OTJ4feo8yuLn3fyu7QYEvhIQFhTp5THrlmtpjlx+FXIU8JWm/uoJ0RjRUeJXUkp+Zg6sdlA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@vaadin/a11y-base": "~24.9.4",
|
||||
@@ -8465,6 +8529,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/progress-bar/-/progress-bar-24.9.4.tgz",
|
||||
"integrity": "sha512-RBxOG5D0lsbUl0WreFDrKCVrtZSHEwrjfr7Wx+7QMI2c26TpElSF2yffF7Z+IRqqtF0Q71MbtQyEx23ej0AQ1A==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -8480,6 +8545,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/radio-group/-/radio-group-24.9.4.tgz",
|
||||
"integrity": "sha512-qQ4VlIEsq4p5SNaSg8EglHAmmLnVpz53cMp91qb7fCLPD0zlaoC3wvQKiISpSDnYKlskUf7r6yyYjDi5yQL9vQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -8581,6 +8647,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/scroller/-/scroller-24.9.4.tgz",
|
||||
"integrity": "sha512-yOfKlwbItnzD9XR51/WThvjyFUIBVeIKu75FHpmlmUnjjAnwj4kyw0AjnUu9itGX+R5D83ZidondbcWtqhFexQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -8597,6 +8664,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/select/-/select-24.9.4.tgz",
|
||||
"integrity": "sha512-Ii70lQGcys1alONgIKo2ufithPzFI4s0K5uDFFSH3e12NHFAZu8VxA8tYbWwLh1H5q209ib1MnfUuUevHghVUA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.2.0",
|
||||
@@ -8620,6 +8688,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/side-nav/-/side-nav-24.9.4.tgz",
|
||||
"integrity": "sha512-pLAcoZzzMA+vQHuqo6jy8N4G0bz6qFtIM+BpJ9kcHYiulUg/Y7NPa4K2YsHF5gChZwE9cbxGMc0lPoN6p8QeKg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@vaadin/a11y-base": "~24.9.4",
|
||||
@@ -8635,6 +8704,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/split-layout/-/split-layout-24.9.4.tgz",
|
||||
"integrity": "sha512-oTL0IT7Cm6flCOtMg3odZo+qEczSmL2OS61Mf67BIAYvOG08OMNZElKJ57tVe50NL5DIGMmMtcfyC5Mni/mWuw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -8650,6 +8720,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/tabs/-/tabs-24.9.4.tgz",
|
||||
"integrity": "sha512-YfFZ+SV+2HtHIvdhHf0jTFc0spUjt3gQK8ckdw12DrOR2OPnShx1BFEDkor0NQqVLmLgvksbPxYJPhVf8wvj1w==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -8667,6 +8738,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/tabsheet/-/tabsheet-24.9.4.tgz",
|
||||
"integrity": "sha512-Yy37yU2p2zazxqVx2rj2ljjrrZVq/jqu01HhgwCrnA7kpi21p9uVV8EvuAg17qU9uKFc8LQseNRKMtis9K7TcA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -8684,6 +8756,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/text-area/-/text-area-24.9.4.tgz",
|
||||
"integrity": "sha512-pihekoVk4wIeoqykvNyb8S81PS8NFHLKRZqcnsDgtMyIEg7UlRPSIgUZEbFAGzrU9WnY+NJehzhVnPFKBuePxw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -8702,6 +8775,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/text-field/-/text-field-24.9.4.tgz",
|
||||
"integrity": "sha512-1Yr8Hn5o4r3mBA0jXgRiMiAlfIOe2H/3tkWHj8KBdxH2PfwDGjzXvf5995g6kO4/b2E2sWNtMTQYufEr1HLlWw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -8720,6 +8794,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/time-picker/-/time-picker-24.9.4.tgz",
|
||||
"integrity": "sha512-4agGlkzbEfCXPtwiTNrG22r8ayA6R1U/jq0HeaXGFb6toLDZZyPsxKlZfA0FEvOF3EmyGHIKuEG6sR5X+GyCTg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -8740,6 +8815,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/tooltip/-/tooltip-24.9.4.tgz",
|
||||
"integrity": "sha512-p+zGTb5Q0HsCjbF8rbNU5SoadH9vC1Yz1qrvsgji61cDDcQ8VRQYbYjq85YHVQ9l0GHIhIJTIkC8MvqjPeGCtg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -8758,6 +8834,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/upload/-/upload-24.9.4.tgz",
|
||||
"integrity": "sha512-71yoqOL3p5mbuBZvMh9qzZLpEbbjXRUd+Q7kQsNLHaZHC+ca2usFvXRk9XK8GmFE9in9/+0fsMw9a33D1OF3Vw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -8775,13 +8852,15 @@
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/vaadin-development-mode-detector/-/vaadin-development-mode-detector-2.0.7.tgz",
|
||||
"integrity": "sha512-9FhVhr0ynSR3X2ao+vaIEttcNU5XfzCbxtmYOV8uIRnUCtNgbvMOIcyGBvntsX9I5kvIP2dV3cFAOG9SILJzEA==",
|
||||
"license": "Apache-2.0"
|
||||
"license": "Apache-2.0",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@vaadin/vaadin-lumo-styles": {
|
||||
"version": "24.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/vaadin-lumo-styles/-/vaadin-lumo-styles-24.9.4.tgz",
|
||||
"integrity": "sha512-g6qx0Fp9VOvjKUnYyxgE42Jl5c+LdiA9T6KxPCzOF8EiaHKsCLLKecnvwmV6SelQ45AbVLg4YiROFJ27k38d/w==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
"@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",
|
||||
"integrity": "sha512-XfxSq/pgVDAbGXKLJuDqR5uFT0zRac6UfY8Q+aNzv2R53PAm3vrt/S6XQ8VHDMCV/+8mVT2dZSnl22pafSEDcw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"lit": "^3.0.0",
|
||||
@@ -8817,6 +8897,7 @@
|
||||
"integrity": "sha512-8r4TNknD7OJQADe3VygeofFR7UNAXZ2/jjBFP5dgI8+2uMfnuGYgbuHivasKr9WSQ64sPej6m8rDoM1uSllXjQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"integrity": "sha512-kiVhwIGUXSA+fi6klIzLXauVF4TfYu07ME2BkwnC0I4ymQqN4SZiAemUP+0/Gp5XRJ23H4q5cSTYws5u8C4cwA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
"@vaadin/component-base": "~24.9.4",
|
||||
@@ -8843,6 +8925,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vaadin/virtual-list/-/virtual-list-24.9.4.tgz",
|
||||
"integrity": "sha512-Jc7puX8ln83CrVUQ2t3pK9B1crq+MJATEkKTwYZNZ1u4VfpwDWE3srN8ZTidIjUSOWWNXPHbBlnlAJSaOmyk/Q==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-wc/dedupe-mixin": "^1.3.0",
|
||||
"@polymer/polymer": "^3.0.0",
|
||||
@@ -8948,6 +9031,7 @@
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -9201,6 +9285,12 @@
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
@@ -9238,6 +9328,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.9",
|
||||
"caniuse-lite": "^1.0.30001746",
|
||||
@@ -9490,7 +9581,8 @@
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/client-only": {
|
||||
"version": "0.0.1",
|
||||
@@ -10087,6 +10179,7 @@
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@@ -10390,6 +10483,7 @@
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
|
||||
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"peer": true,
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
@@ -10900,6 +10994,7 @@
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.22.tgz",
|
||||
"integrity": "sha512-ZgGvdxXCw55ZYvhoZChTlG6pUuehecgvEAJz0BHoC5pQKW1EC5xf1Mul1ej5+ai+pVY0pylyFfdl45qnM1/GsA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.23.21",
|
||||
"motion-utils": "^12.23.6",
|
||||
@@ -12385,6 +12480,7 @@
|
||||
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.1.tgz",
|
||||
"integrity": "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@lit/reactive-element": "^2.1.0",
|
||||
"lit-element": "^4.2.0",
|
||||
@@ -12489,6 +12585,7 @@
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz",
|
||||
"integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
@@ -13444,7 +13541,8 @@
|
||||
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
|
||||
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/own-keys": {
|
||||
"version": "1.0.1",
|
||||
@@ -13686,6 +13784,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -13789,6 +13888,7 @@
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
@@ -13871,6 +13971,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
|
||||
"integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -14000,6 +14101,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz",
|
||||
"integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
@@ -14096,6 +14198,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.3.tgz",
|
||||
"integrity": "sha512-zf45LZp5skDC6I3jDLXQUu0u26jtuP4lEGbc7BbdyxenBN1vJSTA18czM2D+h5qyMBuMrD+9uB+mU37HIoKGRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
@@ -14167,6 +14270,16 @@
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"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",
|
||||
"integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -15260,6 +15374,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz",
|
||||
"integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/dcastil"
|
||||
@@ -15376,6 +15491,7 @@
|
||||
"integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==",
|
||||
"devOptional": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.3",
|
||||
"acorn": "^8.15.0",
|
||||
@@ -15625,6 +15741,7 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"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": {
|
||||
"version": "6.0.3",
|
||||
"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",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@@ -16474,6 +16583,7 @@
|
||||
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"rollup": "dist/bin/rollup"
|
||||
},
|
||||
|
||||
+8
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gameyfin",
|
||||
"version": "2.2.1",
|
||||
"version": "2.3.0-preview",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@heroui/react": "^2.8.5",
|
||||
@@ -26,6 +26,7 @@
|
||||
"@vaadin/vaadin-material-styles": "24.9.4",
|
||||
"@vaadin/vaadin-themable-mixin": "24.9.4",
|
||||
"@vaadin/vaadin-usage-statistics": "2.1.3",
|
||||
"blurhash": "^2.0.5",
|
||||
"classnames": "^2.5.1",
|
||||
"construct-style-sheets-polyfill": "3.1.0",
|
||||
"date-fns": "2.29.3",
|
||||
@@ -49,10 +50,10 @@
|
||||
"react-player": "^2.16.0",
|
||||
"react-realtime-chart": "^0.8.1",
|
||||
"react-router": "7.6.3",
|
||||
"react-window": "^2.2.3",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"swiper": "^11.2.6",
|
||||
"valtio": "^2.1.5",
|
||||
"valtio-reactive": "^0.1.2",
|
||||
"yup": "^1.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -64,6 +65,7 @@
|
||||
"@types/node": "^22.4.0",
|
||||
"@types/react": "19.1.17",
|
||||
"@types/react-dom": "19.1.11",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@vaadin/hilla-generator-cli": "24.9.4",
|
||||
"@vaadin/hilla-generator-core": "24.9.4",
|
||||
"@vaadin/hilla-generator-plugin-backbone": "24.9.4",
|
||||
@@ -137,7 +139,6 @@
|
||||
"react-markdown": "$react-markdown",
|
||||
"remark-breaks": "$remark-breaks",
|
||||
"valtio": "$valtio",
|
||||
"valtio-reactive": "$valtio-reactive",
|
||||
"fzf": "$fzf",
|
||||
"@vaadin/router": "2.0.0",
|
||||
"@tailwindcss/vite": "$@tailwindcss/vite",
|
||||
@@ -202,7 +203,9 @@
|
||||
"@vaadin/upload": "24.9.4",
|
||||
"@vaadin/vertical-layout": "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": {
|
||||
"dependencies": {
|
||||
@@ -264,6 +267,6 @@
|
||||
"workbox-precaching": "7.3.0"
|
||||
},
|
||||
"disableUsageStatistics": true,
|
||||
"hash": "45fe1cd9320d2da603b811b433279d79b37370c9732e877490fc304807ef6163"
|
||||
"hash": "d06c4b56ae3a7bc3c4356d3669fc1ed559d83e5285df4e8b3e99bff46869f939"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -19,6 +19,7 @@ import {initializeGameRequestState} from "Frontend/state/GameRequestState";
|
||||
import {initializePlatformState} from "Frontend/state/PlatformState";
|
||||
import {initializeDownloadSessionState} from "Frontend/state/DownloadSessionState";
|
||||
import {initializeUserState} from "Frontend/state/UserState";
|
||||
import {initializeCollectionState} from "Frontend/state/CollectionState";
|
||||
|
||||
export default function App() {
|
||||
client.middlewares = [ErrorHandlingMiddleware];
|
||||
@@ -48,10 +49,11 @@ function ViewWithAuth() {
|
||||
if (auth.state.initializing || auth.state.loading) return;
|
||||
|
||||
initializeLibraryState();
|
||||
initializeGameState();
|
||||
initializeCollectionState();
|
||||
initializePlatformState();
|
||||
initializeGameRequestState();
|
||||
initializePluginState();
|
||||
initializeGameState();
|
||||
|
||||
if (isAdmin(auth)) {
|
||||
initializeScanState();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {useNavigate} from "react-router";
|
||||
import Avatar from "Frontend/components/general/Avatar";
|
||||
@@ -19,7 +19,7 @@ export default function ProfileMenu() {
|
||||
{
|
||||
label: "Administration",
|
||||
icon: <GearFineIcon/>,
|
||||
onClick: () => navigate("/administration/libraries"),
|
||||
onClick: () => navigate("/administration/games"),
|
||||
showIf: isAdmin(auth)
|
||||
},
|
||||
{
|
||||
|
||||
@@ -12,7 +12,7 @@ import {DownloadSessionCard} from "Frontend/components/general/cards/DownloadSes
|
||||
import {humanFileSize} from "Frontend/util/utils";
|
||||
|
||||
function DownloadManagementLayout({getConfig, formik}: any) {
|
||||
const sessions = useSnapshot(downloadSessionState).all as SessionStatsDto[];
|
||||
const sessions = useSnapshot(downloadSessionState).all;
|
||||
const [lastDaySum, setLastDaySum] = React.useState<number>(0);
|
||||
|
||||
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">
|
||||
<ConfigFormField
|
||||
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")}/>
|
||||
</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
|
||||
isOpen={editorModal.isOpen}
|
||||
onOpenChange={editorModal.onOpenChange}
|
||||
selectedTemplate={selectedTemplate!!}
|
||||
selectedTemplate={selectedTemplate!}
|
||||
/>
|
||||
|
||||
<SendTestNotificationModal
|
||||
isOpen={testNotificationModal.isOpen}
|
||||
onOpenChange={testNotificationModal.onOpenChange}
|
||||
selectedTemplate={selectedTemplate!!}
|
||||
selectedTemplate={selectedTemplate!}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function PluginManagement() {
|
||||
<div className="flex flex-col gap-8">
|
||||
{pluginTypes.map(type =>
|
||||
// @ts-ignore
|
||||
<PluginManagementSection key={type} type={type} plugins={state.pluginsByType[type]}/>
|
||||
<PluginManagementSection key={type} type={type}/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+24
-40
@@ -3,14 +3,14 @@ import withConfigPage from "Frontend/components/administration/withConfigPage";
|
||||
import * as Yup from 'yup';
|
||||
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
|
||||
import Section from "Frontend/components/general/Section";
|
||||
import {addToast, Button, Checkbox, CheckboxGroup, Tooltip} from "@heroui/react";
|
||||
import { MagicWandIcon, WarningIcon } from "@phosphor-icons/react";
|
||||
import {addToast, Button} from "@heroui/react";
|
||||
import {MagicWandIcon} from "@phosphor-icons/react";
|
||||
|
||||
function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) {
|
||||
function SecurityManagementLayout({getConfig, formik, setSaveMessage}: any) {
|
||||
|
||||
useEffect(() => {
|
||||
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 {
|
||||
setSaveMessage(null);
|
||||
}
|
||||
@@ -43,41 +43,26 @@ function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Section title="SSO configuration"/>
|
||||
<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"/>
|
||||
<h2 className="text-xl font-bold mb-4">SSO Provider Configuration</h2>
|
||||
<ConfigFormField configElement={getConfig("sso.oidc.client-id")}
|
||||
isDisabled={!formik.values.sso.oidc.enabled}/>
|
||||
<ConfigFormField configElement={getConfig("sso.oidc.client-secret")}
|
||||
@@ -111,7 +96,6 @@ const validationSchema = Yup.object({
|
||||
sso: Yup.object({
|
||||
oidc: Yup.object({
|
||||
enabled: Yup.boolean(),
|
||||
"auto-register-new-users": Yup.boolean().required(),
|
||||
"match-existing-users-by": Yup.string().required(),
|
||||
"client-id": Yup.string().when("enabled", ([enabled], schema) =>
|
||||
enabled ? schema.required("Client ID is required") : schema
|
||||
@@ -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 {UserEndpoint} from "Frontend/generated/endpoints";
|
||||
import {UserManagementCard} from "Frontend/components/general/cards/UserManagementCard";
|
||||
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
||||
import { InfoIcon, UserPlusIcon } from "@phosphor-icons/react";
|
||||
import {UserPlusIcon} from "@phosphor-icons/react";
|
||||
import {Button, Divider, Tooltip, useDisclosure} from "@heroui/react";
|
||||
import InviteUserModal from "Frontend/components/general/modals/InviteUserModal";
|
||||
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">
|
||||
<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">
|
||||
<Button isIconOnly variant="flat" onPress={inviteUserModal.onOpen}>
|
||||
<UserPlusIcon/>
|
||||
|
||||
@@ -3,7 +3,7 @@ import {ConfigEndpoint} from "Frontend/generated/endpoints";
|
||||
import ConfigEntryDto from "Frontend/generated/org/gameyfin/app/config/dto/ConfigEntryDto";
|
||||
import {Form, Formik} from "formik";
|
||||
import {Button, Skeleton} from "@heroui/react";
|
||||
import { CheckIcon, InfoIcon } from "@phosphor-icons/react";
|
||||
import {CheckIcon, InfoIcon} from "@phosphor-icons/react";
|
||||
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
||||
import {configState, initializeConfigState, NestedConfig} from "Frontend/state/ConfigState";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
@@ -32,7 +32,7 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
|
||||
}
|
||||
|
||||
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> {
|
||||
|
||||
@@ -11,16 +11,15 @@ import {
|
||||
} from "@heroui/react";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {scanState} from "Frontend/state/ScanState";
|
||||
import LibraryScanProgress from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryScanProgress";
|
||||
import {libraryState} from "Frontend/state/LibraryState";
|
||||
import { TargetIcon, WarningIcon } from "@phosphor-icons/react";
|
||||
import {TargetIcon, WarningIcon} from "@phosphor-icons/react";
|
||||
import {timeBetween, timeUntil, toTitleCase} from "Frontend/util/utils";
|
||||
import LibraryScanStatus from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryScanStatus";
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
export default function ScanProgressPopover() {
|
||||
const libraries = useSnapshot(libraryState).state;
|
||||
const scans = useSnapshot(scanState).sortedByStartTime as LibraryScanProgress[];
|
||||
const scans = useSnapshot(scanState).sortedByStartTime;
|
||||
const scanInProgress = useSnapshot(scanState).isScanning;
|
||||
|
||||
// Add state to track current time and force re-renders
|
||||
@@ -50,7 +49,7 @@ export default function ScanProgressPopover() {
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<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 ?
|
||||
<p className="flex h-12 items-center justify-center text-sm text-default-500">
|
||||
No scans in progress or in history.
|
||||
@@ -59,12 +58,12 @@ export default function ScanProgressPopover() {
|
||||
{scans.map((scan, index) =>
|
||||
<div className="flex flex-col" key={scan.scanId}>
|
||||
<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
|
||||
<Link underline="always"
|
||||
color="foreground"
|
||||
size="sm"
|
||||
href={`/administration/libraries/library/${scan.libraryId}`}>
|
||||
href={`/administration/games/library/${scan.libraryId}`}>
|
||||
{libraries[scan.libraryId].name}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
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 {gameState} from "Frontend/state/GameState";
|
||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||
import {useNavigate} from "react-router";
|
||||
import {GameCover} from "Frontend/components/general/covers/GameCover";
|
||||
|
||||
@@ -10,7 +9,7 @@ export default function SearchBar() {
|
||||
|
||||
const navigate = useNavigate();
|
||||
const state = useSnapshot(gameState);
|
||||
const games = state.games as GameDto[];
|
||||
const games = state.games;
|
||||
|
||||
return <Autocomplete
|
||||
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 GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||
import React from "react";
|
||||
import {LibraryEndpoint} from "Frontend/generated/endpoints";
|
||||
import {GameCover} from "Frontend/components/general/covers/GameCover";
|
||||
@@ -23,7 +22,9 @@ export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
|
||||
const randomGames = 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 [];
|
||||
return games.slice(0, MAX_COVER_COUNT);
|
||||
}
|
||||
@@ -40,7 +41,7 @@ export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
|
||||
{randomGames.length > 0 &&
|
||||
<div className="absolute flex flex-row">
|
||||
{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>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
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 React, {ReactNode} from "react";
|
||||
import PluginDetailsModal from "Frontend/components/general/modals/PluginDetailsModal";
|
||||
@@ -105,11 +120,11 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
|
||||
return state === PluginState.DISABLED;
|
||||
}
|
||||
|
||||
function togglePluginEnabled() {
|
||||
async function togglePluginEnabled() {
|
||||
if (isDisabled(plugin.state)) {
|
||||
PluginEndpoint.enablePlugin(plugin.id);
|
||||
await PluginEndpoint.enablePlugin(plugin.id);
|
||||
} 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 {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 {
|
||||
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) {
|
||||
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 (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,212px))] gap-4 justify-center">
|
||||
{games.map((game) => (
|
||||
<GameCover key={game.id} game={game} interactive={true}/>
|
||||
))}
|
||||
<div ref={containerRef} className="w-full">
|
||||
{containerWidth > 0 && (
|
||||
<Grid<{}>
|
||||
columnCount={columnCount}
|
||||
columnWidth={getColumnWidth}
|
||||
rowCount={rowCount}
|
||||
rowHeight={coverHeight + GAP}
|
||||
defaultWidth={containerWidth}
|
||||
cellComponent={Cell}
|
||||
cellProps={{}}
|
||||
style={{overflowX: 'hidden'}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +1,166 @@
|
||||
import React, {useEffect, useRef, useState} from "react";
|
||||
import {GameCover} from "Frontend/components/general/covers/GameCover";
|
||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||
import {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 {
|
||||
games: GameDto[];
|
||||
title: string;
|
||||
onPressShowMore: () => void;
|
||||
link: string;
|
||||
}
|
||||
|
||||
const aspectRatio = 12 / 17; // aspect ratio of the game cover
|
||||
const defaultImageHeight = 300; // default height for the image
|
||||
const defaultImageWidth = aspectRatio * defaultImageHeight; // default width for the image
|
||||
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 [visibleCount, setVisibleCount] = useState(games.length);
|
||||
|
||||
// Update container width on resize
|
||||
useEffect(() => {
|
||||
const calculateVisible = () => {
|
||||
const updateWidth = () => {
|
||||
if (containerRef.current) {
|
||||
const containerWidth = containerRef.current.offsetWidth;
|
||||
const maxFit = Math.floor((containerWidth - defaultImageWidth) / defaultImageWidth) + 1;
|
||||
setVisibleCount(maxFit < games.length ? maxFit : games.length);
|
||||
setContainerWidth(containerRef.current.offsetWidth);
|
||||
}
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(calculateVisible);
|
||||
const resizeObserver = new ResizeObserver(updateWidth);
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
calculateVisible(); // initial calculation
|
||||
updateWidth();
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col mb-4">
|
||||
<p className="text-2xl font-bold mb-4">{title}</p>
|
||||
<div className="w-full relative">
|
||||
<div ref={containerRef} className="flex flex-row gap-2 rounded-md bg-transparent">
|
||||
{games.slice(0, visibleCount).map((game, index) => (
|
||||
<GameCover key={index} game={game} radius="sm" interactive={true}/>
|
||||
))}
|
||||
<div className="flex flex-row justify-between items-baseline mb-4">
|
||||
<Link href={link} className="flex flex-row gap-1 w-fit items-baseline" color="foreground"
|
||||
underline="hover">
|
||||
<p className="text-2xl font-bold">{title}</p>
|
||||
<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>
|
||||
|
||||
{showMore && (
|
||||
<div className="flex flex-row items-center justify-end cursor-pointer"
|
||||
onClick={onPressShowMore}>
|
||||
<div className="absolute h-full w-1/4 right-0 bottom-0
|
||||
bg-linear-to-r from-transparent to-background
|
||||
transition-all duration-300 ease-in-out hover:opacity-80"/>
|
||||
<div
|
||||
className="absolute h-full right-0 bottom-0 flex flex-row items-center gap-2 pointer-events-none">
|
||||
<p className="text-xl font-semibold">Show more</p>
|
||||
<ArrowRightIcon weight="bold"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={containerRef} className="w-full relative overflow-hidden">
|
||||
{containerWidth > 0 && (
|
||||
<Grid<{}>
|
||||
gridRef={gridRef}
|
||||
columnCount={games.length}
|
||||
columnWidth={defaultImageWidth + gap}
|
||||
rowCount={1}
|
||||
rowHeight={defaultImageHeight}
|
||||
defaultHeight={defaultImageHeight}
|
||||
defaultWidth={containerWidth}
|
||||
cellComponent={Cell}
|
||||
cellProps={{}}
|
||||
className="scrollbar-hide"
|
||||
style={{overflow: 'auto'}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,105 @@
|
||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||
import {Image} from "@heroui/react";
|
||||
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 {
|
||||
game: GameDto;
|
||||
size?: number;
|
||||
radius?: "none" | "sm" | "md" | "lg";
|
||||
interactive?: boolean;
|
||||
lazy?: boolean;
|
||||
}
|
||||
|
||||
export function GameCover({game, size = 300, radius = "sm", interactive = false}: GameCoverProps) {
|
||||
const coverContent = Number.isInteger(game.coverId) ? (
|
||||
<div className={`${interactive ? "rounded-md scale-95 hover:scale-100 shine transition-all" : ""}`}>
|
||||
export function GameCover({game, size = 300, radius = "sm", interactive = false, lazy = false}: GameCoverProps) {
|
||||
const [shouldLoad, setShouldLoad] = useState(!lazy);
|
||||
// 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
|
||||
alt={game.title}
|
||||
className="z-0 object-cover aspect-12/17"
|
||||
src={`images/cover/${game.coverId}`}
|
||||
src={shouldLoad && isImageLoaded ? `images/cover/${game.cover.id}` : blurhashUrl}
|
||||
radius={radius}
|
||||
height={size}
|
||||
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 React from "react";
|
||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {gameState} from "Frontend/state/GameState";
|
||||
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
|
||||
@@ -17,7 +16,9 @@ export default function LibraryHeader({library, className}: LibraryHeaderProps)
|
||||
const randomGames = 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 [];
|
||||
return games.slice(0, MAX_COVER_COUNT);
|
||||
}
|
||||
@@ -36,7 +37,7 @@ export default function LibraryHeader({library, className}: LibraryHeaderProps)
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`/images/screenshot/${game.imageIds![0]}`}
|
||||
src={`/images/screenshot/${game.images![0].id}`}
|
||||
alt={`Image ${idx}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {FieldArray, useField} from "formik";
|
||||
import {Button, Chip, Input, Popover, PopoverContent, PopoverTrigger} from "@heroui/react";
|
||||
import {KeyboardEvent, useState} from "react";
|
||||
import { PlusIcon } from "@phosphor-icons/react";
|
||||
import {PlusIcon} from "@phosphor-icons/react";
|
||||
|
||||
// @ts-ignore
|
||||
const ArrayInput = ({label, ...props}) => {
|
||||
@@ -35,13 +35,23 @@ const ArrayInput = ({label, ...props}) => {
|
||||
|
||||
<div className="flex flex-row flex-wrap gap-2 items-center">
|
||||
{field.value.map((element: any, index: number) => (
|
||||
<Chip key={index} onClose={() => arrayHelpers.remove(index)}>
|
||||
<Chip key={index}
|
||||
onClose={() => arrayHelpers.remove(index)}
|
||||
isDisabled={props.isDisabled}
|
||||
>
|
||||
{element}
|
||||
</Chip>
|
||||
))}
|
||||
<Popover placement="bottom" showArrow={true}>
|
||||
<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>
|
||||
<PopoverContent>
|
||||
<Input
|
||||
|
||||
@@ -77,7 +77,7 @@ export default function FileTreeView({onPathChange}: { onPathChange: (file: stri
|
||||
if (subDirectories === undefined) return;
|
||||
|
||||
const newNodes = fileDtosToNodes(subDirectories as FileDto[]);
|
||||
const updatedTree = updateTreeWithNewNodes(fileTree!!, element.id, newNodes);
|
||||
const updatedTree = updateTreeWithNewNodes(fileTree!, element.id, newNodes);
|
||||
|
||||
setFileTree(updatedTree);
|
||||
setFlattenedFileTree(flattenTree(updatedTree));
|
||||
|
||||
@@ -2,7 +2,7 @@ import {Image, useDisclosure} from "@heroui/react";
|
||||
import React from "react";
|
||||
import {useField} from "formik";
|
||||
import {GameCoverPickerModal} from "Frontend/components/general/modals/GameCoverPickerModal";
|
||||
import { ImageBrokenIcon, PencilIcon } from "@phosphor-icons/react";
|
||||
import {ImageBrokenIcon, PencilIcon} from "@phosphor-icons/react";
|
||||
|
||||
|
||||
// @ts-ignore
|
||||
@@ -16,12 +16,12 @@ export default function GameCoverPicker({game, showErrorUntouched = false, ...pr
|
||||
return (<>
|
||||
<div className="relative group aspect-12/17 cursor-pointer bg-background/50"
|
||||
onClick={gameCoverPickerModal.onOpenChange}>
|
||||
{field.value || game.coverId ?
|
||||
{field.value || game.cover?.id ?
|
||||
<div className="size-full overflow-hidden">
|
||||
<Image
|
||||
alt={game.title}
|
||||
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}
|
||||
{...field}
|
||||
radius="none"
|
||||
|
||||
@@ -16,12 +16,12 @@ export default function GameHeaderPicker({game, showErrorUntouched = false, ...p
|
||||
return (<>
|
||||
<div className="relative group size-full cursor-pointer bg-background/50"
|
||||
onClick={gameHeaderPickerModal.onOpenChange}>
|
||||
{field.value || game.headerId ?
|
||||
{field.value || game.header?.id ?
|
||||
<div className="size-full overflow-hidden">
|
||||
<Image
|
||||
alt={game.title}
|
||||
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}
|
||||
{...field}
|
||||
radius="none"
|
||||
|
||||
@@ -14,6 +14,7 @@ import * as Yup from "yup";
|
||||
import ArrayInputAutocomplete from "Frontend/components/general/input/ArrayInputAutocomplete";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {platformState} from "Frontend/state/PlatformState";
|
||||
import CheckboxInput from "Frontend/components/general/input/CheckboxInput";
|
||||
|
||||
interface LibraryManagementDetailsProps {
|
||||
library: LibraryDto;
|
||||
@@ -45,7 +46,7 @@ export default function LibraryManagementDetails({library}: LibraryManagementDet
|
||||
color: "success"
|
||||
});
|
||||
|
||||
navigate("/administration/libraries");
|
||||
navigate("/administration/games");
|
||||
} catch (e) {
|
||||
addToast({
|
||||
title: "Error deleting library",
|
||||
@@ -84,6 +85,8 @@ export default function LibraryManagementDetails({library}: LibraryManagementDet
|
||||
|
||||
<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"/>
|
||||
|
||||
<DirectoryMappingInput name="directories"/>
|
||||
|
||||
@@ -38,12 +38,12 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
||||
const rowsPerPage = 25;
|
||||
|
||||
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 [filter, setFilter] = useState<"all" | "confirmed" | "nonConfirmed">("all");
|
||||
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 matchGameModal = useDisclosure();
|
||||
|
||||
@@ -94,7 +94,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
||||
|
||||
function getFilteredGames() {
|
||||
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.publishers?.some(publisher => publisher.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") {
|
||||
return filteredGames.filter(g => g.metadata.matchConfirmed);
|
||||
}
|
||||
if (filter === "nonConfirmed") {
|
||||
} else if (filter === "nonConfirmed") {
|
||||
return filteredGames.filter(g => !g.metadata.matchConfirmed);
|
||||
}
|
||||
|
||||
return filteredGames;
|
||||
}
|
||||
|
||||
@@ -178,7 +178,8 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
||||
<Link href={`/game/${item.id}`}
|
||||
color="foreground"
|
||||
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>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -238,7 +239,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
||||
<EditGameMetadataModal game={selectedGame}
|
||||
isOpen={editGameModal.isOpen}
|
||||
onOpenChange={editGameModal.onOpenChange}/>
|
||||
<MatchGameModal path={selectedGame.metadata.path!!}
|
||||
<MatchGameModal path={selectedGame.metadata.path!}
|
||||
libraryId={library.id}
|
||||
replaceGameId={selectedGame.id}
|
||||
initialSearchTerm={selectedGame.title}
|
||||
|
||||
@@ -85,7 +85,7 @@ export default function LibraryManagementIgnoredPaths({library}: LibraryManageme
|
||||
}
|
||||
|
||||
function getFilteredPaths() {
|
||||
return library.ignoredPaths!!.filter((path) =>
|
||||
return library.ignoredPaths!.filter((path) =>
|
||||
path.path.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
}
|
||||
@@ -165,7 +165,10 @@ export default function LibraryManagementIgnoredPaths({library}: LibraryManageme
|
||||
</Tooltip>
|
||||
<Tooltip content="Remove entry from list">
|
||||
<Button isIconOnly size="sm" color="danger"
|
||||
onPress={() => deleteIgnoredPath(item.path)}><TrashIcon/>
|
||||
onPress={() => deleteIgnoredPath(item.path)}
|
||||
isDisabled={item.path.sourceType !== IgnoredPathSourceTypeDto.USER}
|
||||
>
|
||||
<TrashIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</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 overflow-scroll"
|
||||
}}>
|
||||
<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 {useSnapshot} from "valtio/react";
|
||||
import {pluginState} from "Frontend/state/PluginState";
|
||||
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||
|
||||
interface GameCoverPickerModalProps {
|
||||
game: GameDto;
|
||||
@@ -110,7 +109,7 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
|
||||
/>
|
||||
<div
|
||||
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}/>
|
||||
<p className="text-s text-center">{cover.title}</p>
|
||||
<ArrowRightIcon/>
|
||||
|
||||
@@ -7,7 +7,6 @@ import {ArrowRightIcon, MagnifyingGlassIcon} from "@phosphor-icons/react";
|
||||
import PluginIcon from "Frontend/components/general/plugin/PluginIcon";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {pluginState} from "Frontend/state/PluginState";
|
||||
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||
|
||||
interface GameHeaderPickerModalProps {
|
||||
game: GameDto;
|
||||
@@ -109,7 +108,7 @@ export function GameHeaderPickerModal({game, isOpen, onOpenChange, setHeaderUrl}
|
||||
/>
|
||||
<div
|
||||
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}/>
|
||||
<p className="text-s text-center">{header.title}</p>
|
||||
<ArrowRightIcon/>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import React, {useState} from "react";
|
||||
import {addToast, Button, Checkbox, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
|
||||
import {Form, Formik} from "formik";
|
||||
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
|
||||
import {LibraryEndpoint} from "Frontend/generated/endpoints";
|
||||
import Input from "Frontend/components/general/input/Input";
|
||||
import * as Yup from "yup";
|
||||
import DirectoryMappingInput from "Frontend/components/general/input/DirectoryMappingInput";
|
||||
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
|
||||
import ArrayInputAutocomplete from "Frontend/components/general/input/ArrayInputAutocomplete";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {platformState} from "Frontend/state/PlatformState";
|
||||
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
|
||||
|
||||
interface LibraryCreationModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -24,8 +23,8 @@ export default function LibraryCreationModal({
|
||||
const [scanAfterCreation, setScanAfterCreation] = useState<boolean>(true);
|
||||
const availablePlatforms = useSnapshot(platformState).available;
|
||||
|
||||
async function createLibrary(library: LibraryDto) {
|
||||
await LibraryEndpoint.createLibrary(library as LibraryAdminDto, scanAfterCreation);
|
||||
async function createLibrary(library: LibraryAdminDto) {
|
||||
await LibraryEndpoint.createLibrary(library, scanAfterCreation);
|
||||
|
||||
addToast({
|
||||
title: "New library created",
|
||||
@@ -39,20 +38,25 @@ export default function LibraryCreationModal({
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="xl">
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<Formik initialValues={{name: "", directories: [], platforms: []}}
|
||||
validationSchema={Yup.object({
|
||||
name: Yup.string()
|
||||
.required("Library name is required")
|
||||
.max(255, "Library name must be 255 characters or less"),
|
||||
directories: Yup.array()
|
||||
.of(Yup.object())
|
||||
.min(1, "At least one directory is required")
|
||||
})}
|
||||
isInitialValid={false}
|
||||
onSubmit={async (values: any) => {
|
||||
await createLibrary(values);
|
||||
onClose();
|
||||
}}
|
||||
<Formik
|
||||
initialValues={{
|
||||
name: "",
|
||||
directories: [],
|
||||
platforms: []
|
||||
}}
|
||||
validationSchema={Yup.object({
|
||||
name: Yup.string()
|
||||
.required("Library name is required")
|
||||
.max(255, "Library name must be 255 characters or less"),
|
||||
directories: Yup.array()
|
||||
.of(Yup.object())
|
||||
.min(1, "At least one directory is required")
|
||||
})}
|
||||
isInitialValid={false}
|
||||
onSubmit={async (values: any) => {
|
||||
await createLibrary(values);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{(formik) =>
|
||||
<Form>
|
||||
|
||||
@@ -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 {useSnapshot} from "valtio/react";
|
||||
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 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">
|
||||
{Object.values(item.originalIds).map(
|
||||
originalId => <PluginIcon
|
||||
plugin={state[originalId.pluginId] as PluginDto}/>
|
||||
plugin={state[originalId.pluginId]}/>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -1,113 +1,39 @@
|
||||
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 {PluginEndpoint} from "Frontend/generated/endpoints";
|
||||
import PrioritiesModal from "./PrioritiesModal";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {pluginState} from "Frontend/state/PluginState";
|
||||
|
||||
interface PluginPrioritiesModalProps {
|
||||
plugins: PluginDto[];
|
||||
isOpen: boolean;
|
||||
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({
|
||||
initialItems: plugins, // Already sorted in parent
|
||||
getKey: (plugin) => plugin.id
|
||||
});
|
||||
const updatePlugins = async (reorderedPlugins: PluginDto[]) => {
|
||||
const prioritiesMap: Record<string, number> = {};
|
||||
const totalPlugins = reorderedPlugins.length;
|
||||
|
||||
let {dragAndDropHooks} = useDragAndDrop({
|
||||
getItems: (keys) =>
|
||||
[...keys].map((key) => ({'text/plain': sortedPlugins.getItem(key)!.name})),
|
||||
onReorder(e) {
|
||||
if (e.keys.has(e.target.key)) return;
|
||||
|
||||
if (e.target.dropPosition === 'before' || e.target.dropPosition === 'on') {
|
||||
sortedPlugins.moveBefore(e.target.key, e.keys);
|
||||
} else if (e.target.dropPosition === 'after') {
|
||||
sortedPlugins.moveAfter(e.target.key, e.keys);
|
||||
}
|
||||
|
||||
// Recalculate priority based on new position (reversed)
|
||||
sortedPlugins.items.forEach((plugin, index) => {
|
||||
const reversedPriority = sortedPlugins.items.length - index;
|
||||
sortedPlugins.update(plugin.id, {...plugin, priority: reversedPriority});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function generatePrioritiesMap(): Record<string, number> {
|
||||
let map: Record<string, number> = {};
|
||||
const totalPlugins = sortedPlugins.items.length;
|
||||
sortedPlugins.items.forEach((plugin, index) => {
|
||||
map[plugin.id] = totalPlugins - index; // Reverse order
|
||||
reorderedPlugins.forEach((plugin, index) => {
|
||||
// Reverse order: first item gets highest priority
|
||||
prioritiesMap[plugin.id] = totalPlugins - index;
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
async function setPluginPriorities(onClose: () => void) {
|
||||
try {
|
||||
const prioritiesMap = generatePrioritiesMap();
|
||||
await PluginEndpoint.setPluginPriorities(prioritiesMap);
|
||||
|
||||
addToast({
|
||||
title: "Plugin order updated",
|
||||
description: "Plugin order has been updated successfully.",
|
||||
color: "success"
|
||||
});
|
||||
onClose();
|
||||
} catch (e) {
|
||||
addToast({
|
||||
title: "Error",
|
||||
description: "An error occurred while updating plugin order.",
|
||||
color: "warning"
|
||||
});
|
||||
}
|
||||
}
|
||||
await PluginEndpoint.setPluginPriorities(prioritiesMap);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
<p>Edit plugin order</p>
|
||||
<p className="text-small font-normal">Plugins higher on the list are preferred</p>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<ListBox items={sortedPlugins.items}
|
||||
dragAndDropHooks={dragAndDropHooks}
|
||||
className="flex flex-col gap-2">
|
||||
{(plugin: PluginDto) => (
|
||||
<ListBoxItem
|
||||
key={plugin.id}
|
||||
className="flex flex-row p-2 rounded-lg justify-between items-center bg-foreground/5">
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<Chip size="sm" color="primary">
|
||||
{sortedPlugins.items.findIndex(p => p.id === plugin.id) + 1}
|
||||
</Chip>
|
||||
<p className="font-normal text-small">{plugin.name}</p>
|
||||
</div>
|
||||
<CaretUpDownIcon/>
|
||||
</ListBoxItem>
|
||||
)}
|
||||
</ListBox>
|
||||
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="primary" onPress={() => setPluginPriorities(onClose)}>
|
||||
Save
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<PrioritiesModal
|
||||
title="Edit plugin order"
|
||||
subtitle="Plugins higher on the list are preferred"
|
||||
items={plugins}
|
||||
updateItems={updatePlugins}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 { ListNumbersIcon } from "@phosphor-icons/react";
|
||||
import {ListNumbersIcon} from "@phosphor-icons/react";
|
||||
import {PluginManagementCard} from "Frontend/components/general/cards/PluginManagementCard";
|
||||
import React from "react";
|
||||
import PluginPrioritiesModal from "Frontend/components/general/modals/PluginPrioritiesModal";
|
||||
import {camelCaseToTitle} from "Frontend/util/utils";
|
||||
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {pluginState} from "Frontend/state/PluginState";
|
||||
|
||||
interface PluginManagementSectionProps {
|
||||
type: string;
|
||||
plugins: PluginDto[];
|
||||
}
|
||||
|
||||
export function PluginManagementSection({type, plugins = []}: PluginManagementSectionProps) {
|
||||
export function PluginManagementSection({type}: PluginManagementSectionProps) {
|
||||
const plugins = useSnapshot(pluginState).sortedByType[type];
|
||||
|
||||
const pluginPrioritiesModal = useDisclosure();
|
||||
|
||||
return (
|
||||
@@ -40,10 +42,9 @@ export function PluginManagementSection({type, plugins = []}: PluginManagementSe
|
||||
</div>}
|
||||
|
||||
<PluginPrioritiesModal
|
||||
key={plugins.map(p => p.id + p.priority).join(',')} // force re-mount if plugin order changes
|
||||
plugins={[...plugins].sort((a, b) => b.priority - a.priority)}
|
||||
isOpen={pluginPrioritiesModal.isOpen}
|
||||
onOpenChange={pluginPrioritiesModal.onOpenChange}
|
||||
type={type}
|
||||
/>
|
||||
</div>);
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import {router} from './routes';
|
||||
const container = document.getElementById('outlet')!;
|
||||
const root = createRoot(container);
|
||||
|
||||
declare module 'valtio' {
|
||||
function useSnapshot<T extends object>(p: T): T
|
||||
}
|
||||
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<RouterProvider router={router}/>
|
||||
|
||||
@@ -4,10 +4,10 @@ import HomeView from "Frontend/views/HomeView";
|
||||
import SetupView from "Frontend/views/SetupView";
|
||||
import {ThemeSelector} from "Frontend/components/theming/ThemeSelector";
|
||||
import App from "Frontend/App";
|
||||
import {LibraryManagement} from "Frontend/components/administration/LibraryManagement";
|
||||
import {GameManagement} from "Frontend/components/administration/GameManagement";
|
||||
import {UserManagement} from "Frontend/components/administration/UserManagement";
|
||||
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 {ProfileView} from "Frontend/views/ProfileView";
|
||||
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 LibraryManagementView from "Frontend/views/LibraryManagementView";
|
||||
import SearchView from "Frontend/views/SearchView";
|
||||
import RecentlyAddedView from "Frontend/views/RecentlyAddedView";
|
||||
import LibraryView from "Frontend/views/LibraryView";
|
||||
import {RouterConfigurationBuilder} from "@vaadin/hilla-file-router/runtime.js";
|
||||
import ErrorView from "Frontend/views/ErrorView";
|
||||
import GameRequestView from "Frontend/views/GameRequestView";
|
||||
import {GameRequestManagement} from "Frontend/components/administration/GameRequestManagement";
|
||||
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()
|
||||
.withReactRoutes([
|
||||
@@ -45,11 +46,6 @@ export const {router, routes} = new RouterConfigurationBuilder()
|
||||
element: <SearchView/>,
|
||||
handle: {title: 'Search'}
|
||||
},
|
||||
{
|
||||
path: 'recently-added',
|
||||
element: <RecentlyAddedView/>,
|
||||
handle: {title: 'Recently Added'}
|
||||
},
|
||||
{
|
||||
path: '/requests',
|
||||
element: <GameRequestView/>,
|
||||
@@ -59,6 +55,10 @@ export const {router, routes} = new RouterConfigurationBuilder()
|
||||
path: 'library/:libraryId',
|
||||
element: <LibraryView/>
|
||||
},
|
||||
{
|
||||
path: 'collection/:collectionId',
|
||||
element: <CollectionView/>
|
||||
},
|
||||
{
|
||||
path: 'game/:gameId',
|
||||
element: <GameView/>
|
||||
@@ -86,15 +86,20 @@ export const {router, routes} = new RouterConfigurationBuilder()
|
||||
handle: {title: 'Administration'},
|
||||
children: [
|
||||
{
|
||||
path: 'libraries',
|
||||
element: <LibraryManagement/>,
|
||||
handle: {title: 'Administration - Libraries'}
|
||||
path: 'games',
|
||||
element: <GameManagement/>,
|
||||
handle: {title: 'Administration - Games'}
|
||||
},
|
||||
{
|
||||
path: 'libraries/library/:libraryId',
|
||||
path: 'games/library/:libraryId',
|
||||
element: <LibraryManagementView/>,
|
||||
handle: {title: 'Administration - Library'}
|
||||
},
|
||||
{
|
||||
path: 'games/collection/:collectionId',
|
||||
element: <CollectionManagementView/>,
|
||||
handle: {title: 'Administration - Collection'}
|
||||
},
|
||||
{
|
||||
path: 'requests',
|
||||
element: <GameRequestManagement/>,
|
||||
@@ -111,9 +116,9 @@ export const {router, routes} = new RouterConfigurationBuilder()
|
||||
handle: {title: 'Administration - Users'}
|
||||
},
|
||||
{
|
||||
path: 'sso',
|
||||
element: <SsoManagement/>,
|
||||
handle: {title: 'Administration - SSO'}
|
||||
path: 'security',
|
||||
element: <SecurityManagement/>,
|
||||
handle: {title: 'Administration - Security'}
|
||||
},
|
||||
{
|
||||
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;
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,10 +11,10 @@ type GameState = {
|
||||
state: Record<number, GameDto>;
|
||||
games: GameDto[];
|
||||
gamesByLibraryId: Record<number, GameDto[]>;
|
||||
gamesByCollectionId: Record<number, GameDto[]>;
|
||||
sortedAlphabetically: GameDto[];
|
||||
recentlyAdded: GameDto[];
|
||||
recentlyUpdated: GameDto[];
|
||||
randomlyOrderedGamesByLibraryId: Record<number, GameDto[]>;
|
||||
randomlyOrderedGamesByCollectionId: Record<number, GameDto[]>;
|
||||
knownPublishers: Set<string>;
|
||||
knownDevelopers: Set<string>;
|
||||
knownGenres: Set<string>;
|
||||
@@ -38,26 +38,33 @@ export const gameState = proxy<GameState>({
|
||||
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() {
|
||||
return this.games
|
||||
.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() {
|
||||
const result: Record<number, GameDto[]> = {};
|
||||
for (const libraryId in this.gamesByLibraryId) {
|
||||
const rand = new Rand(libraryId.toString());
|
||||
const rand = new Rand(`library-${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(() => rand.next() - 0.5);
|
||||
}
|
||||
|
||||
@@ -23,8 +23,20 @@ export const libraryState = proxy<LibraryState>({
|
||||
},
|
||||
get sorted() {
|
||||
return Object.values<LibraryDto>(this.state).sort((a, b) => {
|
||||
if (a.name === undefined || b.name === undefined) return 0;
|
||||
return a.name.localeCompare(b.name);
|
||||
const orderA = a.metadata!.displayOrder;
|
||||
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,7 +9,7 @@ type PluginState = {
|
||||
isLoaded: boolean;
|
||||
state: Record<string, PluginDto>;
|
||||
plugins: PluginDto[];
|
||||
pluginsByType: Record<string, PluginDto[]>;
|
||||
sortedByType: Record<string, PluginDto[]>;
|
||||
};
|
||||
|
||||
export const pluginState = proxy<PluginState>({
|
||||
@@ -20,8 +20,8 @@ export const pluginState = proxy<PluginState>({
|
||||
get plugins() {
|
||||
return Object.values<PluginDto>(this.state);
|
||||
},
|
||||
get pluginsByType() {
|
||||
return groupPluginsByType(this.state);
|
||||
get sortedByType() {
|
||||
return sortPluginsByType(this.state);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -52,7 +52,7 @@ export async function initializePluginState() {
|
||||
|
||||
/** Computed **/
|
||||
|
||||
function groupPluginsByType(pluginsMap: Record<string, PluginDto>): Record<string, PluginDto[]> {
|
||||
function sortPluginsByType(pluginsMap: Record<string, PluginDto>): Record<string, PluginDto[]> {
|
||||
const pluginsByType: Record<string, PluginDto[]> = {};
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import {Middleware, MiddlewareContext, MiddlewareNext} from '@vaadin/hilla-frontend';
|
||||
import {addToast} from "@heroui/react";
|
||||
import {getReasonPhrase} from "http-status-codes";
|
||||
|
||||
export const ErrorHandlingMiddleware: Middleware = async function (
|
||||
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") {
|
||||
addToast({
|
||||
title: getReasonPhrase(response.status),
|
||||
title: "Error",
|
||||
description: json.message,
|
||||
color: "danger"
|
||||
})
|
||||
} else {
|
||||
addToast({
|
||||
title: getReasonPhrase(response.status),
|
||||
title: "Error",
|
||||
description: `${endpoint}.${method}`,
|
||||
color: "danger"
|
||||
})
|
||||
|
||||
@@ -13,8 +13,8 @@ import withSideMenu, {MenuItem} from "Frontend/components/general/withSideMenu";
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
title: "Libraries",
|
||||
url: "libraries",
|
||||
title: "Games",
|
||||
url: "games",
|
||||
icon: <GameControllerIcon/>
|
||||
},
|
||||
{
|
||||
@@ -33,8 +33,8 @@ const menuItems: MenuItem[] = [
|
||||
icon: <UsersIcon/>
|
||||
},
|
||||
{
|
||||
title: "SSO",
|
||||
url: "sso",
|
||||
title: "Security",
|
||||
url: "security",
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -24,8 +24,9 @@ import EditGameMetadataModal from "Frontend/components/general/modals/EditGameMe
|
||||
import GameUpdateDto from "Frontend/generated/org/gameyfin/app/games/dto/GameUpdateDto";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import {GameAdminDto} from "Frontend/dtos/GameDtos";
|
||||
import ChipList from "Frontend/components/general/ChipList";
|
||||
import {collectionState} from "Frontend/state/CollectionState";
|
||||
import {GameMetadataAdminDto} from "Frontend/dtos/GameDtos";
|
||||
|
||||
export default function GameView() {
|
||||
const {gameId} = useParams();
|
||||
@@ -37,7 +38,8 @@ export default function GameView() {
|
||||
const matchGameModal = useDisclosure();
|
||||
|
||||
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>>();
|
||||
|
||||
@@ -69,7 +71,7 @@ export default function GameView() {
|
||||
await GameEndpoint.updateGame(
|
||||
{
|
||||
id: game.id,
|
||||
metadata: {matchConfirmed: !game.metadata.matchConfirmed}
|
||||
metadata: {matchConfirmed: !(game.metadata as GameMetadataAdminDto).matchConfirmed}
|
||||
} as GameUpdateDto
|
||||
)
|
||||
}
|
||||
@@ -87,17 +89,17 @@ export default function GameView() {
|
||||
return game && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="overflow-hidden relative rounded-t-lg">
|
||||
{game.headerId ? (
|
||||
{game.header?.id ? (
|
||||
<img
|
||||
className="w-full h-96 object-cover brightness-50 blur-sm scale-110"
|
||||
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
|
||||
className="w-full h-96 object-cover brightness-50 blur-sm scale-110"
|
||||
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"/>
|
||||
@@ -137,7 +139,7 @@ export default function GameView() {
|
||||
<div className="flex flex-row items-center gap-8">
|
||||
{isAdmin(auth) && <div className="flex flex-row gap-2">
|
||||
<Button isIconOnly onPress={toggleMatchConfirmed}>
|
||||
{game.metadata.matchConfirmed ?
|
||||
{(game.metadata as GameMetadataAdminDto).matchConfirmed ?
|
||||
<Tooltip content="Unconfirm match">
|
||||
<CheckCircleIcon weight="fill" className="fill-success"/>
|
||||
</Tooltip> :
|
||||
@@ -220,7 +222,7 @@ export default function GameView() {
|
||||
color="foreground" underline="hover">
|
||||
{dev}
|
||||
</Link>
|
||||
{index !== game.developers!!.length - 1 && <p>/</p>}
|
||||
{index !== game.developers!.length - 1 && <p>/</p>}
|
||||
</>
|
||||
)
|
||||
: <Tooltip content="Missing data" color="foreground" placement="right">
|
||||
@@ -295,6 +297,25 @@ export default function GameView() {
|
||||
}
|
||||
</td>
|
||||
</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>
|
||||
</table>
|
||||
</div>
|
||||
@@ -302,22 +323,24 @@ export default function GameView() {
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-default-500">Media</p>
|
||||
<ImageCarousel
|
||||
imageUrls={game.imageIds?.map(id => `/images/screenshot/${id}`)}
|
||||
imageUrls={game.images?.map(image => `/images/screenshot/${image.id}`)}
|
||||
videosUrls={game.videoUrls}
|
||||
className="-mx-24"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EditGameMetadataModal game={game}
|
||||
isOpen={editGameModal.isOpen}
|
||||
onOpenChange={editGameModal.onOpenChange}/>
|
||||
<MatchGameModal path={game.metadata.path!!}
|
||||
libraryId={game.libraryId}
|
||||
replaceGameId={game.id}
|
||||
initialSearchTerm={game.title}
|
||||
isOpen={matchGameModal.isOpen}
|
||||
onOpenChange={matchGameModal.onOpenChange}/>
|
||||
{isAdmin(auth) && <>
|
||||
<EditGameMetadataModal game={game}
|
||||
isOpen={editGameModal.isOpen}
|
||||
onOpenChange={editGameModal.onOpenChange}/>
|
||||
<MatchGameModal path={(game.metadata as GameMetadataAdminDto).path!}
|
||||
libraryId={game.libraryId}
|
||||
replaceGameId={game.id}
|
||||
initialSearchTerm={game.title}
|
||||
isOpen={matchGameModal.isOpen}
|
||||
onOpenChange={matchGameModal.onOpenChange}/>
|
||||
</>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,78 @@
|
||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||
import {CoverRow} from "Frontend/components/general/covers/CoverRow";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {libraryState} from "Frontend/state/LibraryState";
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
const librariesState = useSnapshot(libraryState);
|
||||
const collectionsState = useSnapshot(collectionState);
|
||||
const gamesState = useSnapshot(gameState);
|
||||
const recentlyAddedGames = gamesState.recentlyAdded as GameDto[];
|
||||
const gamesByLibrary = gamesState.gamesByLibraryId as Record<number, GameDto[]>;
|
||||
const gamesByLibrary = gamesState.gamesByLibraryId;
|
||||
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 (
|
||||
<div className="w-full">
|
||||
<div className="flex flex-col gap-2">
|
||||
<CoverRow title="Recently added" games={recentlyAddedGames}
|
||||
onPressShowMore={() => navigate("/recently-added")}/>
|
||||
{librariesState.libraries.map((library) => (
|
||||
<div className="flex flex-col gap-4">
|
||||
{(filteredAndSortedLibraries.length + filteredAndSortedCollections.length > 0) &&
|
||||
<div className="flex flex-col gap-2">
|
||||
<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}
|
||||
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>
|
||||
|
||||
@@ -19,18 +19,18 @@ export default function LibraryManagementView() {
|
||||
|
||||
useEffect(() => {
|
||||
if (state.isLoaded && (!libraryId || !state.state[parseInt(libraryId)])) {
|
||||
navigate("/administration/libraries");
|
||||
navigate("/administration/games");
|
||||
}
|
||||
}, [state, libraryId]);
|
||||
|
||||
return libraryId && state.state[parseInt(libraryId)] && <div className="flex flex-col gap-4">
|
||||
<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/>
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold">Manage library</h1>
|
||||
</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
|
||||
selectedKey={hash.length > 0 ? hash : "#details"}
|
||||
onSelectionChange={(newKey) => navigate(newKey.toString(), {replace: true})}>
|
||||
|
||||
@@ -4,25 +4,29 @@ 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 GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||
|
||||
export default function LibraryView() {
|
||||
const {libraryId} = useParams();
|
||||
const navigate = useNavigate();
|
||||
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(() => {
|
||||
if (libraries.isLoaded && (!libraryId || !libraries.state[parseInt(libraryId)])) {
|
||||
navigate("/", {replace: true});
|
||||
}
|
||||
document.title = libraries.state[parseInt(libraryId!!)]?.name || "Gameyfin";
|
||||
document.title = libraries.state[parseInt(libraryId!)]?.name || "Gameyfin";
|
||||
}, [libraryId, libraries]);
|
||||
|
||||
return (
|
||||
<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}/>
|
||||
{games.length === 0 && <p className="text-center text-gray-500">This library is empty.</p>}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -13,19 +13,18 @@ import {useSearchParams} from "react-router";
|
||||
import React, {useEffect, useMemo, useState} from "react";
|
||||
import {Fzf} from "fzf";
|
||||
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 {compoundRating} from "Frontend/util/utils";
|
||||
|
||||
export default function SearchView() {
|
||||
const games = useSnapshot(gameState).sortedAlphabetically as GameDto[];
|
||||
const knownDevelopers = useSnapshot(gameState).knownDevelopers as Set<string>;
|
||||
const games = useSnapshot(gameState).sortedAlphabetically;
|
||||
const knownDevelopers = useSnapshot(gameState).knownDevelopers;
|
||||
const knownGenres = useSnapshot(gameState).knownGenres;
|
||||
const knownThemes = useSnapshot(gameState).knownThemes;
|
||||
const knownFeatures = useSnapshot(gameState).knownFeatures;
|
||||
const knownPerspectives = useSnapshot(gameState).knownPerspectives;
|
||||
const knownKeywords = useSnapshot(gameState).knownKeywords;
|
||||
const libraries = useSnapshot(libraryState).libraries as LibraryDto[];
|
||||
const libraries = useSnapshot(libraryState).libraries;
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
|
||||
@@ -46,6 +45,9 @@ export default function SearchView() {
|
||||
|
||||
// Load initial filter values from URL parameters on component mount
|
||||
useEffect(() => {
|
||||
// Scroll to top on load
|
||||
window.scrollTo(0, 0)
|
||||
|
||||
// Get all parameters from the URL
|
||||
const term = searchParams.get("term") || "";
|
||||
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
|
||||
) {
|
||||
|
||||
/** Libraries */
|
||||
sealed class Libraries {
|
||||
/** Security */
|
||||
sealed class Security {
|
||||
data object AllowPublicAccess : ConfigProperties<Boolean>(
|
||||
Boolean::class,
|
||||
"library.allow-public-access",
|
||||
"security.allow-public-access",
|
||||
"Allow access to Gameyfin without login",
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
/** Libraries */
|
||||
sealed class Libraries {
|
||||
sealed class Scan {
|
||||
data object EnableFilesystemWatcher : ConfigProperties<Boolean>(
|
||||
Boolean::class,
|
||||
"library.scan.enable-filesystem-watcher",
|
||||
"Enable automatic library scanning using file system watchers (coming soon™)",
|
||||
"Enable automatic library scanning using file system watchers",
|
||||
false
|
||||
)
|
||||
|
||||
@@ -189,13 +192,6 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
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>(
|
||||
String::class,
|
||||
"sso.oidc.roles-claim",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.gameyfin.app.config
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.gameyfin.app.config.dto.ConfigEntryDto
|
||||
import org.gameyfin.app.config.dto.ConfigUpdateDto
|
||||
@@ -15,7 +17,8 @@ import kotlin.time.toJavaDuration
|
||||
|
||||
@Service
|
||||
class ConfigService(
|
||||
private val appConfigRepository: ConfigRepository
|
||||
private val appConfigRepository: ConfigRepository,
|
||||
private val objectMapper: ObjectMapper
|
||||
) {
|
||||
companion object {
|
||||
private val log = KotlinLogging.logger {}
|
||||
@@ -50,7 +53,7 @@ class ConfigService(
|
||||
|
||||
val appConfig = appConfigRepository.findByIdOrNull(configProperty.key)
|
||||
return if (appConfig != null) {
|
||||
getValue(appConfig.value, configProperty)
|
||||
deserializeValue(appConfig.value, configProperty)
|
||||
} else {
|
||||
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.
|
||||
* 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)
|
||||
|
||||
val parsedValue =
|
||||
if (value.javaClass.isArray) {
|
||||
(value as Array<Serializable>).joinToString(",")
|
||||
} else
|
||||
value.toString()
|
||||
val serializedValue = serializeValue(value, key)
|
||||
|
||||
if (configEntry == null) {
|
||||
configEntry = ConfigEntry(configProperty.key, parsedValue)
|
||||
configEntry = ConfigEntry(configProperty.key, serializedValue)
|
||||
} else {
|
||||
configEntry.value = parsedValue
|
||||
configEntry.value = serializedValue
|
||||
}
|
||||
|
||||
appConfigRepository.save(configEntry)
|
||||
@@ -149,17 +160,6 @@ class ConfigService(
|
||||
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.
|
||||
* 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")
|
||||
private fun <T : Serializable> getValue(value: Serializable, configProperty: ConfigProperties<T>): T {
|
||||
val value = value.toString()
|
||||
return when {
|
||||
configProperty.type == String::class -> value as T
|
||||
configProperty.type == Boolean::class -> value.toBoolean() as T
|
||||
configProperty.type == Int::class -> value.toFloat().toInt() as T
|
||||
configProperty.type == Float::class -> value.toFloat() as T
|
||||
private fun <T : Serializable> deserializeValue(value: Serializable, configProperty: ConfigProperties<T>): T {
|
||||
return try {
|
||||
val typeReference = objectMapper.typeFactory.constructType(configProperty.type.java)
|
||||
objectMapper.readValue(value.toString(), typeReference) as T
|
||||
} catch (e: JsonProcessingException) {
|
||||
throw IllegalArgumentException(
|
||||
"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
|
||||
enumConstants.firstOrNull { it.toString() == value }
|
||||
?: throw IllegalArgumentException("Unknown enum value '$value' for key ${configProperty.key}")
|
||||
}
|
||||
|
||||
configProperty.type.java.isArray -> {
|
||||
val componentType = configProperty.type.java.componentType
|
||||
// Remove the brackets and split the string by commas
|
||||
val elements = value
|
||||
.removeSurrounding("[", "]")
|
||||
.split(",")
|
||||
.filter { it.isNotBlank() }
|
||||
|
||||
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}")
|
||||
/**
|
||||
* Serialize a value to be stored in the database.
|
||||
*
|
||||
* @param value: The value to serialize
|
||||
* @param key: The config key (for error messages)
|
||||
* @return The serialized value as a string
|
||||
*/
|
||||
private fun <T : Serializable> serializeValue(value: T, key: String): String {
|
||||
return try {
|
||||
objectMapper.writeValueAsString(value)
|
||||
} catch (e: JsonProcessingException) {
|
||||
throw IllegalArgumentException(
|
||||
"Failed to serialize value for key '$key': ${e.message}",
|
||||
e
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import jakarta.persistence.PostPersist
|
||||
import jakarta.persistence.PostRemove
|
||||
import jakarta.persistence.PostUpdate
|
||||
import org.gameyfin.app.config.ConfigProperties
|
||||
import org.gameyfin.app.core.events.LibraryFilesystemWatcherConfigUpdatedEvent
|
||||
import org.gameyfin.app.core.events.LibraryScanScheduleUpdatedEvent
|
||||
import org.gameyfin.app.util.EventPublisherHolder
|
||||
|
||||
@@ -19,7 +20,12 @@ class ConfigEntryEntityListener {
|
||||
}
|
||||
|
||||
ConfigProperties.Libraries.Scan.EnableFilesystemWatcher.key -> {
|
||||
TODO()
|
||||
EventPublisherHolder.publish(
|
||||
LibraryFilesystemWatcherConfigUpdatedEvent(
|
||||
this,
|
||||
configEntry.value.toBoolean()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ class DynamicAccessInterceptor(
|
||||
clazz.isAnnotationPresent(DynamicPublicAccess::class.java)
|
||||
|
||||
if (hasDynamicPublicAccess) {
|
||||
if (request.userPrincipal != null || config.get(ConfigProperties.Libraries.AllowPublicAccess) == true) {
|
||||
if (request.userPrincipal != null || config.get(ConfigProperties.Security.AllowPublicAccess) == true) {
|
||||
return true
|
||||
}
|
||||
response.status = HttpServletResponse.SC_UNAUTHORIZED
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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.TokenType
|
||||
import org.gameyfin.app.games.entities.Game
|
||||
@@ -24,6 +25,7 @@ class PasswordResetRequestEvent(source: Any, val token: Token<TokenType.Password
|
||||
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 UserUpdatedEvent(source: Any, val previousState: User, val currentState: User) : ApplicationEvent(source)
|
||||
@@ -35,3 +37,7 @@ class GameDeletedEvent(source: Any, val game: Game) : ApplicationEvent(source)
|
||||
class LibraryCreatedEvent(source: Any, val library: Library) : ApplicationEvent(source)
|
||||
class LibraryUpdatedEvent(source: Any, val currentState: 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.UserUpdatedEvent
|
||||
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.util.EventPublisherHolder
|
||||
import org.gameyfin.pluginapi.gamemetadata.Platform
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ class DynamicPublicAccessAuthorizationManager(
|
||||
): AuthorizationDecision {
|
||||
val auth = authentication?.get()
|
||||
val allow = (auth?.isAuthenticated == true && auth.principal != "anonymousUser") ||
|
||||
config.get(ConfigProperties.Libraries.AllowPublicAccess) == true
|
||||
config.get(ConfigProperties.Security.AllowPublicAccess) == true
|
||||
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.repositories.GameRepository
|
||||
import org.gameyfin.app.libraries.entities.Library
|
||||
import org.gameyfin.app.media.Image
|
||||
import org.gameyfin.app.media.ImageService
|
||||
import org.gameyfin.app.media.ImageType
|
||||
import org.gameyfin.app.users.UserService
|
||||
import org.gameyfin.pluginapi.gamemetadata.*
|
||||
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.Sinks
|
||||
import java.nio.file.Path
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.util.concurrent.Executors
|
||||
@@ -236,7 +239,7 @@ class GameService(
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun update(game: Game): Game? {
|
||||
fun updateMetadata(game: Game): Game? {
|
||||
var wasGameUpdated = false
|
||||
|
||||
val game = getById(game.id!!)
|
||||
@@ -447,6 +450,11 @@ class GameService(
|
||||
return if (wasGameUpdated) game else null
|
||||
}
|
||||
|
||||
fun update(game: Game): Game {
|
||||
game.updatedAt = Instant.now()
|
||||
return gameRepository.save(game)
|
||||
}
|
||||
|
||||
fun delete(gameId: Long) {
|
||||
gameRepository.deleteById(gameId)
|
||||
}
|
||||
@@ -680,7 +688,7 @@ class GameService(
|
||||
.toMap()
|
||||
|
||||
if (metadataResults.isEmpty()) {
|
||||
log.info { "Could not identify game at path '$path'" }
|
||||
log.debug { "Could not identify game at path '$path'" }
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.gameyfin.app.games.dto
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude
|
||||
import org.gameyfin.app.media.ImageDto
|
||||
import org.gameyfin.pluginapi.gamemetadata.*
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
@@ -10,10 +11,11 @@ sealed interface GameDto {
|
||||
val createdAt: Instant
|
||||
val updatedAt: Instant
|
||||
val libraryId: Long
|
||||
val collectionIds: List<Long>
|
||||
val title: String
|
||||
val platforms: List<Platform>
|
||||
val coverId: Long?
|
||||
val headerId: Long?
|
||||
val cover: ImageDto?
|
||||
val header: ImageDto?
|
||||
val comment: String?
|
||||
val summary: String?
|
||||
val release: LocalDate?
|
||||
@@ -26,7 +28,7 @@ sealed interface GameDto {
|
||||
val keywords: List<String>?
|
||||
val features: List<GameFeature>?
|
||||
val perspectives: List<PlayerPerspective>?
|
||||
val imageIds: List<Long>?
|
||||
val images: List<ImageDto>?
|
||||
val videoUrls: List<String>?
|
||||
val metadata: GameMetadataDto
|
||||
}
|
||||
@@ -37,10 +39,11 @@ data class GameUserDto(
|
||||
override val createdAt: Instant,
|
||||
override val updatedAt: Instant,
|
||||
override val libraryId: Long,
|
||||
override val collectionIds: List<Long>,
|
||||
override val title: String,
|
||||
override val platforms: List<Platform>,
|
||||
override val coverId: Long?,
|
||||
override val headerId: Long?,
|
||||
override val cover: ImageDto?,
|
||||
override val header: ImageDto?,
|
||||
override val comment: String?,
|
||||
override val summary: String?,
|
||||
override val release: LocalDate?,
|
||||
@@ -53,7 +56,7 @@ data class GameUserDto(
|
||||
override val keywords: List<String>?,
|
||||
override val features: List<GameFeature>?,
|
||||
override val perspectives: List<PlayerPerspective>?,
|
||||
override val imageIds: List<Long>?,
|
||||
override val images: List<ImageDto>?,
|
||||
override val videoUrls: List<String>?,
|
||||
override val metadata: GameMetadataUserDto
|
||||
) : GameDto
|
||||
@@ -64,10 +67,11 @@ data class GameAdminDto(
|
||||
override val createdAt: Instant,
|
||||
override val updatedAt: Instant,
|
||||
override val libraryId: Long,
|
||||
override val collectionIds: List<Long>,
|
||||
override val title: String,
|
||||
override val platforms: List<Platform>,
|
||||
override val coverId: Long?,
|
||||
override val headerId: Long?,
|
||||
override val cover: ImageDto?,
|
||||
override val header: ImageDto?,
|
||||
override val comment: String?,
|
||||
override val summary: String?,
|
||||
override val release: LocalDate?,
|
||||
@@ -80,7 +84,7 @@ data class GameAdminDto(
|
||||
override val keywords: List<String>?,
|
||||
override val features: List<GameFeature>?,
|
||||
override val perspectives: List<PlayerPerspective>?,
|
||||
override val imageIds: List<Long>?,
|
||||
override val images: List<ImageDto>?,
|
||||
override val videoUrls: List<String>?,
|
||||
override val metadata: GameMetadataAdminDto
|
||||
) : GameDto
|
||||
|
||||
@@ -2,7 +2,9 @@ package org.gameyfin.app.games.entities
|
||||
|
||||
import jakarta.persistence.*
|
||||
import jakarta.persistence.CascadeType.*
|
||||
import org.gameyfin.app.collections.entities.Collection
|
||||
import org.gameyfin.app.libraries.entities.Library
|
||||
import org.gameyfin.app.media.Image
|
||||
import org.gameyfin.pluginapi.gamemetadata.*
|
||||
import org.hibernate.annotations.CreationTimestamp
|
||||
import org.hibernate.annotations.UpdateTimestamp
|
||||
@@ -79,6 +81,9 @@ class Game(
|
||||
@ElementCollection
|
||||
var videoUrls: List<URI> = emptyList(),
|
||||
|
||||
@ManyToMany(mappedBy = "games", fetch = FetchType.EAGER)
|
||||
var collections: MutableList<Collection> = mutableListOf(),
|
||||
|
||||
@Embedded
|
||||
var metadata: GameMetadata
|
||||
) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.gameyfin.app.games.extensions
|
||||
import org.gameyfin.app.core.security.isCurrentUserAdmin
|
||||
import org.gameyfin.app.games.dto.*
|
||||
import org.gameyfin.app.games.entities.*
|
||||
import org.gameyfin.app.media.toDto
|
||||
import java.time.ZoneOffset
|
||||
|
||||
|
||||
@@ -28,10 +29,11 @@ fun Game.toAdminDto(): GameAdminDto {
|
||||
createdAt = createdAt!!,
|
||||
updatedAt = updatedAt!!,
|
||||
libraryId = this.library.id!!,
|
||||
collectionIds = this.collections.mapNotNull { it.id },
|
||||
title = title!!,
|
||||
platforms = this.platforms,
|
||||
coverId = this.coverImage?.id,
|
||||
headerId = this.headerImage?.id,
|
||||
cover = this.coverImage?.toDto(),
|
||||
header = this.headerImage?.toDto(),
|
||||
comment = this.comment,
|
||||
summary = this.summary,
|
||||
release = this.release?.atZone(ZoneOffset.UTC)?.toLocalDate(),
|
||||
@@ -44,7 +46,7 @@ fun Game.toAdminDto(): GameAdminDto {
|
||||
keywords = this.keywords.toList(),
|
||||
features = this.features,
|
||||
perspectives = this.perspectives,
|
||||
imageIds = this.images.mapNotNull { it.id },
|
||||
images = this.images.map { it.toDto() },
|
||||
videoUrls = this.videoUrls.map { it.toString() },
|
||||
metadata = this.metadata.toAdminDto()
|
||||
)
|
||||
@@ -56,10 +58,11 @@ fun Game.toUserDto(): GameUserDto {
|
||||
createdAt = createdAt!!,
|
||||
updatedAt = updatedAt!!,
|
||||
libraryId = this.library.id!!,
|
||||
collectionIds = this.collections.mapNotNull { it.id },
|
||||
title = title!!,
|
||||
platforms = this.platforms,
|
||||
coverId = this.coverImage?.id,
|
||||
headerId = this.headerImage?.id,
|
||||
cover = this.coverImage?.toDto(),
|
||||
header = this.headerImage?.toDto(),
|
||||
comment = this.comment,
|
||||
summary = this.summary,
|
||||
release = this.release?.atZone(ZoneOffset.UTC)?.toLocalDate(),
|
||||
@@ -72,7 +75,7 @@ fun Game.toUserDto(): GameUserDto {
|
||||
keywords = this.keywords.toList(),
|
||||
features = this.features,
|
||||
perspectives = this.perspectives,
|
||||
imageIds = this.images.mapNotNull { it.id },
|
||||
images = this.images.map { it.toDto() },
|
||||
videoUrls = this.videoUrls.map { it.toString() },
|
||||
metadata = this.metadata.toUserDto()
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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.stereotype.Repository
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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
|
||||
|
||||
interface ImageRepository : JpaRepository<Image, Long> {
|
||||
|
||||
@@ -46,6 +46,9 @@ class LibraryEndpoint(
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun updateLibrary(library: LibraryUpdateDto) = libraryService.update(library)
|
||||
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun updateLibraries(libraries: Collection<LibraryUpdateDto>) = libraryService.update(libraries)
|
||||
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun deleteLibrary(libraryId: Long) = libraryService.delete(libraryId)
|
||||
}
|
||||
@@ -102,60 +102,37 @@ class LibraryScanService(
|
||||
emit(progress)
|
||||
|
||||
try {
|
||||
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) }
|
||||
|
||||
progress.currentStep = LibraryScanStep(
|
||||
description = "Processing new games",
|
||||
current = 0,
|
||||
total = newPaths.size + pluginIgnoredPathsToRescan.size
|
||||
)
|
||||
emit(progress)
|
||||
val scanData = performFilesystemScan(library)
|
||||
|
||||
// 1. Process each new game independently (including re-scanned plugin ignored paths)
|
||||
val allPathsToProcess = newPaths + pluginIgnoredPathsToRescan
|
||||
val (newUnmatchedPaths, persistedNewGames) = processNewGames(library, allPathsToProcess, progress)
|
||||
val (newUnmatchedPaths, persistedNewGames) = processNewGamesWithProgress(
|
||||
library,
|
||||
scanData.allPathsToProcess,
|
||||
progress
|
||||
)
|
||||
|
||||
// 2. Update library (removed games/ignored paths, and add persisted new ones)
|
||||
val (removedGames) = updateLibrary(
|
||||
library,
|
||||
removedIgnoredPaths,
|
||||
scanData.removedIgnoredPaths,
|
||||
newUnmatchedPaths,
|
||||
removedGamePaths
|
||||
scanData.removedGamePaths
|
||||
)
|
||||
|
||||
// 3. Finish scan: persist library changes and report
|
||||
progress.currentStep = LibraryScanStep(
|
||||
description = "Finishing up",
|
||||
current = 0,
|
||||
total = persistedNewGames.size
|
||||
)
|
||||
emit(progress)
|
||||
finishScanWithProgress(persistedNewGames, library, progress)
|
||||
|
||||
finishScanPersisted(persistedNewGames, library, progress)
|
||||
|
||||
progress.currentStep = LibraryScanStep(description = "Finished")
|
||||
progress.finishedAt = Instant.now()
|
||||
progress.status = LibraryScanStatus.COMPLETED
|
||||
progress.result = QuickScanResult(
|
||||
new = persistedNewGames.size,
|
||||
removed = removedGames.size,
|
||||
unmatched = newUnmatchedPaths.size
|
||||
// 4. Send final progress update
|
||||
completeScan(
|
||||
progress,
|
||||
QuickScanResult(
|
||||
new = persistedNewGames.size,
|
||||
removed = removedGames.size,
|
||||
unmatched = newUnmatchedPaths.size
|
||||
)
|
||||
)
|
||||
emit(progress)
|
||||
} catch (e: Exception) {
|
||||
log.error { "Error during quick scan for library ${library.id}: ${e.message}" }
|
||||
log.debug(e) {}
|
||||
progress.status = LibraryScanStatus.FAILED
|
||||
progress.finishedAt = Instant.now()
|
||||
emit(progress)
|
||||
handleScanError(e, library, progress, "quick scan")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,16 +147,7 @@ class LibraryScanService(
|
||||
emit(progress)
|
||||
|
||||
try {
|
||||
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 scanData = performFilesystemScan(library)
|
||||
|
||||
// 1. Update existing games (individually)
|
||||
progress.currentStep = LibraryScanStep(
|
||||
@@ -192,54 +160,109 @@ class LibraryScanService(
|
||||
val (updatedGames) = updateExistingGames(library.games, progress)
|
||||
|
||||
// 2. Process new games (individually, including re-scanned plugin ignored paths)
|
||||
val allPathsToProcess = newPaths + pluginIgnoredPathsToRescan
|
||||
progress.currentStep = LibraryScanStep(
|
||||
description = "Processing new games",
|
||||
current = 0,
|
||||
total = allPathsToProcess.size
|
||||
val (newUnmatchedPaths, persistedNewGames) = processNewGamesWithProgress(
|
||||
library,
|
||||
scanData.allPathsToProcess,
|
||||
progress
|
||||
)
|
||||
emit(progress)
|
||||
|
||||
val (newUnmatchedPaths, persistedNewGames) = processNewGames(library, allPathsToProcess, progress)
|
||||
|
||||
val (removedGames) = updateLibrary(
|
||||
library,
|
||||
removedIgnoredPaths,
|
||||
scanData.removedIgnoredPaths,
|
||||
newUnmatchedPaths,
|
||||
removedGamePaths
|
||||
scanData.removedGamePaths
|
||||
)
|
||||
|
||||
// 3. Finish scan
|
||||
progress.currentStep = LibraryScanStep(
|
||||
description = "Finishing up",
|
||||
current = 0,
|
||||
total = persistedNewGames.size
|
||||
)
|
||||
emit(progress)
|
||||
|
||||
finishScanPersisted(persistedNewGames, library, progress)
|
||||
finishScanWithProgress(persistedNewGames, library, progress)
|
||||
|
||||
// 4. Send final progress update
|
||||
progress.currentStep = LibraryScanStep(description = "Finished")
|
||||
progress.finishedAt = Instant.now()
|
||||
progress.status = LibraryScanStatus.COMPLETED
|
||||
progress.result = FullScanResult(
|
||||
new = persistedNewGames.size,
|
||||
removed = removedGames.size,
|
||||
unmatched = newUnmatchedPaths.size,
|
||||
updated = updatedGames.size
|
||||
completeScan(
|
||||
progress,
|
||||
FullScanResult(
|
||||
new = persistedNewGames.size,
|
||||
removed = removedGames.size,
|
||||
unmatched = newUnmatchedPaths.size,
|
||||
updated = updatedGames.size
|
||||
)
|
||||
)
|
||||
emit(progress)
|
||||
} catch (e: Exception) {
|
||||
log.error { "Error during full scan for library ${library.id}: ${e.message}" }
|
||||
log.debug(e) {}
|
||||
progress.status = LibraryScanStatus.FAILED
|
||||
progress.finishedAt = Instant.now()
|
||||
emit(progress)
|
||||
return
|
||||
handleScanError(e, library, progress, "full scan")
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
library: Library,
|
||||
gamePaths: List<Path>,
|
||||
|
||||
@@ -5,6 +5,7 @@ import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.gameyfin.app.core.security.getCurrentAuth
|
||||
import org.gameyfin.app.libraries.dto.*
|
||||
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.enums.ScanType
|
||||
import org.gameyfin.app.libraries.extensions.toDtos
|
||||
@@ -34,7 +35,7 @@ class LibraryService(
|
||||
private val libraryAdminEvents = Sinks.many().multicast().onBackpressureBuffer<LibraryAdminEvent>(1024, false)
|
||||
|
||||
fun subscribeUser(): Flux<List<LibraryUserEvent>> {
|
||||
log.debug { "New user subscription for libraryEvents" }
|
||||
log.debug { "New user subscription for libraryUserEvents" }
|
||||
return libraryUserEvents.asFlux()
|
||||
.buffer(100.milliseconds.toJavaDuration())
|
||||
.doOnSubscribe {
|
||||
@@ -46,7 +47,7 @@ class LibraryService(
|
||||
}
|
||||
|
||||
fun subscribeAdmin(): Flux<List<LibraryAdminEvent>> {
|
||||
log.debug { "New admin subscription for libraryEvents" }
|
||||
log.debug { "New admin subscription for libraryAdminEvents" }
|
||||
return libraryAdminEvents.asFlux()
|
||||
.buffer(100.milliseconds.toJavaDuration())
|
||||
.doOnSubscribe {
|
||||
@@ -166,15 +167,16 @@ class LibraryService(
|
||||
library.platforms.addAll(it)
|
||||
}
|
||||
|
||||
// Only allow updating USER sourced ignored paths; preserve PLUGIN sourced ones
|
||||
libraryUpdateDto.ignoredPaths
|
||||
?.filter { it.sourceType == IgnoredPathSourceTypeDto.USER } // Only USER source type is supported for updates
|
||||
?.let { dtos ->
|
||||
// Get current user for USER source type paths
|
||||
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 existingPath = ignoredPathRepository.findByPath(dto.path)
|
||||
existingPath ?: dto.toEntity(currentUser)
|
||||
@@ -183,10 +185,21 @@ class LibraryService(
|
||||
library.ignoredPaths.addAll(pathsToAdd)
|
||||
}
|
||||
|
||||
libraryUpdateDto.metadata?.let {
|
||||
library.metadata = it.toEntity()
|
||||
}
|
||||
|
||||
library.updatedAt = Instant.now()
|
||||
libraryRepository.save(library)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates multiple libraries in the repository.
|
||||
*/
|
||||
fun update(libraries: Collection<LibraryUpdateDto>) {
|
||||
libraries.forEach { update(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 org.gameyfin.pluginapi.gamemetadata.Platform
|
||||
import java.time.Instant
|
||||
|
||||
interface LibraryDto {
|
||||
val id: Long
|
||||
val name: String
|
||||
val games: List<Long>?
|
||||
val createdAt: Instant?
|
||||
val gameIds: List<Long>?
|
||||
val metadata: LibraryMetadataDto?
|
||||
}
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
data class LibraryUserDto(
|
||||
override val id: Long,
|
||||
override val name: String,
|
||||
override val games: List<Long>?
|
||||
override val createdAt: Instant?,
|
||||
override val gameIds: List<Long>?,
|
||||
override val metadata: LibraryMetadataDto?
|
||||
) : LibraryDto
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
data class LibraryAdminDto(
|
||||
override val id: Long,
|
||||
override val name: String,
|
||||
override val createdAt: Instant?,
|
||||
val directories: List<DirectoryMappingDto>,
|
||||
val platforms: List<Platform>,
|
||||
override val games: List<Long>?,
|
||||
override val gameIds: List<Long>?,
|
||||
val stats: LibraryStatsDto?,
|
||||
val ignoredPaths: List<IgnoredPathDto>?
|
||||
val ignoredPaths: List<IgnoredPathDto>?,
|
||||
override val metadata: LibraryMetadataDto?
|
||||
) : 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 directories: List<DirectoryMappingDto>? = 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(),
|
||||
|
||||
@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.libraries.dto.*
|
||||
import org.gameyfin.app.libraries.entities.Library
|
||||
import org.gameyfin.app.libraries.entities.LibraryMetadata
|
||||
|
||||
|
||||
fun Library.toDto(): LibraryDto {
|
||||
@@ -25,7 +26,9 @@ fun Library.toUserDto(): LibraryUserDto {
|
||||
return LibraryUserDto(
|
||||
id = this.id!!,
|
||||
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(
|
||||
id = this.id!!,
|
||||
name = this.name,
|
||||
createdAt = this.createdAt!!,
|
||||
directories = this.directories.map { DirectoryMappingDto(it.internalPath, it.externalPath) },
|
||||
platforms = this.platforms,
|
||||
games = this.games.mapNotNull { it.id },
|
||||
gameIds = this.games.mapNotNull { it.id },
|
||||
stats = LibraryStatsDto(
|
||||
gamesCount = this.games.size,
|
||||
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
|
||||
var updated: Game? = null
|
||||
try {
|
||||
updated = gameService.update(game)
|
||||
updated = gameService.updateMetadata(game)
|
||||
if (updated != null) {
|
||||
// Download any images now associated with the game
|
||||
downloadImagesForGame(updated)
|
||||
|
||||
+4
-2
@@ -1,4 +1,4 @@
|
||||
package org.gameyfin.app.games.entities
|
||||
package org.gameyfin.app.media
|
||||
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.GeneratedValue
|
||||
@@ -25,7 +25,9 @@ class Image(
|
||||
var contentLength: Long? = null,
|
||||
|
||||
@MimeType
|
||||
var mimeType: String? = null
|
||||
var mimeType: String? = null,
|
||||
|
||||
var blurhash: String? = null
|
||||
)
|
||||
|
||||
enum class ImageType {
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.gameyfin.app.media
|
||||
|
||||
data class ImageDto(
|
||||
val id: Long,
|
||||
val type: ImageType,
|
||||
val blurhash: String?
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user