Compare commits

...

20 Commits

Author SHA1 Message Date
grimsi
ecd369cd30 Migrate to Spring Boot 4 (#868)
* Switched from TomCat to Jetty
* Hibernate migrations
* Removed dependency on Spring-Boot-Content-FS
* Migrate to Jackson 3
* Migrate LegacyExtensionFinder -> IndexedExtensionFinder
* Fix code inspection issues
* Exclude Config classes from Sonar coverage calcualtion
* Add FileStorageServiceTest
* Add tests for (De-)serializers
* Exclude H2 package from Sonar coverage reporting
* Add Sonar scan
* Update JVM in CI
* Update dependency versions
2026-02-05 13:07:41 +01:00
grimsi
111e164fab Update feature request and bug issue forms 2026-02-05 13:06:41 +01:00
grimsi
e5ad8b90d6 Add YourKit endorsement 2026-01-24 14:04:43 +01:00
grimsi
0fe22cb1b8 Update README.md with a clear description of what Gameyfin is 2025-12-22 17:00:29 +01:00
grimsi
4b3f692667 Update README.md with new link 2025-12-22 14:48:11 +01:00
grimsi
2139df3ca8 chore: release v2.3.3 2025-12-22 10:43:49 +00:00
Simon
005a1611ce
Release 2.3.3 (#839)
* chore: bump version to v2.3.3-preview

* Optimiziation for multiple parallel and long-running downloads (#838)

* Add missing Content-Type header to downloads (#837)

Conditionally add Content-Length header to downloads
Only calculate fileSize if file is not a directory in DirectDownloadPlugin

* Update dependencies, add Google Guava

* Refactor and optimize download bandwidth monitoring and throttling

* Update .jar layer extraction command in Dockerfile

* Fix Dockerfile.ubuntu

* Furhter performance and tracking improvements for downloads

* Fix tests

* Update HeroUI version

* Encode filenames in Content-Disposition header according to RFC 6266 (#841)

* Encode filenames in Content-Disposition header with UTF-8 according to RFC 6266

* Fix tests
2025-12-22 11:34:39 +01:00
grimsi
abc12f146b chore: release v2.3.2 2025-12-17 10:18:24 +00:00
Simon
386374f39c
Release 2.3.2 (#831)
* Optimize performance of web UI while downloads are active

* chore: bump version to v2.3.2-preview

* Fix test

* Fix GameCover not refreshing until reload

* Bump actions/upload-artifact from 4 to 6 (#829)

Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  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>

* Bump actions/download-artifact from 5 to 7 (#830)

Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 5 to 7.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v5...v7)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

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

* Fix login redirect issue when behind NPM (#832)

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-17 11:05:04 +01:00
grimsi
400c4d1c61 chore: release v2.3.1 2025-12-14 21:13:50 +00:00
Simon
bb6f0ac931
Release 2.3.1 (#828)
* chore: bump version to v2.3.1-preview

* Fix flashing of game covers

* Change sort order of games on the start page (most recently added first)

* Add "Date added" column to table in Collection Management
2025-12-14 22:05:56 +01:00
grimsi
cd0149bb64 chore: release v2.3.0 2025-12-10 12:10:11 +00:00
Simon
09953a3f78
Release 2.3.0 (#804)
* chore: bump version to v2.3.0-preview

* Customize start page (#803)

* Update ConfigService to support complex Objects
Implemented tests for ConfigService

* Added DB migration for config table

* Fixed version in banner.txt not being displayed

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

* Fix build.gradle.kts

* FIx bug when creating libraries

* Fix TypeScript errors
Fix library sorting

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

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

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

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

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

* Implement collections (#814)

* Backend implementation for collections

* Fix database schema and migration script

* Refactor some config values
Fix ArrayInput not being deactivatable

* Remove "AutoRegisterNewUsers" config option

* Fix bug when removing ignored paths

* Add UI for collections (WIP)

* Fix table actions not synced with state
Fix tests

* Finish implementation of collection feature

* Fix tests

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

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

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

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

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

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

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

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

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

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

* Overhaul startpage (#823)

* WIP: Update start page layout

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

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

* Fix bugs and test

* Fix code analysis issues

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

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

* Remove alpine based image (#825)

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

* Delete image files in background

* Fix layout

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-10 12:58:14 +01:00
Simon
608a0b5ac1
Revert "Overhaul startpage (#821)" (#822)
This reverts commit 8d8dca32d8.
2025-12-10 00:24:32 +01:00
Simon
8d8dca32d8
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>
2025-12-10 00:22:58 +01:00
grimsi
38b95ae102 chore: release v2.2.1 2025-11-20 16:06:39 +00:00
Simon
5a3077d219
Release 2.2.1 (#799)
* chore: bump version to v2.2.1-preview

* Fix Platform filter being ignored in matchManually (#798)

* Fix platforms being ignored in manual match

* Update tests
2025-11-20 16:59:31 +01:00
Simon
bb9e70b578
Remove container image migration notice from README
Removed important notice about container image migration.
2025-11-18 08:35:32 +01:00
grimsi
5184c2501d chore: release v2.2.0 2025-11-17 07:57:29 +00:00
Simon
717a423449
Release v2.2.0 (#741)
* Migrate to TailwindCSS v4 (#740)

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

* Clean up Tailwind configs before upgrade

* Run HeroUI upgrade

* Run TailwindCSS upgrade

* Replace PostCSS with Vite

* Migrate custom styles to v4

* Remove tailwind.config.ts

* Add heroui.ts
Add tailwind vite plugin

* Fix small UI color inconsistency

* Fix theming system
Rename purple theme to pink

* Re-implement stepper in HeroUI

* Fix RoleChip colors

* Migrate icon names (#743)

* Add migration script for phosphor-icons

* Migrate icon usages

* Update version to 2.2.0-preview

* Revert accidental rename of menu title

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

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

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

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

* Improve library scanning (#749)

* Update script to generate example libraries using SteamSpy API

* Refactor library scanning process

* Display Flyway startup log by default

* Fix race condition in CompanyService

* Fix race condition in ImageService
Remove obsolete table

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

* Disable length limit for config values (#757)

* Deprecate DockerHub image (#759)

* Remove deprecation warning from web UI

* Reworked the CICD pipelines

* Optimize container image (#761)

* Fix Gradle warning

* Rework Docker image to improve layer caching

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

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

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

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

* Multi platform support (#764)

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

* Replace placeholder in LibraryOverviewCard (#767)

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

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

* Multi platform support (#773)

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

* Hide Flyway query logs by default

* Extend migration script for multi platform tables

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

* Add "plugindata" directory to entrypoint scripts

* Improve download handling (#756)

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

* Increase default session timeout to 24h

* Use virtual thread pool for download task in background

* Make KSP extensions.idx generation more robust

* Implement download bandwidth limiter
Implement SliderInput
Refactor NumberInput

* Implement download bandwidth throttling
Implement real-time download monitoring

* Improve UI for DownloadManagement
Track more stats in SessionStats

* Update Hilla
Use React 19

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

* Update docker-compose.example.yml

* Improve DownloadSessionCard (#784)

* Fix unit on y-axis of download graph

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

* Migrate torrent plugin to libtorrent (#775)

* Disable TorrentDownloadPlugin in Alpine based Docker image

* Improve test coverage (#785)

* Fix potential divide by zero bug

* Add mockk dependency

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

* Add tests for Filesytem package
Fix DownloadServiceTest

* Fix FilesystemServiceTest

* Add tests for "job" package

* Upgrade Gradle wrapper
Enable Gradle config cache

* Added more tests

* Added tests for the "security" package

* Add tests for "game" package

* Fix AsyncFileTailer not shutting down properly on Windows

* Fix GameServiceTest

* Added tests for "libraries" package

* Added tests for "media" package

* Fix warning in ImageService

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

* Add tests for "platforms" package

* Add tests for "requests" package

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

* Add tests for "token" package

* Fix issue in RoleEnum.safeValueOf() throwing Exception

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

* Added tests for "user" package

* Migrate package for "token" in FE

* Publish test report in CI

* Fix workflow permissions

* Remove test because of timing issue in CI

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

* Use new "AutoComplete" component (#793)

* Use ArrayInputAutocomplete in EditGameMetadataModal

* Add test for getEnumPropertyValues

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-17 08:45:39 +01:00
465 changed files with 50712 additions and 9947 deletions

39
.dockerignore Normal file
View File

@ -0,0 +1,39 @@
# Exclude VCS and IDE files
.git
.gitignore
.idea/
*.iml
# Gradle caches
.gradle/
**/.gradle/
# Node modules and app build cache
app/node_modules/
app/.pnpm-store/
app/.npm/
app/.yarn/
app/.vite/
app/dist/
# General build outputs (keep only the jars we actually need)
**/build/
!app/build/
!app/build/libs/
!app/build/libs/app.jar
# Only keep plugin jars in build/libs
plugins/**
!plugins/*/build/
!plugins/*/build/libs/
!plugins/*/build/libs/*.jar
# Large local/runtime data not needed in image context
data/
db/
logs/
plugindata/
# Docker intermediate artifacts
**/.DS_Store

View File

@ -1,32 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**General info (please complete the following information):**
- Browser (if bug is in the Web UI) [e.g. chrome, safari]
- Gameyfin version [e.g. v2.0.0.beta3]
**Additional context**
Add any other context about the problem here.
Screenshots can also be attached here.

128
.github/ISSUE_TEMPLATE/bug_report.yaml vendored Normal file
View File

@ -0,0 +1,128 @@
name: Bug report
description: Create a report to help us improve
type: bug
title: '[Bug] '
labels:
- Bug
assignees:
- grimsi
body:
- type: markdown
attributes:
value: |
> [!IMPORTANT]
> **Before submitting your bug report**
>
> To help us resolve your issue efficiently, please ensure you have reviewed our [FAQs](https://gameyfin.org/faq/) and [Getting started guide](https://gameyfin.org/installation/getting-started/).
>
> Issues that could have been resolved by following these resources may be closed to allow us to focus on genuine bugs.
- type: checkboxes
id: prerequisites
attributes:
label: Prerequisites
description: Please confirm you have read and understood the following resources
options:
- label: I have read and understood the [FAQs](https://gameyfin.org/faq/)
required: true
- label: I have read and understood the [Getting started guide](https://gameyfin.org/installation/getting-started/)
required: true
- type: textarea
id: description
attributes:
label: Bug Description
description: A clear and concise description of what the bug is
placeholder: Describe the bug...
validations:
required: true
- type: input
id: version
attributes:
label: Gameyfin Version
description: What version of Gameyfin are you running?
placeholder: e.g. v2.0.0.beta3
validations:
required: true
- type: dropdown
id: installation-type
attributes:
label: Installation Type
description: How did you install Gameyfin?
options:
- Docker
- Unraid
- Other (please specify in Additional Context)
validations:
required: true
- type: input
id: browser
attributes:
label: Browser with Version
description: Which browser are you using?
placeholder: e.g. Chrome 120.0.6099.129, Firefox 121.0, Safari 17.2
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: How to Reproduce
description: Steps to reproduce the behavior
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected Behavior
description: A clear and concise description of what you expected to happen
placeholder: What should have happened?
validations:
required: true
- type: textarea
id: actual-behavior
attributes:
label: Actual Behavior
description: A clear and concise description of what actually happened
placeholder: What actually happened?
validations:
required: true
- type: textarea
id: logs
attributes:
label: Application Logs
description: Please provide relevant logs from the application. You can usually find these in the logs directory or container logs
placeholder: Paste your logs here
render: shell
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: If applicable, add screenshots to help explain your problem
placeholder: Drag and drop images here or paste them
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Add any other context about the problem here
placeholder: Any additional information that might be helpful
validations:
required: false

View File

@ -1,10 +0,0 @@
---
name: Feature request
about: Request a feature
title: ''
labels: ''
assignees: ''
---
Please use the [discussions](https://github.com/gameyfin/gameyfin/discussions/categories/feature-requests) for feature requests!

View File

@ -0,0 +1,24 @@
name: Feature request
description: Request a feature
title: 'Feature request'
labels: []
assignees: []
body:
- type: markdown
attributes:
value: |
> [!CAUTION]
> **Feature requests should not be submitted as issues!**
>
> Please use the [discussions](https://github.com/gameyfin/gameyfin/discussions/categories/feature-requests) for feature requests instead.
- type: input
id: acknowledgment
attributes:
label: I understand
description: Type "I will use discussions" to acknowledge
placeholder: I will use discussions
validations:
required: true

View File

@ -1,42 +1,28 @@
name: 'Docker Build and Push'
description: 'Builds and pushes Docker images to Docker Hub and GHCR with flexible tagging.'
description: 'Builds and pushes Docker images to GHCR with flexible tagging.'
runs:
using: 'composite'
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ inputs.dockerhub_username }}
password: ${{ inputs.dockerhub_token }}
- name: Log in to GitHub Container Registry
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ inputs.ghcr_username }}
password: ${{ inputs.ghcr_token }}
- name: Prepare Ubuntu tags
id: ubuntu_tags
- name: Prepare combined tags (default + ubuntu suffix)
id: combined_tags
shell: bash
run: |
TAGS="${{ inputs.tags }}"
# Generate both default tags and ubuntu-suffixed tags
DEFAULT_TAGS="$TAGS"
UBUNTU_TAGS=$(echo "$TAGS" | awk -F, '{for(i=1;i<=NF;i++){split($i,a,":"); printf "%s:%s-ubuntu", a[1], a[2]; if(i<NF) printf ","}}')
echo "ubuntu_tags=$UBUNTU_TAGS" >> $GITHUB_OUTPUT
- name: Build and push Docker image (Alpine)
uses: docker/build-push-action@v5
with:
context: ${{ inputs.context }}
file: ${{ inputs.dockerfile }}
platforms: ${{ inputs.platforms }}
push: true
tags: ${{ inputs.tags }}
cache-from: type=gha
cache-to: type=gha
COMBINED_TAGS="$DEFAULT_TAGS,$UBUNTU_TAGS"
echo "combined_tags=$COMBINED_TAGS" >> $GITHUB_OUTPUT
- name: Build and push Docker image (Ubuntu)
uses: docker/build-push-action@v5
@ -45,17 +31,11 @@ runs:
file: docker/Dockerfile.ubuntu
platforms: ${{ inputs.platforms }}
push: true
tags: ${{ steps.ubuntu_tags.outputs.ubuntu_tags }}
tags: ${{ steps.combined_tags.outputs.combined_tags }}
cache-from: type=gha
cache-to: type=gha
inputs:
dockerhub_username:
required: true
description: 'Docker Hub username'
dockerhub_token:
required: true
description: 'Docker Hub token'
ghcr_username:
required: true
description: 'GHCR username'
@ -74,3 +54,4 @@ inputs:
tags:
required: true
description: 'Comma-separated list of image tags'

View File

@ -1,4 +1,4 @@
name: Delete Docker Tag on Merge
name: Delete Image Tags on Merge
on:
pull_request:
@ -9,10 +9,11 @@ jobs:
delete-docker-tag:
if: startsWith(github.event.pull_request.head.ref, 'fix/') || startsWith(github.event.pull_request.head.ref, 'release/')
runs-on: ubuntu-latest
name: Cleanup Image Tags from GHCR
permissions:
packages: write
steps:
- name: Extract merged branch name and tag
- name: Extract tag from branch name
id: extract_branch
run: |
BRANCH="${{ github.event.pull_request.head.ref }}"
@ -26,50 +27,8 @@ jobs:
echo "tag=$TAG" >> $GITHUB_OUTPUT
shell: bash
- name: Delete image tag from Docker Hub
- name: Delete tags
if: steps.extract_branch.outputs.tag != ''
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
TAG: ${{ steps.extract_branch.outputs.tag }}
run: |
echo "Deleting Docker tag from Docker Hub: $TAG"
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE -u "$DOCKERHUB_USERNAME:$DOCKERHUB_TOKEN" \
"https://hub.docker.com/v2/repositories/grimsi/gameyfin/tags/$TAG/")
if [ "$RESPONSE" != "204" ]; then
echo "Failed to delete Docker Hub tag: $TAG (HTTP $RESPONSE)" >&2
exit 1
fi
shell: bash
- name: Delete image tag from GHCR
if: steps.extract_branch.outputs.tag != ''
env:
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.extract_branch.outputs.tag }}
REPO: gameyfin/gameyfin
OWNER: ${{ github.repository_owner }}
run: |
echo "Deleting Docker tag from GHCR: $TAG"
# Get the package ID
PACKAGE_ID=$(curl -s -H "Authorization: Bearer $GHCR_TOKEN" \
"https://api.github.com/users/$OWNER/packages/container/$REPO" | jq -r '.id')
if [ "$PACKAGE_ID" = "null" ] || [ -z "$PACKAGE_ID" ]; then
echo "Failed to get GHCR package ID for $REPO" >&2
exit 1
fi
# Get the version ID for the tag
VERSION_ID=$(curl -s -H "Authorization: Bearer $GHCR_TOKEN" \
"https://api.github.com/users/$OWNER/packages/container/$REPO/versions" | jq -r ".[] | select(.metadata.container.tags[]? == \"$TAG\") | .id")
if [ -z "$VERSION_ID" ]; then
echo "Failed to find GHCR version for tag: $TAG" >&2
exit 1
fi
# Delete the version
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE -H "Authorization: Bearer $GHCR_TOKEN" \
"https://api.github.com/users/$OWNER/packages/container/$REPO/versions/$VERSION_ID")
if [ "$RESPONSE" != "204" ]; then
echo "Failed to delete GHCR tag: $TAG (HTTP $RESPONSE)" >&2
exit 1
fi
shell: bash
uses: dataaxiom/ghcr-cleanup-action@v1
with:
tags: ${{ steps.extract_branch.outputs.tag }},${{ steps.extract_branch.outputs.tag }}-ubuntu

View File

@ -1,44 +0,0 @@
name: Build and Push Docker Image
on:
push:
branches:
- main
workflow_dispatch:
inputs:
image_tag:
description: 'Docker image tag'
required: false
default: 'develop'
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '21'
- name: Run production build
env:
GAMEYFIN_KEYSTORE_PASSWORD: ${{ secrets.GAMEYFIN_KEYSTORE_PASSWORD }}
run: ./gradlew clean build -Pvaadin.productionMode=true
- name: Build and push Docker image
uses: ./.github/actions/docker-build-push
with:
dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }}
ghcr_username: ${{ github.actor }}
ghcr_token: ${{ secrets.GITHUB_TOKEN }}
context: .
dockerfile: docker/Dockerfile
platforms: linux/arm64/v8,linux/amd64
tags: grimsi/gameyfin:${{ inputs.image_tag || 'develop' }},ghcr.io/gameyfin/gameyfin:${{ inputs.image_tag || 'develop' }}

View File

@ -6,25 +6,56 @@ on:
- 'fix/*'
jobs:
build-and-push:
build:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
checks: write
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Set up JDK 21
- name: Set up JDK 25
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '21'
java-version: '25'
- name: Run production build
env:
GAMEYFIN_KEYSTORE_PASSWORD: ${{ secrets.GAMEYFIN_KEYSTORE_PASSWORD }}
run: ./gradlew clean build -Pvaadin.productionMode=true
- name: Publish Test Report
uses: mikepenz/action-junit-report@v6
if: success() || failure() # always run even if the previous step fails
with:
report_paths: '**/build/test-results/test/TEST-*.xml'
- name: Upload build outputs
uses: actions/upload-artifact@v6
with:
name: build-outputs
path: |
app/build/libs/**
plugins/**/build/libs/**/*.jar
docker:
needs: build
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Download build outputs
uses: actions/download-artifact@v7
with:
name: build-outputs
path: .
- name: Extract tag from branch name
id: extract_tag
run: |
@ -35,11 +66,9 @@ jobs:
- name: Build and push Docker image
uses: ./.github/actions/docker-build-push
with:
dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }}
ghcr_username: ${{ github.actor }}
ghcr_token: ${{ secrets.GITHUB_TOKEN }}
context: .
dockerfile: docker/Dockerfile
platforms: linux/arm64/v8,linux/amd64
tags: grimsi/gameyfin:${{ steps.extract_tag.outputs.tag }},ghcr.io/gameyfin/gameyfin:${{ steps.extract_tag.outputs.tag }}
tags: ghcr.io/gameyfin/gameyfin:${{ steps.extract_tag.outputs.tag }}

View File

@ -6,40 +6,94 @@ on:
- 'release/*'
jobs:
build-and-push:
build:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
checks: write
outputs:
version: ${{ steps.extract_version.outputs.version }}
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up JDK 21
- name: Set up JDK 25
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '21'
java-version: '25'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
- name: Extract version from branch name
id: extract_version
run: |
BRANCH_NAME="${GITHUB_REF#refs/heads/}"
VERSION="${BRANCH_NAME#release/}-preview"
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Update version in build.gradle.kts
run: |
sed -i "s/^version = .*/version = \"${{ steps.extract_version.outputs.version }}\"/" build.gradle.kts
- name: Update version in app/package.json
run: |
jq ".version = \"${{ steps.extract_version.outputs.version }}\"" app/package.json > app/package.json.tmp && mv app/package.json.tmp app/package.json
- name: Commit version bump (only if changes)
uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: 'chore: bump version to v${{ steps.extract_version.outputs.version }}'
file_pattern: |
build.gradle.kts
app/package.json
- name: Run production build
env:
GAMEYFIN_KEYSTORE_PASSWORD: ${{ secrets.GAMEYFIN_KEYSTORE_PASSWORD }}
run: ./gradlew clean build -Pvaadin.productionMode=true
- name: Extract tag from branch name
id: extract_tag
run: |
BRANCH_NAME="${GITHUB_REF#refs/heads/}"
TAG="${BRANCH_NAME#release/}-preview"
echo "tag=$TAG" >> $GITHUB_OUTPUT
- name: Publish Test Report
uses: mikepenz/action-junit-report@v6
if: success() || failure() # always run even if the previous step fails
with:
report_paths: '**/build/test-results/test/TEST-*.xml'
- name: Upload build outputs
uses: actions/upload-artifact@v6
with:
name: build-outputs
path: |
app/build/libs/**
plugins/**/build/libs/**/*.jar
docker:
needs: build
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Download build outputs
uses: actions/download-artifact@v7
with:
name: build-outputs
path: .
- name: Build and push Docker image
uses: ./.github/actions/docker-build-push
with:
dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }}
ghcr_username: ${{ github.actor }}
ghcr_token: ${{ secrets.GITHUB_TOKEN }}
context: .
dockerfile: docker/Dockerfile
platforms: linux/arm64/v8,linux/amd64
tags: grimsi/gameyfin:${{ steps.extract_tag.outputs.tag }},ghcr.io/gameyfin/gameyfin:${{ steps.extract_tag.outputs.tag }}
tags: ghcr.io/gameyfin/gameyfin:${{ needs.build.outputs.version }}

View File

@ -0,0 +1,35 @@
name: GHCR Image Registry Maintenance
on:
workflow_dispatch:
inputs:
older_than:
description: 'Only remove images older than (e.g. "1 year", leave empty to remove all untagged images)'
required: false
dry_run:
description: 'Dry run?'
required: true
default: true
type: boolean
validate:
description: 'Validate all multi-architecture images in the registry after cleanup?'
required: true
default: false
type: boolean
jobs:
delete-untagged-images:
runs-on: ubuntu-latest
name: Delete Untagged Images from GHCR
permissions:
packages: write
steps:
- name: Delete untagged, ghost, and orphaned images
uses: dataaxiom/ghcr-cleanup-action@v1
with:
older-than: ${{ github.event.inputs.older_than }}
dry-run: ${{ github.event.inputs.dry_run }}
validate: ${{ github.event.inputs.validate }}
delete-untagged: true
delete-ghost-images: true
delete-orphaned-images: true

View File

@ -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
@ -50,34 +50,36 @@ jobs:
jq ".version = \"$RELEASE_VERSION\"" app/package.json > app/package.json.tmp && mv app/package.json.tmp app/package.json
- name: Upload modified files
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: modified-files
path: |
build.gradle.kts
app/package.json
docker:
build:
needs: setup
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
checks: write
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Download modified files
uses: actions/download-artifact@v5
uses: actions/download-artifact@v7
with:
name: modified-files
- name: Set up JDK 21
- name: Set up JDK 25
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '21'
java-version: '25'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
@ -86,27 +88,59 @@ jobs:
GAMEYFIN_KEYSTORE_PASSWORD: ${{ secrets.GAMEYFIN_KEYSTORE_PASSWORD }}
run: ./gradlew clean build -Pvaadin.productionMode=true
- name: Publish Test Report
uses: mikepenz/action-junit-report@v6
if: success() || failure() # always run even if the previous step fails
with:
report_paths: '**/build/test-results/test/TEST-*.xml'
- name: Upload build outputs
uses: actions/upload-artifact@v6
with:
name: build-outputs
path: |
app/build/libs/**
plugins/**/build/libs/**/*.jar
docker:
needs: [ setup, build ]
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Download modified files
uses: actions/download-artifact@v7
with:
name: modified-files
- name: Download build outputs
uses: actions/download-artifact@v7
with:
name: build-outputs
path: .
- name: Generate container image tags
id: docker_tags
run: |
VERSION="${{ needs.setup.outputs.release_version }}"
DOCKERHUB_TAGS="grimsi/gameyfin:$VERSION"
VERSION='${{ needs.setup.outputs.release_version }}'
GHCR_TAGS="ghcr.io/gameyfin/gameyfin:$VERSION"
if [[ "$VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
MAJOR=${BASH_REMATCH[1]}
MINOR=${BASH_REMATCH[2]}
PATCH=${BASH_REMATCH[3]}
DOCKERHUB_TAGS="grimsi/gameyfin:latest,grimsi/gameyfin:develop,grimsi/gameyfin:$VERSION,grimsi/gameyfin:$MAJOR.$MINOR,grimsi/gameyfin:$MAJOR"
GHCR_TAGS="ghcr.io/gameyfin/gameyfin:latest,ghcr.io/gameyfin/gameyfin:develop,ghcr.io/gameyfin/gameyfin:$VERSION,ghcr.io/gameyfin/gameyfin:$MAJOR.$MINOR,ghcr.io/gameyfin/gameyfin:$MAJOR"
fi
TAGS="$DOCKERHUB_TAGS,$GHCR_TAGS"
TAGS="$GHCR_TAGS"
echo "tags=$TAGS" >> $GITHUB_OUTPUT
- name: Build and push Docker image
uses: ./.github/actions/docker-build-push
with:
dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }}
ghcr_username: ${{ github.actor }}
ghcr_token: ${{ secrets.GITHUB_TOKEN }}
context: .
@ -119,20 +153,20 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Download modified files
uses: actions/download-artifact@v5
uses: actions/download-artifact@v7
with:
name: modified-files
- name: Set up JDK 21
- name: Set up JDK 25
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '21'
java-version: '25'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
@ -150,18 +184,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Download modified files
uses: actions/download-artifact@v5
uses: actions/download-artifact@v7
with:
name: modified-files
- name: Commit version bump
if: ${{ github.event.inputs.update_version }}
uses: stefanzweifel/git-auto-commit-action@v6
uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: 'chore: release v${{ github.event.inputs.version }}'
tagging_message: v${{ github.event.inputs.version }}

44
.github/workflows/sonar.yml vendored Normal file
View File

@ -0,0 +1,44 @@
name: Sonar Analysis
on:
push:
branches:
- main
pull_request:
types: [ opened, synchronize, reopened ]
jobs:
sonar:
name: Sonar Analysis
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up JDK 25
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '25'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
- name: Cache SonarCloud packages
uses: actions/cache@v4
with:
path: ~/.sonar/cache
key: ${{ runner.os }}-sonar
restore-keys: ${{ runner.os }}-sonar
- name: Run tests and generate JaCoCo report
run: ./gradlew :app:test :app:jacocoTestReport
- name: SonarCloud Scan
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: ./gradlew :app:sonar

7
.gitignore vendored
View File

@ -48,9 +48,14 @@ out/
/packaged_plugins
/logs
/templates
/docker/docker-compose.yml
/app/src/main/bundles/
/app/src/main/frontend/**/*.js
/app/src/main/frontend/**/*.js.map
/app/src/main/frontend/generated/
/torrent_dotfiles/
**/torrent_dotfiles/
*.state.json
/plugins/data/
/plugins/state/
/plugindata/
/docker-debug/

View File

@ -9,7 +9,7 @@
<module name="Gameyfin.app.main" />
<option name="SHORTEN_COMMAND_LINE" value="ARGS_FILE" />
<option name="SPRING_BOOT_MAIN_CLASS" value="org.gameyfin.app.GameyfinApplication" />
<option name="VM_PARAMETERS" value="-Dpf4j.mode=development" />
<option name="VM_PARAMETERS" value="-Dpf4j.mode=development -Djava.net.preferIPv4Stack=true" />
<extension name="coverage">
<pattern>
<option name="PATTERN" value="org.gameyfin.app.*" />

View File

@ -4,7 +4,7 @@
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="scriptParameters" value="-x test" />
<option name="taskDescriptions">
<list />
</option>

View File

@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="UI debug" type="JavascriptDebugType" uri="http://localhost:8080">
<configuration default="false" name="UI debug" type="JavascriptDebugType" engineId="37cae5b9-e8b2-4949-9172-aafa37fbc09c" uri="http://localhost:8080">
<method v="2" />
</configuration>
</component>

View File

@ -4,18 +4,17 @@
</a>
<h2>Gameyfin</h2>
<h4>Manage your video games.</h4>
<p>simple / fast / <a href="https://github.com/gameyfin/gameyfin/blob/main/LICENSE.md">FOSS</a></p>
<p>simple / fast / <a href="https://gameyfin.org/blog/2025/12/22/why-gameyfin-is-foss/">FOSS</a></p>
</div>
> [!IMPORTANT]
> Gameyfins container image will move soon™.
> The new image is available under `ghcr.io/gameyfin/gameyfin`.
> The old image (`grimsi/gameyfin`) will be removed in the future, so please update your setup accordingly.
## Overview
Name and functionality inspired by [Jellyfin](https://jellyfin.org/).
Gameyfin will turn your disorganized collection of video games into a beautiful, easy-to-navigate library that you can access from any device with a web browser.
It will automatically scan your game folders, download metadata and cover images, and present everything in a user-friendly interface.
Download your game files directly from the web UI, share your library with friends, and enjoy your games like never before.
### Documentation
The documentation and screenshots are available at [gameyfin.org](https://gameyfin.org/).
@ -46,3 +45,9 @@ Gameyfin v2 is written in Kotlin and uses the following libraries/frameworks:
* Vaadin Hilla & React for the frontend
* PF4J for the plugin system
* H2 database for persistence
### Acknowledgements
[![YourKit Logo](https://www.yourkit.com/images/yklogo.png)](https://www.yourkit.com/)
Gameyfin is supported by [YourKit](https://www.yourkit.com/), the makers of [YourKit Java Profiler](https://yourkit.com/java/profiler/), a powerful tool for profiling Java and Kotlin applications.

View File

@ -1,4 +1,6 @@
group = "de.grimsi"
import org.apache.tools.ant.filters.ReplaceTokens
group = "org.gameyfin"
val appMainClass = "org.gameyfin.app.GameyfinApplicationKt"
plugins {
@ -10,6 +12,8 @@ plugins {
kotlin("plugin.jpa")
id("com.google.devtools.ksp")
application
jacoco
id("org.sonarqube")
}
application {
@ -31,14 +35,19 @@ 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-aspectj")
implementation("org.springframework.boot:spring-boot-starter-jackson")
implementation("org.springframework.cloud:spring-cloud-starter")
implementation("jakarta.validation:jakarta.validation-api:3.1.0")
implementation("jakarta.validation:jakarta.validation-api:${rootProject.extra["jakartaValidationVersion"]}")
// Kotlin extensions
implementation(kotlin("reflect"))
// Reactive
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.springframework.boot:spring-boot-starter-webflux") {
exclude(group = "org.springframework.boot", module = "spring-boot-starter-reactor-netty")
}
implementation("org.springframework.boot:spring-boot-starter-jetty")
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
@ -46,16 +55,19 @@ dependencies {
implementation("com.vaadin:vaadin-core") {
exclude("com.vaadin:flow-react")
}
implementation("com.vaadin:vaadin-spring-boot-starter")
implementation("com.vaadin:vaadin-spring-boot-starter") {
exclude(group = "org.springframework.boot", module = "spring-boot-starter-tomcat")
}
implementation("com.vaadin:hilla-spring-boot-starter")
// Logging
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
implementation("io.github.oshai:kotlin-logging-jvm:${rootProject.extra["kotlinLoggingVersion"]}")
// Persistence & I/O
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("com.github.paulcwarren:spring-content-fs-boot-starter:3.0.17")
implementation("org.flywaydb:flyway-core")
implementation("commons-io:commons-io:2.18.0")
implementation("org.springframework.boot:spring-boot-starter-flyway")
implementation("commons-io:commons-io:${rootProject.extra["commonsIoVersion"]}")
implementation("com.google.guava:guava:${rootProject.extra["guavaVersion"]}")
// SSO
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
@ -64,22 +76,31 @@ dependencies {
// Notifications
implementation("org.springframework.boot:spring-boot-starter-mail")
implementation("ch.digitalfondue.mjml4j:mjml4j:1.0.3")
implementation("ch.digitalfondue.mjml4j:mjml4j:${rootProject.extra["mjml4jVersion"]}")
// Plugins
implementation(project(":plugin-api"))
// Utils
implementation("org.apache.tika:tika-core:3.1.0")
implementation("me.xdrop:fuzzywuzzy:1.4.0")
implementation("org.apache.tika:tika-core:${rootProject.extra["tikaVersion"]}")
implementation("me.xdrop:fuzzywuzzy:${rootProject.extra["fuzzywuzzyVersion"]}")
implementation("com.vanniktech:blurhash:${rootProject.extra["blurhashVersion"]}")
// Development
developmentOnly("org.springframework.boot:spring-boot-devtools")
developmentOnly("com.vaadin:vaadin-dev")
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
runtimeOnly("com.h2database:h2")
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
testImplementation("org.springframework.boot:spring-boot-starter-test")
// Testing
testImplementation(kotlin("test"))
testImplementation("org.springframework.boot:spring-boot-starter-test") {
exclude(group = "org.mockito", module = "mockito-core")
}
testImplementation("io.mockk:mockk:${rootProject.extra["mockkVersion"]}")
testImplementation("org.springframework.security:spring-security-test")
testImplementation("io.projectreactor:reactor-test")
}
dependencyManagement {
@ -91,4 +112,36 @@ dependencyManagement {
tasks.withType<Test> {
useJUnitPlatform()
}
finalizedBy(tasks.jacocoTestReport)
}
tasks.jacocoTestReport {
dependsOn(tasks.test)
reports {
xml.required = true
xml.outputLocation = layout.buildDirectory.file("reports/jacoco/test/jacocoTestReport.xml")
}
}
tasks.named("sonar") {
dependsOn(tasks.jacocoTestReport)
}
sonar {
properties {
property("sonar.organization", "gameyfin")
property("sonar.projectKey", "gameyfin_gameyfin")
property("sonar.projectName", "gameyfin")
property("sonar.host.url", "https://sonarcloud.io")
property("sonar.coverage.jacoco.xmlReportPaths", "build/reports/jacoco/test/jacocoTestReport.xml")
property("sonar.coverage.exclusions", "**/*Config.kt,**/org/gameyfin/db/h2/**")
}
}
tasks.named<ProcessResources>("processResources") {
val projectVersion = rootProject.version.toString()
filesMatching("application.yml") {
filter<ReplaceTokens>("tokens" to mapOf("project.version" to projectVersion))
}
}

View File

@ -1,7 +0,0 @@
import {HeroUIPluginConfig} from "@heroui/react";
import {compileThemes, themes} from "./src/main/frontend/theming/themes"
export const HeroUIConfig: HeroUIPluginConfig = {
prefix: "gf",
themes: compileThemes(themes)
};

16643
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,116 +1,105 @@
{
"name": "gameyfin",
"version": "2.1.2",
"version": "2.3.3",
"type": "module",
"dependencies": {
"@heroui/react": "2.7.9",
"@material-tailwind/react": "^2.1.10",
"@phosphor-icons/react": "^2.1.7",
"@polymer/polymer": "3.5.2",
"@heroui/react": "^2.8.7",
"@phosphor-icons/react": "^2.1.10",
"@react-stately/data": "^3.12.2",
"@react-types/shared": "^3.28.0",
"@vaadin/bundles": "24.9.0",
"@tailwindcss/vite": "4.1.13",
"@vaadin/aura": "25.0.3",
"@vaadin/common-frontend": "0.0.19",
"@vaadin/hilla-file-router": "24.9.0",
"@vaadin/hilla-frontend": "24.9.0",
"@vaadin/hilla-lit-form": "24.9.0",
"@vaadin/hilla-react-auth": "24.9.0",
"@vaadin/hilla-react-crud": "24.9.0",
"@vaadin/hilla-react-form": "24.9.0",
"@vaadin/hilla-react-i18n": "24.9.0",
"@vaadin/hilla-react-signals": "24.9.0",
"@vaadin/polymer-legacy-adapter": "24.9.0",
"@vaadin/react-components": "24.9.0",
"@vaadin/hilla-file-router": "25.0.4",
"@vaadin/hilla-frontend": "25.0.4",
"@vaadin/hilla-lit-form": "25.0.4",
"@vaadin/hilla-react-auth": "25.0.4",
"@vaadin/hilla-react-crud": "25.0.4",
"@vaadin/hilla-react-form": "25.0.4",
"@vaadin/hilla-react-i18n": "25.0.4",
"@vaadin/hilla-react-signals": "25.0.4",
"@vaadin/react-components": "25.0.3",
"@vaadin/vaadin-development-mode-detector": "2.0.7",
"@vaadin/vaadin-lumo-styles": "24.9.0",
"@vaadin/vaadin-material-styles": "24.9.0",
"@vaadin/vaadin-themable-mixin": "24.9.0",
"@vaadin/vaadin-lumo-styles": "25.0.3",
"@vaadin/vaadin-themable-mixin": "25.0.3",
"@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",
"date-fns": "4.1.0",
"formik": "^2.4.6",
"framer-motion": "^12.5.0",
"framer-motion": "^12.23.22",
"fzf": "^0.5.2",
"http-status-codes": "^2.3.0",
"lit": "3.3.0",
"lit": "3.3.2",
"moment": "^2.30.1",
"moment-timezone": "^0.5.47",
"next-themes": "^0.4.6",
"postcss": "^8.5.6",
"postcss-import": "^16.1.1",
"rand-seed": "^2.1.7",
"react": "18.3.1",
"react": "19.2.3",
"react-accessible-treeview": "^2.11.1",
"react-aria-components": "^1.7.1",
"react-confetti-boom": "^1.0.0",
"react-dom": "18.3.1",
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"react-player": "^2.16.0",
"react-router": "7.6.1",
"react-realtime-chart": "^0.8.1",
"react-router": "7.12.0",
"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": {
"@babel/preset-react": "7.27.1",
"@babel/preset-react": "7.28.5",
"@lit-labs/react": "^2.1.3",
"@preact/signals-react-transform": "0.5.1",
"@rollup/plugin-replace": "6.0.2",
"@rollup/pluginutils": "5.1.4",
"@types/node": "^22.4.0",
"@types/react": "18.3.23",
"@types/react-dom": "18.3.7",
"@vaadin/hilla-generator-cli": "24.9.0",
"@vaadin/hilla-generator-core": "24.9.0",
"@vaadin/hilla-generator-plugin-backbone": "24.9.0",
"@vaadin/hilla-generator-plugin-barrel": "24.9.0",
"@vaadin/hilla-generator-plugin-client": "24.9.0",
"@vaadin/hilla-generator-plugin-model": "24.9.0",
"@vaadin/hilla-generator-plugin-push": "24.9.0",
"@vaadin/hilla-generator-plugin-signals": "24.9.0",
"@vaadin/hilla-generator-plugin-subtypes": "24.9.0",
"@vaadin/hilla-generator-plugin-transfertypes": "24.9.0",
"@vaadin/hilla-generator-utils": "24.9.0",
"@vitejs/plugin-react": "4.5.0",
"@preact/signals-react-transform": "0.6.0",
"@rollup/plugin-replace": "6.0.3",
"@rollup/pluginutils": "5.3.0",
"@types/node": "25.0.3",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@types/react-window": "^1.8.8",
"@vaadin/hilla-generator-cli": "25.0.4",
"@vaadin/hilla-generator-core": "25.0.4",
"@vaadin/hilla-generator-plugin-backbone": "25.0.4",
"@vaadin/hilla-generator-plugin-barrel": "25.0.4",
"@vaadin/hilla-generator-plugin-client": "25.0.4",
"@vaadin/hilla-generator-plugin-model": "25.0.4",
"@vaadin/hilla-generator-plugin-push": "25.0.4",
"@vaadin/hilla-generator-plugin-signals": "25.0.4",
"@vaadin/hilla-generator-plugin-subtypes": "25.0.4",
"@vaadin/hilla-generator-plugin-transfertypes": "25.0.4",
"@vaadin/hilla-generator-utils": "25.0.4",
"@vitejs/plugin-react": "5.1.2",
"@vitejs/plugin-react-swc": "^3.7.0",
"async": "3.2.6",
"autoprefixer": "^10.4.20",
"glob": "11.0.2",
"magic-string": "0.30.17",
"postcss": "^8.4.41",
"postcss-import": "^16.1.0",
"baseline-browser-mapping": "^2.9.19",
"magic-string": "0.30.21",
"rollup-plugin-brotli": "3.1.0",
"rollup-plugin-visualizer": "5.14.0",
"rollup-plugin-visualizer": "6.0.5",
"strip-css-comments": "5.0.0",
"tailwindcss": "^3.4.13",
"tailwindcss": "4.1.13",
"transform-ast": "2.4.4",
"typescript": "5.8.3",
"vite": "6.3.6",
"vite-plugin-checker": "0.9.3",
"workbox-build": "7.3.0",
"workbox-core": "7.3.0",
"workbox-precaching": "7.3.0"
"typescript": "5.9.3",
"vite": "7.3.1",
"vite-plugin-checker": "0.12.0",
"workbox-build": "7.4.0"
},
"overrides": {
"@react-aria/utils": "^3.28.1",
"classnames": "$classnames",
"react": "$react",
"react-dom": "$react-dom",
"@vaadin/bundles": "$@vaadin/bundles",
"@vaadin/common-frontend": "$@vaadin/common-frontend",
"construct-style-sheets-polyfill": "$construct-style-sheets-polyfill",
"lit": "$lit",
"@polymer/polymer": "$@polymer/polymer",
"@phosphor-icons/react": "$@phosphor-icons/react",
"formik": "$formik",
"yup": "$yup",
"next-themes": "$next-themes",
"@heroui/react": "$@heroui/react",
"framer-motion": "$framer-motion",
"@material-tailwind/react": "$@material-tailwind/react",
"http-status-codes": "$http-status-codes",
"@vaadin/polymer-legacy-adapter": "$@vaadin/polymer-legacy-adapter",
"@vaadin/vaadin-development-mode-detector": "$@vaadin/vaadin-development-mode-detector",
"@vaadin/vaadin-usage-statistics": "$@vaadin/vaadin-usage-statistics",
"@vaadin/react-components": "$@vaadin/react-components",
@ -128,7 +117,6 @@
"date-fns": "$date-fns",
"@vaadin/vaadin-themable-mixin": "$@vaadin/vaadin-themable-mixin",
"@vaadin/vaadin-lumo-styles": "$@vaadin/vaadin-lumo-styles",
"@vaadin/vaadin-material-styles": "$@vaadin/vaadin-material-styles",
"@react-types/shared": "$@react-types/shared",
"@react-stately/data": "$@react-stately/data",
"react-aria-components": "$react-aria-components",
@ -140,129 +128,129 @@
"react-markdown": "$react-markdown",
"remark-breaks": "$remark-breaks",
"valtio": "$valtio",
"valtio-reactive": "$valtio-reactive",
"fzf": "$fzf",
"@vaadin/a11y-base": "24.9.0",
"@vaadin/accordion": "24.9.0",
"@vaadin/app-layout": "24.9.0",
"@vaadin/avatar": "24.9.0",
"@vaadin/avatar-group": "24.9.0",
"@vaadin/button": "24.9.0",
"@vaadin/card": "24.9.0",
"@vaadin/checkbox": "24.9.0",
"@vaadin/checkbox-group": "24.9.0",
"@vaadin/combo-box": "24.9.0",
"@vaadin/component-base": "24.9.0",
"@vaadin/confirm-dialog": "24.9.0",
"@vaadin/context-menu": "24.9.0",
"@vaadin/custom-field": "24.9.0",
"@vaadin/date-picker": "24.9.0",
"@vaadin/date-time-picker": "24.9.0",
"@vaadin/details": "24.9.0",
"@vaadin/dialog": "24.9.0",
"@vaadin/email-field": "24.9.0",
"@vaadin/field-base": "24.9.0",
"@vaadin/field-highlighter": "24.9.0",
"@vaadin/form-layout": "24.9.0",
"@vaadin/grid": "24.9.0",
"@vaadin/horizontal-layout": "24.9.0",
"@vaadin/icon": "24.9.0",
"@vaadin/icons": "24.9.0",
"@vaadin/input-container": "24.9.0",
"@vaadin/integer-field": "24.9.0",
"@vaadin/item": "24.9.0",
"@vaadin/list-box": "24.9.0",
"@vaadin/lit-renderer": "24.9.0",
"@vaadin/login": "24.9.0",
"@vaadin/markdown": "24.9.0",
"@vaadin/master-detail-layout": "24.9.0",
"@vaadin/menu-bar": "24.9.0",
"@vaadin/message-input": "24.9.0",
"@vaadin/message-list": "24.9.0",
"@vaadin/multi-select-combo-box": "24.9.0",
"@vaadin/notification": "24.9.0",
"@vaadin/number-field": "24.9.0",
"@vaadin/overlay": "24.9.0",
"@vaadin/password-field": "24.9.0",
"@vaadin/popover": "24.9.0",
"@vaadin/progress-bar": "24.9.0",
"@vaadin/radio-group": "24.9.0",
"@vaadin/scroller": "24.9.0",
"@vaadin/select": "24.9.0",
"@vaadin/side-nav": "24.9.0",
"@vaadin/split-layout": "24.9.0",
"@vaadin/tabs": "24.9.0",
"@vaadin/tabsheet": "24.9.0",
"@vaadin/text-area": "24.9.0",
"@vaadin/text-field": "24.9.0",
"@vaadin/time-picker": "24.9.0",
"@vaadin/tooltip": "24.9.0",
"@vaadin/upload": "24.9.0",
"@vaadin/router": "2.0.0",
"@vaadin/vertical-layout": "24.9.0",
"@vaadin/virtual-list": "24.9.0"
"@tailwindcss/vite": "$@tailwindcss/vite",
"postcss": "$postcss",
"postcss-import": "$postcss-import",
"next-themes": "$next-themes",
"react-realtime-chart": "$react-realtime-chart",
"react-window": "$react-window",
"blurhash": "$blurhash",
"@vaadin/aura": "$@vaadin/aura",
"@vaadin/a11y-base": "25.0.3",
"@vaadin/accordion": "25.0.3",
"@vaadin/app-layout": "25.0.3",
"@vaadin/avatar": "25.0.3",
"@vaadin/avatar-group": "25.0.3",
"@vaadin/button": "25.0.3",
"@vaadin/card": "25.0.3",
"@vaadin/checkbox": "25.0.3",
"@vaadin/checkbox-group": "25.0.3",
"@vaadin/combo-box": "25.0.3",
"@vaadin/component-base": "25.0.3",
"@vaadin/confirm-dialog": "25.0.3",
"@vaadin/context-menu": "25.0.3",
"@vaadin/custom-field": "25.0.3",
"@vaadin/date-picker": "25.0.3",
"@vaadin/date-time-picker": "25.0.3",
"@vaadin/details": "25.0.3",
"@vaadin/dialog": "25.0.3",
"@vaadin/email-field": "25.0.3",
"@vaadin/field-base": "25.0.3",
"@vaadin/field-highlighter": "25.0.3",
"@vaadin/form-layout": "25.0.3",
"@vaadin/grid": "25.0.3",
"@vaadin/horizontal-layout": "25.0.3",
"@vaadin/icon": "25.0.3",
"@vaadin/icons": "25.0.3",
"@vaadin/input-container": "25.0.3",
"@vaadin/integer-field": "25.0.3",
"@vaadin/item": "25.0.3",
"@vaadin/list-box": "25.0.3",
"@vaadin/lit-renderer": "25.0.3",
"@vaadin/login": "25.0.3",
"@vaadin/markdown": "25.0.3",
"@vaadin/master-detail-layout": "25.0.3",
"@vaadin/menu-bar": "25.0.3",
"@vaadin/message-input": "25.0.3",
"@vaadin/message-list": "25.0.3",
"@vaadin/multi-select-combo-box": "25.0.3",
"@vaadin/notification": "25.0.3",
"@vaadin/number-field": "25.0.3",
"@vaadin/overlay": "25.0.3",
"@vaadin/password-field": "25.0.3",
"@vaadin/popover": "25.0.3",
"@vaadin/progress-bar": "25.0.3",
"@vaadin/radio-group": "25.0.3",
"@vaadin/scroller": "25.0.3",
"@vaadin/select": "25.0.3",
"@vaadin/side-nav": "25.0.3",
"@vaadin/split-layout": "25.0.3",
"@vaadin/tabs": "25.0.3",
"@vaadin/tabsheet": "25.0.3",
"@vaadin/text-area": "25.0.3",
"@vaadin/text-field": "25.0.3",
"@vaadin/time-picker": "25.0.3",
"@vaadin/tooltip": "25.0.3",
"@vaadin/upload": "25.0.3",
"@vaadin/router": "2.0.1",
"@vaadin/vertical-layout": "25.0.3",
"@vaadin/virtual-list": "25.0.3"
},
"vaadin": {
"dependencies": {
"@polymer/polymer": "3.5.2",
"@vaadin/bundles": "24.9.0",
"@vaadin/aura": "25.0.3",
"@vaadin/common-frontend": "0.0.19",
"@vaadin/hilla-file-router": "24.9.0",
"@vaadin/hilla-frontend": "24.9.0",
"@vaadin/hilla-lit-form": "24.9.0",
"@vaadin/hilla-react-auth": "24.9.0",
"@vaadin/hilla-react-crud": "24.9.0",
"@vaadin/hilla-react-form": "24.9.0",
"@vaadin/hilla-react-i18n": "24.9.0",
"@vaadin/hilla-react-signals": "24.9.0",
"@vaadin/polymer-legacy-adapter": "24.9.0",
"@vaadin/react-components": "24.9.0",
"@vaadin/hilla-file-router": "25.0.4",
"@vaadin/hilla-frontend": "25.0.4",
"@vaadin/hilla-lit-form": "25.0.4",
"@vaadin/hilla-react-auth": "25.0.4",
"@vaadin/hilla-react-crud": "25.0.4",
"@vaadin/hilla-react-form": "25.0.4",
"@vaadin/hilla-react-i18n": "25.0.4",
"@vaadin/hilla-react-signals": "25.0.4",
"@vaadin/react-components": "25.0.3",
"@vaadin/vaadin-development-mode-detector": "2.0.7",
"@vaadin/vaadin-lumo-styles": "24.9.0",
"@vaadin/vaadin-material-styles": "24.9.0",
"@vaadin/vaadin-themable-mixin": "24.9.0",
"@vaadin/vaadin-lumo-styles": "25.0.3",
"@vaadin/vaadin-themable-mixin": "25.0.3",
"@vaadin/vaadin-usage-statistics": "2.1.3",
"construct-style-sheets-polyfill": "3.1.0",
"date-fns": "2.29.3",
"lit": "3.3.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router": "7.6.1"
"date-fns": "4.1.0",
"lit": "3.3.2",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-router": "7.12.0"
},
"devDependencies": {
"@babel/preset-react": "7.27.1",
"@preact/signals-react-transform": "0.5.1",
"@rollup/plugin-replace": "6.0.2",
"@rollup/pluginutils": "5.1.4",
"@types/react": "18.3.23",
"@types/react-dom": "18.3.7",
"@vaadin/hilla-generator-cli": "24.9.0",
"@vaadin/hilla-generator-core": "24.9.0",
"@vaadin/hilla-generator-plugin-backbone": "24.9.0",
"@vaadin/hilla-generator-plugin-barrel": "24.9.0",
"@vaadin/hilla-generator-plugin-client": "24.9.0",
"@vaadin/hilla-generator-plugin-model": "24.9.0",
"@vaadin/hilla-generator-plugin-push": "24.9.0",
"@vaadin/hilla-generator-plugin-signals": "24.9.0",
"@vaadin/hilla-generator-plugin-subtypes": "24.9.0",
"@vaadin/hilla-generator-plugin-transfertypes": "24.9.0",
"@vaadin/hilla-generator-utils": "24.9.0",
"@vitejs/plugin-react": "4.5.0",
"async": "3.2.6",
"glob": "11.0.2",
"magic-string": "0.30.17",
"@babel/preset-react": "7.28.5",
"@preact/signals-react-transform": "0.6.0",
"@rollup/plugin-replace": "6.0.3",
"@rollup/pluginutils": "5.3.0",
"@types/node": "25.0.3",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@vaadin/hilla-generator-cli": "25.0.4",
"@vaadin/hilla-generator-core": "25.0.4",
"@vaadin/hilla-generator-plugin-backbone": "25.0.4",
"@vaadin/hilla-generator-plugin-barrel": "25.0.4",
"@vaadin/hilla-generator-plugin-client": "25.0.4",
"@vaadin/hilla-generator-plugin-model": "25.0.4",
"@vaadin/hilla-generator-plugin-push": "25.0.4",
"@vaadin/hilla-generator-plugin-signals": "25.0.4",
"@vaadin/hilla-generator-plugin-subtypes": "25.0.4",
"@vaadin/hilla-generator-plugin-transfertypes": "25.0.4",
"@vaadin/hilla-generator-utils": "25.0.4",
"@vitejs/plugin-react": "5.1.2",
"magic-string": "0.30.21",
"rollup-plugin-brotli": "3.1.0",
"rollup-plugin-visualizer": "5.14.0",
"rollup-plugin-visualizer": "6.0.5",
"strip-css-comments": "5.0.0",
"transform-ast": "2.4.4",
"typescript": "5.8.3",
"vite": "6.3.6",
"vite-plugin-checker": "0.9.3",
"workbox-build": "7.3.0",
"workbox-core": "7.3.0",
"workbox-precaching": "7.3.0"
"typescript": "5.9.3",
"vite": "7.3.1",
"vite-plugin-checker": "0.12.0",
"workbox-build": "7.4.0"
},
"disableUsageStatistics": true,
"hash": "dba97848bdace60924f9cee496353baae70cfa4fccc7bacaf827807c51908866"
"hash": "d2c583f908a126db3f53ccbc87688b5089107afb58a87159631dc257a3a279ae"
}
}
}

View File

@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

View File

@ -4,7 +4,7 @@ import {HeroUIProvider} from "@heroui/react";
import {ThemeProvider as NextThemesProvider} from "next-themes";
import {themeNames} from "Frontend/theming/themes";
import {AuthProvider, useAuth} from "Frontend/util/auth";
import {IconContext, X} from "@phosphor-icons/react";
import {IconContext, XIcon} from "@phosphor-icons/react";
import client from "Frontend/generated/connect-client.default";
import {ErrorHandlingMiddleware} from "Frontend/util/middleware";
import {initializeLibraryState} from "Frontend/state/LibraryState";
@ -16,6 +16,10 @@ import {isAdmin} from "Frontend/util/utils";
import {useRouteMetadata} from "Frontend/util/routing";
import {useEffect} from "react";
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];
@ -45,12 +49,16 @@ function ViewWithAuth() {
if (auth.state.initializing || auth.state.loading) return;
initializeLibraryState();
initializeGameState();
initializeCollectionState();
initializePlatformState();
initializeGameRequestState();
initializePluginState();
initializeGameState();
if (isAdmin(auth)) {
initializeScanState();
initializeDownloadSessionState();
initializeUserState();
}
}, [auth]);
@ -63,7 +71,7 @@ function ViewWithAuth() {
radius: "sm",
variant: "flat",
hideIcon: true,
closeIcon: <X/>,
closeIcon: <XIcon/>,
classNames: {
closeButton: "opacity-100 absolute right-4 top-1/2 -translate-y-1/2",
progressTrack: "h-1",

View File

@ -1,5 +1,5 @@
import {useAuth} from "Frontend/util/auth";
import {GearFine, Question, SignOut, User} from "@phosphor-icons/react";
import {GearFineIcon, QuestionIcon, SignOutIcon, UserIcon} from "@phosphor-icons/react";
import {Dropdown, DropdownItem, DropdownMenu, DropdownTrigger} from "@heroui/react";
import {useNavigate} from "react-router";
import Avatar from "Frontend/components/general/Avatar";
@ -13,23 +13,23 @@ export default function ProfileMenu() {
const profileMenuItems = [
{
label: "My Profile",
icon: <User/>,
icon: <UserIcon/>,
onClick: () => navigate("/settings/profile")
},
{
label: "Administration",
icon: <GearFine/>,
onClick: () => navigate("/administration/libraries"),
icon: <GearFineIcon/>,
onClick: () => navigate("/administration/games"),
showIf: isAdmin(auth)
},
{
label: "Help",
icon: <Question/>,
icon: <QuestionIcon/>,
onClick: () => window.open("https://gameyfin.org", "_blank")
},
{
label: "Sign Out",
icon: <SignOut/>,
icon: <SignOutIcon/>,
onClick: auth.logout,
color: "primary"
},

View File

@ -4,6 +4,8 @@ import Input from "Frontend/components/general/input/Input";
import CheckboxInput from "Frontend/components/general/input/CheckboxInput";
import SelectInput from "Frontend/components/general/input/SelectInput";
import ArrayInput from "Frontend/components/general/input/ArrayInput";
import NumberInput from "Frontend/components/general/input/NumberInput";
import SliderInput from "Frontend/components/general/input/SliderInput";
export default function ConfigFormField({configElement, ...props}: any) {
function inputElement(configElement: ConfigEntryDto) {
@ -27,13 +29,22 @@ export default function ConfigFormField({configElement, ...props}: any) {
);
case "float":
return (
<Input label={configElement.description} name={configElement.key} type="number"
step="0.1" {...props}/>
<NumberInput label={configElement.description} name={configElement.key}
step={0.1} {...props}/>
);
case "int":
if (configElement.min != null && configElement.max != null) {
return (
<SliderInput label={configElement.description} name={configElement.key}
min={configElement.min}
max={configElement.max}
step={configElement.step ?? 1}
{...props}/>
);
}
return (
<Input label={configElement.description} name={configElement.key} type="number"
step="1" {...props}/>
<NumberInput label={configElement.description} name={configElement.key}
step={1} {...props}/>
);
case "array":
return (

View File

@ -0,0 +1,84 @@
import React from "react";
import withConfigPage from "Frontend/components/administration/withConfigPage";
import Section from "Frontend/components/general/Section";
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import * as Yup from "yup";
import {Alert, Button, Divider, Tooltip} from "@heroui/react";
import {FlaskIcon, SigmaIcon} from "@phosphor-icons/react";
import {useSnapshot} from "valtio/react";
import {downloadSessionState} from "Frontend/state/DownloadSessionState";
import SessionStatsDto from "Frontend/generated/org/gameyfin/app/core/download/bandwidth/SessionStatsDto";
import {DownloadSessionCard} from "Frontend/components/general/cards/DownloadSessionCard";
import {humanFileSize} from "Frontend/util/utils";
function DownloadManagementLayout({getConfig, formik}: any) {
const sessions = useSnapshot(downloadSessionState).all;
const [lastDaySum, setLastDaySum] = React.useState<number>(0);
React.useEffect(() => {
const sum = sessions.reduce((total: number, session: SessionStatsDto) => total + session.totalBytesTransferred, 0);
setLastDaySum(sum);
}, [sessions]);
return (
<div className="flex flex-col">
<Alert
title="Experimental Feature"
description="Bandwidth limiting is an experimental feature and may not work as expected. Please report any issues you encounter."
variant="solid"
hideIconWrapper={true}
icon={<FlaskIcon size={24}/>}
endContent={
<Button variant="flat"
className="bg-default-400"
onPress={() => window.open("https://github.com/gameyfin/gameyfin/issues", "_blank")}>
Open Issues
</Button>
}
classNames={{
title: "font-bold",
base: "mt-6"
}}
/>
<Section title="Bandwidth limiting"/>
<div className="flex flex-col gap-4">
<div className="flex flex-row items-baseline gap-4">
<ConfigFormField configElement={getConfig("downloads.bandwidth-limit.enabled")}/>
<ConfigFormField configElement={getConfig("downloads.bandwidth-limit.mbps")}
isDisabled={!formik.values.downloads["bandwidth-limit"].enabled}/>
</div>
</div>
<div className="flex flex-row justify-between items-end">
<h2 className="text-xl font-bold mt-8 mb-1">Live View</h2>
<Tooltip content="Sum over the last 24 hours" placement="left">
<div className="flex flex-row gap-1">
<SigmaIcon size={26} weight="bold"/>
<p className="font-semibold">{humanFileSize(lastDaySum)}</p>
</div>
</Tooltip>
</div>
<Divider className="mb-4"/>
{sessions.length === 0 &&
<p className="text-center text-default-500">No active download sessions.</p>
}
<div className="flex flex-col gap-2">
{sessions.map((session: SessionStatsDto) =>
<DownloadSessionCard key={session.sessionId} sessionId={session.sessionId}/>
)}
</div>
</div>
);
}
const validationSchema = Yup.object({
downloads: Yup.object({
"bandwidth-limit": Yup.object({
enabled: Yup.boolean().required("Required"),
mbps: Yup.number()
.min(1, "Must be at least 1 Mbps")
.required("Required"),
}).required("Required")
})
});
export const DownloadManagement = withConfigPage(DownloadManagementLayout, "Downloads", validationSchema);

View File

@ -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);

View File

@ -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>

View File

@ -1,117 +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 {Plus} from "@phosphor-icons/react";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import {LibraryOverviewCard} from "Frontend/components/general/cards/LibraryOverviewCard";
import LibraryCreationModal from "Frontend/components/general/modals/LibraryCreationModal";
import LibraryUpdateDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryUpdateDto";
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import {useSnapshot} from "valtio/react";
import {libraryState} from "Frontend/state/LibraryState";
function LibraryManagementLayout({getConfig, formik}: any) {
const libraryCreationModal = useDisclosure();
const state = useSnapshot(libraryState);
async function updateLibrary(library: LibraryUpdateDto) {
await LibraryEndpoint.updateLibrary(library);
addToast({
title: "Library updated",
description: `Library ${library.name} has been updated.`,
color: "success"
})
}
async function removeLibrary(library: LibraryDto) {
await LibraryEndpoint.deleteLibrary(library.id);
addToast({
title: "Library removed",
description: `Library ${library.name} has been removed.`,
color: "success"
})
}
return (
<div className="flex flex-col">
<Section title="Permissions"/>
<ConfigFormField configElement={getConfig("library.allow-public-access")}/>
<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}>
<Plus/>
</Button>
</Tooltip>
</div>
<Divider className="mb-4"/>
{state.sorted.length > 0 ?
// Aspect ratio of cover = 12/17 -> 5 covers = 60/17 -> 353px * 100px
<div id="library-cards" className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(353px,1fr))]">
{state.sorted.map((library) =>
// @ts-ignore
<LibraryOverviewCard library={library} updateLibrary={updateLibrary}
removeLibrary={removeLibrary} key={library.name}/>
)}
</div> :
<p className="mt-4 text-center text-default-500">No libraries found</p>
}
<LibraryCreationModal
// @ts-ignore
libraries={state.sorted}
isOpen={libraryCreationModal.isOpen}
onOpenChange={libraryCreationModal.onOpenChange}
/>
</div>
);
}
const validationSchema = Yup.object({
library: Yup.object({
metadata: Yup.object({
update: Yup.object({
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);

View File

@ -4,7 +4,7 @@ import withConfigPage from "Frontend/components/administration/withConfigPage";
import * as Yup from 'yup';
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import {addToast, Button, Code, Divider, Tooltip} from "@heroui/react";
import {ArrowUDownLeft, SortAscending} from "@phosphor-icons/react";
import { ArrowUDownLeftIcon, SortAscendingIcon } from "@phosphor-icons/react";
function LogManagementLayout({getConfig, formik}: any) {
const [logEntries, setLogEntries] = useState<string[]>([]);
@ -51,7 +51,7 @@ function LogManagementLayout({getConfig, formik}: any) {
</div>
<div className="flex flex-col">
<div className="flex flex-row flex-grow justify-between items-baseline">
<div className="flex flex-row grow justify-between items-baseline">
<h2 className={"text-xl font-bold mt-8 mb-1"}>Application logs</h2>
<div className="flex flex-row gap-1">
<Tooltip content="Soft-wrap" placement="bottom">
@ -59,7 +59,7 @@ function LogManagementLayout({getConfig, formik}: any) {
onPress={() => setSoftWrap(!softWrap)}
variant={softWrap ? "solid" : "ghost"}
>
<ArrowUDownLeft/>
<ArrowUDownLeftIcon/>
</Button>
</Tooltip>
<Tooltip content="Auto-scroll" placement="bottom">
@ -67,7 +67,7 @@ function LogManagementLayout({getConfig, formik}: any) {
onPress={() => setAutoScroll(!autoScroll)}
variant={autoScroll ? "solid" : "ghost"}
>
<SortAscending/>
<SortAscendingIcon/>
</Button>
</Tooltip>
</div>

View File

@ -4,7 +4,7 @@ import ConfigFormField from "Frontend/components/administration/ConfigFormField"
import Section from "Frontend/components/general/Section";
import {addToast, Button, Card, Tooltip, useDisclosure} from "@heroui/react";
import {MessageEndpoint, MessageTemplateEndpoint} from "Frontend/generated/endpoints";
import {PaperPlaneRight, Pencil} from "@phosphor-icons/react";
import {PaperPlaneRightIcon, PencilIcon} from "@phosphor-icons/react";
import MessageTemplateDto from "Frontend/generated/org/gameyfin/app/messages/templates/MessageTemplateDto";
import SendTestNotificationModal from "Frontend/components/administration/messages/SendTestNotificationModal";
import EditTemplateModal from "Frontend/components/administration/messages/EditTemplateModel";
@ -31,7 +31,13 @@ function MessageManagementLayout({getConfig, formik}: any) {
password: formik.values.messages.providers.email.password
}
const areCredentialsValid = await MessageEndpoint.verifyCredentials(provider, credentials);
let areCredentialsValid: boolean;
try {
areCredentialsValid = await MessageEndpoint.verifyCredentials(provider, credentials);
} catch (error) {
areCredentialsValid = false;
}
if (areCredentialsValid) {
addToast({
@ -91,7 +97,7 @@ function MessageManagementLayout({getConfig, formik}: any) {
size="sm"
onPress={() => openEditor(template)}
>
<Pencil/>
<PencilIcon/>
</Button>
</Tooltip>
<Tooltip content="Send test notification">
@ -100,7 +106,7 @@ function MessageManagementLayout({getConfig, formik}: any) {
onPress={() => openTestNotification(template)}
isDisabled={!formik.values.messages.providers.email.enabled}
>
<PaperPlaneRight/>
<PaperPlaneRightIcon/>
</Button>
</Tooltip>
<p className="text-lg">{template.description}</p>
@ -115,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>
);
@ -137,7 +143,6 @@ const validationSchema = Yup.object({
.min(0, "Port must be between 0 and 65535")
.max(65535, "Port must be between 0 and 65535"),
username: Yup.string()
.email("Invalid email address")
.required("Username is required"),
})
})

View File

@ -12,14 +12,14 @@ export default function PluginManagement() {
return state.isLoaded && (
<div className="flex flex-col">
<div className="flex flex-row flex-grow justify-between mb-8">
<div className="flex flex-row grow justify-between mb-8">
<h2 className="text-2xl font-bold">Plugins</h2>
</div>
<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>

View File

@ -2,7 +2,7 @@ import Section from "Frontend/components/general/Section";
import Input from "Frontend/components/general/input/Input";
import {addToast, Button, Input as NextUiInput, Tooltip} from "@heroui/react";
import {Form, Formik} from "formik";
import {ArrowCounterClockwise, Check, Info, Trash} from "@phosphor-icons/react";
import { ArrowCounterClockwiseIcon, CheckIcon, InfoIcon, TrashIcon } from "@phosphor-icons/react";
import React, {useEffect, useState} from "react";
import {useAuth} from "Frontend/util/auth";
import * as Yup from "yup";
@ -82,14 +82,14 @@ export default function ProfileManagement() {
>
{(formik: { values: any; isSubmitting: any; dirty: boolean; }) => (
<Form>
<div className="flex flex-row flex-grow justify-between mb-8">
<div className="flex flex-row grow justify-between mb-8">
<h2 className="text-2xl font-bold">My Profile</h2>
{auth.state.user?.managedBySso &&
<p className="text-warning">Your account is managed externally.</p>}
<div className="flex flex-row items-center gap-4">
{formik.values.newPassword.length > 0 &&
<SmallInfoField icon={Info}
<SmallInfoField icon={InfoIcon}
message="You will be logged out of all current sessions"
className="text-default-500"
/>
@ -100,7 +100,7 @@ export default function ProfileManagement() {
isDisabled={!formik.dirty || formik.isSubmitting || configSaved || auth.state.user?.managedBySso}
type="submit"
>
{formik.isSubmitting ? "" : configSaved ? <Check/> : "Save"}
{formik.isSubmitting ? "" : configSaved ? <CheckIcon/> : "Save"}
</Button>
</div>
</div>
@ -117,12 +117,12 @@ export default function ProfileManagement() {
color="success">Upload</Button>
<Tooltip content="Remove your current avatar">
<Button onPress={removeAvatar} isIconOnly color="danger"
isDisabled={auth.state.user?.managedBySso}><Trash/></Button>
isDisabled={auth.state.user?.managedBySso}><TrashIcon/></Button>
</Tooltip>
</div>
</div>
<div className="flex flex-col flex-grow">
<div className="flex flex-col grow">
<Section title="Personal information"/>
<Input name="username" label="Username" type="text" autocomplete="username"
isDisabled={auth.state.user?.managedBySso}/>
@ -145,14 +145,14 @@ export default function ProfileManagement() {
variant="ghost"
className="size-14"
>
<ArrowCounterClockwise size={26}/>
<ArrowCounterClockwiseIcon size={26}/>
</Button>
</Tooltip>
}
</div>
{!messagesEnabled &&
<div className="flex flex-row gap-2 text-warning -mt-5">
<Info/>
<InfoIcon/>
<small>
Email services are disabled. Please contact your administrator.
</small>

View File

@ -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 {MagicWand, Warning} 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">
<Warning 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")}
@ -89,7 +74,7 @@ function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) {
<Button
isDisabled={isAutoPopulateDisabled()}
onPress={autoPopulate}
className="h-14"><MagicWand className="min-w-5"/>Auto-populate</Button>
className="h-14"><MagicWandIcon className="min-w-5"/>Auto-populate</Button>
</div>
<ConfigFormField configElement={getConfig("sso.oidc.authorize-url")}
isDisabled={!formik.values.sso.oidc.enabled}/>
@ -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);

View File

@ -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 {Info, UserPlus} 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";
@ -21,7 +20,7 @@ function UserManagementLayout({getConfig, formik}: any) {
}, []);
return (
<div className="flex flex-col flex-grow">
<div className="flex flex-col grow">
<Section title="Sign-Ups"/>
<div className="flex flex-row">
@ -32,13 +31,9 @@ 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={Info}
message="Automatic user registration for SSO users is disabled"/>
}
<Tooltip content="Invite new user">
<Button isIconOnly variant="flat" onPress={inviteUserModal.onOpen}>
<UserPlus/>
<UserPlusIcon/>
</Button>
</Tooltip>
</div>

View File

@ -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 {Check, Info} 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> {
@ -92,11 +92,11 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
>
{(formik) => (
<Form>
<div className="flex flex-row flex-grow justify-between">
<div className="flex flex-row grow justify-between">
<h1 className="text-2xl font-bold">{title}</h1>
<div className="flex flex-row items-center gap-4">
{saveMessage && <SmallInfoField icon={Info}
{saveMessage && <SmallInfoField icon={InfoIcon}
message={saveMessage}
className="text-warning"/>}
@ -106,7 +106,7 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
isDisabled={formik.isSubmitting || configSaved || !formik.dirty}
type="submit"
>
{formik.isSubmitting ? "" : configSaved ? <Check/> : "Save"}
{formik.isSubmitting ? "" : configSaved ? <CheckIcon/> : "Save"}
</Button>
</div>
</div>
@ -119,7 +119,7 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
)}
</Formik> :
[...Array(4)].map((_e, i) =>
<div className="flex flex-col flex-grow gap-8 mb-12" key={i}>
<div className="flex flex-col grow gap-8 mb-12" key={i}>
<Skeleton className="h-10 w-full rounded-md"/>
<Skeleton className="h-12 flex w-1/3 rounded-md"/>
<div className="flex flex-row gap-8">

View File

@ -0,0 +1,46 @@
import React from "react";
import {Chip, Tooltip} from "@heroui/react";
interface ChipListProps {
items: string[];
maxVisible?: number;
size?: "sm" | "md" | "lg";
radius?: "none" | "sm" | "md" | "lg" | "full";
defaultContent?: string;
}
export default function ChipList({items, maxVisible = 1, size = "sm", radius = "sm", defaultContent}: ChipListProps) {
if (items.length === 0) {
return defaultContent ? <Chip radius={radius} size={size}>{defaultContent}</Chip> : null;
}
const visibleItems = items.slice(0, maxVisible);
const remainingItems = items.slice(maxVisible);
const hasMore = remainingItems.length > 0;
return (
<div className="flex flex-row gap-1">
{visibleItems.map(item => (
<Chip key={item} radius={radius} size={size}>
{item}
</Chip>
))}
{hasMore && (
<Tooltip
content={
<div className="flex flex-col gap-1">
{remainingItems.map(item => (
<div key={item}>{item}</div>
))}
</div>
}
placement="right">
<Chip radius={radius} size={size}>
{maxVisible > 0 && "+"}{remainingItems.length}
</Chip>
</Tooltip>
)}
</div>
);
}

View File

@ -1,29 +1,4 @@
import {
Alien,
Baseball,
Basketball,
CastleTurret,
DiceFive,
GameController,
Ghost,
IconContext,
Joystick,
Lego,
Medal,
PuzzlePiece,
Rocket,
Skull,
SoccerBall,
Star,
Strategy,
Sword,
Target,
ThumbsUp,
TreasureChest,
Trophy,
User,
Volleyball
} from "@phosphor-icons/react";
import { AlienIcon, BaseballIcon, BasketballIcon, CastleTurretIcon, DiceFiveIcon, GameControllerIcon, GhostIcon, IconContext, JoystickIcon, LegoIcon, MedalIcon, PuzzlePieceIcon, RocketIcon, SkullIcon, SoccerBallIcon, StarIcon, StrategyIcon, SwordIcon, TargetIcon, ThumbsUpIcon, TreasureChestIcon, TrophyIcon, UserIcon, VolleyballIcon } from "@phosphor-icons/react";
import React, {useEffect} from "react";
export default function IconBackgroundPattern() {
@ -54,29 +29,29 @@ export default function IconBackgroundPattern() {
return <div ref={containerRef} className="absolute w-full h-full opacity-50">
<IconContext.Provider value={{size: iconSize}}>
<GameController className="absolute fill-primary top-[8%] left-[8%] rotate-[350deg]"/>
<SoccerBall className="absolute fill-primary top-[48%] left-[96%] rotate-[60deg]"/>
<Joystick className="absolute top-[28%] left-[52%] rotate-[90deg]"/>
<Strategy className="absolute fill-primary top-[52%] left-[68%] rotate-[30deg]"/>
<Sword className="absolute top-[72%] left-[12%] rotate-[60deg]"/>
<Alien className="absolute fill-primary top-[12%] left-[88%] rotate-[15deg]"/>
<CastleTurret className="absolute top-[6%] left-[38%] rotate-[320deg]"/>
<Ghost className="absolute fill-primary top-[38%] left-[6%] rotate-[300deg]"/>
<Skull className="absolute top-[82%] left-[28%] rotate-[90deg]"/>
<Trophy className="absolute fill-primary top-[12%] left-[62%] rotate-[45deg]"/>
<Lego className="absolute top-[32%] left-[18%] rotate-[30deg]"/>
<TreasureChest className="absolute top-[68%] left-[48%] rotate-[75deg]"/>
<Basketball className="absolute fill-primary top-[22%] left-[37%] rotate-[10deg]"/>
<Baseball className="absolute top-[92%] left-[82%] rotate-[340deg]"/>
<DiceFive className="absolute top-[62%] left-[22%] rotate-[120deg]"/>
<Medal className="absolute fill-primary top-[18%] left-[28%] rotate-[300deg]"/>
<PuzzlePiece className="absolute top-[42%] left-[78%] rotate-[45deg]"/>
<Rocket className="absolute fill-primary top-[88%] left-[52%] rotate-[15deg]"/>
<Star className="absolute top-[28%] left-[72%] rotate-[60deg]"/>
<Target className="absolute fill-primary top-[68%] left-[62%] rotate-[330deg]"/>
<ThumbsUp className="absolute top-[82%] left-[12%] rotate-[80deg]"/>
<User className="absolute fill-primary top-[38%] left-[62%] rotate-[20deg]"/>
<Volleyball className="absolute top-[78%] left-[92%] rotate-[100deg]"/>
<GameControllerIcon className="absolute fill-primary top-[8%] left-[8%] rotate-350"/>
<SoccerBallIcon className="absolute fill-primary top-[48%] left-[96%] rotate-60"/>
<JoystickIcon className="absolute top-[28%] left-[52%] rotate-90"/>
<StrategyIcon className="absolute fill-primary top-[52%] left-[68%] rotate-30"/>
<SwordIcon className="absolute top-[72%] left-[12%] rotate-60"/>
<AlienIcon className="absolute fill-primary top-[12%] left-[88%] rotate-15"/>
<CastleTurretIcon className="absolute top-[6%] left-[38%] rotate-320"/>
<GhostIcon className="absolute fill-primary top-[38%] left-[6%] rotate-300"/>
<SkullIcon className="absolute top-[82%] left-[28%] rotate-90"/>
<TrophyIcon className="absolute fill-primary top-[12%] left-[62%] rotate-45"/>
<LegoIcon className="absolute top-[32%] left-[18%] rotate-30"/>
<TreasureChestIcon className="absolute top-[68%] left-[48%] rotate-75"/>
<BasketballIcon className="absolute fill-primary top-[22%] left-[37%] rotate-10"/>
<BaseballIcon className="absolute top-[92%] left-[82%] rotate-340"/>
<DiceFiveIcon className="absolute top-[62%] left-[22%] rotate-120"/>
<MedalIcon className="absolute fill-primary top-[18%] left-[28%] rotate-300"/>
<PuzzlePieceIcon className="absolute top-[42%] left-[78%] rotate-45"/>
<RocketIcon className="absolute fill-primary top-[88%] left-[52%] rotate-15"/>
<StarIcon className="absolute top-[28%] left-[72%] rotate-60"/>
<TargetIcon className="absolute fill-primary top-[68%] left-[62%] rotate-330"/>
<ThumbsUpIcon className="absolute top-[82%] left-[12%] rotate-80"/>
<UserIcon className="absolute fill-primary top-[38%] left-[62%] rotate-20"/>
<VolleyballIcon className="absolute top-[78%] left-[92%] rotate-100"/>
</IconContext.Provider>
</div>
}

View File

@ -3,7 +3,7 @@ import {roleToColor, roleToRoleName} from "Frontend/util/utils";
export default function RoleChip({role}: { role: string }) {
return (
<Chip key={role} size="sm" radius="sm" className={`text-xs bg-${roleToColor(role)}-500`}>
<Chip key={role} size="sm" radius="sm" className={`text-xs ${roleToColor(role)}`}>
{roleToRoleName(role)}
</Chip>
);

View File

@ -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 {Target, Warning} 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
@ -45,12 +44,12 @@ export default function ScanProgressPopover() {
classNames={{
spinnerBars: "bg-foreground-500",
}}/> :
<Target className="fill-foreground-500"/>
<TargetIcon className="fill-foreground-500"/>
}
</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&nbsp;
<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>
@ -103,7 +102,7 @@ export default function ScanProgressPopover() {
</p>
}
{scan.status === LibraryScanStatus.FAILED &&
<p className="text-danger flex flex-row gap-1"><Warning weight="fill"/>
<p className="text-danger flex flex-row gap-1"><WarningIcon weight="fill"/>
Scan failed (check logs for details)
</p>
}

View File

@ -1,8 +1,7 @@
import {Autocomplete, AutocompleteItem} from "@heroui/react";
import {CaretRight, MagnifyingGlass} from "@phosphor-icons/react";
import {CaretRightIcon, MagnifyingGlassIcon} from "@phosphor-icons/react";
import {useSnapshot} from "valtio/react";
import {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"
@ -41,7 +40,7 @@ export default function SearchBar() {
},
}}
placeholder="Type to search..."
startContent={<MagnifyingGlass/>}
startContent={<MagnifyingGlassIcon/>}
isVirtualized={true}
maxListboxHeight={300}
itemHeight={91} // 75px (cover) + 16px (margin top/bottom) = 91px
@ -54,7 +53,7 @@ export default function SearchBar() {
<p><b>{item.title}</b> ({item.release && new Date(item.release).getFullYear()})</p>
<p className="text-default-500">{item.developers && [...item.developers].sort().join(" / ")}</p>
</div>
<CaretRight/>
<CaretRightIcon/>
</div>
</AutocompleteItem>
)}

View File

@ -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>
);
}

View File

@ -0,0 +1,136 @@
import {useSnapshot} from "valtio/react";
import {downloadSessionState} from "Frontend/state/DownloadSessionState";
import {Card, Chip, Tooltip} from "@heroui/react";
import {InfoIcon} from "@phosphor-icons/react";
import {convertBpsToMbps, hslToHex, humanFileSize, timeUntil} from "Frontend/util/utils";
import {gameState} from "Frontend/state/GameState";
import RealtimeChart, {RealtimeChartData, RealtimeChartOptions} from "react-realtime-chart";
import {useEffect, useState} from "react";
import {useNavigate} from "react-router";
import {libraryState} from "Frontend/state/LibraryState";
export function DownloadSessionCard({sessionId}: { sessionId: string }) {
const navigate = useNavigate();
const session = useSnapshot(downloadSessionState).byId[sessionId];
const games = useSnapshot(gameState).state;
const libraries = useSnapshot(libraryState).state;
const [currentTime, setCurrentTime] = useState<Date>(new Date());
const [chartData, setChartData] = useState<RealtimeChartData[][]>([]);
const [foregroundColor, setForegroundColor] = useState<string>("#00F");
// Get theme colors from CSS variables
useEffect(() => {
const chartColor = window.getComputedStyle(document.body).getPropertyValue('--heroui-foreground');
if (chartColor) {
setForegroundColor(hslToHex(chartColor.trim()));
}
}, []);
useEffect(() => {
const interval = setInterval(() => {
setCurrentTime(new Date());
}, 1000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (session) {
const dataPoints: RealtimeChartData[] = session.bandwidthHistory.map((bps, idx) => {
let date = new Date();
date.setSeconds(currentTime.getSeconds() - session.bandwidthHistory.length + idx + 1);
return {
date: date,
value: convertBpsToMbps(bps)
};
});
setChartData([dataPoints]);
}
}, [currentTime]);
const chartOptions: RealtimeChartOptions = {
fps: 60,
timeSlots: 30,
colors: [foregroundColor],
margin: {left: 60},
lines: [
{
area: true,
areaColor: foregroundColor,
areaOpacity: 0.03,
lineWidth: 2,
curve: "basis",
},
],
yGrid: {
min: 0,
color: foregroundColor,
opacity: 0.25,
size: 1,
tickNumber: 7,
tickFormat: (v) => `${v}Mb/s`
},
xGrid: {
color: foregroundColor,
opacity: 0.25,
size: 1,
tickNumber: 5
},
};
return (session &&
<Card
className={`flex flex-col gap-2 m-0.5 p-4 border-2
${(session.currentBytesPerSecond > 0) ? "border-primary bg-primary/10" : "border-default"}`}>
<div className="flex flex-row items-center">
<p className="flex flex-row items-center flex-1">
<b>User:</b>&nbsp;
{session.username ?? "Anonymous User"}&nbsp;
<Tooltip
content={<pre>Session ID: {session.sessionId}</pre>}
placement="right"
>
<InfoIcon size={18}/>
</Tooltip>
</p>
<div className="flex-1 flex justify-center">Remote IP:&nbsp;
{<Chip size="sm" radius="sm">
<pre>{session.remoteIp}</pre>
</Chip>}
</div>
<div
className="flex-1 flex justify-end">{session.activeGameIds.length > 0 ? "Session active since" : "Session inactive since"}&nbsp;
{<Chip size="sm" radius="sm">
{timeUntil(session.startTime, undefined, true)}
</Chip>}
</div>
</div>
{/* Only render chart when downloads are active or have been active within the last minute */}
{(session.activeGameIds.length > 0 || (currentTime.getTime() - new Date(session.startTime).getTime() < 60000)) &&
<div className="flex flex-col items-center">
<div className="flex flex-row gap-2">
Active downloads:
{session.activeGameIds.length === 0 && <p>No active downloads</p>}
{session.activeGameIds.map(gameId =>
games[gameId] &&
<Tooltip key={gameId}
size="sm"
content={`Size: ${humanFileSize(games[gameId].metadata.fileSize)} / Library: ${libraries[games[gameId].libraryId]?.name || "Unknown"}`}
placement="bottom">
<Chip size="sm" radius="sm"
onClick={() => navigate(`/game/${gameId}`)}
className="cursor-pointer"
>{games[gameId].title}
</Chip>
</Tooltip>
)}
</div>
<div className="w-full h-48">
<RealtimeChart options={chartOptions} data={chartData}/>
</div>
</div>
}
</Card>
)
}

View File

@ -1,15 +1,15 @@
import {Button, Card, Chip, Tooltip} from "@heroui/react";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {Button, Card, Tooltip} from "@heroui/react";
import React from "react";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import {GameCover} from "Frontend/components/general/covers/GameCover";
import {MagnifyingGlass, MagnifyingGlassPlus, SlidersHorizontal} from "@phosphor-icons/react";
import {MagnifyingGlassIcon, MagnifyingGlassPlusIcon, SlidersHorizontalIcon} from "@phosphor-icons/react";
import ScanType from "Frontend/generated/org/gameyfin/app/libraries/enums/ScanType";
import {useNavigate} from "react-router";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
import ChipList from "Frontend/components/general/ChipList";
interface LibraryOverviewCardProps {
library: LibraryAdminDto;
@ -22,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);
}
@ -39,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>
}
@ -50,17 +52,17 @@ export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
<div className="absolute right-0 top-0 flex flex-row">
<Tooltip content="Scan library (quick)" placement="bottom" color="foreground">
<Button isIconOnly variant="light" onPress={() => triggerScan(ScanType.QUICK)}>
<MagnifyingGlass/>
<MagnifyingGlassIcon/>
</Button>
</Tooltip>
<Tooltip content="Scan library (full)" placement="bottom" color="foreground">
<Button isIconOnly variant="light" onPress={() => triggerScan(ScanType.FULL)}>
<MagnifyingGlassPlus/>
<MagnifyingGlassPlusIcon/>
</Button>
</Tooltip>
<Tooltip content="Configuration" placement="bottom" color="foreground">
<Button isIconOnly variant="light" onPress={() => navigate('library/' + library.id)}>
<SlidersHorizontal/>
<SlidersHorizontalIcon/>
</Button>
</Tooltip>
</div>
@ -73,7 +75,7 @@ export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
<p>Platforms</p>
<p className="font-bold">{library.stats.gamesCount}</p>
<p className="font-bold">{library.stats.downloadedGamesCount}</p>
<Chip size="sm">PC</Chip>
<ChipList items={library.platforms} maxVisible={0} defaultContent="All"/>
</div>
}
</Card>

View File

@ -1,19 +1,19 @@
import {Button, Card, Chip, Tooltip, useDisclosure} from "@heroui/react";
import {
CheckCircle,
CheckCircleIcon,
IconContext,
PauseCircle,
PlayCircle,
Power,
Question,
QuestionMark,
SealCheck,
SealQuestion,
SealWarning,
SlidersHorizontal,
StopCircle,
WarningCircle,
XCircle
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";
@ -54,17 +54,17 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
function stateToIcon(state: PluginState | undefined): ReactNode {
switch (state) {
case PluginState.STARTED:
return <PlayCircle/>;
return <PlayCircleIcon/>;
case PluginState.DISABLED:
return <PauseCircle/>;
return <PauseCircleIcon/>;
case PluginState.STOPPED:
case PluginState.FAILED:
return <StopCircle/>;
return <StopCircleIcon/>;
case PluginState.UNLOADED:
case PluginState.RESOLVED:
return <XCircle/>;
return <XCircleIcon/>;
default:
return <QuestionMark/>;
return <QuestionMarkIcon/>;
}
}
@ -73,19 +73,19 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
case PluginConfigValidationResultType.VALID:
return <Tooltip content="Config valid" placement="bottom" color="foreground">
<Chip size="sm" radius="sm" className="text-xs" color="success">
<CheckCircle/>
<CheckCircleIcon/>
</Chip>
</Tooltip>
case PluginConfigValidationResultType.INVALID:
return <Tooltip content="Config invalid" placement="bottom" color="foreground">
<Chip size="sm" radius="sm" className="text-xs" color="danger">
<WarningCircle/>
<WarningCircleIcon/>
</Chip>
</Tooltip>;
default:
return <Tooltip content="Config could not be validated" placement="bottom" color="foreground">
<Chip size="sm" radius="sm" className="text-xs">
<Question/>
<QuestionIcon/>
</Chip>
</Tooltip>
}
@ -95,23 +95,23 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
switch (trustLevel) {
case PluginTrustLevel.OFFICIAL:
return <Tooltip color="foreground" placement="bottom" content="Official plugin">
<SealCheck className="fill-success"/>
<SealCheckIcon className="fill-success"/>
</Tooltip>;
case PluginTrustLevel.BUNDLED:
return <Tooltip color="foreground" placement="bottom" content="Bundled plugin">
<SealCheck/>
<SealCheckIcon/>
</Tooltip>;
case PluginTrustLevel.THIRD_PARTY:
return <Tooltip color="foreground" placement="bottom" content="3rd party plugin">
<SealWarning/>
<SealWarningIcon/>
</Tooltip>;
case PluginTrustLevel.UNTRUSTED:
return <Tooltip color="foreground" placement="bottom" content="Invalid plugin signature">
<SealWarning className="fill-danger"/>
<SealWarningIcon className="fill-danger"/>
</Tooltip>;
default:
return <Tooltip color="foreground" placement="bottom" content="Unkown verification status">
<SealQuestion/>
<SealQuestionIcon/>
</Tooltip>;
}
}
@ -120,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);
}
}
@ -141,12 +141,12 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
onPress={() => togglePluginEnabled()}
isDisabled={plugin.state == PluginState.UNLOADED || plugin.state == PluginState.RESOLVED}
>
<Power/>
<PowerIcon/>
</Button>
</Tooltip>
<Tooltip content="Configuration" placement="bottom" color="foreground">
<Button isIconOnly variant="light" onPress={pluginDetailsModal.onOpen}>
<SlidersHorizontal/>
<SlidersHorizontalIcon/>
</Button>
</Tooltip>
</div>

View File

@ -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>
);
}

View File

@ -1,12 +1,12 @@
import {Button, Card, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, useDisclosure} from "@heroui/react";
import {DotsThreeVertical} from "@phosphor-icons/react";
import {DotsThreeVerticalIcon} from "@phosphor-icons/react";
import React, {useEffect, useState} from "react";
import {MessageEndpoint, PasswordResetEndpoint, UserEndpoint} from "Frontend/generated/endpoints";
import {AvatarEndpoint} from "Frontend/endpoints/endpoints";
import Avatar from "Frontend/components/general/Avatar";
import ConfirmUserDeletionModal from "Frontend/components/general/modals/ConfirmUserDeletionModal";
import PasswordResetTokenModal from "Frontend/components/general/modals/PasswortResetTokenModal";
import TokenDto from "Frontend/generated/org/gameyfin/app/shared/token/TokenDto";
import TokenDto from "Frontend/generated/org/gameyfin/app/core/token/TokenDto";
import RoleChip from "Frontend/components/general/RoleChip";
import AssignRolesModal from "Frontend/components/general/modals/AssignRolesModal";
import ExtendedUserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/ExtendedUserInfoDto";
@ -112,7 +112,7 @@ export function UserManagementCard({user}: { user: ExtendedUserInfoDto }) {
<Dropdown placement="bottom-end" size="sm" backdrop="opaque">
<DropdownTrigger>
<Button isIconOnly variant="light">
<DotsThreeVertical/>
<DotsThreeVerticalIcon/>
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Static Actions" items={dropdownItems} disabledKeys={disabledKeys}>

View File

@ -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>
);
}

View File

@ -1,16 +1,122 @@
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {GameCover} from "Frontend/components/general/covers/GameCover";
import {Grid} from "react-window";
import React, {useCallback, useEffect, useRef, useState} from "react";
interface CoverGridProps {
games: GameDto[];
}
interface GridCellProps {
columnIndex: number;
rowIndex: number;
style: React.CSSProperties;
games: GameDto[];
columnCount: number;
coverHeight: number;
}
// 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 = useCallback(({
columnIndex,
rowIndex,
style,
games: gamesData,
columnCount: colCount,
coverHeight: height
}: GridCellProps) => {
const gameIndex = rowIndex * colCount + columnIndex;
// Return empty cell if we're past the end of the games array
if (gameIndex >= gamesData.length) {
return <div style={style}/>;
}
const game = gamesData[gameIndex];
return (
<div
style={{
...style,
paddingBottom: GAP,
display: 'flex',
justifyContent: 'center',
boxSizing: 'border-box'
}}
>
<GameCover key={game.id} game={game} interactive={true} size={height} 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<{ games: GameDto[], columnCount: number, coverHeight: number }>
columnCount={columnCount}
columnWidth={getColumnWidth}
rowCount={rowCount}
rowHeight={coverHeight + GAP}
defaultWidth={containerWidth}
cellComponent={Cell}
cellProps={{games, columnCount, coverHeight}}
style={{overflowX: 'hidden'}}
/>
)}
</div>
);
}

View File

@ -1,68 +1,169 @@
import React, {useEffect, useRef, useState} from "react";
import React, {useCallback, useEffect, useRef, useState} from "react";
import {GameCover} from "Frontend/components/general/covers/GameCover";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {ArrowRight} from "@phosphor-icons/react";
import {useNavigate} from "react-router";
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) {
const navigate = useNavigate();
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;
// Define interface for Cell props
interface RowCellProps {
columnIndex: number;
rowIndex: number;
style: React.CSSProperties;
games: GameDto[];
}
// Cell renderer for react-window Grid
const Cell = useCallback(({columnIndex, style, games: gamesData}: RowCellProps) => {
const game = gamesData[columnIndex];
return (
<div style={{...style, paddingRight: gap}}>
<GameCover key={game.id} 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-gradient-to-r from-transparent to-background
transition-all duration-300 ease-in-out hover:opacity-80"/>
<div
className="absolute h-full right-0 bottom-0 flex flex-row items-center gap-2 pointer-events-none">
<p className="text-xl font-semibold">Show more</p>
<ArrowRight weight="bold"/>
</div>
</div>
</div>
<div ref={containerRef} className="w-full relative overflow-hidden">
{containerWidth > 0 && (
<Grid<{ games: GameDto[] }>
gridRef={gridRef}
columnCount={games.length}
columnWidth={defaultImageWidth + gap}
rowCount={1}
rowHeight={defaultImageHeight}
defaultHeight={defaultImageHeight}
defaultWidth={containerWidth}
cellComponent={Cell}
cellProps={{games}}
className="scrollbar-hide"
style={{overflow: 'auto'}}
/>
)}
</div>
</div>

View File

@ -1,21 +1,117 @@
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 {memo, 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" : ""}`}>
const GameCoverComponent = ({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 isCached = game.cover ? loadedImagesCache.has(game.cover.id) : false;
const [isImageLoaded, setIsImageLoaded] = useState(isCached);
const [blurhashUrl, setBlurhashUrl] = useState<string | undefined>(undefined);
const containerRef = useRef<HTMLDivElement>(null);
const prevCoverIdRef = useRef<number | undefined>(game.cover?.id);
// Reset state when cover ID changes
useEffect(() => {
const currentCoverId = game.cover?.id;
if (prevCoverIdRef.current !== currentCoverId) {
prevCoverIdRef.current = currentCoverId;
const newIsCached = currentCoverId ? loadedImagesCache.has(currentCoverId) : false;
setIsImageLoaded(newIsCached);
setBlurhashUrl(undefined);
setShouldLoad(!lazy);
}
}, [game.cover?.id, lazy]);
// Generate blurhash placeholder image
useEffect(() => {
if (game.cover?.blurhash && !blurhashUrl) {
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, blurhashUrl]);
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}`}
className="z-0 object-cover aspect-12/17"
src={(shouldLoad || isCached) && isImageLoaded ? `images/cover/${game.cover.id}` : blurhashUrl}
radius={radius}
height={size}
fallbackSrc={<GameCoverFallback title={game.title} size={size} radius={radius}/>}
@ -30,4 +126,15 @@ export function GameCover({game, size = 300, radius = "sm", interactive = false}
{coverContent}
</a>
) : coverContent;
}
};
// Memoize the component to prevent unnecessary re-renders
// Only re-render if the game ID, cover ID, size, radius, interactive, or lazy props change
export const GameCover = memo(GameCoverComponent, (prevProps, nextProps) => {
return prevProps.game.id === nextProps.game.id &&
prevProps.game.cover?.id === nextProps.game.cover?.id &&
prevProps.size === nextProps.size &&
prevProps.radius === nextProps.radius &&
prevProps.interactive === nextProps.interactive &&
prevProps.lazy === nextProps.lazy;
});

View File

@ -8,7 +8,7 @@ import "swiper/css/navigation";
import "swiper/css/pagination";
import "swiper/css/autoplay";
import {useEffect, useState} from "react";
import {CaretLeft, CaretRight, IconContext, Play} from "@phosphor-icons/react";
import { CaretLeftIcon, CaretRightIcon, IconContext, PlayIcon } from "@phosphor-icons/react";
interface ImageCarouselProps {
@ -61,7 +61,7 @@ export default function ImageCarousel({imageUrls, videosUrls, className}: ImageC
<div className="w-full flex flex-col gap-2 items-center">
<div className="w-full flex flex-row items-center">
<IconContext.Provider value={{size: 50}}>
<CaretLeft className="swiper-custom-button-prev cursor-pointer fill-primary"/>
<CaretLeftIcon className="swiper-custom-button-prev cursor-pointer fill-primary"/>
<Swiper
modules={[Pagination, Navigation, Autoplay]}
slidesPerView={DEFAULT_SLIDES_PER_VIEW > elements.length ? elements.length : DEFAULT_SLIDES_PER_VIEW}
@ -90,14 +90,14 @@ export default function ImageCarousel({imageUrls, videosUrls, className}: ImageC
<Image
src={e.url}
alt={`Game screenshot slide ${index}`}
className={`w-full h-full object-cover aspect-[16/9] cursor-zoom-in ${!isActive ? "scale-90" : ""}`}
className={`w-full h-full object-cover aspect-video cursor-zoom-in ${!isActive ? "scale-90" : ""}`}
onClick={() => showImagePopup(e.url)}
/>
)
}
return (
<Card
className={`w-full h-full aspect-[16/9] ${!isActive ? "scale-90" : ""}`}>
className={`w-full h-full aspect-video ${!isActive ? "scale-90" : ""}`}>
<ReactPlayer
url={e.url}
width="100%"
@ -105,7 +105,7 @@ export default function ImageCarousel({imageUrls, videosUrls, className}: ImageC
light={true}
controls={true}
playing={isActive}
playIcon={<Play weight="fill"/>}
playIcon={<PlayIcon weight="fill"/>}
/>
</Card>
)
@ -115,7 +115,7 @@ export default function ImageCarousel({imageUrls, videosUrls, className}: ImageC
<ImagePopup imageUrl={selectedImageUrl} isOpen={imagePopup.isOpen}
onOpenChange={imagePopup.onOpenChange}/>
</Swiper>
<CaretRight className="swiper-custom-button-next cursor-pointer fill-primary"/>
<CaretRightIcon className="swiper-custom-button-next cursor-pointer fill-primary"/>
</IconContext.Provider>
</div>
<div>
@ -137,7 +137,7 @@ function ImagePopup({imageUrl, isOpen, onOpenChange}: {
<Modal isOpen={isOpen} onOpenChange={onOpenChange} hideCloseButton size="full" backdrop="blur">
<ModalContent className="bg-transparent">
{(onClose) => (
<div className="flex flex-grow items-center justify-center cursor-zoom-out"
<div className="flex grow items-center justify-center cursor-zoom-out"
onClick={onClose}>
<Image
src={imageUrl}

View File

@ -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>

View File

@ -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 {Plus} 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"><Plus/></Button>
<Button isIconOnly
size="sm"
variant="light"
radius="full"
isDisabled={props.isDisabled}
>
<PlusIcon/>
</Button>
</PopoverTrigger>
<PopoverContent>
<Input

View File

@ -0,0 +1,100 @@
import React, {Key, useEffect, useState} from "react";
import {Autocomplete, AutocompleteItem, Chip} from "@heroui/react";
import {FieldArray, useField} from "formik";
type ArrayInputAutocompleteProps = {
label?: string;
placeholder?: string;
options: string[];
name: string;
defaultSelected?: string[];
};
export default function ArrayInputAutocomplete({
options,
label,
placeholder = "Search...",
defaultSelected = [],
...props
}: ArrayInputAutocompleteProps) {
const [field, meta, helpers] = useField(props);
const [search, setSearch] = useState("");
// Initialize field value if undefined or empty
useEffect(() => {
if (!field.value) {
helpers.setValue(defaultSelected.length > 0 ? defaultSelected : []);
} else if (field.value.length === 0 && defaultSelected.length > 0) {
helpers.setValue(defaultSelected);
}
}, [defaultSelected, field.value, helpers]);
return (
<FieldArray name={field.name}
render={arrayHelpers => {
const selectedValues = field.value || [];
const filteredOptions = options.filter(
(option) =>
option.toLowerCase().includes(search.toLowerCase()) &&
!selectedValues.find((selected: string) => selected === option),
);
const handleSelect = (item: string) => {
if (!selectedValues.find((selected: string) => selected === item)) {
arrayHelpers.push(item);
}
};
const handleRemove = (index: number) => {
arrayHelpers.remove(index);
};
return (
<div className="flex flex-col flex-1 gap-2">
{label && (
<div className="flex flex-row justify-between">
<p>{label}</p>
<small>{selectedValues.length} {selectedValues.length === 1 ? "element" : "elements"} selected</small>
</div>
)}
<Autocomplete
{...props}
aria-labelledby="search"
shouldCloseOnBlur={false}
placeholder={placeholder}
inputValue={search}
onInputChange={(value) => setSearch(value)}
onSelectionChange={(value: Key | null) => {
const item = options.find((option) => option === value);
if (item) handleSelect(item);
setSearch("");
}}
>
{filteredOptions.map((option) => (
<AutocompleteItem key={option} data-selected="true">
{option}
</AutocompleteItem>
))}
</Autocomplete>
<div className="flex flex-wrap gap-2">
{selectedValues.map((item: string, index: number) => (
<Chip key={index} variant="flat"
onClose={() => handleRemove(index)}>
{item}
</Chip>
))}
</div>
<div className="min-h-6 text-danger">
{meta.touched && meta.error && meta.error.trim().length > 0 && (
meta.error
)}
</div>
</div>
);
}}
/>
);
}

View File

@ -8,7 +8,7 @@ import {
DropdownTrigger,
SharedSelection
} from "@heroui/react";
import {CaretDown} from "@phosphor-icons/react";
import { CaretDownIcon } from "@phosphor-icons/react";
import {useUserPreferenceService} from "Frontend/util/user-preference-service";
export interface ComboButtonOption {
@ -52,7 +52,7 @@ export default function ComboButton({options, preferredOptionKey, description}:
}
return options[selectedOptionValue] && (
<ButtonGroup className="gap-[1px]">
<ButtonGroup className="gap-px">
<Button color="primary" className="w-52"
onPress={options[selectedOptionValue].action}>
<div className="flex flex-col items-center">
@ -63,7 +63,7 @@ export default function ComboButton({options, preferredOptionKey, description}:
<Dropdown placement="bottom-end">
<DropdownTrigger>
<Button isIconOnly color="primary">
<CaretDown/>
<CaretDownIcon/>
</Button>
</DropdownTrigger>
<DropdownMenu

View File

@ -11,7 +11,7 @@ export default function DatePickerInput({label, showErrorUntouched = false, ...p
return (
<DatePicker
className="min-h-20 flex-grow"
className="min-h-20 grow"
showMonthAndYearPickers
fullWidth={false}
{...props}

View File

@ -1,6 +1,6 @@
import React from "react";
import {Button, Code, useDisclosure} from "@heroui/react";
import {ArrowRight, Minus, Plus, XCircle} from "@phosphor-icons/react";
import { ArrowRightIcon, MinusIcon, PlusIcon, XCircleIcon } from "@phosphor-icons/react";
import PathPickerModal from "Frontend/components/general/modals/PathPickerModal";
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
import DirectoryMappingDto from "Frontend/generated/org/gameyfin/app/libraries/dto/DirectoryMappingDto";
@ -28,7 +28,7 @@ export default function DirectoryMappingInput({name}: DirectoryMappingInputProps
<p className="font-bold">Directories</p>
<Button isIconOnly variant="light" size="sm" color="default"
onPress={pathPickerModal.onOpen}>
<Plus/>
<PlusIcon/>
</Button>
</div>
{(field.value || []).map((directory) => (
@ -43,8 +43,8 @@ export default function DirectoryMappingInput({name}: DirectoryMappingInputProps
/>
{directory.externalPath && (
<>
<div className="flex-shrink-0 flex items-center justify-center mx-2">
<ArrowRight size={20}/>
<div className="shrink-0 flex items-center justify-center mx-2">
<ArrowRightIcon size={20}/>
</div>
<input
type="text"
@ -62,13 +62,13 @@ export default function DirectoryMappingInput({name}: DirectoryMappingInputProps
onPress={() => removeDirectoryMapping(directory)}
className="ml-2"
>
<Minus/>
<MinusIcon/>
</Button>
</Code>
))}
<div className="min-h-6 text-danger">
{meta.touched && meta.error && (
<SmallInfoField icon={XCircle} message={meta.error}/>
<SmallInfoField icon={XCircleIcon} message={meta.error}/>
)}
</div>
<PathPickerModal returnSelectedPath={addDirectoryMapping}

View File

@ -1,5 +1,10 @@
import TreeView, {flattenTree, INode, NodeId} from "react-accessible-treeview";
import {File, Folder, FolderOpen, IconContext} from "@phosphor-icons/react";
import {
FileIcon as PhFileIcon,
FolderIcon as PhFolderIcon,
FolderOpenIcon as PhFolderOpenIcon,
IconContext
} from "@phosphor-icons/react";
import {useEffect, useState} from "react";
import {FilesystemEndpoint} from "Frontend/generated/endpoints";
import FileDto from "Frontend/generated/org/gameyfin/app/core/filesystem/FileDto";
@ -72,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));
@ -146,9 +151,9 @@ export default function FileTreeView({onPathChange}: { onPathChange: (file: stri
}
function FolderIcon({isOpen}: { isOpen: boolean }) {
return isOpen ? <FolderOpen/> : <Folder/>;
return isOpen ? <PhFolderOpenIcon/> : <PhFolderIcon/>;
}
function FileIcon({fileName}: { fileName: string }) {
return <File/>;
return <PhFileIcon/>;
}

View File

@ -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 {ImageBroken, Pencil} from "@phosphor-icons/react";
import {ImageBrokenIcon, PencilIcon} from "@phosphor-icons/react";
// @ts-ignore
@ -14,14 +14,14 @@ export default function GameCoverPicker({game, showErrorUntouched = false, ...pr
const gameCoverPickerModal = useDisclosure();
return (<>
<div className="relative group aspect-[12/17] cursor-pointer bg-background/50"
<div className="relative group aspect-12/17 cursor-pointer bg-background/50"
onClick={gameCoverPickerModal.onOpenChange}>
{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}`}
className="z-0 object-cover group-hover:brightness-25"
src={field.value ? field.value : `images/cover/${game.cover?.id}`}
{...props}
{...field}
radius="none"
@ -30,13 +30,13 @@ export default function GameCoverPicker({game, showErrorUntouched = false, ...pr
<div
className="absolute inset-0 flex flex-col text-center items-center justify-center group-hover:opacity-0"
>
<ImageBroken size={46}/>
<ImageBrokenIcon size={46}/>
<p>No cover image available</p>
</div>}
<div
className="absolute inset-0 flex flex-col gap-2 text-center items-center justify-center opacity-0 group-hover:opacity-100"
>
<Pencil size={46}/>
<PencilIcon size={46}/>
<p>Edit cover</p>
</div>
</div>

View File

@ -1,7 +1,7 @@
import {Image, useDisclosure} from "@heroui/react";
import React from "react";
import {useField} from "formik";
import {ImageBroken, Pencil} from "@phosphor-icons/react";
import {ImageBrokenIcon, PencilIcon} from "@phosphor-icons/react";
import {GameHeaderPickerModal} from "Frontend/components/general/modals/GameHeaderPickerModal";
@ -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}`}
className="z-0 object-cover group-hover:brightness-25"
src={field.value ? field.value : `images/cover/${game.header?.id}`}
{...props}
{...field}
radius="none"
@ -30,13 +30,13 @@ export default function GameHeaderPicker({game, showErrorUntouched = false, ...p
<div
className="absolute inset-0 flex flex-col text-center items-center justify-center group-hover:opacity-0"
>
<ImageBroken size={46}/>
<ImageBrokenIcon size={46}/>
<p>No header image available</p>
</div>}
<div
className="absolute inset-0 flex flex-col gap-2 text-center items-center justify-center opacity-0 group-hover:opacity-100"
>
<Pencil size={46}/>
<PencilIcon size={46}/>
<p>Edit header image</p>
</div>
</div>

View File

@ -1,5 +1,5 @@
import {useField} from "formik";
import {Input as NextUiInput} from "@heroui/react";
import {Input as HeroUiInput} from "@heroui/react";
// @ts-ignore
const Input = ({label, showErrorUntouched = false, ...props}) => {
@ -7,8 +7,8 @@ const Input = ({label, showErrorUntouched = false, ...props}) => {
const [field, meta] = useField(props);
return (
<NextUiInput
className="min-h-20 flex-grow"
<HeroUiInput
className="min-h-20 grow"
fullWidth={false}
{...props}
{...field}

View File

@ -0,0 +1,26 @@
import {useField} from "formik";
import {NumberInput as HeroUiNumberInput} from "@heroui/react";
// @ts-ignore
const NumberInput = ({label, showErrorUntouched = false, ...props}) => {
// @ts-ignore
const [field, meta, helpers] = useField(props);
return (
<HeroUiNumberInput
className="min-h-20 grow"
fullWidth={false}
{...props}
value={field.value}
onValueChange={(value) => helpers.setValue(value)}
onBlur={field.onBlur}
name={field.name}
id={label}
label={label}
isInvalid={(meta.touched || showErrorUntouched) && !!meta.error}
errorMessage={meta.initialError || meta.error}
/>
);
}
export default NumberInput;

View File

@ -9,7 +9,7 @@ const SelectInput = ({label, values, ...props}) => {
const items = values.map((v: string) => ({key: v, label: v}));
return (
<div className="min-h-20 flex-grow">
<div className="min-h-20 grow">
<Select
fullWidth={true}
{...field}

View File

@ -0,0 +1,23 @@
import {useField} from "formik";
import {Slider as HeroUiSlider} from "@heroui/react";
// @ts-ignore
const SliderInput = ({label, showErrorUntouched = false, ...props}) => {
// @ts-ignore
const [field, meta, helpers] = useField(props);
return (
<HeroUiSlider
className="min-h-20 grow"
{...props}
value={field.value}
onChange={(value) => helpers.setValue(value)}
onBlur={field.onBlur}
name={field.name}
id={label}
label={label}
/>
);
}
export default SliderInput;

View File

@ -8,7 +8,7 @@ export default function TextAreaInput({label, showErrorUntouched = false, ...pro
return (
<Textarea
className={`flex-grow ${meta.initialError || meta.error ? "" : "mb-6"}`}
className={`grow ${meta.initialError || meta.error ? "" : "mb-6"}`}
fullWidth={false}
{...props}
{...field}

View File

@ -1,5 +1,5 @@
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import {Check} from "@phosphor-icons/react";
import {CheckIcon} from "@phosphor-icons/react";
import {addToast, Button} from "@heroui/react";
import React from "react";
import {Form, Formik} from "formik";
@ -11,6 +11,10 @@ import DirectoryMappingInput from "Frontend/components/general/input/DirectoryMa
import Section from "Frontend/components/general/Section";
import {useNavigate} from "react-router";
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;
@ -19,6 +23,7 @@ interface LibraryManagementDetailsProps {
export default function LibraryManagementDetails({library}: LibraryManagementDetailsProps) {
const navigate = useNavigate();
const [librarySaved, setLibrarySaved] = React.useState(false);
const availablePlatforms = useSnapshot(platformState).available;
async function handleSubmit(values: LibraryDto): Promise<void> {
const changed = deepDiff(library, values) as LibraryUpdateDto;
@ -41,7 +46,7 @@ export default function LibraryManagementDetails({library}: LibraryManagementDet
color: "success"
});
navigate("/administration/libraries");
navigate("/administration/games");
} catch (e) {
addToast({
title: "Error deleting library",
@ -66,7 +71,7 @@ export default function LibraryManagementDetails({library}: LibraryManagementDet
>
{(formik) => (
<Form>
<div className="flex flex-row flex-grow justify-between mb-4">
<div className="flex flex-row grow justify-between mb-4">
<h1 className="text-2xl font-bold">Edit library details</h1>
<Button
color="primary"
@ -74,12 +79,16 @@ export default function LibraryManagementDetails({library}: LibraryManagementDet
isDisabled={formik.isSubmitting || librarySaved || !formik.dirty}
type="submit"
>
{formik.isSubmitting ? "" : librarySaved ? <Check/> : "Save"}
{formik.isSubmitting ? "" : librarySaved ? <CheckIcon/> : "Save"}
</Button>
</div>
<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"/>
<Section title="Danger zone"/>

View File

@ -17,7 +17,7 @@ import {
Tooltip,
useDisclosure
} from "@heroui/react";
import {CheckCircle, MagnifyingGlass, Pencil, Trash} from "@phosphor-icons/react";
import {CheckCircleIcon, MagnifyingGlassIcon, PencilIcon, TrashIcon} from "@phosphor-icons/react";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import {GameEndpoint} from "Frontend/generated/endpoints";
@ -28,6 +28,7 @@ import MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
import {GameAdminDto} from "Frontend/dtos/GameDtos";
import MetadataCompletenessIndicator from "Frontend/components/general/MetadataCompletenessIndicator";
import {metadataCompleteness} from "Frontend/util/utils";
import ChipList from "Frontend/components/general/ChipList";
interface LibraryManagementGamesProps {
library: LibraryDto;
@ -37,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();
@ -93,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()))
@ -101,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;
}
@ -162,6 +163,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
}>
<TableHeader>
<TableColumn key="title" allowsSorting>Game</TableColumn>
<TableColumn key="platforms">Platforms</TableColumn>
<TableColumn key="addedToLibrary" allowsSorting>Added to library</TableColumn>
<TableColumn key="downloadCount" allowsSorting>Download count</TableColumn>
<TableColumn>Path</TableColumn>
@ -176,9 +178,13 @@ 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>
<ChipList items={item.platforms} maxVisible={1} defaultContent="Unspecified"/>
</TableCell>
<TableCell>
{new Date(item.createdAt).toLocaleString()}
</TableCell>
@ -196,10 +202,10 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
<Button isIconOnly size="sm" onPress={() => toggleMatchConfirmed(item)}>
{item.metadata.matchConfirmed ?
<Tooltip content="Unconfirm match">
<CheckCircle weight="fill" className="fill-success"/>
<CheckCircleIcon weight="fill" className="fill-success"/>
</Tooltip> :
<Tooltip content="Confirm match">
<CheckCircle/>
<CheckCircleIcon/>
</Tooltip>}
</Button>
<Button isIconOnly size="sm" onPress={() => {
@ -207,7 +213,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
editGameModal.onOpenChange();
}}>
<Tooltip content="Edit metadata">
<Pencil/>
<PencilIcon/>
</Tooltip>
</Button>
<Button isIconOnly size="sm" onPress={() => {
@ -215,13 +221,13 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
matchGameModal.onOpenChange();
}}>
<Tooltip content="Match game">
<MagnifyingGlass/>
<MagnifyingGlassIcon/>
</Tooltip>
</Button>
<Button isIconOnly size="sm" color="danger"
onPress={() => deleteGame(item)}>
<Tooltip content="Remove from library">
<Trash/>
<TrashIcon/>
</Tooltip>
</Button>
</div>
@ -233,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}

View File

@ -12,35 +12,45 @@ import {
Tooltip,
useDisclosure
} from "@heroui/react";
import {MagnifyingGlass, Trash} from "@phosphor-icons/react";
import {MagnifyingGlassIcon, TrashIcon} from "@phosphor-icons/react";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import {useMemo, useState} from "react";
import LibraryUpdateDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryUpdateDto";
import {fileNameFromPath, hashCode} from "Frontend/util/utils";
import {fileNameFromPath} from "Frontend/util/utils";
import MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
import IgnoredPathDto from "Frontend/generated/org/gameyfin/app/libraries/dto/IgnoredPathDto";
import IgnoredPathSourceTypeDto from "Frontend/generated/org/gameyfin/app/libraries/dto/IgnoredPathSourceTypeDto";
import {useSnapshot} from "valtio/react";
import {pluginState} from "Frontend/state/PluginState";
import {userState} from "Frontend/state/UserState";
import PluginIcon from "Frontend/components/general/plugin/PluginIcon";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
interface LibraryManagementUnmatchedPathsProps {
interface LibraryManagementIgnoredPathsProps {
library: LibraryAdminDto;
}
export default function LibraryManagementUnmatchedPaths({library}: LibraryManagementUnmatchedPathsProps) {
export default function LibraryManagementIgnoredPaths({library}: LibraryManagementIgnoredPathsProps) {
const plugins = useSnapshot(pluginState).state;
const users = useSnapshot(userState).state;
const matchGameModal = useDisclosure();
const [page, setPage] = useState(1);
const rowsPerPage = 25;
const [searchTerm, setSearchTerm] = useState("");
const [selectedPath, setSelectedPath] = useState(library.unmatchedPaths ? library.unmatchedPaths[0] : null);
const [selectedPath, setSelectedPath] = useState(library.ignoredPaths ? library.ignoredPaths[0] : null);
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({column: "path", direction: "ascending"});
const pages = useMemo(() => {
return Math.ceil(getFilteredPaths().length / rowsPerPage);
}, [library.unmatchedPaths, searchTerm]);
}, [library.ignoredPaths, searchTerm]);
const filteredPaths = useMemo(() => {
return library.unmatchedPaths!
.filter((path) => path.toLowerCase().includes(searchTerm.toLowerCase()))
.map((path) => ({key: hashCode(path), path}));
return library.ignoredPaths!
.filter((path) => path.path.toLowerCase().includes(searchTerm.toLowerCase()))
.map((path) => ({key: path.id, path}));
}, [library, searchTerm]);
const sortedPaths = useMemo(() => {
@ -48,7 +58,7 @@ export default function LibraryManagementUnmatchedPaths({library}: LibraryManage
let cmp: number;
switch (sortDescriptor.column) {
case "path":
cmp = a.path.localeCompare(b.path);
cmp = a.path.path.localeCompare(b.path.path);
break;
default:
cmp = 0;
@ -66,22 +76,44 @@ export default function LibraryManagementUnmatchedPaths({library}: LibraryManage
return sortedPaths.slice(start, end);
}, [page, sortedPaths]);
async function deleteUnmatchedPath(unmatchedPath: string) {
async function deleteIgnoredPath(ignoredPath: IgnoredPathDto) {
const libraryUpdateDto: LibraryUpdateDto = {
id: library.id,
unmatchedPaths: library.unmatchedPaths!.filter((path) => path !== unmatchedPath)
ignoredPaths: library.ignoredPaths!.filter((path) => path.id !== ignoredPath.id)
}
await LibraryEndpoint.updateLibrary(libraryUpdateDto);
}
function getFilteredPaths() {
return library.unmatchedPaths!!.filter((path) =>
path.toLowerCase().includes(searchTerm.toLowerCase())
return library.ignoredPaths!.filter((path) =>
path.path.toLowerCase().includes(searchTerm.toLowerCase())
)
}
function renderSource(ignoredPath: IgnoredPathDto) {
if (ignoredPath.sourceType === IgnoredPathSourceTypeDto.USER) {
const userId = Number(ignoredPath.source);
const user = users[userId];
return user ? `Manually added by user (${user.username})` : "Unknown user";
} else if (ignoredPath.sourceType === IgnoredPathSourceTypeDto.PLUGIN) {
const pluginIds: string[] = JSON.parse(ignoredPath.source)
return pluginIds ?
<div className="flex flex-row gap-2 items-center">
<p>Automatically added by plugins (</p>
{pluginIds.map(id => {
const p = plugins[id];
return p ? <PluginIcon key={id} plugin={p as PluginDto}/>
: "Unknown plugin";
})}
<p>)</p>
</div>
: "Unknown plugins"
}
return ignoredPath.source;
}
return <div className="flex flex-col gap-4">
<h1 className="text-2xl font-bold">Manage unmatched paths</h1>
<h1 className="text-2xl font-bold">Manage ignored paths</h1>
<Input
className="w-96"
isClearable
@ -109,13 +141,17 @@ export default function LibraryManagementUnmatchedPaths({library}: LibraryManage
}>
<TableHeader>
<TableColumn key="path" allowsSorting>Path</TableColumn>
<TableColumn key="source">Source</TableColumn>
<TableColumn width={1}>Actions</TableColumn>
</TableHeader>
<TableBody emptyContent="This library has no unmatched paths." items={pagedPaths}>
<TableBody emptyContent="This library has no ignored paths." items={pagedPaths}>
{(item) => (
<TableRow key={item.key}>
<TableCell>
{item.path}
{item.path.path}
</TableCell>
<TableCell>
{renderSource(item.path)}
</TableCell>
<TableCell>
<div className="flex flex-row gap-2">
@ -124,12 +160,15 @@ export default function LibraryManagementUnmatchedPaths({library}: LibraryManage
setSelectedPath(item.path);
matchGameModal.onOpenChange();
}}>
<MagnifyingGlass/>
<MagnifyingGlassIcon/>
</Button>
</Tooltip>
<Tooltip content="Remove entry from list">
<Button isIconOnly size="sm" color="danger"
onPress={() => deleteUnmatchedPath(item.path)}><Trash/>
onPress={() => deleteIgnoredPath(item.path)}
isDisabled={item.path.sourceType !== IgnoredPathSourceTypeDto.USER}
>
<TrashIcon/>
</Button>
</Tooltip>
</div>
@ -138,9 +177,9 @@ export default function LibraryManagementUnmatchedPaths({library}: LibraryManage
)}
</TableBody>
</Table>
{selectedPath && <MatchGameModal path={selectedPath}
{selectedPath && <MatchGameModal path={selectedPath.path}
libraryId={library.id}
initialSearchTerm={fileNameFromPath(selectedPath, false)}
initialSearchTerm={fileNameFromPath(selectedPath.path, false)}
isOpen={matchGameModal.isOpen}
onOpenChange={matchGameModal.onOpenChange}/>
}

View File

@ -83,7 +83,7 @@ export default function AssignRolesModal({isOpen, onOpenChange, user}: AssignRol
placeholder="Select roles"
renderValue={(items: SelectedItems<Role>) => {
return (
<div className="flex flex-grow flex-wrap gap-2">
<div className="flex grow flex-wrap gap-2">
{items.map((item) => (
<RoleChip key={item.key} role={item.textValue as string}/>
))}

View File

@ -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>
</>
);
}

View File

@ -0,0 +1,188 @@
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;
case "dateAdded":
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
break;
default:
cmp = 0;
}
if (sortDescriptor.direction === "descending") {
cmp *= -1;
}
return cmp;
})
.map(game => ({...game, _inCollection: isGameInCollection(game)}));
}, [filteredGames, sortDescriptor, libraries, gameInCollectionMap]);
async function addGameToCollection(game: GameAdminDto) {
await CollectionEndpoint.addGameToCollection(collectionId, game.id);
}
async function removeGameFromCollection(game: GameAdminDto) {
await CollectionEndpoint.removeGameFromCollection(collectionId, game.id);
}
return (
<div className="flex flex-col gap-2">
<div className="flex flex-row gap-2 justify-between">
<Input
className="w-96"
isClearable
placeholder="Search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onClear={() => setSearchTerm("")}
/>
<Select
selectedKeys={[filter]}
disallowEmptySelection
onSelectionChange={keys => setFilter(Array.from(keys)[0] as any)}
className="w-64"
>
<SelectItem key="all">Show all games</SelectItem>
<SelectItem key="inCollection">Show only games in collection</SelectItem>
<SelectItem key="notInCollection">Show only games not in collection</SelectItem>
</Select>
</div>
<Table isStriped isHeaderSticky
sortDescriptor={sortDescriptor}
onSortChange={setSortDescriptor}
classNames={{
base: "h-96"
}}>
<TableHeader>
<TableColumn key="title" allowsSorting>Title</TableColumn>
<TableColumn key="library" allowsSorting>Library</TableColumn>
<TableColumn key="dateAdded" allowsSorting>Date added</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>
{new Date(game.createdAt).toLocaleString()}
</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>
);
}

View File

@ -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}
/>
);
}

View File

@ -11,8 +11,9 @@ import {
} from "@heroui/react";
import {Form, Formik} from "formik";
import Input from "Frontend/components/general/input/Input";
import React from "react";
import React, {useEffect, useState} from "react";
import GameUpdateDto from "Frontend/generated/org/gameyfin/app/games/dto/GameUpdateDto";
import GameEnumPropertyValuesDto from "Frontend/generated/org/gameyfin/app/games/dto/GameEnumPropertyValuesDto";
import {deepDiff} from "Frontend/util/utils";
import {GameEndpoint} from "Frontend/generated/endpoints";
import TextAreaInput from "Frontend/components/general/input/TextAreaInput";
@ -21,6 +22,9 @@ import GameCoverPicker from "Frontend/components/general/input/GameCoverPicker";
import DatePickerInput from "Frontend/components/general/input/DatePickerInput";
import ArrayInput from "Frontend/components/general/input/ArrayInput";
import GameHeaderPicker from "Frontend/components/general/input/GameHeaderPicker";
import ArrayInputAutocomplete from "Frontend/components/general/input/ArrayInputAutocomplete";
import {useSnapshot} from "valtio/react";
import {platformState} from "Frontend/state/PlatformState";
interface EditGameMetadataModalProps {
game: GameDto;
@ -29,7 +33,14 @@ interface EditGameMetadataModalProps {
}
export default function EditGameMetadataModal({game, isOpen, onOpenChange}: EditGameMetadataModalProps) {
return (
const availablePlatforms = useSnapshot(platformState).available;
const [propertyEnumValues, setPropertyEnumValues] = useState<GameEnumPropertyValuesDto>();
useEffect(() => {
GameEndpoint.getEnumPropertyValues().then(setPropertyEnumValues);
}, []);
return propertyEnumValues && (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="3xl">
<ModalContent>
{(onClose) => {
@ -69,6 +80,8 @@ export default function EditGameMetadataModal({game, isOpen, onOpenChange}: Edit
<DatePickerInput key="release" name="release" label="Release"
className="w-fit"/>
</div>
<ArrayInputAutocomplete options={Array.from(availablePlatforms)}
name="platforms" label="Platforms"/>
<TextAreaInput key="summary" name="summary" label="Summary (HTML)"/>
<TextAreaInput key="comment" name="comment" label="Comment (Markdown)"/>
<Accordion variant="splitted"
@ -81,14 +94,21 @@ export default function EditGameMetadataModal({game, isOpen, onOpenChange}: Edit
title="Additional Metadata">
<ArrayInput key="developers" name="developers" label="Developers"/>
<ArrayInput key="publishers" name="publishers" label="Publishers"/>
<ArrayInput key="genres" name="genres" label="Genres"/>
<ArrayInput key="themes" name="themes" label="Themes"/>
<ArrayInputAutocomplete options={propertyEnumValues.genres}
defaultSelected={game.genres}
key="genres" name="genres" label="Genres"/>
<ArrayInputAutocomplete options={propertyEnumValues.themes}
defaultSelected={game.themes}
key="themes" name="themes" label="Themes"/>
<ArrayInputAutocomplete options={propertyEnumValues.features}
defaultSelected={game.features}
key="features" name="features"
label="Features"/>
<ArrayInputAutocomplete options={propertyEnumValues.perspectives}
defaultSelected={game.perspectives}
key="perspectives" name="perspectives"
label="Perspectives"/>
<ArrayInput key="keywords" name="keywords" label="Keywords"/>
<ArrayInput key="features" name="features" label="Features"/>
<ArrayInput key="perspectives" name="perspectives"
label="Perspectives"/>
<ArrayInput key="keywords" name="keywords"
label="Keywords"/>
</AccordionItem>
</Accordion>
</ModalBody>

View File

@ -3,11 +3,10 @@ import {Button, Image, Input, Modal, ModalBody, ModalContent, ModalHeader, Scrol
import React, {useEffect, useState} from "react";
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
import {GameEndpoint} from "Frontend/generated/endpoints";
import {ArrowRight, MagnifyingGlass} from "@phosphor-icons/react";
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;
@ -33,7 +32,7 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
async function search() {
setIsSearching(true);
const results = await GameEndpoint.getPotentialMatches(searchTerm);
const results = await GameEndpoint.getPotentialMatches(searchTerm, game.platforms);
let validResults = results.filter(result => result.coverUrls && result.coverUrls.length > 0);
setSearchResults(validResults);
setIsSearching(false);
@ -59,7 +58,7 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
setCoverUrl(coverUrl);
onClose();
}}>
<ArrowRight/>
<ArrowRightIcon/>
</Button>
</div>
<div className="flex flex-row gap-2 mb-4">
@ -74,7 +73,7 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
}}
/>
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}>
<MagnifyingGlass/>
<MagnifyingGlassIcon/>
</Button>
</div>
{searchResults.length === 0 && !isSearching &&
@ -103,17 +102,17 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
>
<Image
alt={cover.title}
className="z-0 object-cover aspect-[12/17] group-hover:brightness-[25%]"
className="z-0 object-cover aspect-12/17 group-hover:brightness-25"
src={cover.url}
radius="none"
height={216}
/>
<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>
<ArrowRight/>
<ArrowRightIcon/>
</div>
</div>
))}

View File

@ -3,11 +3,10 @@ import {Button, Image, Input, Modal, ModalBody, ModalContent, ModalHeader, Scrol
import React, {useEffect, useState} from "react";
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
import {GameEndpoint} from "Frontend/generated/endpoints";
import {ArrowRight, MagnifyingGlass} from "@phosphor-icons/react";
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;
@ -33,7 +32,7 @@ export function GameHeaderPickerModal({game, isOpen, onOpenChange, setHeaderUrl}
async function search() {
setIsSearching(true);
const results = await GameEndpoint.getPotentialMatches(searchTerm);
const results = await GameEndpoint.getPotentialMatches(searchTerm, game.platforms);
let validResults = results.filter(result => result.headerUrls && result.headerUrls.length > 0);
setSearchResults(validResults);
setIsSearching(false);
@ -59,7 +58,7 @@ export function GameHeaderPickerModal({game, isOpen, onOpenChange, setHeaderUrl}
setHeaderUrl(headerUrl);
onClose();
}}>
<ArrowRight/>
<ArrowRightIcon/>
</Button>
</div>
<div className="flex flex-row gap-2 mb-4">
@ -74,7 +73,7 @@ export function GameHeaderPickerModal({game, isOpen, onOpenChange, setHeaderUrl}
}}
/>
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}>
<MagnifyingGlass/>
<MagnifyingGlassIcon/>
</Button>
</div>
{searchResults.length === 0 && !isSearching &&
@ -103,16 +102,16 @@ export function GameHeaderPickerModal({game, isOpen, onOpenChange, setHeaderUrl}
>
<Image
alt={header.title}
className="z-0 object-cover group-hover:brightness-[25%]"
className="z-0 object-cover group-hover:brightness-25"
src={header.url}
radius="none"
/>
<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>
<ArrowRight/>
<ArrowRightIcon/>
</div>
</div>
))}

View File

@ -1,7 +1,7 @@
import React, {useEffect, useState} from "react";
import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, Snippet} from "@heroui/react";
import {MessageEndpoint, RegistrationEndpoint, UserEndpoint} from "Frontend/generated/endpoints";
import TokenDto from "Frontend/generated/org/gameyfin/app/shared/token/TokenDto";
import TokenDto from "Frontend/generated/org/gameyfin/app/core/token/TokenDto";
import {Form, Formik, FormikErrors} from "formik";
import Input from "Frontend/components/general/input/Input";
import * as Yup from "yup";

View File

@ -1,30 +1,30 @@
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 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 {
libraries: LibraryDto[];
setLibraries: (libraries: LibraryDto[]) => void;
isOpen: boolean;
onOpenChange: () => void;
}
export default function LibraryCreationModal({
libraries,
isOpen,
onOpenChange
}: LibraryCreationModalProps) {
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",
@ -33,25 +33,30 @@ export default function LibraryCreationModal({
});
}
return (
return (availablePlatforms &&
<>
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="xl">
<ModalContent>
{(onClose) => (
<Formik initialValues={{name: "", directories: []}}
validationSchema={Yup.object({
name: Yup.string()
.required("Library name is required")
.max(255, "Library name must be 255 characters or less"),
directories: Yup.array()
.of(Yup.object())
.min(1, "At least one directory is required")
})}
isInitialValid={false}
onSubmit={async (values: any) => {
await createLibrary(values);
onClose();
}}
<Formik
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>
@ -65,6 +70,11 @@ export default function LibraryCreationModal({
value={formik.values.name}
required
/>
<ArrayInputAutocomplete options={Array.from(availablePlatforms)}
name="platforms"
label="Platforms"
placeholder="Platform(s) of the games in this library (leave empty for all platforms)"
/>
<DirectoryMappingInput name="directories"/>
</div>
</ModalBody>

View File

@ -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}
/>
);
}

View File

@ -13,13 +13,14 @@ import {
Tooltip
} from "@heroui/react";
import React, {useEffect, useState} from "react";
import {ArrowRight, MagnifyingGlass} from "@phosphor-icons/react";
import {ArrowRightIcon, MagnifyingGlassIcon} from "@phosphor-icons/react";
import {GameEndpoint} from "Frontend/generated/endpoints";
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
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";
interface MatchGameModalProps {
path: string;
@ -44,6 +45,7 @@ export default function MatchGameModal({
const [isMatching, setIsMatching] = useState<string | null>(null);
const state = useSnapshot(pluginState).state;
const librariesState = useSnapshot(libraryState).state;
useEffect(() => {
setSearchTerm(initialSearchTerm);
@ -56,7 +58,7 @@ export default function MatchGameModal({
async function search() {
setIsSearching(true);
const results = await GameEndpoint.getPotentialMatches(searchTerm);
const results = await GameEndpoint.getPotentialMatches(searchTerm, (librariesState[libraryId] as LibraryAdminDto).platforms);
setSearchResults(results);
setIsSearching(false);
}
@ -84,7 +86,7 @@ export default function MatchGameModal({
}}
/>
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}>
<MagnifyingGlass/>
<MagnifyingGlassIcon/>
</Button>
</div>
@ -126,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>
@ -141,7 +143,7 @@ export default function MatchGameModal({
setIsMatching(null);
onClose();
}}>
<ArrowRight/>
<ArrowRightIcon/>
</Button>
</Tooltip>
</TableCell>

View File

@ -1,7 +1,7 @@
import React, {useEffect, useState} from "react";
import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {Input as NextInput} from "@heroui/input";
import {WarningCircle} from "@phosphor-icons/react";
import { WarningCircleIcon } from "@phosphor-icons/react";
import {MessageEndpoint, PasswordResetEndpoint} from "Frontend/generated/endpoints";
interface PasswordResetModalProps {
@ -47,7 +47,7 @@ export default function PasswordResetModal({
placeholder="Email"
/> :
<div className="flex flex-row items-center gap-4 text-warning">
<WarningCircle size={40}/>
<WarningCircleIcon size={40}/>
<p>
Password self-service is disabled.<br/>
To reset your password please contact your administrator.

View File

@ -1,6 +1,6 @@
import React, {useEffect, useState} from "react";
import {Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, Snippet} from "@heroui/react";
import TokenDto from "Frontend/generated/org/gameyfin/app/shared/token/TokenDto";
import TokenDto from "Frontend/generated/org/gameyfin/app/core/token/TokenDto";
import {timeUntil} from "Frontend/util/utils";
interface PasswordResetTokenModalProps {

View File

@ -4,7 +4,7 @@ import React, {useEffect, useState} from "react";
import Input from "Frontend/components/general/input/Input";
import FileTreeView from "Frontend/components/general/input/FileTreeView";
import DirectoryMappingDto from "Frontend/generated/org/gameyfin/app/libraries/dto/DirectoryMappingDto";
import {ArrowRight} from "@phosphor-icons/react";
import { ArrowRightIcon } from "@phosphor-icons/react";
interface PathPickerModalProps {
returnSelectedPath: (path: DirectoryMappingDto) => void;
@ -45,7 +45,7 @@ export default function PathPickerModal({returnSelectedPath, isOpen, onOpenChang
isDisabled
required
/>
<ArrowRight className="mb-8"/>
<ArrowRightIcon className="mb-8"/>
<Input
name="externalPath"
label="External path (optional)"

View File

@ -6,7 +6,7 @@ import Markdown from "react-markdown";
import remarkBreaks from "remark-breaks";
import {PluginEndpoint} from "Frontend/generated/endpoints";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
import {ArrowClockwise} from "@phosphor-icons/react";
import { ArrowClockwiseIcon } from "@phosphor-icons/react";
import PluginConfigMetadataDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginConfigMetadataDto";
import PluginConfigFormField from "Frontend/components/general/plugin/PluginConfigFormField";
@ -161,7 +161,7 @@ export default function PluginDetailsModal({plugin, isOpen, onOpenChange}: Plugi
}
setTimeout(() => setConfigValidated(ValidationState.UNCHECKED), 5000);
}}>
<ArrowClockwise/>
<ArrowClockwiseIcon/>
</Button>
</Tooltip>
</>}

View File

@ -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 {CaretUpDown} from "@phosphor-icons/react";
import {useListData} from "@react-stately/data";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
import {PluginEndpoint} from "Frontend/generated/endpoints";
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>
<CaretUpDown/>
</ListBoxItem>
)}
</ListBox>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="success" 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}
/>
);
}

View File

@ -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>
);
}

View File

@ -1,5 +1,7 @@
import {
addToast,
Autocomplete,
AutocompleteItem,
Button,
Input,
Modal,
@ -14,7 +16,7 @@ import {
Tooltip
} from "@heroui/react";
import React, {useEffect, useState} from "react";
import {ArrowRight, MagnifyingGlass} from "@phosphor-icons/react";
import {ArrowRightIcon, MagnifyingGlassIcon} from "@phosphor-icons/react";
import {GameEndpoint, GameRequestEndpoint} from "Frontend/generated/endpoints";
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
import PluginIcon from "../plugin/PluginIcon";
@ -22,22 +24,29 @@ import {useSnapshot} from "valtio/react";
import {pluginState} from "Frontend/state/PluginState";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
import GameRequestCreationDto from "Frontend/generated/org/gameyfin/app/requests/dto/GameRequestCreationDto";
import Platform from "Frontend/generated/org/gameyfin/pluginapi/gamemetadata/Platform";
import {platformState} from "Frontend/state/PlatformState";
interface RequestGameModalProps {
isOpen: boolean;
onOpenChange: () => void;
}
// TODO: Maybe make this configurable in the admin settings?
const DEFAULT_PLATFORM_FOR_NEW_REQUESTS = "PC (Microsoft Windows)";
export default function RequestGameModal({
isOpen,
onOpenChange
}: RequestGameModalProps) {
const [selectedPlatform, setSelectedPlatform] = useState<string>(DEFAULT_PLATFORM_FOR_NEW_REQUESTS);
const [searchTerm, setSearchTerm] = useState("");
const [searchResults, setSearchResults] = useState<GameSearchResultDto[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [isRequesting, setIsRequesting] = useState<string | null>(null);
const plugins = useSnapshot(pluginState).state;
const availablePlatforms = useSnapshot(platformState).available;
useEffect(() => {
setSearchTerm("");
@ -47,7 +56,9 @@ export default function RequestGameModal({
async function requestGame(game: GameSearchResultDto) {
const request: GameRequestCreationDto = {
title: game.title,
release: game.release
release: game.release,
// Since we can only request for one platform at a time, just pick the first one
platform: game.platforms ? game.platforms[0] : DEFAULT_PLATFORM_FOR_NEW_REQUESTS as Platform
}
try {
@ -66,7 +77,7 @@ export default function RequestGameModal({
async function search() {
setIsSearching(true);
const results = await GameEndpoint.getPotentialMatches(searchTerm);
const results = await GameEndpoint.getPotentialMatches(searchTerm, [selectedPlatform] as Platform[]);
setSearchResults(results);
setIsSearching(false);
}
@ -83,6 +94,18 @@ export default function RequestGameModal({
<div className="flex flex-col items-center">
<h2 className="text-xl font-semibold">Request a game</h2>
</div>
<Autocomplete
label="Platform"
size="sm"
allowsCustomValue={false}
selectedKey={selectedPlatform}
//@ts-ignore
onSelectionChange={(newSelection) => newSelection && setSelectedPlatform(newSelection)}
>
{Array.from(availablePlatforms).map((platform) => (
<AutocompleteItem key={platform}>{platform}</AutocompleteItem>
))}
</Autocomplete>
<div className="flex flex-row gap-2 mb-4">
<Input value={searchTerm}
onValueChange={setSearchTerm}
@ -93,8 +116,11 @@ export default function RequestGameModal({
}
}}
/>
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}>
<MagnifyingGlass/>
<Button isIconOnly
color="primary"
onPress={search}
isLoading={isSearching}>
<MagnifyingGlassIcon/>
</Button>
</div>
@ -151,7 +177,7 @@ export default function RequestGameModal({
setIsRequesting(null);
onClose();
}}>
<ArrowRight/>
<ArrowRightIcon/>
</Button>
</Tooltip>
</TableCell>

View File

@ -1,5 +1,5 @@
import {Image, Tooltip} from "@heroui/react";
import {Plug} from "@phosphor-icons/react";
import { PlugIcon } from "@phosphor-icons/react";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
interface PluginIconProps {
@ -18,7 +18,7 @@ export default function PluginIcon({
const icon = plugin.hasLogo
?
<Image isBlurred={blurred} src={`/images/plugins/${plugin.id}/logo`} width={size} height={size} radius="none"/>
: <Plug size={size} weight="fill"/>;
: <PlugIcon size={size} weight="fill"/>;
return showTooltip
? <Tooltip content={plugin.name}>{icon}</Tooltip>

View File

@ -1,22 +1,24 @@
import {Button, Tooltip, useDisclosure} from "@heroui/react";
import {ListNumbers} from "@phosphor-icons/react";
import {ListNumbersIcon} from "@phosphor-icons/react";
import {PluginManagementCard} from "Frontend/components/general/cards/PluginManagementCard";
import 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 (
<div className="flex flex-col gap-2">
<div className="flex flex-row flex-grow justify-between">
<div className="flex flex-row grow justify-between">
<h2 className="text-xl font-bold">{camelCaseToTitle(type)}</h2>
<Tooltip color="foreground" placement="left" content="Change plugin order">
@ -24,7 +26,7 @@ export function PluginManagementSection({type, plugins = []}: PluginManagementSe
variant="flat"
onPress={pluginPrioritiesModal.onOpen}
isDisabled={plugins.length === 0}>
<ListNumbers/>
<ListNumbersIcon/>
</Button>
</Tooltip>
</div>
@ -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>);
}

View File

@ -1,44 +0,0 @@
import {Button, Link, Popover, PopoverContent, PopoverTrigger} from "@heroui/react";
import {Warning} from "@phosphor-icons/react";
// TODO: Remove this component before the release of version 2.2.0
export default function DockerHubDeprecationPopover() {
return (
<Popover placement="bottom-end" showArrow={true} color="warning">
<PopoverTrigger>
<Button isIconOnly color="warning" variant="flat">
<Warning/>
</Button>
</PopoverTrigger>
<PopoverContent>
<div className="m-4 text-sm leading-relaxed">
<h3 className="mb-2 font-bold">Image deprecation notice</h3>
<p>
Starting with version
<code className="font-semibold"> 2.2.0 </code>
the image{' '}
<Link href="https://hub.docker.com/r/grimsi/gameyfin"
isExternal
underline="always"
size="sm"
className="text-warning-contrast">
grimsi/gameyfin
</Link>
{' '}will no longer be published to Docker Hub.
</p>
<p>
Please switch to{' '}
<Link href="https://github.com/gameyfin/gameyfin/pkgs/container/gameyfin"
isExternal
underline="always"
size="sm"
className="text-warning-contrast">
ghcr.io/gameyfin/gameyfin
</Link>
{' '}if you are currently using the Docker Hub image.
</p>
</div>
</PopoverContent>
</Popover>
);
}

View File

@ -7,7 +7,7 @@ export default function ThemePreview({theme, isSelected}: {
}) {
return (
<Tooltip content={<p className="capitalize">{theme.name?.replace("-", " ")}</p>} placement="bottom">
<div className={`flex flex-col flex-grow aspect-square border-2 rounded-large overflow-hidden
<div className={`flex flex-col grow aspect-square border-2 rounded-large overflow-hidden
${theme.name}-dark
${isSelected ? "border-foreground" : "border-foreground-200 hover:border-focus"}`}>
<div className="flex-1 bg-primary"/>

View File

@ -0,0 +1,208 @@
import type {ComponentProps} from "react";
import React from "react";
import type {ButtonProps} from "@heroui/react";
import {cn} from "@heroui/react";
import {useControlledState} from "@react-stately/utils";
import {domAnimation, LazyMotion, m} from "framer-motion"; // reintroduce LazyMotion & domAnimation
export type StepDescriptor = {
title?: React.ReactNode;
icon?: React.ReactNode;
className?: string;
};
export interface StepperProps extends React.HTMLAttributes<HTMLButtonElement> {
steps?: StepDescriptor[];
color?: ButtonProps["color"];
currentStep?: number;
defaultStep?: number;
hideProgressBars?: boolean;
className?: string;
stepClassName?: string;
onStepChange?: (stepIndex: number) => void;
allowFutureNavigation?: boolean;
}
function CheckIcon(props: ComponentProps<"svg">) {
return (
<svg {...props} fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<m.path
animate={{pathLength: 1}}
d="M5 13l4 4L19 7"
initial={{pathLength: 0}}
strokeLinecap="round"
strokeLinejoin="round"
transition={{
delay: 0.2,
type: "tween",
ease: "easeOut",
duration: 0.3,
}}
/>
</svg>
);
}
const Stepper = React.forwardRef<HTMLButtonElement, StepperProps>(
(
{
color = "primary",
steps = [],
defaultStep = 0,
onStepChange,
currentStep: currentStepProp,
hideProgressBars = false,
stepClassName,
className,
allowFutureNavigation = false,
...props
},
ref
) => {
const [currentStep, setCurrentStep] = useControlledState(currentStepProp, defaultStep, onStepChange);
const colors = React.useMemo(() => {
let userColor;
let fgColor;
const colorsVars = [
"[--active-fg-color:var(--step-fg-color)]",
"[--active-border-color:var(--step-color)]",
"[--active-color:var(--step-color)]",
"[--complete-background-color:var(--step-color)]",
"[--complete-border-color:var(--step-color)]",
"[--inactive-border-color:hsl(var(--heroui-default-300))]",
"[--inactive-color:hsl(var(--heroui-default-300))]"
];
switch (color) {
case "secondary":
userColor = "[--step-color:hsl(var(--heroui-secondary))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-secondary-foreground))]";
break;
case "success":
userColor = "[--step-color:hsl(var(--heroui-success))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-success-foreground))]";
break;
case "warning":
userColor = "[--step-color:hsl(var(--heroui-warning))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-warning-foreground))]";
break;
case "danger":
userColor = "[--step-color:hsl(var(--heroui-error))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-error-foreground))]";
break;
case "default":
userColor = "[--step-color:hsl(var(--heroui-default))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-default-foreground))]";
break;
case "primary":
default:
userColor = "[--step-color:hsl(var(--heroui-primary))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-primary-foreground))]";
break;
}
if (!className?.includes("--step-fg-color")) colorsVars.unshift(fgColor);
if (!className?.includes("--step-color")) colorsVars.unshift(userColor);
if (!className?.includes("--inactive-bar-color"))
colorsVars.push("[--inactive-bar-color:hsl(var(--heroui-default-300))]");
return colorsVars;
}, [color, className]);
// Compute statuses once
const statuses = steps.map((_, i) => (
currentStep === i ? "active" : currentStep < i ? "inactive" : "complete"
));
return (
<LazyMotion features={domAnimation}> {/* enable pathLength & variants animations */}
<nav aria-label="Progress" className={cn("relative w-full overflow-x-visible py-4", colors, className)}>
{/* Circles + connectors row */}
<div className="flex w-full items-center">
{steps.map((step, idx) => {
const status = statuses[idx];
const canNavigate = allowFutureNavigation || idx <= currentStep;
const isLast = idx === steps.length - 1;
return (
<div key={idx}
className={cn("flex items-center", !isLast && "flex-1")}> {/* flex-1 only if there is a connector after */}
<button
ref={ref}
aria-current={status === "active" ? "step" : undefined}
type="button"
onClick={() => canNavigate && setCurrentStep(idx)}
className={cn(
"group relative flex h-[38px] w-[38px] items-center justify-center rounded-full border-medium font-semibold text-large bg-content1 transition-colors duration-300",
!canNavigate && "pointer-events-none opacity-60",
step.className,
stepClassName,
status === "inactive" && "text-(--inactive-color) border-(--inactive-border-color)",
status === "active" && "text-(--active-color) border-(--active-border-color)",
status === "complete" && "border-(--complete-border-color) bg-(--complete-background-color) shadow-lg"
)}
{...props}
>
<m.div
animate={status}
initial={false}
variants={{
inactive: {scale: 1, opacity: 0.85},
active: {scale: 1.04, opacity: 1},
complete: {scale: 1, opacity: 1}
}}
transition={{type: "spring", stiffness: 260, damping: 20}}
className="flex items-center justify-center w-full h-full"
>
{status === "complete" ? (
<CheckIcon key={`check-${idx}`}
className="h-6 w-6 text-(--active-fg-color)"/>
) : step.icon ? (
step.icon
) : (
<span>{idx + 1}</span>
)}
</m.div>
</button>
{!isLast && !hideProgressBars && (
<div className="flex-1">
<div
className="mx-3 h-0.5 rounded-full bg-(--inactive-bar-color) relative">{/* gap so line does not touch circles */}
<m.div
className="absolute left-0 top-0 h-full rounded-full bg-(--active-border-color)"
animate={{width: idx < currentStep ? '100%' : 0}}
transition={{duration: 0.35, ease: 'easeInOut'}}
/>
</div>
</div>
)}
</div>
);
})}
</div>
{/* Titles row */}
<div className={cn("mt-2 grid w-full")}
style={{gridTemplateColumns: `repeat(${steps.length}, minmax(0,1fr))`}}>
{steps.map((step, idx) => {
const status = statuses[idx];
return (
<div key={idx} className="flex justify-center px-1 text-center">
{step.title && (
<span
className={cn(
"text-small lg:text-medium font-medium transition-[color,opacity] duration-300",
status === "inactive" ? "text-default-500" : "text-default-foreground"
)}
>
{step.title}
</span>
)}
</div>
);
})}
</div>
</nav>
</LazyMotion>
);
}
);
Stepper.displayName = "Stepper";
export default Stepper;

View File

@ -1,20 +1,22 @@
import React, {ReactNode, useState} from "react";
import {Form, Formik, FormikBag, FormikHelpers} from "formik";
import {ArrowLeft, ArrowRight, Check} from "@phosphor-icons/react";
import {ArrowLeftIcon, ArrowRightIcon, CheckIcon} from "@phosphor-icons/react";
import {Button} from "@heroui/react";
import {Step, Stepper} from "@material-tailwind/react";
import Stepper from "./Stepper";
const Wizard = ({children, initialValues, onSubmit}: {
children: ReactNode,
initialValues: any,
onSubmit: (values: any, bag: FormikHelpers<any> | FormikBag<any, any>) => Promise<any>
}) => {
type WizardProps = {
children: ReactNode;
initialValues: any;
onSubmit: (values: any, bag: FormikHelpers<any> | FormikBag<any, any>) => Promise<any>;
};
const Wizard = ({children, initialValues, onSubmit}: WizardProps) => {
const allSteps = React.Children.toArray(children);
const [stepNumber, setStepNumber] = useState(0);
const steps = React.Children.toArray(children);
const [snapshot, setSnapshot] = useState(initialValues);
const step = steps[stepNumber];
const totalSteps = steps.length;
const step = allSteps[stepNumber];
const totalSteps = allSteps.length;
const isFirstStep = stepNumber === 0;
const isLastStep = stepNumber === totalSteps - 1;
@ -28,10 +30,11 @@ const Wizard = ({children, initialValues, onSubmit}: {
setStepNumber(Math.max(stepNumber - 1, 0));
};
const handleSubmit = async (values: any, bag: FormikBag<any, any> | FormikHelpers<any>) => {
/*// @ts-ignore*/
const handleSubmit = async (values: any, bag: FormikHelpers<any> | FormikBag<any, any>) => {
// per-step custom submit if provided
// @ts-ignore
if (step.props.onSubmit) {
/*// @ts-ignore*/
// @ts-ignore
await step.props.onSubmit(values, bag);
}
if (isLastStep) {
@ -42,53 +45,44 @@ const Wizard = ({children, initialValues, onSubmit}: {
}
};
const stepsMeta = allSteps.map((child: any) => ({
title: child.props?.title ?? child.props?.label,
icon: child.props?.icon
}));
return (
<Formik
initialValues={snapshot}
onSubmit={handleSubmit}
/*// @ts-ignore*/
validationSchema={step.props.validationSchema}
// @ts-ignore
validationSchema={step.props?.validationSchema}
enableReinitialize={false}
>
{(formik) => (
<Form className="flex flex-col h-full">
<div className="w-full mb-8">
<Stepper activeStep={stepNumber} activeLineClassName="bg-primary"
lineClassName="bg-foreground"
placeholder={undefined}
onPointerEnterCapture={undefined}
onPointerLeaveCapture={undefined}
onResize={undefined}
onResizeCapture={undefined}>
{steps.map((child, index) => (
<Step key={index}
className="bg-foreground text-background"
activeClassName="bg-primary"
completedClassName="bg-primary"
placeholder={undefined}
onPointerEnterCapture={undefined}
onPointerLeaveCapture={undefined}
onResize={undefined}
onResizeCapture={undefined}>
{/*@ts-ignore*/}
{child.props.icon}
</Step>
))}
</Stepper>
<Stepper
steps={stepsMeta}
currentStep={stepNumber}
onStepChange={(idx) => {
// only allow backwards navigation to keep validation flow
if (idx <= stepNumber) setStepNumber(idx);
}}
hideProgressBars={false}
/>
</div>
<div className="flex grow">
{step}
</div>
<div className="left-8 right-8 absolute bottom-8 -z-1">
<div className="flex grow">{step}</div>
<div className="left-8 right-8 absolute bottom-8">
<div className="flex justify-between">
<Button color="primary" onClick={() => previous(formik.values)} isDisabled={isFirstStep}>
<ArrowLeft/>
</Button>
<Button
color="primary"
isLoading={formik.isSubmitting}
type="submit"
onPress={() => previous(formik.values)}
isDisabled={isFirstStep || formik.isSubmitting}
>
{formik.isSubmitting ? "" : isLastStep ? <Check/> : <ArrowRight/>}
<ArrowLeftIcon/>
</Button>
<Button color="primary" isLoading={formik.isSubmitting} type="submit">
{formik.isSubmitting ? "" : isLastStep ? <CheckIcon/> : <ArrowRightIcon/>}
</Button>
</div>
</div>
@ -98,4 +92,4 @@ const Wizard = ({children, initialValues, onSubmit}: {
);
};
export default Wizard;
export default Wizard;

View File

@ -0,0 +1,10 @@
import {heroui, HeroUIPluginConfig} from "@heroui/react";
import {compileThemes, themes} from "./theming/themes"
export const HeroUIConfig: HeroUIPluginConfig = {
// TODO: Prefix disabled until bug in heroui is fixed: https://github.com/heroui-inc/heroui/issues/5403
// prefix: "gf",
themes: compileThemes(themes)
};
export default heroui(HeroUIConfig);

View File

@ -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}/>

View File

@ -1,44 +1,38 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
@layer utilities {
.gradient-primary {
@apply bg-gradient-to-br from-primary-400 to-primary-700;
}
@plugin './heroui.ts';
.button-secondary {
@apply bg-primary-300 text-background/80;
}
@source "./index.html";
@source "./**/*.{js,ts,jsx,tsx}";
@source "../../../node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}";
/* Prevent TailwindCSS from purging the custom themes (since they are not referenced at compile-time) */
@source inline("{gameyfin-blue,gameyfin-violet,gameyfin-classic,neutral,slate,red,rose,orange,pink,blue,yellow,violet,colorblind}-{light,dark}");
@custom-variant dark (&:is(.dark *));
@theme {
--color-gf-primary: #2332c8;
--color-gf-secondary: #6441a5;
}
/* Custom CSS */
:root {
/* Overwrite default Hilla styles (e.g. loading indicator) */
--lumo-primary-color: theme(colors.primary);
/* Overwrite SwiperJS styles */
--swiper-navigation-color: theme(colors.primary);
--swiper-pagination-color: theme(colors.primary);
.swiper-pagination-bullet {
background-color: theme(colors.primary);
}
/* Custom gridTemplateColumns */
@utility grid-cols-300px {
grid-template-columns: repeat(auto-fit, 300px);
}
@utility grid-cols-auto-fill {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
/* List box drag & drop */
.react-aria-ListBoxItem {
&[data-dragging] {
opacity: 0.6;
}
}
.react-aria-DropIndicator[data-drop-target] {
outline: 1px solid theme(colors.primary);
/* Re-added custom utilities (Tailwind v4 style) */
@utility gradient-primary {
@apply bg-gradient-to-br from-primary-400 to-primary-700;
}
@utility button-secondary {
@apply bg-primary-300 text-background/80;
}
/* Shine animation for game covers */
.shine {
position: relative;
overflow: hidden;
@ -71,4 +65,30 @@
100% {
left: 125%;
}
}
/* List box drag & drop */
.react-aria-ListBoxItem[data-dragging] {
opacity: 0.6;
}
.react-aria-DropIndicator[data-drop-target] {
outline: 1px solid hsl(var(--heroui-primary));
}
/* Root variable overrides */
:root {
/* Overwrite default Hilla styles (e.g. loading indicator) */
--lumo-primary-color: hsl(var(--heroui-primary));
/* Overwrite SwiperJS styles */
--swiper-theme-color: hsl(var(--heroui-primary));
--swiper-navigation-color: hsl(var(--heroui-primary));
--swiper-pagination-color: hsl(var(--heroui-primary));
/* Overwrite SwiperJS styles */
.swiper-pagination-bullet {
background-color: hsl(var(--heroui-primary));
}
}

View File

@ -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,12 +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([
@ -44,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/>,
@ -58,6 +55,10 @@ export const {router, routes} = new RouterConfigurationBuilder()
path: 'library/:libraryId',
element: <LibraryView/>
},
{
path: 'collection/:collectionId',
element: <CollectionView/>
},
{
path: 'game/:gameId',
element: <GameView/>
@ -85,29 +86,39 @@ 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/>,
handle: {title: 'Administration - Game Requests'}
},
{
path: 'downloads',
element: <DownloadManagement/>,
handle: {title: 'Administration - Downloads'}
},
{
path: 'users',
element: <UserManagement/>,
handle: {title: 'Administration - Users'}
},
{
path: 'sso',
element: <SsoManagement/>,
handle: {title: 'Administration - SSO'}
path: 'security',
element: <SecurityManagement/>,
handle: {title: 'Administration - Security'}
},
{
path: 'messages',

View File

@ -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;
}
})
});
}

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