mirror of
https://github.com/grimsi/gameyfin.git
synced 2026-02-06 11:27:07 +00:00
Compare commits
19 Commits
main
...
v2.2.0.rc2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2206afbdc3 | ||
|
|
5c17843626 | ||
|
|
a7ee48b54c | ||
|
|
0ecb1c03df | ||
|
|
5962ee4256 | ||
|
|
a41f624c12 | ||
|
|
dc1eed87d4 | ||
|
|
60f375b636 | ||
|
|
d5d6af2e69 | ||
|
|
b7e9636b9c | ||
|
|
07e6a8bdb1 | ||
|
|
49e2f14185 | ||
|
|
5a920ed35b | ||
|
|
308f835112 | ||
|
|
a889cf9d31 | ||
|
|
a93f8b2dc2 | ||
|
|
8020f67c48 | ||
|
|
b2554788a9 | ||
|
|
a63397740d |
38
.dockerignore
Normal file
38
.dockerignore
Normal 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
|
||||
|
||||
23
.github/actions/docker-build-push/action.yml
vendored
23
.github/actions/docker-build-push/action.yml
vendored
@ -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'
|
||||
|
||||
55
.github/workflows/docker-delete-tag-on-merge.yml
vendored
55
.github/workflows/docker-delete-tag-on-merge.yml
vendored
@ -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
|
||||
|
||||
44
.github/workflows/docker-develop.yml
vendored
44
.github/workflows/docker-develop.yml
vendored
@ -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' }}
|
||||
38
.github/workflows/docker-fix.yml
vendored
38
.github/workflows/docker-fix.yml
vendored
@ -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 }}
|
||||
|
||||
74
.github/workflows/docker-preview.yml
vendored
74
.github/workflows/docker-preview.yml
vendored
@ -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 }}
|
||||
|
||||
35
.github/workflows/image-registry-maintenance.yml
vendored
Normal file
35
.github/workflows/image-registry-maintenance.yml
vendored
Normal 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
|
||||
59
.github/workflows/release.yml
vendored
59
.github/workflows/release.yml
vendored
@ -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
5
.gitignore
vendored
@ -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/
|
||||
|
||||
@ -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.*" />
|
||||
|
||||
@ -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>
|
||||
@ -1,4 +1,4 @@
|
||||
group = "de.grimsi"
|
||||
group = "org.gameyfin"
|
||||
val appMainClass = "org.gameyfin.app.GameyfinApplicationKt"
|
||||
|
||||
plugins {
|
||||
|
||||
@ -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
11436
app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
297
app/package.json
297
app/package.json
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
Binary file not shown.
@ -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",
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -0,0 +1,84 @@
|
||||
import React from "react";
|
||||
import withConfigPage from "Frontend/components/administration/withConfigPage";
|
||||
import Section from "Frontend/components/general/Section";
|
||||
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
|
||||
import * as Yup from "yup";
|
||||
import {Alert, Button, Divider, Tooltip} from "@heroui/react";
|
||||
import {FlaskIcon, SigmaIcon} from "@phosphor-icons/react";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {downloadSessionState} from "Frontend/state/DownloadSessionState";
|
||||
import SessionStatsDto from "Frontend/generated/org/gameyfin/app/core/download/bandwidth/SessionStatsDto";
|
||||
import {DownloadSessionCard} from "Frontend/components/general/cards/DownloadSessionCard";
|
||||
import {humanFileSize} from "Frontend/util/utils";
|
||||
|
||||
function DownloadManagementLayout({getConfig, formik}: any) {
|
||||
const sessions = useSnapshot(downloadSessionState).all as SessionStatsDto[];
|
||||
const [lastDaySum, setLastDaySum] = React.useState<number>(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
const sum = sessions.reduce((total: number, session: SessionStatsDto) => total + session.totalBytesTransferred, 0);
|
||||
setLastDaySum(sum);
|
||||
}, [sessions]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Alert
|
||||
title="Experimental Feature"
|
||||
description="Bandwidth limiting is an experimental feature and may not work as expected. Please report any issues you encounter."
|
||||
variant="solid"
|
||||
hideIconWrapper={true}
|
||||
icon={<FlaskIcon size={24}/>}
|
||||
endContent={
|
||||
<Button variant="flat"
|
||||
className="bg-default-400"
|
||||
onPress={() => window.open("https://github.com/gameyfin/gameyfin/issues", "_blank")}>
|
||||
Open Issues
|
||||
</Button>
|
||||
|
||||
}
|
||||
classNames={{
|
||||
title: "font-bold",
|
||||
base: "mt-6"
|
||||
}}
|
||||
/>
|
||||
<Section title="Bandwidth limiting"/>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-row items-baseline gap-4">
|
||||
<ConfigFormField configElement={getConfig("downloads.bandwidth-limit.enabled")}/>
|
||||
<ConfigFormField configElement={getConfig("downloads.bandwidth-limit.mbps")}
|
||||
isDisabled={!formik.values.downloads["bandwidth-limit"].enabled}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row justify-between items-end">
|
||||
<h2 className="text-xl font-bold mt-8 mb-1">Live View</h2>
|
||||
<Tooltip content="Sum over the last 24 hours" placement="left">
|
||||
<div className="flex flex-row gap-1">
|
||||
<SigmaIcon size={26} weight="bold"/>
|
||||
<p className="font-semibold">{humanFileSize(lastDaySum)}</p>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Divider className="mb-4"/>
|
||||
{sessions.length === 0 &&
|
||||
<p className="text-center text-default-500">No active download sessions.</p>
|
||||
}
|
||||
<div className="flex flex-col gap-2">
|
||||
{sessions.map((session: SessionStatsDto) =>
|
||||
<DownloadSessionCard key={session.sessionId} sessionId={session.sessionId}/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
downloads: Yup.object({
|
||||
"bandwidth-limit": Yup.object({
|
||||
enabled: Yup.boolean().required("Required"),
|
||||
mbps: Yup.number()
|
||||
.min(1, "Must be at least 1 Mbps")
|
||||
.required("Required"),
|
||||
}).required("Required")
|
||||
})
|
||||
});
|
||||
export const DownloadManagement = withConfigPage(DownloadManagementLayout, "Downloads", validationSchema);
|
||||
@ -5,7 +5,7 @@ import Section from "Frontend/components/general/Section";
|
||||
import * as Yup from 'yup';
|
||||
import "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}
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"),
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}/>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
46
app/src/main/frontend/components/general/ChipList.tsx
Normal file
46
app/src/main/frontend/components/general/ChipList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
{session.username ?? "Anonymous User"}
|
||||
<Tooltip
|
||||
content={<pre>Session ID: {session.sessionId}</pre>}
|
||||
placement="right"
|
||||
>
|
||||
<InfoIcon size={18}/>
|
||||
</Tooltip>
|
||||
</p>
|
||||
<div className="flex-1 flex justify-center">Remote IP:
|
||||
{<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"}
|
||||
{<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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -0,0 +1,100 @@
|
||||
import React, {Key, useEffect, useState} from "react";
|
||||
import {Autocomplete, AutocompleteItem, Chip} from "@heroui/react";
|
||||
import {FieldArray, useField} from "formik";
|
||||
|
||||
type ArrayInputAutocompleteProps = {
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
options: string[];
|
||||
name: string;
|
||||
defaultSelected?: string[];
|
||||
};
|
||||
|
||||
export default function ArrayInputAutocomplete({
|
||||
options,
|
||||
label,
|
||||
placeholder = "Search...",
|
||||
defaultSelected = [],
|
||||
...props
|
||||
}: ArrayInputAutocompleteProps) {
|
||||
const [field, meta, helpers] = useField(props);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
// Initialize field value if undefined or empty
|
||||
useEffect(() => {
|
||||
if (!field.value) {
|
||||
helpers.setValue(defaultSelected.length > 0 ? defaultSelected : []);
|
||||
} else if (field.value.length === 0 && defaultSelected.length > 0) {
|
||||
helpers.setValue(defaultSelected);
|
||||
}
|
||||
}, [defaultSelected, field.value, helpers]);
|
||||
|
||||
return (
|
||||
<FieldArray name={field.name}
|
||||
render={arrayHelpers => {
|
||||
const selectedValues = field.value || [];
|
||||
const filteredOptions = options.filter(
|
||||
(option) =>
|
||||
option.toLowerCase().includes(search.toLowerCase()) &&
|
||||
!selectedValues.find((selected: string) => selected === option),
|
||||
);
|
||||
|
||||
const handleSelect = (item: string) => {
|
||||
if (!selectedValues.find((selected: string) => selected === item)) {
|
||||
arrayHelpers.push(item);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
arrayHelpers.remove(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 gap-2">
|
||||
{label && (
|
||||
<div className="flex flex-row justify-between">
|
||||
<p>{label}</p>
|
||||
<small>{selectedValues.length} {selectedValues.length === 1 ? "element" : "elements"} selected</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Autocomplete
|
||||
{...props}
|
||||
aria-labelledby="search"
|
||||
shouldCloseOnBlur={false}
|
||||
placeholder={placeholder}
|
||||
inputValue={search}
|
||||
onInputChange={(value) => setSearch(value)}
|
||||
onSelectionChange={(value: Key | null) => {
|
||||
const item = options.find((option) => option === value);
|
||||
if (item) handleSelect(item);
|
||||
setSearch("");
|
||||
}}
|
||||
>
|
||||
{filteredOptions.map((option) => (
|
||||
<AutocompleteItem key={option} data-selected="true">
|
||||
{option}
|
||||
</AutocompleteItem>
|
||||
))}
|
||||
</Autocomplete>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedValues.map((item: string, index: number) => (
|
||||
<Chip key={index} variant="flat"
|
||||
onClose={() => handleRemove(index)}>
|
||||
{item}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="min-h-6 text-danger">
|
||||
{meta.touched && meta.error && meta.error.trim().length > 0 && (
|
||||
meta.error
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -8,7 +8,7 @@ import {
|
||||
DropdownTrigger,
|
||||
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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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/>;
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
import {useField} from "formik";
|
||||
import {NumberInput as HeroUiNumberInput} from "@heroui/react";
|
||||
|
||||
// @ts-ignore
|
||||
const NumberInput = ({label, showErrorUntouched = false, ...props}) => {
|
||||
// @ts-ignore
|
||||
const [field, meta, helpers] = useField(props);
|
||||
|
||||
return (
|
||||
<HeroUiNumberInput
|
||||
className="min-h-20 grow"
|
||||
fullWidth={false}
|
||||
{...props}
|
||||
value={field.value}
|
||||
onValueChange={(value) => helpers.setValue(value)}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
id={label}
|
||||
label={label}
|
||||
isInvalid={(meta.touched || showErrorUntouched) && !!meta.error}
|
||||
errorMessage={meta.initialError || meta.error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default NumberInput;
|
||||
@ -9,7 +9,7 @@ const SelectInput = ({label, values, ...props}) => {
|
||||
const items = values.map((v: string) => ({key: v, label: v}));
|
||||
|
||||
return (
|
||||
<div className="min-h-20 flex-grow">
|
||||
<div className="min-h-20 grow">
|
||||
<Select
|
||||
fullWidth={true}
|
||||
{...field}
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
import {useField} from "formik";
|
||||
import {Slider as HeroUiSlider} from "@heroui/react";
|
||||
|
||||
// @ts-ignore
|
||||
const SliderInput = ({label, showErrorUntouched = false, ...props}) => {
|
||||
// @ts-ignore
|
||||
const [field, meta, helpers] = useField(props);
|
||||
|
||||
return (
|
||||
<HeroUiSlider
|
||||
className="min-h-20 grow"
|
||||
{...props}
|
||||
value={field.value}
|
||||
onChange={(value) => helpers.setValue(value)}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
id={label}
|
||||
label={label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SliderInput;
|
||||
@ -8,7 +8,7 @@ export default function TextAreaInput({label, showErrorUntouched = false, ...pro
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
className={`flex-grow ${meta.initialError || meta.error ? "" : "mb-6"}`}
|
||||
className={`grow ${meta.initialError || meta.error ? "" : "mb-6"}`}
|
||||
fullWidth={false}
|
||||
{...props}
|
||||
{...field}
|
||||
|
||||
@ -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"/>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}/>
|
||||
))}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)"
|
||||
|
||||
@ -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>
|
||||
</>}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -1,44 +0,0 @@
|
||||
import {Button, Link, Popover, PopoverContent, PopoverTrigger} from "@heroui/react";
|
||||
import {Warning} from "@phosphor-icons/react";
|
||||
|
||||
// TODO: Remove this component before the release of version 2.2.0
|
||||
export default function DockerHubDeprecationPopover() {
|
||||
return (
|
||||
<Popover placement="bottom-end" showArrow={true} color="warning">
|
||||
<PopoverTrigger>
|
||||
<Button isIconOnly color="warning" variant="flat">
|
||||
<Warning/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<div className="m-4 text-sm leading-relaxed">
|
||||
<h3 className="mb-2 font-bold">Image deprecation notice</h3>
|
||||
<p>
|
||||
Starting with version
|
||||
<code className="font-semibold"> 2.2.0 </code>
|
||||
the image{' '}
|
||||
<Link href="https://hub.docker.com/r/grimsi/gameyfin"
|
||||
isExternal
|
||||
underline="always"
|
||||
size="sm"
|
||||
className="text-warning-contrast">
|
||||
grimsi/gameyfin
|
||||
</Link>
|
||||
{' '}will no longer be published to Docker Hub.
|
||||
</p>
|
||||
<p>
|
||||
Please switch to{' '}
|
||||
<Link href="https://github.com/gameyfin/gameyfin/pkgs/container/gameyfin"
|
||||
isExternal
|
||||
underline="always"
|
||||
size="sm"
|
||||
className="text-warning-contrast">
|
||||
ghcr.io/gameyfin/gameyfin
|
||||
</Link>
|
||||
{' '}if you are currently using the Docker Hub image.
|
||||
</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@ -7,7 +7,7 @@ export default function ThemePreview({theme, isSelected}: {
|
||||
}) {
|
||||
return (
|
||||
<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"/>
|
||||
|
||||
208
app/src/main/frontend/components/wizard/Stepper.tsx
Normal file
208
app/src/main/frontend/components/wizard/Stepper.tsx
Normal 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;
|
||||
@ -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;
|
||||
|
||||
10
app/src/main/frontend/heroui.ts
Normal file
10
app/src/main/frontend/heroui.ts
Normal 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);
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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/>,
|
||||
|
||||
81
app/src/main/frontend/state/DownloadSessionState.ts
Normal file
81
app/src/main/frontend/state/DownloadSessionState.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
47
app/src/main/frontend/state/PlatformState.ts
Normal file
47
app/src/main/frontend/state/PlatformState.ts
Normal 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);
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
@ -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];
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import {Theme} from "../theme";
|
||||
|
||||
export const Purple: Theme = {
|
||||
name: 'purple',
|
||||
export const Pink: Theme = {
|
||||
name: 'pink',
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: '#DD62ED',
|
||||
@ -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)}`;
|
||||
}
|
||||
|
||||
@ -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/>
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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": "You’ve wandered off the map. This level doesn’t exist—or maybe it’s 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 – You’ve encountered a glitch in the system!",
|
||||
"subtitle": "The page you’re looking for couldn’t load. Don’t 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 isn’t 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 doesn’t 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 Didn’t 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 doesn’t 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/>
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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?
|
||||
)
|
||||
@ -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
Loading…
Reference in New Issue
Block a user