Compare commits

...

19 Commits

Author SHA1 Message Date
Simon
2206afbdc3
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
2025-10-30 16:46:43 +01:00
grimsi
5c17843626 Add "plugindata" directory to entrypoint scripts 2025-10-29 18:42:16 +01:00
grimsi
a7ee48b54c Plugins now store their data and state in ./plugindata 2025-10-28 13:29:58 +01:00
Simon
0ecb1c03df
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
2025-10-28 09:00:04 +01:00
dependabot[bot]
5962ee4256
Bump actions/upload-artifact from 4 to 5 (#770) 2025-10-28 05:27:19 +01:00
dependabot[bot]
a41f624c12
Bump actions/download-artifact from 5 to 6 (#769) 2025-10-28 05:26:22 +01:00
Simon
dc1eed87d4
Replace placeholder in LibraryOverviewCard (#767) 2025-10-27 16:49:53 +01:00
Simon
60f375b636
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
2025-10-27 16:36:49 +01:00
dependabot[bot]
d5d6af2e69
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>
2025-10-26 10:23:20 +01:00
Simon
b7e9636b9c
Optimize container image (#761)
* Fix Gradle warning

* Rework Docker image to improve layer caching
2025-10-18 21:17:34 +02:00
Simon
07e6a8bdb1
Deprecate DockerHub image (#759)
* Remove deprecation warning from web UI

* Reworked the CICD pipelines
2025-10-18 12:39:23 +02:00
Simon
49e2f14185
Disable length limit for config values (#757) 2025-10-17 16:51:02 +02:00
Simon
5a920ed35b
Fix SMTP config requiring an email as username (#755) 2025-10-17 15:02:34 +02:00
Simon
308f835112
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
2025-10-17 14:04:16 +02:00
dependabot[bot]
a889cf9d31
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>
2025-10-17 13:47:44 +02:00
grimsi
a93f8b2dc2 Revert accidental rename of menu title 2025-10-10 18:47:32 +02:00
grimsi
8020f67c48 Update version to 2.2.0-preview 2025-10-10 11:31:28 +02:00
Simon
b2554788a9
Migrate icon names (#743)
* Add migration script for phosphor-icons

* Migrate icon usages
2025-10-10 11:30:08 +02:00
Simon
a63397740d
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
2025-10-10 11:06:08 +02:00
204 changed files with 10433 additions and 7563 deletions

38
.dockerignore Normal file
View File

@ -0,0 +1,38 @@
# 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/
# Docker intermediate artifacts
**/.DS_Store

View File

@ -1,18 +1,12 @@
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
@ -21,6 +15,7 @@ runs:
- name: Prepare Ubuntu tags
id: ubuntu_tags
if: ${{ inputs.variant != 'alpine' }}
shell: bash
run: |
TAGS="${{ inputs.tags }}"
@ -28,6 +23,7 @@ runs:
echo "ubuntu_tags=$UBUNTU_TAGS" >> $GITHUB_OUTPUT
- name: Build and push Docker image (Alpine)
if: ${{ inputs.variant != 'ubuntu' }}
uses: docker/build-push-action@v5
with:
context: ${{ inputs.context }}
@ -39,6 +35,7 @@ runs:
cache-to: type=gha
- name: Build and push Docker image (Ubuntu)
if: ${{ inputs.variant != 'alpine' }}
uses: docker/build-push-action@v5
with:
context: ${{ inputs.context }}
@ -50,12 +47,6 @@ runs:
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 +65,7 @@ inputs:
tags:
required: true
description: 'Comma-separated list of image tags'
variant:
required: true
default: 'both'
description: 'Image variant to build: alpine, ubuntu, or both'

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,10 +6,8 @@ on:
- 'fix/*'
jobs:
build-and-push:
build:
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v5
@ -25,6 +23,33 @@ jobs:
GAMEYFIN_KEYSTORE_PASSWORD: ${{ secrets.GAMEYFIN_KEYSTORE_PASSWORD }}
run: ./gradlew clean build -Pvaadin.productionMode=true
- name: Upload build outputs
uses: actions/upload-artifact@v4
with:
name: build-outputs
path: |
app/build/libs/**
plugins/**/build/libs/**/*.jar
docker:
needs: build
runs-on: ubuntu-latest
permissions:
packages: write
strategy:
fail-fast: false
matrix:
variant: [ alpine, ubuntu ]
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Download build outputs
uses: actions/download-artifact@v5
with:
name: build-outputs
path: .
- name: Extract tag from branch name
id: extract_tag
run: |
@ -32,14 +57,13 @@ jobs:
TAG="${BRANCH_NAME#fix/}"
echo "tag=$TAG" >> $GITHUB_OUTPUT
- name: Build and push Docker image
- name: Build and push Docker image (${{ matrix.variant }})
uses: ./.github/actions/docker-build-push
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 }}
variant: ${{ matrix.variant }}

View File

@ -6,13 +6,18 @@ on:
- 'release/*'
jobs:
build-and-push:
build:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
outputs:
version: ${{ steps.extract_version.outputs.version }}
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up JDK 21
uses: actions/setup-java@v5
@ -20,26 +25,73 @@ jobs:
distribution: 'temurin'
java-version: '21'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
- name: Extract version from branch name
id: extract_version
run: |
BRANCH_NAME="${GITHUB_REF#refs/heads/}"
VERSION="${BRANCH_NAME#release/}-preview"
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Update version in build.gradle.kts
run: |
sed -i "s/^version = .*/version = \"${{ steps.extract_version.outputs.version }}\"/" build.gradle.kts
- name: Update version in app/package.json
run: |
jq ".version = \"${{ steps.extract_version.outputs.version }}\"" app/package.json > app/package.json.tmp && mv app/package.json.tmp app/package.json
- name: Commit version bump (only if changes)
uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: 'chore: bump version to v${{ steps.extract_version.outputs.version }}'
file_pattern: |
build.gradle.kts
app/package.json
- name: Run production build
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: Upload build outputs
uses: actions/upload-artifact@v4
with:
name: build-outputs
path: |
app/build/libs/**
plugins/**/build/libs/**/*.jar
- name: Build and push Docker image
docker:
needs: build
runs-on: ubuntu-latest
permissions:
packages: write
strategy:
fail-fast: false
matrix:
variant: [ alpine, ubuntu ]
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Download build outputs
uses: actions/download-artifact@v5
with:
name: build-outputs
path: .
- name: Build and push Docker image (${{ matrix.variant }})
uses: ./.github/actions/docker-build-push
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 }}
variant: ${{ matrix.variant }}

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

@ -50,18 +50,18 @@ 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@v5
with:
name: modified-files
path: |
build.gradle.kts
app/package.json
docker:
build:
needs: setup
runs-on: ubuntu-latest
permissions:
packages: write
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v5
@ -69,7 +69,7 @@ jobs:
fetch-depth: 0
- name: Download modified files
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: modified-files
@ -86,33 +86,64 @@ jobs:
GAMEYFIN_KEYSTORE_PASSWORD: ${{ secrets.GAMEYFIN_KEYSTORE_PASSWORD }}
run: ./gradlew clean build -Pvaadin.productionMode=true
- name: Upload build outputs
uses: actions/upload-artifact@v4
with:
name: build-outputs
path: |
app/build/libs/**
plugins/**/build/libs/**/*.jar
docker:
needs: [ setup, build ]
runs-on: ubuntu-latest
permissions:
packages: write
strategy:
fail-fast: false
matrix:
variant: [ alpine, ubuntu ]
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Download modified files
uses: actions/download-artifact@v5
with:
name: modified-files
- name: Download build outputs
uses: actions/download-artifact@v5
with:
name: build-outputs
path: .
- name: Generate container image tags
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
- name: Build and push Docker image (${{ matrix.variant }})
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: ${{ steps.docker_tags.outputs.tags }}
variant: ${{ matrix.variant }}
plugin_api:
needs: setup
@ -124,7 +155,7 @@ jobs:
fetch-depth: 0
- name: Download modified files
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: modified-files
@ -155,13 +186,13 @@ jobs:
fetch-depth: 0
- name: Download modified files
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
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 }}

5
.gitignore vendored
View File

@ -52,5 +52,8 @@ out/
/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/

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

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

@ -1,4 +1,4 @@
group = "de.grimsi"
group = "org.gameyfin"
val appMainClass = "org.gameyfin.app.GameyfinApplicationKt"
plugins {

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

11436
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,51 +1,54 @@
{
"name": "gameyfin",
"version": "2.1.2",
"version": "2.2.0-preview",
"type": "module",
"dependencies": {
"@heroui/react": "2.7.9",
"@material-tailwind/react": "^2.1.10",
"@heroui/react": "^2.8.5",
"@phosphor-icons/react": "^2.1.7",
"@polymer/polymer": "3.5.2",
"@react-stately/data": "^3.12.2",
"@react-types/shared": "^3.28.0",
"@vaadin/bundles": "24.9.0",
"@tailwindcss/vite": "4.1.13",
"@vaadin/bundles": "24.9.4",
"@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": "24.9.4",
"@vaadin/hilla-frontend": "24.9.4",
"@vaadin/hilla-lit-form": "24.9.4",
"@vaadin/hilla-react-auth": "24.9.4",
"@vaadin/hilla-react-crud": "24.9.4",
"@vaadin/hilla-react-form": "24.9.4",
"@vaadin/hilla-react-i18n": "24.9.4",
"@vaadin/hilla-react-signals": "24.9.4",
"@vaadin/polymer-legacy-adapter": "24.9.4",
"@vaadin/react-components": "24.9.4",
"@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": "24.9.4",
"@vaadin/vaadin-material-styles": "24.9.4",
"@vaadin/vaadin-themable-mixin": "24.9.4",
"@vaadin/vaadin-usage-statistics": "2.1.3",
"classnames": "^2.5.1",
"construct-style-sheets-polyfill": "3.1.0",
"date-fns": "2.29.3",
"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.1",
"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.1.1",
"react-accessible-treeview": "^2.11.1",
"react-aria-components": "^1.7.1",
"react-confetti-boom": "^1.0.0",
"react-dom": "18.3.1",
"react-dom": "19.1.1",
"react-markdown": "^10.1.0",
"react-player": "^2.16.0",
"react-router": "7.6.1",
"react-realtime-chart": "^0.8.1",
"react-router": "7.6.3",
"remark-breaks": "^4.0.0",
"swiper": "^11.2.6",
"valtio": "^2.1.5",
@ -55,39 +58,35 @@
"devDependencies": {
"@babel/preset-react": "7.27.1",
"@lit-labs/react": "^2.1.3",
"@preact/signals-react-transform": "0.5.1",
"@preact/signals-react-transform": "0.6.0",
"@rollup/plugin-replace": "6.0.2",
"@rollup/pluginutils": "5.1.4",
"@rollup/pluginutils": "5.3.0",
"@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",
"@types/react": "19.1.17",
"@types/react-dom": "19.1.11",
"@vaadin/hilla-generator-cli": "24.9.4",
"@vaadin/hilla-generator-core": "24.9.4",
"@vaadin/hilla-generator-plugin-backbone": "24.9.4",
"@vaadin/hilla-generator-plugin-barrel": "24.9.4",
"@vaadin/hilla-generator-plugin-client": "24.9.4",
"@vaadin/hilla-generator-plugin-model": "24.9.4",
"@vaadin/hilla-generator-plugin-push": "24.9.4",
"@vaadin/hilla-generator-plugin-signals": "24.9.4",
"@vaadin/hilla-generator-plugin-subtypes": "24.9.4",
"@vaadin/hilla-generator-plugin-transfertypes": "24.9.4",
"@vaadin/hilla-generator-utils": "24.9.4",
"@vitejs/plugin-react": "4.7.0",
"@vitejs/plugin-react-swc": "^3.7.0",
"async": "3.2.6",
"autoprefixer": "^10.4.20",
"glob": "11.0.2",
"magic-string": "0.30.17",
"postcss": "^8.4.41",
"postcss-import": "^16.1.0",
"glob": "11.0.3",
"magic-string": "0.30.19",
"rollup-plugin-brotli": "3.1.0",
"rollup-plugin-visualizer": "5.14.0",
"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",
"vite": "6.4.1",
"vite-plugin-checker": "0.10.3",
"workbox-build": "7.3.0",
"workbox-core": "7.3.0",
"workbox-precaching": "7.3.0"
@ -105,10 +104,8 @@
"@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",
@ -142,127 +139,131 @@
"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",
"@vaadin/a11y-base": "24.9.4",
"@vaadin/accordion": "24.9.4",
"@vaadin/app-layout": "24.9.4",
"@vaadin/avatar": "24.9.4",
"@vaadin/avatar-group": "24.9.4",
"@vaadin/button": "24.9.4",
"@vaadin/card": "24.9.4",
"@vaadin/checkbox": "24.9.4",
"@vaadin/checkbox-group": "24.9.4",
"@vaadin/combo-box": "24.9.4",
"@vaadin/component-base": "24.9.4",
"@vaadin/confirm-dialog": "24.9.4",
"@vaadin/context-menu": "24.9.4",
"@vaadin/custom-field": "24.9.4",
"@vaadin/date-picker": "24.9.4",
"@vaadin/date-time-picker": "24.9.4",
"@vaadin/details": "24.9.4",
"@vaadin/dialog": "24.9.4",
"@vaadin/email-field": "24.9.4",
"@vaadin/field-base": "24.9.4",
"@vaadin/field-highlighter": "24.9.4",
"@vaadin/form-layout": "24.9.4",
"@vaadin/grid": "24.9.4",
"@vaadin/horizontal-layout": "24.9.4",
"@vaadin/icon": "24.9.4",
"@vaadin/icons": "24.9.4",
"@vaadin/input-container": "24.9.4",
"@vaadin/integer-field": "24.9.4",
"@vaadin/item": "24.9.4",
"@vaadin/list-box": "24.9.4",
"@vaadin/lit-renderer": "24.9.4",
"@vaadin/login": "24.9.4",
"@vaadin/markdown": "24.9.4",
"@vaadin/master-detail-layout": "24.9.4",
"@vaadin/menu-bar": "24.9.4",
"@vaadin/message-input": "24.9.4",
"@vaadin/message-list": "24.9.4",
"@vaadin/multi-select-combo-box": "24.9.4",
"@vaadin/notification": "24.9.4",
"@vaadin/number-field": "24.9.4",
"@vaadin/overlay": "24.9.4",
"@vaadin/password-field": "24.9.4",
"@vaadin/popover": "24.9.4",
"@vaadin/progress-bar": "24.9.4",
"@vaadin/radio-group": "24.9.4",
"@vaadin/scroller": "24.9.4",
"@vaadin/select": "24.9.4",
"@vaadin/side-nav": "24.9.4",
"@vaadin/split-layout": "24.9.4",
"@vaadin/tabs": "24.9.4",
"@vaadin/tabsheet": "24.9.4",
"@vaadin/text-area": "24.9.4",
"@vaadin/text-field": "24.9.4",
"@vaadin/time-picker": "24.9.4",
"@vaadin/tooltip": "24.9.4",
"@vaadin/upload": "24.9.4",
"@vaadin/vertical-layout": "24.9.4",
"@vaadin/virtual-list": "24.9.4",
"react-realtime-chart": "$react-realtime-chart"
},
"vaadin": {
"dependencies": {
"@polymer/polymer": "3.5.2",
"@vaadin/bundles": "24.9.0",
"@vaadin/bundles": "24.9.4",
"@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": "24.9.4",
"@vaadin/hilla-frontend": "24.9.4",
"@vaadin/hilla-lit-form": "24.9.4",
"@vaadin/hilla-react-auth": "24.9.4",
"@vaadin/hilla-react-crud": "24.9.4",
"@vaadin/hilla-react-form": "24.9.4",
"@vaadin/hilla-react-i18n": "24.9.4",
"@vaadin/hilla-react-signals": "24.9.4",
"@vaadin/polymer-legacy-adapter": "24.9.4",
"@vaadin/react-components": "24.9.4",
"@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": "24.9.4",
"@vaadin/vaadin-material-styles": "24.9.4",
"@vaadin/vaadin-themable-mixin": "24.9.4",
"@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"
"lit": "3.3.1",
"react": "19.1.1",
"react-dom": "19.1.1",
"react-router": "7.6.3"
},
"devDependencies": {
"@babel/preset-react": "7.27.1",
"@preact/signals-react-transform": "0.5.1",
"@preact/signals-react-transform": "0.6.0",
"@rollup/plugin-replace": "6.0.2",
"@rollup/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",
"@rollup/pluginutils": "5.3.0",
"@types/react": "19.1.17",
"@types/react-dom": "19.1.11",
"@vaadin/hilla-generator-cli": "24.9.4",
"@vaadin/hilla-generator-core": "24.9.4",
"@vaadin/hilla-generator-plugin-backbone": "24.9.4",
"@vaadin/hilla-generator-plugin-barrel": "24.9.4",
"@vaadin/hilla-generator-plugin-client": "24.9.4",
"@vaadin/hilla-generator-plugin-model": "24.9.4",
"@vaadin/hilla-generator-plugin-push": "24.9.4",
"@vaadin/hilla-generator-plugin-signals": "24.9.4",
"@vaadin/hilla-generator-plugin-subtypes": "24.9.4",
"@vaadin/hilla-generator-plugin-transfertypes": "24.9.4",
"@vaadin/hilla-generator-utils": "24.9.4",
"@vitejs/plugin-react": "4.7.0",
"glob": "11.0.3",
"magic-string": "0.30.19",
"rollup-plugin-brotli": "3.1.0",
"rollup-plugin-visualizer": "5.14.0",
"strip-css-comments": "5.0.0",
"transform-ast": "2.4.4",
"typescript": "5.8.3",
"vite": "6.3.6",
"vite-plugin-checker": "0.9.3",
"vite": "6.4.1",
"vite-plugin-checker": "0.10.3",
"workbox-build": "7.3.0",
"workbox-core": "7.3.0",
"workbox-precaching": "7.3.0"
},
"disableUsageStatistics": true,
"hash": "dba97848bdace60924f9cee496353baae70cfa4fccc7bacaf827807c51908866"
"hash": "45fe1cd9320d2da603b811b433279d79b37370c9732e877490fc304807ef6163"
}
}
}

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,8 @@ 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";
export default function App() {
client.middlewares = [ErrorHandlingMiddleware];
@ -46,11 +48,13 @@ function ViewWithAuth() {
initializeLibraryState();
initializeGameState();
initializePlatformState();
initializeGameRequestState();
initializePluginState();
if (isAdmin(auth)) {
initializeScanState();
initializeDownloadSessionState();
}
}, [auth]);
@ -63,7 +67,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/>,
icon: <GearFineIcon/>,
onClick: () => navigate("/administration/libraries"),
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 as SessionStatsDto[];
const [lastDaySum, setLastDaySum] = React.useState<number>(0);
React.useEffect(() => {
const sum = sessions.reduce((total: number, session: SessionStatsDto) => total + session.totalBytesTransferred, 0);
setLastDaySum(sum);
}, [sessions]);
return (
<div className="flex flex-col">
<Alert
title="Experimental Feature"
description="Bandwidth limiting is an experimental feature and may not work as expected. Please report any issues you encounter."
variant="solid"
hideIconWrapper={true}
icon={<FlaskIcon size={24}/>}
endContent={
<Button variant="flat"
className="bg-default-400"
onPress={() => window.open("https://github.com/gameyfin/gameyfin/issues", "_blank")}>
Open Issues
</Button>
}
classNames={{
title: "font-bold",
base: "mt-6"
}}
/>
<Section title="Bandwidth limiting"/>
<div className="flex flex-col gap-4">
<div className="flex flex-row items-baseline gap-4">
<ConfigFormField configElement={getConfig("downloads.bandwidth-limit.enabled")}/>
<ConfigFormField configElement={getConfig("downloads.bandwidth-limit.mbps")}
isDisabled={!formik.values.downloads["bandwidth-limit"].enabled}/>
</div>
</div>
<div className="flex flex-row justify-between items-end">
<h2 className="text-xl font-bold mt-8 mb-1">Live View</h2>
<Tooltip content="Sum over the last 24 hours" placement="left">
<div className="flex flex-row gap-1">
<SigmaIcon size={26} weight="bold"/>
<p className="font-semibold">{humanFileSize(lastDaySum)}</p>
</div>
</Tooltip>
</div>
<Divider className="mb-4"/>
{sessions.length === 0 &&
<p className="text-center text-default-500">No active download sessions.</p>
}
<div className="flex flex-col gap-2">
{sessions.map((session: SessionStatsDto) =>
<DownloadSessionCard key={session.sessionId} sessionId={session.sessionId}/>
)}
</div>
</div>
);
}
const validationSchema = Yup.object({
downloads: Yup.object({
"bandwidth-limit": Yup.object({
enabled: Yup.boolean().required("Required"),
mbps: Yup.number()
.min(1, "Must be at least 1 Mbps")
.required("Required"),
}).required("Required")
})
});
export const DownloadManagement = withConfigPage(DownloadManagementLayout, "Downloads", validationSchema);

View File

@ -5,7 +5,7 @@ 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 {PlusIcon} from "@phosphor-icons/react";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import {LibraryOverviewCard} from "Frontend/components/general/cards/LibraryOverviewCard";
import LibraryCreationModal from "Frontend/components/general/modals/LibraryCreationModal";
@ -65,7 +65,7 @@ function LibraryManagementLayout({getConfig, formik}: any) {
<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/>
<PlusIcon/>
</Button>
</Tooltip>
</div>
@ -83,8 +83,6 @@ function LibraryManagementLayout({getConfig, formik}: any) {
}
<LibraryCreationModal
// @ts-ignore
libraries={state.sorted}
isOpen={libraryCreationModal.isOpen}
onOpenChange={libraryCreationModal.onOpenChange}
/>

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>
@ -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,7 +12,7 @@ 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>

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

@ -4,7 +4,7 @@ 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 { MagicWandIcon, WarningIcon } from "@phosphor-icons/react";
function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) {
@ -57,7 +57,7 @@ function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) {
Automatically create new users after registration
</Checkbox>
<Tooltip content={"Currently not configurable (always enabled)"} placement="right">
<Warning weight="fill"/>
<WarningIcon weight="fill"/>
</Tooltip>
</div>
</CheckboxGroup>
@ -89,7 +89,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}/>

View File

@ -5,7 +5,7 @@ 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 { InfoIcon, 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 +21,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">
@ -33,12 +33,12 @@ 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}
<SmallInfoField className="mb-4 text-warning" icon={InfoIcon}
message="Automatic user registration for SSO users is disabled"/>
}
<Tooltip content="Invite new user">
<Button isIconOnly variant="flat" onPress={inviteUserModal.onOpen}>
<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";
@ -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

@ -13,7 +13,7 @@ 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";
@ -45,7 +45,7 @@ export default function ScanProgressPopover() {
classNames={{
spinnerBars: "bg-foreground-500",
}}/> :
<Target className="fill-foreground-500"/>
<TargetIcon className="fill-foreground-500"/>
}
</Button>
</PopoverTrigger>
@ -103,7 +103,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,5 +1,5 @@
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";
@ -41,7 +41,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 +54,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,119 @@
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, timeUntil} from "Frontend/util/utils";
import {gameState} from "Frontend/state/GameState";
import RealtimeChart, {RealtimeChartData, RealtimeChartOptions} from "react-realtime-chart";
import {useEffect, useState} from "react";
export function DownloadSessionCard({sessionId}: { sessionId: string }) {
const session = useSnapshot(downloadSessionState).byId[sessionId];
const games = useSnapshot(gameState).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: 24,
timeSlots: 30,
colors: [foregroundColor],
margin: {left: 60},
lines: [
{
area: true,
areaColor: foregroundColor,
areaOpacity: 0.05,
lineWidth: 1,
curve: "basis",
},
],
yGrid: {
min: 0,
opacity: 0,
size: 1,
tickNumber: 5,
tickFormat: (v) => `${v}MB/s`
},
xGrid: {
opacity: 0,
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] && <Chip size="sm" radius="sm" key={gameId}>{games[gameId].title}</Chip>
)}
</div>
<div className="w-full h-48">
<RealtimeChart options={chartOptions} data={chartData}/>
</div>
</div>
}
</Card>
)
}

View File

@ -1,15 +1,16 @@
import {Button, Card, Chip, Tooltip} from "@heroui/react";
import {Button, Card, Tooltip} from "@heroui/react";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import 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;
@ -50,17 +51,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 +74,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,20 +1,5 @@
import {Button, Card, Chip, Tooltip, useDisclosure} from "@heroui/react";
import {
CheckCircle,
IconContext,
PauseCircle,
PlayCircle,
Power,
Question,
QuestionMark,
SealCheck,
SealQuestion,
SealWarning,
SlidersHorizontal,
StopCircle,
WarningCircle,
XCircle
} from "@phosphor-icons/react";
import { CheckCircleIcon, IconContext, PauseCircleIcon, PlayCircleIcon, PowerIcon, QuestionIcon, QuestionMarkIcon, SealCheckIcon, SealQuestionIcon, SealWarningIcon, SlidersHorizontalIcon, StopCircleIcon, WarningCircleIcon, XCircleIcon } from "@phosphor-icons/react";
import PluginState from "Frontend/generated/org/pf4j/PluginState";
import React, {ReactNode} from "react";
import PluginDetailsModal from "Frontend/components/general/modals/PluginDetailsModal";
@ -54,17 +39,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 +58,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 +80,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>;
}
}
@ -141,12 +126,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

@ -1,5 +1,5 @@
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";
@ -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

@ -1,8 +1,7 @@
import React, {useEffect, useRef, useState} from "react";
import {GameCover} from "Frontend/components/general/covers/GameCover";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {ArrowRight} from "@phosphor-icons/react";
import {useNavigate} from "react-router";
import {ArrowRightIcon} from "@phosphor-icons/react";
interface CoverRowProps {
games: GameDto[];
@ -16,7 +15,6 @@ const defaultImageWidth = aspectRatio * defaultImageHeight; // default width for
export function CoverRow({games, title, onPressShowMore}: CoverRowProps) {
const navigate = useNavigate();
const containerRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(games.length);
@ -55,12 +53,12 @@ export function CoverRow({games, title, onPressShowMore}: CoverRowProps) {
<div className="flex flex-row items-center justify-end cursor-pointer"
onClick={onPressShowMore}>
<div className="absolute h-full w-1/4 right-0 bottom-0
bg-gradient-to-r from-transparent to-background
bg-linear-to-r from-transparent to-background
transition-all duration-300 ease-in-out hover:opacity-80"/>
<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"/>
<ArrowRightIcon weight="bold"/>
</div>
</div>
)}

View File

@ -14,7 +14,7 @@ export function GameCover({game, size = 300, radius = "sm", interactive = false}
<div className={`${interactive ? "rounded-md scale-95 hover:scale-100 shine transition-all" : ""}`}>
<Image
alt={game.title}
className="z-0 object-cover aspect-[12/17]"
className="z-0 object-cover aspect-12/17"
src={`images/cover/${game.coverId}`}
radius={radius}
height={size}

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,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}) => {
@ -41,7 +41,7 @@ const ArrayInput = ({label, ...props}) => {
))}
<Popover placement="bottom" showArrow={true}>
<PopoverTrigger>
<Button isIconOnly size="sm" variant="light" radius="full"><Plus/></Button>
<Button isIconOnly size="sm" variant="light" radius="full"><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";
@ -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,13 +14,13 @@ 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 ?
<div className="size-full overflow-hidden">
<Image
alt={game.title}
className="z-0 object-cover group-hover:brightness-[25%]"
className="z-0 object-cover group-hover:brightness-25"
src={field.value ? field.value : `images/cover/${game.coverId}`}
{...props}
{...field}
@ -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";
@ -20,7 +20,7 @@ export default function GameHeaderPicker({game, showErrorUntouched = false, ...p
<div className="size-full overflow-hidden">
<Image
alt={game.title}
className="z-0 object-cover group-hover:brightness-[25%]"
className="z-0 object-cover group-hover:brightness-25"
src={field.value ? field.value : `images/cover/${game.headerId}`}
{...props}
{...field}
@ -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,9 @@ 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";
interface LibraryManagementDetailsProps {
library: LibraryDto;
@ -19,6 +22,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;
@ -66,7 +70,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 +78,14 @@ 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"/>
<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;
@ -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>
@ -179,6 +181,9 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
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 +201,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 +212,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
editGameModal.onOpenChange();
}}>
<Tooltip content="Edit metadata">
<Pencil/>
<PencilIcon/>
</Tooltip>
</Button>
<Button isIconOnly size="sm" onPress={() => {
@ -215,13 +220,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>

View File

@ -12,7 +12,7 @@ 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";
@ -124,12 +124,12 @@ 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={() => deleteUnmatchedPath(item.path)}><TrashIcon/>
</Button>
</Tooltip>
</div>

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

@ -21,6 +21,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,6 +32,8 @@ interface EditGameMetadataModalProps {
}
export default function EditGameMetadataModal({game, isOpen, onOpenChange}: EditGameMetadataModalProps) {
const availablePlatforms = useSnapshot(platformState).available;
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="3xl">
<ModalContent>
@ -69,6 +74,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"

View File

@ -3,7 +3,7 @@ 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";
@ -33,7 +33,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 +59,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 +74,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,7 +103,7 @@ 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}
@ -113,7 +113,7 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
<PluginIcon plugin={state[cover.source] as PluginDto} size={32}
blurred={false} showTooltip={false}/>
<p className="text-s text-center">{cover.title}</p>
<ArrowRight/>
<ArrowRightIcon/>
</div>
</div>
))}

View File

@ -3,7 +3,7 @@ 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";
@ -33,7 +33,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 +59,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 +74,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,7 +103,7 @@ 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"
/>
@ -112,7 +112,7 @@ export function GameHeaderPickerModal({game, isOpen, onOpenChange, setHeaderUrl}
<PluginIcon plugin={state[header.source] as PluginDto} size={32}
blurred={false} showTooltip={false}/>
<p className="text-s text-center">{header.title}</p>
<ArrowRight/>
<ArrowRightIcon/>
</div>
</div>
))}

View File

@ -7,21 +7,22 @@ import Input from "Frontend/components/general/input/Input";
import * as Yup from "yup";
import DirectoryMappingInput from "Frontend/components/general/input/DirectoryMappingInput";
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
import ArrayInputAutocomplete from "Frontend/components/general/input/ArrayInputAutocomplete";
import {useSnapshot} from "valtio/react";
import {platformState} from "Frontend/state/PlatformState";
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);
@ -33,12 +34,12 @@ export default function LibraryCreationModal({
});
}
return (
return (availablePlatforms &&
<>
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="xl">
<ModalContent>
{(onClose) => (
<Formik initialValues={{name: "", directories: []}}
<Formik initialValues={{name: "", directories: [], platforms: []}}
validationSchema={Yup.object({
name: Yup.string()
.required("Library name is required")
@ -65,6 +66,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

@ -13,13 +13,15 @@ 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 +46,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 +59,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 +87,7 @@ export default function MatchGameModal({
}}
/>
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}>
<MagnifyingGlass/>
<MagnifyingGlassIcon/>
</Button>
</div>
@ -141,7 +144,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

@ -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,7 +1,7 @@
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 { CaretUpDownIcon } from "@phosphor-icons/react";
import {useListData} from "@react-stately/data";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
import {PluginEndpoint} from "Frontend/generated/endpoints";
@ -91,7 +91,7 @@ export default function PluginPrioritiesModal({plugins, isOpen, onOpenChange}: P
</Chip>
<p className="font-normal text-small">{plugin.name}</p>
</div>
<CaretUpDown/>
<CaretUpDownIcon/>
</ListBoxItem>
)}
</ListBox>
@ -101,7 +101,7 @@ export default function PluginPrioritiesModal({plugins, isOpen, onOpenChange}: P
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="success" onPress={() => setPluginPriorities(onClose)}>
<Button color="primary" onPress={() => setPluginPriorities(onClose)}>
Save
</Button>
</ModalFooter>

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,5 +1,5 @@
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";
@ -16,7 +16,7 @@ export function PluginManagementSection({type, plugins = []}: PluginManagementSe
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 +24,7 @@ export function PluginManagementSection({type, plugins = []}: PluginManagementSe
variant="flat"
onPress={pluginPrioritiesModal.onOpen}
isDisabled={plugins.length === 0}>
<ListNumbers/>
<ListNumbersIcon/>
</Button>
</Tooltip>
</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

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

@ -26,6 +26,7 @@ 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";
export const {router, routes} = new RouterConfigurationBuilder()
.withReactRoutes([
@ -99,6 +100,11 @@ export const {router, routes} = new RouterConfigurationBuilder()
element: <GameRequestManagement/>,
handle: {title: 'Administration - Game Requests'}
},
{
path: 'downloads',
element: <DownloadManagement/>,
handle: {title: 'Administration - Downloads'}
},
{
path: 'users',
element: <UserManagement/>,

View File

@ -0,0 +1,81 @@
import {proxy} from 'valtio';
import {BandwidthMonitoringEndpoint} from "Frontend/generated/endpoints";
import SessionStatsDto from "Frontend/generated/org/gameyfin/app/core/download/bandwidth/SessionStatsDto";
import {Subscription} from "@vaadin/hilla-frontend";
import {convertBpsToMbps} from "Frontend/util/utils";
type DownloadSessionState = {
subscription?: Subscription<SessionStatsDto[][]>;
isLoaded: boolean;
all: SessionStatsDto[];
byId: Record<string, SessionStatsDto>;
activeSessions: number;
bandwidthInUse: number;
};
export const downloadSessionState = proxy<DownloadSessionState>({
get isLoaded() {
return this.subscription != null;
},
all: [],
byId: {},
get activeSessions() {
return this.all.filter((session: SessionStatsDto) => session.activeDownloads > 0).length;
},
get bandwidthInUse() {
return this.all.reduce((total: number, session: SessionStatsDto) => total + session.currentBytesPerSecond, 0);
}
});
/** Subscribe to and process download session updates from backend **/
export async function initializeDownloadSessionState() {
if (downloadSessionState.isLoaded) return;
// Fetch initial configuration data
const initialSessions = await BandwidthMonitoringEndpoint.getActiveSessions();
downloadSessionState.all = sortSessions(initialSessions);
initialSessions.forEach((session: SessionStatsDto) => {
downloadSessionState.byId[session.sessionId] = session;
});
// Subscribe to real-time updates
downloadSessionState.subscription = BandwidthMonitoringEndpoint.subscribe().onNext((downloadSessionUpdate: SessionStatsDto[][]) => {
downloadSessionUpdate.forEach((updateBatch: SessionStatsDto[]) => {
downloadSessionState.all = sortSessions(updateBatch);
updateBatch.forEach((session: SessionStatsDto) => {
downloadSessionState.byId[session.sessionId] = session;
});
});
});
}
/** Sort sessions: active sessions (by bandwidth, then oldest first), inactive sessions (newest first) **/
function sortSessions(sessions: SessionStatsDto[]): SessionStatsDto[] {
return [...sessions].sort((a, b) => {
const aIsActive = a.activeDownloads > 0;
const bIsActive = b.activeDownloads > 0;
// Active sessions come first
if (aIsActive !== bIsActive) {
return bIsActive ? 1 : -1;
}
// For active sessions: sort by bandwidth (highest first), then by age (oldest first)
if (aIsActive) {
const bandwidthDiff = convertBpsToMbps(b.currentBytesPerSecond, 0) - convertBpsToMbps(a.currentBytesPerSecond, 0);
if (bandwidthDiff !== 0) {
return bandwidthDiff;
}
// Tie breaker: oldest first
const aTime = new Date(a.startTime).getTime();
const bTime = new Date(b.startTime).getTime();
return aTime - bTime;
}
// For inactive sessions: sort by age (newest first)
const aTime = new Date(a.startTime).getTime();
const bTime = new Date(b.startTime).getTime();
return bTime - aTime;
});
}

View File

@ -0,0 +1,47 @@
import {proxy} from 'valtio';
import {PlatformEndpoint} from "Frontend/generated/endpoints";
import PlatformStatsDto from "Frontend/generated/org/gameyfin/app/platforms/dto/PlatformStatsDto";
import {Subscription} from "@vaadin/hilla-frontend";
type PlatformState = {
subscription?: Subscription<PlatformStatsDto[]>;
isLoaded: boolean;
available: Set<string>;
usedByGames: Set<string>;
usedByLibraries: Set<string>;
};
export const platformState = proxy<PlatformState>({
get isLoaded() {
return this.subscription != null;
},
available: new Set<string>,
usedByGames: new Set<string>,
usedByLibraries: new Set<string>
});
/** Subscribe to and process platform updates from backend **/
export async function initializePlatformState() {
if (platformState.isLoaded) return;
// Fetch initial configuration data
const initialPlatformStats = await PlatformEndpoint.getStats();
platformState.available = new Set(initialPlatformStats.available);
platformState.usedByGames = new Set(initialPlatformStats.inUseByGames);
platformState.usedByLibraries = new Set(initialPlatformStats.inUseByLibraries);
// Subscribe to real-time updates
platformState.subscription = PlatformEndpoint.subscribe().onNext((platformStats: Partial<PlatformStatsDto>[]) => {
platformStats.forEach((updateDto: Partial<PlatformStatsDto>) => {
if (updateDto.available !== undefined) {
platformState.available = new Set(updateDto.available);
}
if (updateDto.inUseByGames !== undefined) {
platformState.usedByGames = new Set(updateDto.inUseByGames);
}
if (updateDto.inUseByLibraries !== undefined) {
platformState.usedByLibraries = new Set(updateDto.inUseByLibraries);
}
})
});
}

View File

@ -1,7 +1,7 @@
import {GameyfinClassic} from "./themes/gameyfin-classic";
import {GameyfinBlue} from "./themes/gameyfin-blue";
import {GameyfinViolet} from "./themes/gameyfin-violet";
import {Purple} from "./themes/purple";
import {Pink} from "./themes/pink";
import {Neutral} from "./themes/neutral";
import {Slate} from "./themes/slate";
import {Red} from "./themes/red";
@ -44,4 +44,4 @@ export function themeNames(): string[] {
return Object.keys(compileThemes(themes));
}
export const themes: Theme[] = [GameyfinBlue, GameyfinViolet, GameyfinClassic, Neutral, Slate, Red, Rose, Orange, Purple, Blue, Yellow, Violet, Colorblind];
export const themes: Theme[] = [GameyfinBlue, GameyfinViolet, GameyfinClassic, Neutral, Slate, Red, Rose, Orange, Pink, Blue, Yellow, Violet, Colorblind];

View File

@ -1,7 +1,7 @@
import {Theme} from "../theme";
export const Purple: Theme = {
name: 'purple',
export const Pink: Theme = {
name: 'pink',
colors: {
primary: {
DEFAULT: '#DD62ED',

View File

@ -37,13 +37,13 @@ export function hashCode(string: string) {
export function roleToColor(role: string) {
switch (role) {
case "ROLE_SUPERADMIN":
return "red";
return "bg-red-500";
case "ROLE_ADMIN":
return "orange";
return "bg-orange-500";
case "ROLE_USER":
return "blue";
return "bg-blue-500";
default:
return "gray";
return "bg-gray-500";
}
}
@ -62,9 +62,10 @@ export async function fetchWithAuth(url: string, body: any = null, method = "POS
* Calculate the time difference between a given Instant and the current time in the user's timezone.
* @param {string} instantString - The Instant string returned by the backend.
* @param {string} timeZone - The user's timezone.
* @param {boolean} timeOnly - Whether to exclude "ago" or "in" prefix/suffix.
* @returns {string} - The time difference in a human-readable format.
*/
export function timeUntil(instantString: string, timeZone: string = moment.tz.guess()): string {
export function timeUntil(instantString: string, timeZone: string = moment.tz.guess(), timeOnly: boolean = false): string {
const givenDate = moment.tz(instantString, timeZone);
const now = moment.tz(timeZone);
const diffInSeconds = givenDate.diff(now, 'seconds');
@ -84,7 +85,10 @@ export function timeUntil(instantString: string, timeZone: string = moment.tz.gu
for (const unit of units) {
const value = Math.floor(absDiffInSeconds / unit.seconds);
if (value >= 1) {
return `${isPast ? '' : 'in'} ${value} ${unit.name}${value > 1 ? 's' : ''} ${isPast ? 'ago' : ''}`;
if (timeOnly) {
return `${value} ${unit.name}${value > 1 ? 's' : ''}`;
}
return `${isPast ? '' : 'in '}${value} ${unit.name}${value > 1 ? 's' : ''} ${isPast ? 'ago' : ''}`;
}
}
@ -217,7 +221,7 @@ export function fileNameFromPath(path: string, includeExtension: boolean = true)
*/
export function metadataCompleteness(game: GameDto) {
// Total number of fields considered for completeness
// Includes all fields except "comment"
// Includes all fields except "comment" and "platforms"
const totalFields = 21;
const filledFields = Object.values(game).filter(value => {
@ -227,7 +231,8 @@ export function metadataCompleteness(game: GameDto) {
return true;
}).length;
return Math.round((filledFields / totalFields) * 100);
const completeness = Math.round((filledFields / totalFields) * 100);
return Math.min(100, completeness); // Never exceed 100%
}
/**
@ -276,11 +281,65 @@ export function compoundRating(game: GameDto, scale = [0, 100]): number {
* @param game The GameDto object containing the ratings.
* @returns A string representing the star rating out of 5, or "N/A" if no ratings are available.
*/
export function starRatingAsString(game: GameDto) {
export function starRatingAsString(game: GameDto): string {
const starRange = [1, 5];
const rating = compoundRating(game, starRange);
if (rating === 0) return "N/A";
return rating.toFixed(1);
}
}
/**
* Convert bytes per second to megabits per second.
* @param bps
* @param fractionDigits
*/
export function convertBpsToMbps(bps: number, fractionDigits: number = 0): number {
// Formula: (bytes per second * 8) / 1,000,000 = megabits per second
const mbps = bps / 125000;
const multiplier = 10 ** fractionDigits;
return Math.round(mbps * multiplier) / multiplier;
}
/**
* Convert an HSL string to a hex RGB string.
* @param hslString HSL string in the format "H S% L%" (e.g., "339.2 90.36% 51.18%")
* @returns Hex RGB string in the format "#RRGGBB" (e.g., "#ff0080")
*/
export function hslToHex(hslString: string): string {
// Parse the HSL string
const parts = hslString.trim().split(/\s+/);
const h = parseFloat(parts[0]) / 360;
const s = parseFloat(parts[1]) / 100;
const l = parseFloat(parts[2]) / 100;
let r, g, b;
if (s === 0) {
r = g = b = l; // achromatic
} else {
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
// Convert to hex
const toHex = (x: number) => {
const hex = Math.round(x * 255).toString(16);
return hex.length === 1 ? '0' + hex : hex;
};
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}

View File

@ -1,46 +1,61 @@
import {Disc, Envelope, GameController, LockKey, Log, Plug, Users, Wrench} from "@phosphor-icons/react";
import {
DiscIcon,
DownloadSimpleIcon,
EnvelopeIcon,
GameControllerIcon,
LockKeyIcon,
LogIcon,
PlugIcon,
UsersIcon,
WrenchIcon
} from "@phosphor-icons/react";
import withSideMenu, {MenuItem} from "Frontend/components/general/withSideMenu";
const menuItems: MenuItem[] = [
{
title: "Libraries",
url: "libraries",
icon: <GameController/>
icon: <GameControllerIcon/>
},
{
title: "Game Requests",
url: "requests",
icon: <Disc/>
icon: <DiscIcon/>
},
{
title: "Downloads",
url: "downloads",
icon: <DownloadSimpleIcon/>
},
{
title: "Users",
url: "users",
icon: <Users/>
icon: <UsersIcon/>
},
{
title: "SSO",
url: "sso",
icon: <LockKey/>
icon: <LockKeyIcon/>
},
{
title: "Messages",
url: "messages",
icon: <Envelope/>
icon: <EnvelopeIcon/>
},
{
title: "Plugins",
url: "plugins",
icon: <Plug/>
icon: <PlugIcon/>
},
{
title: "Logs",
url: "logs",
icon: <Log/>
icon: <LogIcon/>
},
{
title: "System",
url: "system",
icon: <Wrench/>
icon: <WrenchIcon/>
}
]

View File

@ -1,14 +1,14 @@
import {Card, CardBody, CardHeader} from "@heroui/react";
import {useNavigate, useSearchParams} from "react-router";
import React, {useEffect, useState} from "react";
import {CheckCircle, Warning, WarningCircle} from "@phosphor-icons/react";
import {CheckCircleIcon, WarningCircleIcon, WarningIcon} from "@phosphor-icons/react";
import TokenValidationResult from "Frontend/generated/org/gameyfin/app/shared/token/TokenValidationResult";
import {EmailConfirmationEndpoint} from "Frontend/generated/endpoints";
import {useAuth} from "Frontend/util/auth";
export default function EmailConfirmationView() {
const auth = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [validationResult, setValidationResult] = useState<TokenValidationResult>(TokenValidationResult.INVALID);
@ -34,7 +34,7 @@ export default function EmailConfirmationView() {
}
return (
<div className="flex flex-row flex-grow items-center justify-center size-full gradient-primary">
<div className="flex flex-row grow items-center justify-center size-full gradient-primary">
<Card className="p-4 min-w-[468px]">
<CardHeader className="mb-4">
<img
@ -46,7 +46,7 @@ export default function EmailConfirmationView() {
<CardBody className="flex flex-row justify-center">
{validationResult === TokenValidationResult.VALID ?
<div className="flex flex-row items-center gap-4 text-success">
<CheckCircle size={40}/>
<CheckCircleIcon size={40}/>
<p>
Email confirmed<br/>
You will be redirected shortly
@ -54,7 +54,7 @@ export default function EmailConfirmationView() {
</div>
: validationResult === TokenValidationResult.EXPIRED ?
<div className="flex flex-row items-center gap-4 text-warning">
<WarningCircle size={40}/>
<WarningCircleIcon size={40}/>
<p>
Expired token<br/>
Please request a new one
@ -62,7 +62,7 @@ export default function EmailConfirmationView() {
</div>
:
<div className="flex flex-row items-center gap-4 text-danger">
<Warning size={40}/>
<WarningIcon size={40}/>
<p>
Invalid token<br/>
Please try again

View File

@ -1,23 +1,6 @@
import {Button} from "@heroui/react";
import {useNavigate} from "react-router";
import {
Alien,
Compass,
Cube,
DiceFive,
FlagCheckered,
GameController,
Ghost,
Icon,
IconContext,
Joystick,
MagicWand,
PuzzlePiece,
RocketLaunch,
Skull,
SmileyXEyes,
Sword
} from "@phosphor-icons/react";
import { AlienIcon, CompassIcon, CubeIcon, DiceFiveIcon, FlagCheckeredIcon, GameControllerIcon, GhostIcon, Icon, IconContext, JoystickIcon, MagicWandIcon, PuzzlePieceIcon, RocketLaunchIcon, SkullIcon, SmileyXEyesIcon, SwordIcon } from "@phosphor-icons/react";
import React, {ReactElement, useState} from "react";
import GameyfinLogo from "Frontend/components/theming/GameyfinLogo";
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
@ -37,85 +20,85 @@ export default function ErrorView() {
"title": "404 Level Not Found!",
"subtitle": "Youve wandered off the map. This level doesnt exist—or maybe its still in development.",
"buttonText": "Go back to the main menu",
"icon": <Joystick/>
"icon": <JoystickIcon/>
},
{
"title": "404 Quest Failed",
"subtitle": "The path you seek does not exist. Maybe it was just a side quest after all.",
"buttonText": "Return to the guild hall",
"icon": <Compass/>
"icon": <CompassIcon/>
},
{
"title": "404 Youve encountered a glitch in the system!",
"subtitle": "The page youre looking for couldnt load. Dont worry, no coins were lost.",
"buttonText": "Retry mission",
"icon": <Alien/>
"icon": <AlienIcon/>
},
{
"title": "404 Game Cartridge Not Inserted",
"subtitle": "This page failed to load. Did you blow on the cartridge and try again?",
"buttonText": "Reset the console",
"icon": <DiceFive/>
"icon": <DiceFiveIcon/>
},
{
"title": "404 You are in the wrong zone…",
"subtitle": "This area is off-limits… or was never meant to be explored. Tread carefully.",
"buttonText": "Find a safe path",
"icon": <SmileyXEyes/>
"icon": <SmileyXEyesIcon/>
},
{
"title": "404 You Missed the Jump!",
"subtitle": "The platform you were trying to reach isnt here. Maybe it was a hidden level?",
"buttonText": "Respawn at Start",
"icon": <GameController/>
"icon": <GameControllerIcon/>
},
{
"title": "404 Signal Lost in Deep Space",
"subtitle": "We've lost contact with this page. All we have is static and void.",
"buttonText": "Return to Command Center",
"icon": <RocketLaunch/>
"icon": <RocketLaunchIcon/>
},
{
"title": "404 The Page Has Vanished in a Puff of Smoke",
"subtitle": "A forbidden spell may have erased the page from existence. Try another path.",
"buttonText": "Return to the Grimoire",
"icon": <MagicWand/>
"icon": <MagicWandIcon/>
},
{
"title": "404 Block Not Found",
"subtitle": "The page you're looking for hasn't been crafted yet. Gather more resources and try again.",
"buttonText": "Back to Base",
"icon": <Cube/>
"icon": <CubeIcon/>
},
{
"title": "404 Puzzle Piece Missing",
"subtitle": "This page doesnt quite fit. Try rotating it… or just go back.",
"buttonText": "Solve a different puzzle",
"icon": <PuzzlePiece/>
"icon": <PuzzlePieceIcon/>
},
{
"title": "404 You Took a Wrong Turn!",
"subtitle": "You drifted off course and into the digital void.",
"buttonText": "Return to the Starting Line",
"icon": <FlagCheckered/>
"icon": <FlagCheckeredIcon/>
},
{
"title": "404 This Page Didnt Survive",
"subtitle": "Only ruins remain. Whatever was here is long gone.",
"buttonText": "Search for safe house",
"icon": <Skull/>
"icon": <SkullIcon/>
},
{
"title": "404 Instance Not Found",
"subtitle": "This dungeon has been removed or doesnt exist on this realm.",
"buttonText": "Return to your stronghold",
"icon": <Sword/>
"icon": <SwordIcon/>
},
{
"title": "404 The Page Was… Never Really Here…",
"subtitle": "You were warned not to look. But you clicked anyway.",
"buttonText": "Turn Back Now",
"icon": <Ghost/>
"icon": <GhostIcon/>
}
];

View File

@ -16,7 +16,7 @@ import {
useDisclosure
} from "@heroui/react";
import RequestGameModal from "Frontend/components/general/modals/RequestGameModal";
import {ArrowUp, Check, Info, PlusCircle, Trash, X} from "@phosphor-icons/react";
import {ArrowUpIcon, CheckIcon, InfoIcon, PlusCircleIcon, TrashIcon, XIcon} from "@phosphor-icons/react";
import React, {useEffect, useMemo, useState} from "react";
import {useAuth} from "Frontend/util/auth";
import {ConfigEndpoint, GameRequestEndpoint} from "Frontend/generated/endpoints";
@ -145,9 +145,10 @@ export default function GameRequestView() {
switch (status) {
case GameRequestStatus.APPROVED:
return <Chip size="sm" radius="sm"
className="text-xs bg-success-300 text-success-foreground">Approved</Chip>;
className="text-xs bg-success text-success-foreground">Approved</Chip>;
case GameRequestStatus.FULFILLED:
return <Chip size="sm" radius="sm" className="text-xs bg-success">Fulfilled</Chip>;
return <Chip size="sm" radius="sm"
className="text-xs bg-success-100 text-success-foreground">Fulfilled</Chip>;
case GameRequestStatus.REJECTED:
return <Chip size="sm" radius="sm"
className="text-xs bg-danger-300 text-danger-foreground">Rejected</Chip>;
@ -162,13 +163,13 @@ export default function GameRequestView() {
<h1 className="text-2xl font-bold">Game Requests</h1>
<div className="flex flex-row items-center gap-4">
{!areGameRequestsEnabled &&
<SmallInfoField icon={Info}
<SmallInfoField icon={InfoIcon}
message="Request submission is disabled"
className="text-default-500"/>
}
<Button className="w-fit"
color="primary"
startContent={<PlusCircle weight="fill"/>}
startContent={<PlusCircleIcon weight="fill"/>}
onPress={requestGameModal.onOpen}
isDisabled={!areGameRequestsEnabled || (!auth.state.user && !areGuestsAllowedToRequestGames)}>
Request a Game
@ -219,6 +220,7 @@ export default function GameRequestView() {
>
<TableHeader>
<TableColumn key="title" allowsSorting>Title & Release</TableColumn>
<TableColumn key="platform">Platform</TableColumn>
<TableColumn>Submitted by</TableColumn>
<TableColumn key="createdAt" allowsSorting>Submitted</TableColumn>
<TableColumn key="updatedAt" allowsSorting>Updated</TableColumn>
@ -232,6 +234,9 @@ export default function GameRequestView() {
<TableCell>
{item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"})
</TableCell>
<TableCell>
<Chip size="sm" radius="sm" className="text-xs max-w-32 truncate">{item.platform}</Chip>
</TableCell>
<TableCell>
<p className="text-default-500">
{item.requester ?
@ -259,7 +264,7 @@ export default function GameRequestView() {
variant={hasUserVotedForRequest(item as GameRequestDto) ? "solid" : "bordered"}
color={hasUserVotedForRequest(item as GameRequestDto) ? "primary" : "default"}
isDisabled={!auth.state.user || item.status === GameRequestStatus.FULFILLED}
startContent={<ArrowUp/>}
startContent={<ArrowUpIcon/>}
onPress={async () => await toggleVote(item.id)}>
{item.voters.length}
</Button>
@ -272,7 +277,7 @@ export default function GameRequestView() {
color={item.status === GameRequestStatus.APPROVED ? "primary" : "default"}
isDisabled={item.status === GameRequestStatus.FULFILLED}
onPress={async () => await toggleApprove(item as GameRequestDto)}>
<Check/>
<CheckIcon/>
</Button>
</Tooltip>
<Tooltip content="Reject this request">
@ -281,7 +286,7 @@ export default function GameRequestView() {
color={item.status === GameRequestStatus.REJECTED ? "primary" : "default"}
isDisabled={item.status === GameRequestStatus.FULFILLED}
onPress={async () => await toggleReject(item as GameRequestDto)}>
<X/>
<XIcon/>
</Button>
</Tooltip>
</div>}
@ -290,7 +295,7 @@ export default function GameRequestView() {
<Button size="sm" isIconOnly
color="danger"
onPress={async () => await deleteRequest(item.id)}>
<Trash/>
<TrashIcon/>
</Button>
</Tooltip>
}

View File

@ -9,7 +9,15 @@ import {humanFileSize, isAdmin, starRatingAsString, toTitleCase} from "Frontend/
import {DownloadEndpoint} from "Frontend/endpoints/endpoints";
import {gameState} from "Frontend/state/GameState";
import {useSnapshot} from "valtio/react";
import {CheckCircle, Info, MagnifyingGlass, Pencil, Star, Trash, TriangleDashed} from "@phosphor-icons/react";
import {
CheckCircleIcon,
InfoIcon,
MagnifyingGlassIcon,
PencilIcon,
StarIcon,
TrashIcon,
TriangleDashedIcon
} from "@phosphor-icons/react";
import {useAuth} from "Frontend/util/auth";
import MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
import EditGameMetadataModal from "Frontend/components/general/modals/EditGameMetadataModal";
@ -17,6 +25,7 @@ import GameUpdateDto from "Frontend/generated/org/gameyfin/app/games/dto/GameUpd
import Markdown from "react-markdown";
import remarkBreaks from "remark-breaks";
import {GameAdminDto} from "Frontend/dtos/GameDtos";
import ChipList from "Frontend/components/general/ChipList";
export default function GameView() {
const {gameId} = useParams();
@ -93,12 +102,12 @@ export default function GameView() {
) : (
<div className="w-full h-96 bg-secondary relative"/>
)}
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-background"/>
<div className="absolute inset-0 bg-linear-to-b from-transparent to-background"/>
</div>
<div className="flex flex-col gap-4 mx-24">
<div className="flex flex-row justify-between">
<div className="flex flex-row gap-4">
<div className="mt-[-16.25rem]">
<div className="-mt-65">
<GameCover game={game} size={320} radius="none"/>
</div>
<div className="flex flex-col gap-1">
@ -107,7 +116,7 @@ export default function GameView() {
{game.title}
</p>
<div className="flex flex-row gap-1 mb-0.5 text-default-500">
<Star weight="fill"/>
<StarIcon weight="fill"/>
{starRatingAsString(game)}
</div>
</div>
@ -116,10 +125,11 @@ export default function GameView() {
{game.release !== undefined ? new Date(game.release).getFullYear() :
<p className="text-default-500">no data</p>}
</p>
<ChipList items={game.platforms} maxVisible={1}/>
<Tooltip
content={`Last update: ${new Date(game.updatedAt).toLocaleString()}`}
placement="right">
<Info/>
<InfoIcon/>
</Tooltip>
</div>
</div>
@ -129,20 +139,20 @@ export default function GameView() {
<Button isIconOnly onPress={toggleMatchConfirmed}>
{game.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>
<Tooltip content="Edit metadata">
<Button isIconOnly onPress={editGameModal.onOpenChange}>
<Pencil/>
<PencilIcon/>
</Button>
</Tooltip>
<Tooltip content="Search for metadata">
<Button isIconOnly onPress={matchGameModal.onOpenChange}>
<MagnifyingGlass/>
<MagnifyingGlassIcon/>
</Button>
</Tooltip>
<Tooltip content="Remove from library">
@ -151,7 +161,7 @@ export default function GameView() {
await deleteGame();
navigate("/");
}}>
<Trash/>
<TrashIcon/>
</Button>
</Tooltip>
</div>}
@ -168,7 +178,7 @@ export default function GameView() {
<AccordionItem key="information"
aria-label="Information"
title="Information"
startContent={<Info weight="fill"/>}>
startContent={<InfoIcon weight="fill"/>}>
<Markdown
remarkPlugins={[remarkBreaks]}
components={{
@ -195,11 +205,12 @@ export default function GameView() {
<p>No summary available</p>
}
</div>
<div className="flex flex-col flex-1 gap-2">
<div className="flex flex-col flex-1">
<p className="text-default-500">Details</p>
<table className="text-left w-full table-auto">
<table
className="text-left w-full table-auto border-separate border-spacing-y-1">
<tbody>
<tr className="h-6">
<tr>
<td className="text-default-500 w-0 min-w-32">Developed by</td>
<td className="flex flex-row gap-1">
{game.developers && game.developers.length > 0
@ -213,64 +224,73 @@ export default function GameView() {
</>
)
: <Tooltip content="Missing data" color="foreground" placement="right">
<TriangleDashed className="fill-default-500 h-6 bottom-0"/>
<TriangleDashedIcon className="fill-default-500 h-6 bottom-0"/>
</Tooltip>
}
</td>
</tr>
<tr className="h-6">
<tr>
<td className="text-default-500 w-0 min-w-32">Published by</td>
<td className="flex flex-row gap-1">
{game.publishers && game.publishers.length > 0
? [...game.publishers].sort().join(" / ")
: <Tooltip content="Missing data" color="foreground" placement="right">
<TriangleDashed className="fill-default-500 h-6 bottom-0"/>
<TriangleDashedIcon className="fill-default-500 h-6 bottom-0"/>
</Tooltip>
}
</td>
</tr>
<tr className="h-6">
<tr>
<td className="text-default-500 w-0 min-w-32">Genres</td>
<td className="flex flex-row gap-1">
{game.genres && game.genres.length > 0
? [...game.genres].sort().map(genre =>
<Link key={genre} href={`/search?genre=${encodeURIComponent(genre)}`}>
<Chip radius="sm">{toTitleCase(genre)}</Chip>
<Chip radius="sm" size="sm"
className="text-sm">
{toTitleCase(genre)}
</Chip>
</Link>
)
: <Tooltip content="Missing data" color="foreground" placement="right">
<TriangleDashed className="fill-default-500 h-6 bottom-0"/>
<TriangleDashedIcon className="fill-default-500 h-6 bottom-0"/>
</Tooltip>
}
</td>
</tr>
<tr className="h-6">
<tr>
<td className="text-default-500 w-0 min-w-32">Themes</td>
<td className="flex flex-row gap-1">
{game.themes && game.themes.length > 0
? [...game.themes].sort().map(theme =>
<Link key={theme} href={`/search?theme=${encodeURIComponent(theme)}`}>
<Chip radius="sm">{toTitleCase(theme)}</Chip>
<Chip radius="sm" size="sm"
className="text-sm">
{toTitleCase(theme)}
</Chip>
</Link>
)
: <Tooltip content="Missing data" color="foreground" placement="right">
<TriangleDashed className="fill-default-500 h-6 bottom-0"/>
<TriangleDashedIcon className="fill-default-500 h-6 bottom-0"/>
</Tooltip>
}
</td>
</tr>
<tr className="h-6">
<tr>
<td className="text-default-500 w-0 min-w-32">Features</td>
<td className="flex flex-row gap-1">
{game.features && game.features.length > 0
? [...game.features].sort().map(feature =>
<Link key={feature}
href={`/search?feature=${encodeURIComponent(feature)}`}>
<Chip radius="sm">{toTitleCase(feature)}</Chip>
<Chip radius="sm" size="sm"
className="text-sm">
{toTitleCase(feature)}
</Chip>
</Link>
)
: <Tooltip content="Missing data" color="foreground" placement="right">
<TriangleDashed className="fill-default-500 h-6 bottom-0"/>
<TriangleDashedIcon className="fill-default-500 h-6 bottom-0"/>
</Tooltip>
}
</td>

View File

@ -5,7 +5,7 @@ import Input from "Frontend/components/general/input/Input";
import * as Yup from "yup";
import {RegistrationEndpoint} from "Frontend/generated/endpoints";
import React, {useEffect, useState} from "react";
import {Warning} from "@phosphor-icons/react";
import { WarningIcon } from "@phosphor-icons/react";
import UserInvitationAcceptanceResult
from "Frontend/generated/org/gameyfin/app/users/enums/UserInvitationAcceptanceResult";
@ -63,7 +63,7 @@ export default function InvitationRegistrationView() {
}
return (
<div className="flex flex-row flex-grow items-center justify-center size-full gradient-primary">
<div className="flex flex-row grow items-center justify-center size-full gradient-primary">
<Card className="p-4 min-w-[468px]">
<CardHeader className="mb-4">
<img
@ -114,8 +114,8 @@ export default function InvitationRegistrationView() {
)}
</Formik>
:
<p className="flex flex-row flex-grow justify-center items-center gap-2 text-danger text-2xl font-bold">
<Warning weight="fill"/>
<p className="flex flex-row grow justify-center items-center gap-2 text-danger text-2xl font-bold">
<WarningIcon weight="fill"/>
Invalid token
</p>
}

View File

@ -2,7 +2,7 @@ import {useLocation, useNavigate, useParams} from "react-router";
import React, {useEffect} from "react";
import LibraryHeader from "Frontend/components/general/covers/LibraryHeader";
import {Button, Tab, Tabs} from "@heroui/react";
import {ArrowLeft} from "@phosphor-icons/react";
import { ArrowLeftIcon } from "@phosphor-icons/react";
import LibraryManagementDetails from "Frontend/components/general/library/LibraryManagementDetails";
import LibraryManagementGames from "Frontend/components/general/library/LibraryManagementGames";
import {useSnapshot} from "valtio/react";
@ -26,7 +26,7 @@ export default function LibraryManagementView() {
return libraryId && state.state[parseInt(libraryId)] && <div className="flex flex-col gap-4">
<div className="flex flex-row gap-4 items-center">
<Button isIconOnly variant="light" onPress={() => navigate("/administration/libraries")}>
<ArrowLeft/>
<ArrowLeftIcon/>
</Button>
<h1 className="text-2xl font-bold">Manage library</h1>
</div>

View File

@ -47,7 +47,7 @@ export default function LoginView() {
alt="Gameyfin Logo"
/>
</CardHeader>
<CardBody className="mt-8 mb-2 w-80 max-w-screen-lg sm:w-96">
<CardBody className="mt-8 mb-2 w-80 max-w-(--breakpoint-lg) sm:w-96">
<Formik
initialValues={{}}
onSubmit={tryLogin}>

View File

@ -5,7 +5,15 @@ import GameyfinLogo from "Frontend/components/theming/GameyfinLogo";
import * as PackageJson from "../../../../package.json";
import {Outlet, useLocation, useNavigate} from "react-router";
import {useAuth} from "Frontend/util/auth";
import {ArrowLeft, DiceSix, Disc, Heart, House, ListMagnifyingGlass, SignIn} from "@phosphor-icons/react";
import {
ArrowLeftIcon,
DiceSixIcon,
DiscIcon,
HeartIcon,
HouseIcon,
ListMagnifyingGlassIcon,
SignInIcon
} from "@phosphor-icons/react";
import Confetti, {ConfettiProps} from "react-confetti-boom";
import {useTheme} from "next-themes";
import {useUserPreferenceService} from "Frontend/util/user-preference-service";
@ -14,7 +22,6 @@ import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import ScanProgressPopover from "Frontend/components/general/ScanProgressPopover";
import {isAdmin} from "Frontend/util/utils";
import DockerHubDeprecationPopover from "Frontend/components/temp/DockerHubDeprecationPopover";
export default function MainLayout() {
const navigate = useNavigate();
@ -71,10 +78,10 @@ export default function MainLayout() {
{isHomePage ? <GameyfinLogo className="h-10 fill-foreground"/> :
<div className="flex flex-row gap-2">
<Button isIconOnly onPress={() => history.back()} variant="light">
<ArrowLeft size={26} weight="bold"/>
<ArrowLeftIcon size={26} weight="bold"/>
</Button>
<Button isIconOnly onPress={() => navigate("/")} variant="light">
<House size={26} weight="fill"/>
<HouseIcon size={26} weight="fill"/>
</Button>
</div>
}
@ -84,13 +91,13 @@ export default function MainLayout() {
<Button isIconOnly variant="light"
onPress={() => navigate("/game/" + getRandomGameId())}
isDisabled={gameState.games.length === 0}>
<DiceSix/>
<DiceSixIcon/>
</Button>
</Tooltip>
<SearchBar/>
<Tooltip content="Advanced search" placement="bottom">
<Button isIconOnly variant="light" onPress={() => navigate("/search")}>
<ListMagnifyingGlass/>
<ListMagnifyingGlassIcon/>
</Button>
</Tooltip>
</NavbarContent>}
@ -100,20 +107,13 @@ export default function MainLayout() {
<Button color="primary"
isDisabled={window.location.pathname.startsWith("/requests")}
onPress={() => navigate("/requests")}
startContent={<Disc weight="fill"/>}>
startContent={<DiscIcon weight="fill"/>}>
Requests
</Button>
</Tooltip>
</NavbarItem>
{isAdmin(auth) &&
<div className="flex flex-row">
<NavbarItem>
<Tooltip content="Important information" placement="bottom">
<div>
<DockerHubDeprecationPopover/>
</div>
</Tooltip>
</NavbarItem>
<NavbarItem>
<Tooltip content="View library scan results" placement="bottom">
<div>
@ -139,7 +139,7 @@ export default function MainLayout() {
This triggers Hilla to redirect to the correct login page (integrated or SSO) automatically.
Otherwise, SSO login would not be possible if we redirect to "/login" directly */
onPress={() => window.location.href = "/loginredirect"}>
<SignIn fill="text-background/80"/>
<SignInIcon fill="text-background/80"/>
</Button>
</Tooltip>
</NavbarItem>
@ -147,7 +147,7 @@ export default function MainLayout() {
</NavbarContent>
</Navbar>
<div className="flex flex-col flex-grow 2xl:px-[12.5%] overflow-x-hidden mt-4">
<div className="flex flex-col grow 2xl:px-[12.5%] overflow-x-hidden mt-4">
<Outlet/>
</div>
@ -157,7 +157,7 @@ export default function MainLayout() {
<p>Gameyfin {PackageJson.version}</p>
<p className="flex flex-row gap-1 items-baseline">
Made with
<Heart size={16} weight="fill" className="text-primary" onClick={easterEgg}/>
<HeartIcon size={16} weight="fill" className="text-primary" onClick={easterEgg}/>
by
<Link href="https://github.com/grimsi" target="_blank">grimsi</Link> and
<Link href="https://github.com/gameyfin/gameyfin/graphs/contributors" target="_blank">

View File

@ -5,11 +5,11 @@ import Input from "Frontend/components/general/input/Input";
import * as Yup from "yup";
import {PasswordResetEndpoint} from "Frontend/generated/endpoints";
import React, {useEffect, useState} from "react";
import {Warning} from "@phosphor-icons/react";
import {WarningIcon} from "@phosphor-icons/react";
import TokenValidationResult from "Frontend/generated/org/gameyfin/app/shared/token/TokenValidationResult";
export default function PasswordResetView() {
const [searchParams, setSearchParams] = useSearchParams();
const [searchParams] = useSearchParams();
const [token, setToken] = useState<string>();
const navigate = useNavigate();
@ -50,7 +50,7 @@ export default function PasswordResetView() {
}
return (
<div className="flex flex-row flex-grow items-center justify-center size-full gradient-primary">
<div className="flex flex-row grow items-center justify-center size-full gradient-primary">
<Card className="p-4 min-w-[468px]">
<CardHeader className="mb-4">
<img
@ -91,8 +91,8 @@ export default function PasswordResetView() {
)}
</Formik>
:
<p className="flex flex-row flex-grow justify-center items-center gap-2 text-danger text-2xl font-bold">
<Warning weight="fill"/>
<p className="flex flex-row grow justify-center items-center gap-2 text-danger text-2xl font-bold">
<WarningIcon weight="fill"/>
Invalid token
</p>
}

View File

@ -1,16 +1,16 @@
import {Palette, User} from "@phosphor-icons/react";
import { PaletteIcon, UserIcon } from "@phosphor-icons/react";
import withSideMenu from "Frontend/components/general/withSideMenu";
const menuItems = [
{
title: "My Profile",
url: "profile",
icon: <User/>
icon: <UserIcon/>
},
{
title: "Appearance",
url: "appearance",
icon: <Palette/>
icon: <PaletteIcon/>
},
/* TODO: Implement account self management
{

View File

@ -1,5 +1,11 @@
import {Button, Input, Select, SelectedItems, SelectItem, Tooltip} from "@heroui/react";
import {FunnelSimple, FunnelSimpleX, MagnifyingGlass, SortAscending, Star} from "@phosphor-icons/react";
import {
FunnelSimpleIcon,
FunnelSimpleXIcon,
MagnifyingGlassIcon,
SortAscendingIcon,
StarIcon
} from "@phosphor-icons/react";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import {libraryState} from "Frontend/state/LibraryState";
@ -249,7 +255,7 @@ export default function SearchView() {
const stars = [];
for (let i = 0; i < total; i++) {
stars.push(
<Star key={i} weight={i < filled ? "fill" : "regular"} className="inline-block"/>
<StarIcon key={i} weight={i < filled ? "fill" : "regular"} className="inline-block"/>
);
}
return <div className="flex flex-row">
@ -261,13 +267,13 @@ export default function SearchView() {
<div className="flex w-full justify-between px-12 gap-4 flex-col lg:flex-row">
<Input
classNames={{
base: "w-full lg:w-96 flex-shrink-0",
base: "w-full lg:w-96 shrink-0",
mainWrapper: "h-full",
inputWrapper:
"h-full font-normal text-default-500 bg-default-400/20 dark:bg-default-500/20",
}}
placeholder="Type to search..."
startContent={<MagnifyingGlass/>}
startContent={<MagnifyingGlassIcon/>}
type="search"
value={searchTerm}
isClearable
@ -276,7 +282,7 @@ export default function SearchView() {
/>
<div className="flex flex-row gap-2">
<Select
startContent={<SortAscending/>}
startContent={<SortAscendingIcon/>}
selectedKeys={[sortBy]}
disallowEmptySelection
selectionMode="single"
@ -301,7 +307,7 @@ export default function SearchView() {
onPress={() => setShowFilters(!showFilters)}
aria-label="Toggle Filters"
>
<FunnelSimple/>
<FunnelSimpleIcon/>
</Button>
</Tooltip>
<Tooltip content="Clear All Filters">
@ -318,7 +324,7 @@ export default function SearchView() {
}}
aria-label="Clear All Filters"
>
<FunnelSimpleX/>
<FunnelSimpleXIcon/>
</Button>
</Tooltip>
</div>

View File

@ -3,7 +3,7 @@ import * as Yup from 'yup';
import Wizard from "Frontend/components/wizard/Wizard";
import WizardStep from "Frontend/components/wizard/WizardStep";
import Input from "Frontend/components/general/input/Input";
import {HandWaving, Palette, User} from "@phosphor-icons/react";
import { HandWavingIcon, PaletteIcon, UserIcon } from "@phosphor-icons/react";
import {addToast, Card} from "@heroui/react";
import {SetupEndpoint} from "Frontend/generated/endpoints";
import {ThemeSelector} from "Frontend/components/theming/ThemeSelector";
@ -35,7 +35,7 @@ function WelcomeStep() {
function ThemeStep() {
return (
<div className="flex flex-col flex-grow gap-6 items-center">
<div className="flex flex-col grow gap-6 items-center">
<p className="text-2xl font-bold">Choose your style</p>
<ThemeSelector/>
</div>
@ -44,7 +44,7 @@ function ThemeStep() {
function UserStep() {
return (
<div className="flex flex-row flex-grow justify-center">
<div className="flex flex-row grow justify-center">
<div className="flex flex-col w-1/3 min-w-96 gap-6 items-center">
<p className="text-2xl font-bold">Create your account</p>
<p>This will set up the initial admin user account.</p>
@ -108,10 +108,10 @@ function SetupView() {
}
}
>
<WizardStep icon={<HandWaving/>}>
<WizardStep icon={<HandWavingIcon/>}>
<WelcomeStep/>
</WizardStep>
<WizardStep icon={<Palette/>}>
<WizardStep icon={<PaletteIcon/>}>
<ThemeStep/>
</WizardStep>
<WizardStep
@ -128,7 +128,7 @@ function SetupView() {
.equals([Yup.ref('password')], 'Passwords do not match')
.required('Required')
})}
icon={<User/>}
icon={<UserIcon/>}
>
<UserStep/>
</WizardStep>

View File

@ -2,7 +2,6 @@ package org.gameyfin.app
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.scheduling.annotation.EnableAsync
import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.transaction.annotation.EnableTransactionManagement
@ -10,7 +9,6 @@ import org.springframework.transaction.annotation.EnableTransactionManagement
@SpringBootApplication
@EnableScheduling
@EnableTransactionManagement
@EnableAsync
class GameyfinApplication
fun main(args: Array<String>) {

View File

@ -9,7 +9,10 @@ sealed class ConfigProperties<T : Serializable>(
val key: String,
val description: String,
val default: T? = null,
val allowedValues: List<T>? = null
val allowedValues: List<T>? = null,
val min: Number? = null,
val max: Number? = null,
val step: Number? = null
) {
/** Libraries */
@ -53,8 +56,11 @@ sealed class ConfigProperties<T : Serializable>(
data object TitleMatchMinRatio : ConfigProperties<Int>(
Int::class,
"library.scan.title-match-min-ratio",
"Minimum ratio for title matching (0-100). Higher values mean stricter matching.",
90
"Minimum ratio for title matching. Higher values mean stricter matching.",
default = 90,
min = 0,
max = 100,
step = 1
)
data object GameFileExtensions : ConfigProperties<Array<String>>(
@ -129,6 +135,23 @@ sealed class ConfigProperties<T : Serializable>(
}
}
/** Downloads */
sealed class Downloads {
data object BandwidthLimitEnabled : ConfigProperties<Boolean>(
Boolean::class,
"downloads.bandwidth-limit.enabled",
"Enable per-user bandwidth limiting for downloads",
false
)
data object BandwidthLimitMbps : ConfigProperties<Int>(
Int::class,
"downloads.bandwidth-limit.mbps",
"Maximum download speed in Megabits per second (Mbps)",
100
)
}
/** User management */
sealed class Users {
sealed class SignUps {

View File

@ -91,9 +91,12 @@ class ConfigService(
value = get(configProperty),
defaultValue = configProperty.default,
type = configProperty.type.simpleName ?: "Unknown",
description = configProperty.description,
elementType = configProperty.type.java.componentType?.simpleName,
allowedValues = configProperty.allowedValues?.map { it.toString() },
description = configProperty.description
min = configProperty.min,
max = configProperty.max,
step = configProperty.step
)
}
}

View File

@ -11,5 +11,8 @@ data class ConfigEntryDto(
val defaultValue: Serializable?,
val type: String,
val elementType: String?,
val allowedValues: List<String>?
val allowedValues: List<String>?,
val min: Number?,
val max: Number?,
val step: Number?
)

View File

@ -11,6 +11,7 @@ class ConfigEntry(
@Column(name = "`key`", unique = true)
val key: String,
@Lob
@Column(name = "`value`")
@Convert(converter = EncryptionConverter::class)
var value: String

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